Step 183. 어셈블리 2: gdb 기초 — 실행 중인 프로그램의 현미경
Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★★☆ | 예상 소요 시간 5시간
전제: Step 182(레지스터와 스택 프레임).
gcc -S출력을 읽을 수 있고, 스택 프레임 그림을 그릴 수 있습니다.
⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
- 준비물: WSL 우분투(gcc, gdb). 실측 환경은 Ubuntu 24.04, gcc 13.3.0, gdb 15.1, x86-64입니다.
- 주의: 오늘 실습은 100% 안전합니다. 내가 컴파일한 프로그램을 내 디버거로 관찰할 뿐입니다. gef/pwndbg 같은 gdb 확장은 외부 다운로드가 필요해 이 환경에서는 설치하지 않고 "화면 예시"로만 소개합니다.
gdb는 실행 중인 프로그램을 원하는 지점에서 얼려서 내부를 들여다보는 현미경입니다. 어제까지 우리는 컴파일 결과물(.s 파일)을 정지 화면으로 읽었습니다. 오늘부터는 살아 있는 프로그램 — 명령 한 줄이 지날 때마다 바뀌는 레지스터와 스택 — 를 관찰합니다. Pwn 공격이 "입력이 스택 어디에 어떻게 쌓이는가"를 gdb로 관찰하며 설계되는 이유를, 오늘 몸으로 익힙니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
gcc -g -O0의 의미(-g: 디버그 정보, -O0: 최적화 끄기)를 설명하고 디버깅용 바이너리를 만든다- 브레이크포인트(
b)를 걸고run으로 프로그램을 그 지점까지 실행한다 next/ni/si/finish의 차이를 알고 상황에 맞게 고른다info registers,print,x/명령으로 레지스터·변수·메모리를 관찰한다disassemble출력의=>표시로 "지금 어디 있는가"를 읽는다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | C + gdb, WSL 우분투 bash (실측: gcc 13.3.0, gdb 15.1) |
| 오늘의 명령어 | b 위치, run, next/ni/si/finish, print 변수, info registers, x/10gx $rsp, disassemble |
| 필요한 개념 | 브레이크포인트, 명령어 단위 vs 소스 줄 단위 진행, 디버그 정보(-g), 스택 프레임(Step 182) |
| 오늘의 산출물 | gdb 명령 카드 + 함수 호출 관찰 노트 |
2-1. 디버거의 원리 — 얼리기와 들여다보기
디버거(debugger)가 하는 일은 두 가지입니다. ① 프로그램을 지정한 지점에서 멈추게 하기(브레이크포인트), ② 멈춘 상태에서 레지스터·메모리·변수를 읽기. 이 두 개의 조합으로 "실행을 슬로우 모션으로 재생"합니다.
일반 실행이 "영화 상영"이라면 디버깅은 "한 프레임씩 넘기며 확대"입니다. 그리고 이 현미경의 대상은 오늘처럼 내 프로그램일 수도, 소스 없는 남의 바이너리(리버싱)일 수도 있습니다.
2-2. 진행 명령의 계보 — next, ni, si, finish
"한 단계 진행"은 네 가지 얼굴이 있습니다:
| 명령 | 단위 | 함수 호출을 만나면 |
|---|---|---|
next (n) |
소스 한 줄 | 지나친다 (함수를 통째로 실행) |
step (s) |
소스 한 줄 | 들어간다 |
ni |
기계어 한 줄 | 지나친다 |
si |
기계어 한 줄 | 들어간다 |
finish |
— | 현재 함수의 끝까지 실행 |
ni와 si의 차이는 오직 call을 만났을 때뿐입니다. 평지에서는 둘이 같습니다. 이 차이를 오늘 실측으로 확인합니다.
2-3. 메모리 읽기 — x/ 명령의 문법
x/는 "examine(살피다)" — 메모리를 직접 읽는 명령입니다. 문법은 x/개수크기형식 주소:
x/s 주소 → 문자열로 읽기 (s = string)
x/4wx 주소 → 4개를, 워드(4바이트) 단위로, 16진수로 (w = word, x = hex)
x/10gx $rsp → 10개를, 자이언트 워드(8바이트)로, 16진수로 — 64비트 스택 보기의 표준
변수는 print 변수명(p로 줄여 씀), 변수의 주소는 &변수명입니다. x/s &name은 "name의 주소부터 문자열로 읽어 줘"입니다.
2-4. gef와 pwndbg — 화면 예시
실전 Pwn에서는 gef나 pwndbg 같은 gdb 확장이 표준입니다. 명령 한 번마다 레지스터·스택·디스어셈블리가 한 화면에 자동 표시됩니다 (화면 예시 — 이 환경에는 설치하지 않았습니다):
# 화면 예시 — gef의 정지 화면 구성
───────────────────────── registers ────
$rax: 0x11 $rdi: 0x11 $rsp: 0x7fffffffe640
───────────────────────── stack ────
0x7fffffffe640│+0x0000: 0x00007fffffffe670
───────────────────────── code:x86:64 ────
→ 0x555555555174 <square+11> mov eax, DWORD PTR [rbp-0x14]
설치는 인터넷에서 스크립트를 받아 한 줄이면 되지만, 순수 gdb로 먼저 익숙해지는 것이 오늘의 목표입니다 — 확장이 보여 주는 것이 결국 오늘 배우는 명령들의 출력이기 때문입니다.
3. 따라 하기
3-1. 연습용 프로그램과 디버깅 컴파일
입력 (test.c)
#include <stdio.h>
int square(int n) {
int r = n * n;
return r;
}
int main(void) {
char name[] = "guardian";
int age = 17;
int sq = square(age);
printf("%s: %d\n", name, sq);
return 0;
}
컴파일과 시운전
gcc -g -O0 test.c -o test
./test
guardian: 289
(2026-09-09 실측.)
옵션 읽는 법: -g는 "변수명·줄 번호 등 디버그 정보를 바이너리에 싣기", -O0는 "최적화 끄기"입니다. 둘 다 없으면 gdb가 변수명을 모르고 코드 순서도 뒤섞입니다 — 벽 1에서 실제 증상을 봅니다.
3-2. 브레이크포인트와 run — 얼리기
gdb ./test
GNU gdb (Ubuntu 15.1-1ubuntu1~24.04.1) 15.1
...
Reading symbols from ./test...
(gdb)
이제 gdb의 대화형 프롬프트입니다. main에 브레이크포인트를 걸고 실행합니다:
(gdb) b main
Breakpoint 1 at 0x118e: file test.c, line 8.
(gdb) run
Breakpoint 1, main () at test.c:8
8 int main(void) {
(2026-09-09 실측. 아래 세션 출력들도 같은 날 실측이며, 주소는 실행마다 달라집니다.)
읽는 법: b는 브레이크포인트(breakpoint) — "여기서 얼려라"는 표지입니다. run은 프로그램 시작. main의 입구에서 얼었고, gdb는 "지금 막 실행할 줄"을 보여 줍니다. 참고로 오늘 챕터의 세션들은 명령을 파일에 모아 gdb -batch -x 명령파일 ./test로도 실행할 수 있습니다 — 같은 명령을 반복할 때 편합니다.
3-3. 변수와 레지스터 관찰 — 들여다보기
(gdb) next
9 char name[] = "guardian";
(gdb) next
10 int age = 17;
(gdb) next
11 int sq = square(age);
(gdb) print age
$1 = 17
(gdb) x/s &name
0x7fffffffe65f: "guardian"
(gdb) info registers rip
rip 0x5555555551b6 0x5555555551b6 <main+52>
읽는 법: next 세 번으로 대입문 두 개를 지났고, 변수들이 값을 가졌습니다. print age의 $1은 gdb가 붙이는 결과 번호입니다. x/s &name은 문자열 그대로를, rip는 지금 실행할 명령의 주소 — 현미경의 십자선입니다. 참고로 next를 덜 누른 상태에서 print age를 하면 쓰레기 값이 보입니다 — 대입 전이니까요. 이게 "관찰 시점"의 의미입니다.
3-4. ni와 si의 갈림길 — call에서
지금 11번 줄, square(age) 호출 직전입니다. 여기서 명령어 단위로 갑니다:
ni로 지나친 경우
(gdb) ni (mov ... — age를 레지스터에 싣는 명령)
(gdb) ni (mov edi, ... — 첫 인자 rdi에)
(gdb) ni (call square — 지나침! 0x...51c0, 아직 11번 줄)
(gdb) ni (mov [rbp-...], eax — 결과를 sq에 저장)
12 printf("%s: %d\n", name, sq);
(gdb) print sq
$2 = 289
square가 통째로 실행되고 12번 줄에 도착했습니다. sq에는 결과 289가 들어 있습니다. 주목할 점 — call을 지나친 직후에도 아직 11번 줄입니다. "sq에 대입"이 같은 소스 줄의 남은 명령이라 한 번 더 ni해야 12번 줄로 넘어갑니다 (2026-09-09 실측).
si로 들어간 경우 (같은 지점에서 다시)
(gdb) si (mov ...)
(gdb) si (mov edi, ...)
(gdb) si (call square — 들어감!)
square (n=0) at test.c:3
3 int square(int n) {
si는 call을 만나 함수 안으로 들어왔습니다. 방금 진입한 순간이라 n=0으로 보이는데, 이는 인자가 아직 스택에 내려가기 전 — 서문 실행 전이기 때문입니다 (Step 182의 서문을 기억하세요).
3-5. 함수 안에서 — 레지스터와 스택 실측
함수 안 관찰은 b square로 직행하는 것이 깔끔합니다:
(gdb) b square
Breakpoint 2 at 0x1174: file test.c, line 4.
(gdb) run
Breakpoint 2, square (n=17) at test.c:4
4 int r = n * n;
(gdb) print n
$1 = 17
(gdb) info registers rdi rip
rdi 0x11 17
rip 0x555555555174 0x555555555174 <square+11>
읽는 법: b square는 서문을 지난 첫 소스 줄에 겁니다. 그래서 n이 벌써 17입니다. 호출 규약의 실측 — 첫 인자 17이 rdi(0x11)에 있습니다. rip는 square의 11바이트 지점을 가리킵니다.
스택을 봅니다:
(gdb) x/10gx $rsp
0x7fffffffe640: 0x00007fffffffe670 0x00005555555551c0
0x7fffffffe650: 0x0000001100000000 0x67007ffff7fe5af0
0x7fffffffe660: 0x006e616964726175 0xafa9197956204100
0x7fffffffe670: 0x00007fffffffe710 0x00007ffff7c2a1ca
0x7fffffffe680: 0x00007fffffffe6c0 0x00007fffffffe798
출력 읽는 법: 각 줄이 주소와 8바이트 두 덩어리입니다. 세 곳을 찾으세요. ① 0x…e640의 첫 덩어리 0x…e670 — 저장된 옛 rbp(square 서문이 push한 것). ② 그 옆 0x5555555551c0 — 리턴 주소(main에서 call 다음 명령). ③ 0x…e660의 0x006e616964726175 — 이 바이트를 거꾸로(리틀 엔디안) 읽으면 67 75 61 72 64 69 61 6e, 즉 "guardian"입니다. main의 지역 변수 name이 스택에 살아 있는 모습입니다. 스택은 낯선 숫자판이 아니라, 배운 구조물이 실제로 쌓인 현장입니다.
3-6. disassemble과 => — 지금 어디인가
(gdb) disassemble square
Dump of assembler code for function square:
0x0000555555555169 <+0>: endbr64
0x000055555555516d <+4>: push rbp
0x000055555555516e <+5>: mov rbp,rsp
0x0000555555555171 <+8>: mov DWORD PTR [rbp-0x14],edi
=> 0x0000555555555174 <+11>: mov eax,DWORD PTR [rbp-0x14]
0x0000555555555177 <+14>: imul eax,eax
0x000055555555517a <+17>: mov DWORD PTR [rbp-0x4],eax
0x000055555555517d <+20>: mov eax,DWORD PTR [rbp-0x4]
0x0000555555555180 <+23>: pop rbp
0x0000555555555181 <+24>: ret
End of assembler dump.
출력 읽는 법: =>가 현재 위치 — rip가 가리키는 바로 그 명령입니다. 3-5에서 rip가 <square+11>이었던 것과 일치합니다. ni로 한 줄 진행할 때마다 =>가 한 칸씩 내려갑니다. 길을 잃으면 disassemble로 현위치를 확인하세요 — 디버거 안의 "현위치" 버튼입니다.
3-7. finish — 함수의 끝까지
(gdb) finish
0x00005555555551c0 in main () at test.c:11
11 int sq = square(age);
Value returned is $3 = 289
(gdb) next
12 printf("%s: %d\n", name, sq);
(gdb) print sq
$4 = 289
읽는 법: finish는 현재 함수가 ret할 때까지 달리고, 돌아온 값까지 보고합니다. 반환값 289가 rax에 실려 main으로 돌아왔고, 대입문을 지나니 sq에도 289가 찼습니다. 호출 규약의 마지막 조각 — "결과는 rax로" — 이 실행으로 확인됐습니다.
4. 미션과 연습문제
미션 — 함수 호출 관찰 노트
- 아래 코드를
gcc -g -O0로 컴파일한다:
int add(int a, int b, int c) {
int s = a + b + c;
return s;
}
int main(void) {
int r = add(10, 20, 30);
return r;
}
b add로 멈춘 뒤info registers rdi rsi rdx로 인자 셋이 실린 것을 확인한다x/8gx $rsp로 스택 맨 위의 리턴 주소를 찾고,disassemble main에서call다음 명령의 주소와 대조한다 — 같은가?finish후 반환값(60)을 확인한다- ①~④의 과정을 "관찰 노트"로 정리한다 — 명령, 관찰값, 그것이 확인해 준 약속(호출 규약의 어느 조항인지)
연습문제
문제 1. -g 없이 컴파일한 바이너리를 gdb에서 열어 print 변수명을 하면 어떻게 되나요? 그 이유는?
문제 2. ni와 si가 다르게 동작하는 순간은 언제인가요? 평지(일반 명령)에서는 어떤가요?
문제 3. x/10gx $rsp의 각 글자(10, g, x)가 무슨 뜻인지 풀이하세요.
문제 4. disassemble 출력의 =>는 무엇을 가리키며, 어느 레지스터와 일치하나요?
5. 모범 답안과 완료 기준
미션 모범 답안
관찰 노트의 예 (값은 2026-09-09 실측 환경의 test.c 세션과 같은 원리):
[관찰 노트: add(10,20,30)]
① b add → run → 멈춤. info registers rdi rsi rdx
rdi=0xa(10), rsi=0x14(20), rdx=0x1e(30)
→ 확인한 약속: 인자는 rdi, rsi, rdx 순
② x/8gx $rsp → 스택 맨 위 = 0x5555...51xx
disassemble main → call add 바로 다음 명령의 주소와 일치
→ 확인한 약속: call은 리턴 주소를 스택에 push
③ finish → "Value returned is $N = 60"
→ 확인한 약속: 반환값은 rax를 통해 전달
검증하는 법: ① 노트에 관찰값이 "확인한 약속"과 짝을 이뤄 적혔는가. ② 리턴 주소 대조에서 두 주소의 일치를 직접 확인했는가(같다고 추정하지 말 것). ③ -O0와 -g를 빼먹지 않았는가.
연습문제 해답
문제 1 해답. No symbol "age" in current context.라는 오류가 납니다 (2026-09-09 실측). -g 없이 컴파일하면 변수명·줄 번호 정보가 바이너리에 없어, gdb는 주소만 알 뿐 "age"라는 이름을 모릅니다. 디버깅용 컴파일에는 -g가 필수입니다.
문제 2 해답. call을 만났을 때만 다릅니다 — ni는 함수를 통째로 실행하고 지나치고, si는 함수 안으로 들어갑니다. mov, add 같은 일반 명령에서는 둘 다 "기계어 한 줄 진행"으로 같습니다.
문제 3 해답. 10은 개수, g는 자이언트 워드(8바이트, 64비트 스택의 한 칸), x는 16진수 표시입니다. 즉 "rsp가 가리키는 곳부터 8바이트씩 10개를 16진수로"입니다.
문제 4 해답. 다음에 실행될 명령, 즉 현재 위치를 가리킵니다. rip 레지스터가 가리키는 주소와 일치합니다 — 3-5와 3-6에서 둘 다 <square+11>였습니다.
완료 기준 체크리스트
- [ ]
-g와-O0의 역할을 설명하고 디버깅용 컴파일을 할 수 있다 - [ ]
b,run으로 원하는 함수 입구에서 프로그램을 얼렸다 - [ ]
next/step/ni/si/finish의 차이를 말할 수 있다 - [ ]
print와x/s로 변수를,info registers로 레지스터를 읽었다 - [ ]
x/10gx $rsp로 스택을 보고 리턴 주소와 지역 변수를 찾았다 - [ ]
disassemble의=>로 현재 위치를 확인했다 - [ ] 미션: add 함수의 호출 관찰 노트를 완성했다
6. 흔한 실수와 해결
벽 1. No symbol "age" in current context.
증상: print age가 이 오류를 뱉습니다 (2026-09-09 실측).
원인: 두 가지 가능성. ① -g 없이 컴파일해 변수명 정보가 없다. ② 아직 그 변수의 스코프(함수) 안에 들어오지 않았다.
해결: gcc -g -O0로 다시 컴파일하고, 그 변수가 사는 함수 안에 멈춰 있는지 확인하세요. main에 멈췄는데 square의 n을 print하면 같은 오류입니다.
벽 2. b 함수명이 안 걸리고 묻는다
증상: Make breakpoint pending on future shared library load? (y or [n])라고 묻습니다 (2026-09-09 실측 — 존재하지 않는 함수명에 걸 때).
원인: 그런 이름의 함수가 없습니다 — 오탈자이거나, 최적화로 함수가 인라인됐거나.
해결: 오탈자를 확인하세요. 함수 이름이 맞는데 안 걸리면 -O0로 다시 컴파일 — 최적화는 작은 함수를 호출부에 흡수시켜 "함수의 실체"를 없앱니다.
벽 3. print 했는데 이상한 값이 나온다
증상: 분명 17을 넣었는데 0이나 쓰레기 값이 나옵니다.
원인: 대입문을 지나기 전에 관찰했습니다. next가 보여 주는 줄은 "방금 실행한 줄"이 아니라 "지금 막 실행할 줄"입니다 (2026-09-09 실측 — 10번 줄에 서 있는 동안 age는 아직 0이었습니다).
해결: 대입문을 한 줄 더 next로 지난 뒤 관찰하세요. "보이는 줄 = 미실행 줄"이 gdb 화면 읽기의 첫 규칙입니다.
벽 4. ni를 눌렀는데 줄이 안 바뀐다
증상: ni를 눌러도 같은 소스 줄이 반복 표시됩니다.
원인: 한 소스 줄은 여러 기계어 명령입니다. char name[] = "guardian" 같은 줄은 문자 복사 명령 여러 개로 번역됩니다 (2026-09-09 실측 — 9번 줄에서 ni가 여러 번 필요했습니다).
해결: 정상입니다. 명령어 단위로 가고 싶은 것이니까요. 소스 줄 단위가 좋으면 next를 쓰세요.
벽 5. 스택 덤프의 숫자가 전부 낯설다
증상: x/10gx $rsp의 값들 중 아는 것이 하나도 없습니다.
원인: 스택에는 운영체제가 쓴 값들도 섞여 있습니다. 전부 아는 것이 목표가 아닙니다.
해결: 세 가지만 찾는 연습을 하세요 — ① 저장된 rbp(서문이 push한 주소 모양 값), ② 리턴 주소(0x5555…로 시작, 코드 영역), ③ 아는 데이터(오늘은 "guardian" 바이트). 나머지는 배경입니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 디버거 | 실행 중인 프로그램을 얼려서 내부를 보는 도구 |
| 브레이크포인트 | "여기서 얼려라" 표지 — 함수명, 줄 번호, 주소로 건다 |
| 디버그 정보(-g) | 변수명·줄 번호를 바이너리에 싣는 컴파일 옵션 |
| next vs step | 함수를 지나치느냐 들어가느냐 (ni/si는 그 기계어 판) |
| x/ 명령 | 메모리 직접 읽기 — 개수·크기·형식을 지정 |
| => 와 rip | 디스어셈블리 속 현재 위치 표시 = rip가 가리키는 곳 |
| gef/pwndbg | gdb 확장 — 오늘 배운 출력들을 한 화면에 모아 보여 줌 |
오늘의 명령어
| 명령 | 하는 일 |
|---|---|
gcc -g -O0 test.c -o test |
디버깅용 컴파일 |
b main / b *주소 |
브레이크포인트 걸기 |
run |
브레이크포인트까지 실행 |
next / ni / si / finish |
진행 — 소스 줄 / 기계어(지나침) / 기계어(진입) / 함수 끝까지 |
print 변수 |
변수 값 보기 |
info registers |
레지스터 보기 |
x/10gx $rsp, x/s &변수 |
메모리·문자열 읽기 |
disassemble 함수 |
어셈블리 보기 (=> = 현재 위치) |
명령어보다 중요한 감각
디버거의 본질은 "명령어를 외우는 것"이 아니라 질문을 던지는 것입니다. "지금 rdi에 뭐가 있지?" — info registers rdi. "이 함수 끝나면 뭐가 돌아오지?" — finish. 오늘 실측에서 우리는 호출 규약의 세 약속(인자, 리턴 주소, 반환값)을 책이 아니라 살아 있는 프로그램에서 확인했습니다.
이 관찰력이 곧 공격 설계 도구가 됩니다. 버퍼 오버플로우 문제를 gdb로 관찰한다는 것은, 오늘 "guardian"이 스택에서 발견된 것처럼 내 입력이 스택의 어느 칸에 놓이는가를 보는 것입니다. 현미경 사용법은 익혔습니다 — 이제 볼 대상을 찾는 훈련이 남았습니다.
전부 체크되면 Step 183 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.