Step 182. 어셈블리 1: 레지스터, mov/push/pop/call/ret, 스택 프레임 — 공용 언어의 문자표
Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★★☆ | 예상 소요 시간 6시간
전제: Step 64(어셈블리 첫 만남 — C와 어셈블리 대조), Step 62(스택과 버퍼).
gcc -S로.s를 뽑아 본 경험이 있습니다.
⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
- 준비물: WSL 우분투(gcc, gdb). 실측 환경은 Ubuntu 24.04, gcc 13.3.0, gdb 15.1, x86-64입니다.
- 주의: 오늘 실습은 100% 안전합니다. 내가 쓴 C를 어셈블리로 번역해 읽고, 실행 중 레지스터를 관찰할 뿐입니다.
Step 64에서 우리는 어셈블리와 "첫 만남"을 가졌습니다 — if가 cmp와 점프라는 것쯤은 봤습니다. 오늘부터는 Pwn과 Reversing의 공용 언어로서 제대로 배웁니다. 좋은 소식이 있습니다: 실전에서 만나는 명령의 대부분은 열 개 남짓입니다. 그리고 그 명령들이 움직이는 무대 — 레지스터와 스택 프레임 — 를 이해하면, Step 62에서 몸으로 겪은 버퍼 오버플로우의 "왜"가 전부 설명됩니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- x86-64 범용 레지스터의 이름과 용도(rax, rdi, rsp, rbp 등)를 안다
mov,push/pop,call/ret,cmp+분기,add/sub,xor,lea를 한 줄씩 해설한다- 호출 규약 — 인자는 rdi, rsi, rdx, rcx, r8, r9 순, 반환값은 rax — 을 어셈블리에서 확인한다
- 함수 서문(
push rbp; mov rbp, rsp; sub rsp, N)이 만드는 스택 프레임을 그림으로 그린다 - 소스 없는 어셈블리 10줄을 보고 "이 함수는 뭘 하나" 추론한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 어셈블리어(x86-64, 인텔 문법) + C + WSL bash (실측: gcc 13.3.0, gdb 15.1) |
| 오늘의 명령어 | gcc -S -masm=intel -O0 파일.c, gcc -g -O0 파일.c -o 파일, gdb b *함수, ni, info registers |
| 필요한 개념 | 범용 레지스터 16개, 명령어 10종, 호출 규약, 스택 프레임, 인텔 vs AT&T 문법 |
| 오늘의 산출물 | 명령어 카드 10장 + 스택 프레임 그림 + 어셈블리 해설 노트 |
2-1. 레지스터 지도 — 열여섯 개의 손바닥 상자
Step 64에서 레지스터를 "CPU의 손바닥 상자"로 만났습니다. 오늘은 전체 지도를 봅니다. x86-64의 범용 레지스터는 16개이고, 용도 약속이 있습니다:
| 레지스터 | 약속된 용도 |
|---|---|
rax |
반환값, 계산의 주무대 |
rdi rsi rdx rcx r8 r9 |
함수 인자 — 첫째부터 순서대로 |
rsp |
스택 꼭대기 포인터 (push/pop이 움직임) |
rbp |
현재 함수의 기준점 (스택 프레임의 앵커) |
rbx, r10~r15 |
임시 작업용 |
eax, edi처럼 e로 시작하는 이름은 같은 상자의 아래쪽 32비트입니다. int 하나 다루는 데 64비트는 과하니까 아랫부분만 쓰는 것 — 어셈블리에서 두 이름이 섞여 보이는 이유입니다.
2-2. 명령어 카드 — 열 개면 읽기 시작된다
| 명령 | 뜻 | 한 줄 예 |
|---|---|---|
mov a, b |
b를 a에 복사 | mov eax, 3 — eax에 3 |
lea a, [b] |
b의 주소(값이 아니라 위치)를 a에 | 문자열·배열의 주소 만들 때 |
push x |
스택에 x를 올림 (rsp가 8 감소) | push rbp |
pop x |
스택 맨 위를 x로 꺼냄 (rsp가 8 증가) | pop rbp |
call f |
돌아올 주소를 스택에 push하고 f로 점프 | call mix |
ret |
스택 맨 위 주소로 pop하며 점프 | 함수 끝 |
cmp a, b |
a−b를 계산해 결과 표식(플래그)만 남김 | cmp eax, 59 |
je/jne/jmp 등 |
플래그 보고 점프 / 무조건 점프 | jle .L2 |
add/sub |
덧셈 / 뺄셈 | sub rsp, 16 |
xor a, a |
자기 자신과 XOR = 0으로 초기화 관용구 | xor eax, eax |
call과 ret이 오늘의 주인공입니다. 둘은 단순한 점프가 아니라 스택을 이용한 왕복 티켓입니다 — 갈 때 돌아올 주소를 스택에 끼워 두고, 돌아올 때 그것을 꺼내 씁니다. 이 약속이 스택 프레임의 첫 층입니다.
2-3. 스택 프레임 — 함수 하나의 책상
함수가 불릴 때마다 스택에 그 함수만의 작업 공간이 차려집니다. 이것이 스택 프레임(stack frame)입니다. 표준 배치:
높은 주소 ─┬─ 이전 함수의 프레임
├─ 리턴 주소 ← call이 push한 것
├─ 저장된 옛 rbp ← push rbp
현재 rbp → ┤
├─ 지역 변수들 ← sub rsp, N으로 확보
낮은 주소 └─ (rsp, 꼭대기)
세 줄의 서문 — push rbp(옛 기준점 저장), mov rbp, rsp(내 기준점 선언), sub rsp, N(책상 넓히기) — 이 이 구조를 만듭니다. Step 62에서 버퍼가 넘치면 "돌아갈 주소"가 덮였던 이유가 이 그림에 있습니다: 지역 변수(버퍼)의 바로 위층에 리턴 주소가 있기 때문입니다.
2-4. 인텔 vs AT&T — 방향 반대의 두 필기법
Step 64에서 만났던 것의 복습이자 정착입니다. 인텔 문법 mov eax, 3은 "3을 eax에", AT&T 문법 movl $3, %eax는 순서가 반대입니다. 오늘도 -masm=intel로 통일하지만, 실전 자료(특히 gdb 기본 출력)는 AT&T인 경우가 많습니다. %와 $가 보이면 "순서 반대" 경계등을 켜세요 — 오늘 실습에서 둘을 나란히 비교합니다.
3. 따라 하기
3-1. 실험 코드 — 인자 셋짜리 함수
인자가 셋인 함수를 만들어 호출 규약과 스택 프레임을 동시에 관찰합니다.
입력 (frame.c)
int mix(int a, int b, int c) {
int t = a + b;
int u = t * c;
return u;
}
int main(void) {
int r = mix(3, 4, 5);
return r;
}
컴파일 — 두 문법으로 각각 뽑기
gcc -S -masm=intel -O0 frame.c -o frame_intel.s
gcc -S -O0 frame.c -o frame_att.s
3-2. 인텔 문법으로 읽기 — mix 해부
grep -v -e "^\." -e cfi frame_intel.s
mix:
endbr64
push rbp
mov rbp, rsp
mov DWORD PTR -20[rbp], edi
mov DWORD PTR -24[rbp], esi
mov DWORD PTR -28[rbp], edx
mov edx, DWORD PTR -20[rbp]
mov eax, DWORD PTR -24[rbp]
add eax, edx
mov DWORD PTR -8[rbp], eax
mov eax, DWORD PTR -8[rbp]
imul eax, DWORD PTR -28[rbp]
mov DWORD PTR -4[rbp], eax
mov eax, DWORD PTR -4[rbp]
pop rbp
ret
(2026-09-09 실측. .file 같은 조립 지시 줄은 생략했습니다.)
줄별 해설:
push rbp; mov rbp, rsp— 서문. 옛 기준점을 스택에 저장하고 내 기준점을 선언.mov -20[rbp], edi등 세 줄 — 레지스터로 도착한 인자 a, b, c를 스택의 내 책상에 내려놓기. rdi→a, rsi→b, rdx→c. 호출 규약의 실물입니다.mov두 줄 +add eax, edx— t = a + b. 결과는 eax, 그리고-8[rbp](변수 t)에 저장.imul eax, -28[rbp]— u = t × c. 결과를-4[rbp](변수 u)에.mov eax, -4[rbp]— 반환값을 rax(eax)에 싣기. "결과는 rax로 돌려준다" 약속.pop rbp; ret— 옛 기준점 복구, 스택 맨 위의 리턴 주소로 귀환.
예측:
-20[rbp],-24[rbp]처럼 rbp보다 낮은 주소를 쓰는 이유는? 2-3의 그림에서 지역 변수가 rbp 아래층이기 때문입니다. 그림에 a, b, c, t, u 다섯 칸을 직접 적어 보세요.
3-3. 같은 함수, AT&T 문법 — 방향 반대 읽기
sed -n "/^mix:/,/ret/p" frame_att.s | grep -v cfi
mix:
endbr64
pushq %rbp
movq %rsp, %rbp
movl %edi, -20(%rbp)
movl %esi, -24(%rbp)
movl %edx, -28(%rbp)
movl -20(%rbp), %edx
movl -24(%rbp), %eax
addl %edx, %eax
...
popq %rbp
ret
(2026-09-09 실측.)
출력 읽는 법: 같은 함수인데 모양이 다릅니다. movl %edi, -20(%rbp)는 "edi를 -20(%rbp)에" — 출발이 왼쪽, 도착이 오른쪽. 인텔과 정확히 반대입니다. 레지스터의 %, 상수의 $, 명령 끝의 길이 표시(l=32비트, q=64비트)가 AT&T의 신호입니다. gdb의 기본 출력이 이 문법이라, 내일 Step 183부터는 변환기를 켜는 법을 배웁니다.
3-4. 호출하는 쪽 — main에서 약속 지키기
main:
endbr64
push rbp
mov rbp, rsp
sub rsp, 16
mov edx, 5
mov esi, 4
mov edi, 3
call mix
mov DWORD PTR -4[rbp], eax
mov eax, DWORD PTR -4[rbp]
leave
ret
(2026-09-09 실측, 같은 frame_intel.s의 main 부분.)
출력 읽는 법: call mix 직전 세 줄 — 인자 셋이 edx(셋째), esi(둘째), edi(첫째)에 역순처럼 보이게 실립니다(순서는 컴파일러 재량, 규약은 레지스터 배정만 강제). call 다음엔 돌아오자마자 eax에서 결과를 꺼냅니다. 그리고 main의 서문에는 sub rsp, 16이 있습니다 — 변수 r을 위한 책상 넓히기입니다. leave는 "rbp를 rsp에 복사 후 pop rbp" — 서문의 정확한 역순, 즉 책상 접기입니다.
3-5. gdb로 스택 프레임이 생기는 순간 관찰
이번엔 그림이 아니라 실행 중인 실물을 봅니다. 디버그 정보를 담아 컴파일하고, mix의 진입점에서 멈춥니다:
gcc -g -O0 frame.c -o frame
gdb -batch -x watch.gdb ./frame
입력 (watch.gdb — 명령 모음 파일)
set disassembly-flavor intel
b *mix
run
info registers rsp rbp
x/1gx $rsp
ni
ni
info registers rsp rbp
x/2gx $rsp
ni
info registers rsp rbp
출력 (발췌, 2026-09-09 실측 — 주소는 실행마다 달라집니다)
Breakpoint 1, mix (a=0, b=0, c=0) at frame.c:1
=== 진입 직후 (push rbp 전) ===
rsp 0x7fffffffe668 0x7fffffffe668
rbp 0x7fffffffe680 0x7fffffffe680
0x7fffffffe668: 0x0000555555555174 ← 스택 맨 위 = 리턴 주소
=== push rbp 한 줄 지남 ===
rsp 0x7fffffffe660 ← 8 줄었다
rbp 0x7fffffffe680
0x7fffffffe660: 0x00007fffffffe680 0x0000555555555174
↑ 옛 rbp가 저장됨 ↑ 그 위에 리턴 주소
=== mov rbp, rsp 한 줄 지남 ===
rsp 0x7fffffffe660
rbp 0x7fffffffe660 ← rbp = rsp: 내 기준점 선언 완료
출력 읽는 법: b *mix는 함수의 첫 기계어 명령에 거는 브레이크포인트입니다. ① 진입 직후 스택 맨 위에는 이미 리턴 주소가 있습니다 — call이 push해 둔 것(0x…5174는 main에서 call 다음 명령의 주소). ② push rbp 한 줄에 rsp가 8 감소하고 옛 rbp(0x…e680)가 새 맨 위에 저장됐습니다. ③ mov rbp, rsp 후 rbp=rsp — 여기서부터 -20[rbp] 같은 표기의 기준점이 확정됩니다. 2-3의 그림이 숫자로 확인됐습니다.
3-6. 소스 없는 10줄 추론 연습
마지막으로, 출처를 가린 어셈블리를 읽어 봅시다:
func:
push rbp
mov rbp, rsp
mov DWORD PTR -4[rbp], edi
mov eax, DWORD PTR -4[rbp]
add eax, DWORD PTR -4[rbp]
pop rbp
ret
추론 과정: 인자 하나(edi)를 받고 → 그 값을 eax에 싣고 → 같은 값을 한 번 더 더함 → eax에 두고 ret. 이 함수는 "입력을 두 배 하는 함수"입니다. imul eax, 2로 번역될 수도 있었지만 -O0의 컴파일러는 add를 택했습니다. 이런 추론 — 레지스터 추적 + 연산 읽기 — 이 Reversing 문제의 매 판 첫 수입니다.
4. 미션과 연습문제
미션 — 명령어 카드 10장 + 스택 프레임 그림
- 2-2의 명령어 10개에 대해 카드를 만든다 — 앞면: 명령, 뒷면: 한 줄 뜻 + 오늘 실습에서 본 실제 사용 예 한 줄
- 3-5의 실측 값을 이용해 mix 함수의 스택 프레임을 그림으로 그린다 — 리턴 주소, 옛 rbp, 지역 변수 a·b·c·t·u의 칸과 각 주소(-20[rbp] 등)를 표시
- 3-6 스타일로, 여러분이 직접 쓴 간단한 함수(세 수의 평균 등)를
gcc -S로 뽑아 소스를 가린 뒤, 어셈블리만 보고 "무슨 함수인지" 추론하는 연습을 한다
연습문제
문제 1. call 명령이 일반 점프(jmp)와 다른 점을 "스택"을 키워드로 설명하세요.
문제 2. push rbp 한 줄에서 일어나는 두 가지 일(rsp의 변화, 메모리의 변화)을 말하세요.
문제 3. 인자가 다섯 개인 함수 int f(a,b,c,d,e)를 부를 때, 각 인자가 실리는 레지스터를 순서대로 쓰세요.
문제 4. xor eax, eax는 어느 관용구이며, mov eax, 0 대신 자주 쓰이는 이유를 추측해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
스택 프레임 그림의 예 (3-5 실측 주소 사용):
0x...e680 ── 옛 rbp(호출자의 기준점) ← 실제로는 call 이전 프레임
0x...e668 ── 리턴 주소 0x...5174 ← call이 push
0x...e660 ── 저장된 옛 rbp = 0x...e680 ← push rbp ┐ mix의 프레임
(rbp = rsp = 0x...e660) │ 기준점 확정
-4[rbp] ── u ← imul의 결과 │
-8[rbp] ── t ← add의 결과 │
-20[rbp] ── a(edi에서) -24[rbp] ── b -28[rbp] ── c ┘
카드 뒷면의 예: "call — 돌아올 주소를 스택에 push하고 점프. 예: main에서 call mix → 스택에 0x…5174가 올라감(3-5 실측)."
검증하는 법: ① 카드 열 장 전부에 "오늘 실습의 예"가 붙었는가(사전 정의만 베낀 카드는 미완). ② 그림에 리턴 주소가 옛 rbp보다 위(높은 주소)에 있는가. ③ 추론 연습에서 정답을 보기 전에 예측을 적었는가.
연습문제 해답
문제 1 해답. jmp는 가기만 하고 돌아올 길을 남기지 않습니다. call은 점프하기 전에 다음 명령의 주소(리턴 주소)를 스택에 push합니다. 그래서 불린 함수가 ret로 그 주소를 pop하며 정확히 돌아올 수 있습니다. 3-5에서 진입 직후 스택 맨 위에 이미 리턴 주소가 있던 것이 이 증거입니다.
문제 2 해답. ① rsp가 8 감소합니다(스택은 낮은 주소로 자람). ② 새 rsp 자리에 옛 rbp 값이 저장됩니다. 실측으로는 0x…e668 → 0x…e660으로 줄고, 0x…e660에 0x…e680이 기록됐습니다.
문제 3 해답. rdi, rsi, rdx, rcx, r8, r9 순입니다. (일곱 개째부터는 스택으로 넘어갑니다 — 그 얘기는 심화 단계에서.)
문제 4 해답. "레지스터를 0으로 초기화"하는 관용구입니다. 자기 자신과 XOR하면 항상 0이 됩니다. mov eax, 0보다 기계어가 짧고(바이트 수가 적음) 빠른 경우가 많아 컴파일러가 즐겨 씁니다. 어셈블리를 읽다 만나면 "0 넣기구나"로 해석하면 됩니다.
완료 기준 체크리스트
- [ ] 범용 레지스터 16개 중 rax, rdi~r9, rsp, rbp의 용도를 안다
- [ ] 명령어 10종(mov, lea, push, pop, call, ret, cmp, jcc, add/sub, xor)을 해설한다
- [ ] 인자 6개 레지스터 순서와 반환값 rax 규약을 말할 수 있다
- [ ] 인텔 문법과 AT&T 문법의 방향 차이를 설명한다
- [ ] 서문 세 줄이 스택 프레임을 만드는 과정을 그림으로 그린다
- [ ] gdb에서 push rbp 전후의 rsp 변화와 스택 내용을 실측 확인했다
- [ ] 소스 없는 어셈블리 10줄을 추론해 봤다
- [ ] 미션: 명령어 카드 10장과 스택 프레임 그림을 완성했다
6. 흔한 실수와 해결
벽 1. AT&T 출력을 인텔 순서로 읽어 반대로 이해한다
증상: movl %edi, -20(%rbp)를 "메모리 값을 edi에"로 읽습니다.
원인: AT&T는 출발→도착 순서라 인텔과 반대입니다. 3-3에서 같은 함수의 두 문법을 비교해 보세요.
해결: %와 $가 보이면 AT&T입니다. gdb에서는 set disassembly-flavor intel로 인텔 문법으로 바꿀 수 있습니다 — 3-5의 watch.gdb 첫 줄이 그것입니다.
벽 2. [rbp-4]와 -4[rbp]가 달라 보여 혼란스럽다
증상: gcc -S 출력과 objdump/gdb 출력의 표기가 다릅니다.
원인: 표기 차이일 뿐 뜻은 같습니다 — "rbp에서 4바이트 아래".
해결: 대괄호 안이냐 밖이냐, 10진수냐 16진수냐만 다릅니다. "누구 기준으로 얼마나 떨어진 곳인가"만 읽으세요.
벽 3. b *mix와 b mix의 차이를 모른다
증상: b mix로 걸었더니 서문이 이미 끝난 지점에 멈춥니다.
원인: b mix는 소스의 함수 첫 줄에 걸리고, 디버그 정보가 서문 뒤로 잡아 줍니다. 별표(*)는 함수의 첫 기계어 명령 주소에 겁니다.
해결: 서문 자체를 관찰할 때는 b *함수명 — 오늘 3-5가 그 경우입니다. 변수만 볼 때는 b 함수명이 편합니다.
벽 4. 주소가 책과 다르다고 당황한다
증상: 3-5의 0x7fffffffe660 같은 주소가 내 환경에서 다릅니다.
원인: 스택 주소는 실행할 때마다 운영체제가 배정합니다(ASLR). 다르면 정상입니다.
해결: 절대값이 아니라 관계를 보세요 — rsp가 8 줄었는가, rbp=rsp가 됐는가, 스택 맨 위에 리턴 주소가 있는가. 관계는 언제나 같습니다.
벽 5. 서문·지시 줄을 다 외우려다 지친다
증상: endbr64, .cfi 같은 줄을 파다가 진도가 멈춥니다.
원인: 오늘의 목표는 완역이 아니라 뼈대 읽기입니다.
해결: endbr64는 분기 보호 표지, .cfi는 디버깅용 안내 줄 — 둘 다 지금은 건너뛰어도 됩니다. 읽는 순서는 "인자 내리기(mov …edi) → 연산(add/imul) → 결과 싣기(eax) → pop/ret" 네 마디입니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 범용 레지스터 | CPU 안의 상자 16개 — 용도 약속이 있다 (rax=반환, rdi~=인자) |
| 호출 규약 | 인자는 rdi→rsi→rdx→rcx→r8→r9, 반환값은 rax |
| 스택 프레임 | 함수 하나의 책상 — 리턴 주소 + 옛 rbp + 지역 변수 |
| 서문(prologue) | push rbp; mov rbp,rsp; sub rsp,N — 책상 차리기 |
| call/ret | 리턴 주소를 스택에 push/pop하는 왕복 티켓 |
| xor 관용구 | xor eax, eax = 0으로 초기화 |
| 인텔 vs AT&T | 도착/출발 순서가 반대인 두 필기법 |
오늘의 명령어
| 명령 | 하는 일 |
|---|---|
gcc -S -masm=intel -O0 파일.c |
인텔 문법 어셈블리 뽑기 |
gcc -S -O0 파일.c |
AT&T 문법(기본) 어셈블리 뽑기 |
gcc -g -O0 파일.c -o 파일 |
디버그 정보 포함 컴파일 |
gdb b *함수 |
함수의 첫 기계어 명령에 브레이크 |
gdb ni |
기계어 한 줄 실행 |
gdb info registers rsp rbp |
레지스터 값 보기 |
gdb x/2gx $rsp |
스택 맨 위 8바이트×2개 보기 |
명령어보다 중요한 감각
오늘 실측에서 확인한 것은 결국 하나입니다 — "함수 호출"이라는 추상 개념이, 스택 위에 주소를 쌓고 기준점을 옮기는 물리적인 절차라는 것. push 한 줄에 rsp가 8 줄고 옛 값이 기록되는 모습을 본 눈은, 이후 버퍼 오버플로우의 공격 원리를 "그림의 어느 칸이 덮이는가"로 즉시 이해하게 됩니다.
오늘 그린 스택 프레임 그림은 버리지 마세요. 실행 중인 프로그램을 얼려서 이 무대를 들여다보는 도구를 다룰 때, 그 그림이 지도가 됩니다.
전부 체크되면 Step 182 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.