Step 184. 어셈블리 3: C 프로그램을 gdb로 한 줄씩 따라가기 — 호출 규약의 실황 중계

Step 184. 어셈블리 3: C 프로그램을 gdb로 한 줄씩 따라가기 — 호출 규약의 실황 중계

Level 3 — Pwn 트랙 | 난이도 ★★★★☆ | 예상 소요 시간 4시간

전제: Step 182~183을 마쳤다. 레지스터와 기본 어셈블리 명령(mov, push, call, ret)을 알고, gdb에서 브레이크포인트를 걸고 레지스터를 볼 수 있다.

⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.

  • 준비물: WSL 우분투 터미널, gcc, gdb. 실측 환경은 Ubuntu 24.04, gcc 13.3.0, gdb 15.1, x86-64입니다.
  • 주의: 오늘 다루는 프로그램은 전부 여러분이 직접 쓴 세 스크린 줄짜리 C 코드입니다. 디버거로 내 프로그램을 해부하는 일이니 안전합니다. 다만 오늘 배우는 "호출 규약의 실제"는 이후 챕터에서 공격 기법의 뼈대가 되는 지식입니다.

Step 65에서 "인자는 rdi·rsi로, 결과는 rax로"라는 약속을 배웠고, Step 183에서 gdb의 기본 조작을 익혔습니다. 오늘은 그 약속이 살아 움직이는 현장을 한 줄씩 따라갑니다. call 명령 하나가 실행되는 순간 rip가 어디로 점프하고 스택 맨 위에 무엇이 놓이는지, 함수가 끝날 때 rax에 무엇이 담기는지 — 이 흐름을 눈으로 한 번 본 사람은 어셈블리가 다르게 읽힙니다. 그리고 이 장면들은 곧 여러분이 스택을 공격하고 방어할 때 매번 떠올리게 될 그림입니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • gcc -S로 만든 어셈블리 소스와 gdb의 disas 출력을 대조해 읽는다
  • call 명령 한 걸음이 rip와 스택에 무슨 일을 하는지 설명한다
  • 함수 진입 직후 rdi·rsi·rdx에 실린 인자를 gdb로 확인한다
  • 스택 맨 위에 놓인 리턴 주소가 "call 다음 명령의 주소"임을 대조해 증명한다
  • ni로 진행하며 rax에 반환값이 담기는 순간을 포착하고 finish로 돌아온다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 C 언어, WSL 우분투 bash, gcc 13.3.0, gdb 15.1 (x86-64)
오늘의 명령어·옵션 gcc -g -O0 -no-pie(고정 주소 디버깅용 컴파일), gcc -S(어셈블리 소스 생성), gdb 안에서 disas(어셈블리 보기), ni(기계어 한 명령, 함수 호출은 건너뜀), si(한 명령, 호출 안으로 진입), finish(함수 끝까지), x/2gx $rsp(스택 8바이트짜리 두 칸 보기), info symbol 주소(주소의 이름표)
필요한 개념 호출 규약, 리턴 주소, 레지스터(rdi·rsi·rdx·rax·rip·rsp·rbp), 명령어 포인터의 행진

2-1. 호출 규약 — 함수 사이의 배달 약속

호출 규약(calling convention)은 함수끼리 값을 주고받는 약속입니다. x86-64 리눅스의 약속은 이렇습니다. 인자는 앞에서부터 rdi, rsi, rdx, rcx, r8, r9 순으로 레지스터에 싣고(일곱 번째부터는 스택), 반환값은 rax에 담아 돌아옵니다.

Step 65에서 표로 배운 이 약속을, 오늘은 살아 있는 실행에서 검증합니다. 약속은 문서가 아니라 눈으로 확인해야 내 것이 됩니다.

2-2. call과 ret — 이정표를 점프시키는 한 쌍

call은 두 가지 일을 한꺼번에 합니다. ① "돌아올 주소"(= call 바로 다음 명령의 주소, 즉 리턴 주소(return address))를 스택 맨 위에 밀어 넣고(push), ② rip를 함수의 시작 주소로 점프시킵니다.

ret은 그 반대입니다. 스택 맨 위의 값을 꺼내(pop) rip에 넣습니다. 그래서 함수는 call을 건 자리의 다음 명령으로 정확히 돌아갑니다.

오늘의 핵심 문장: call이 스택에 남긴 리턴 주소 = call 다음 명령의 주소. 이것을 gdb로 대조하는 것이 오늘 실습의 정점입니다.

2-3. ni와 si — 건너뛰기와 들어가기

둘 다 "한 명령 진행"이지만 call을 만났을 때 갈립니다. ni(next instruction)는 call을 한 걸음으로 치고 함수가 끝난 뒤로 건너뜁니다. si(step instruction)는 함수 안으로 따라 들어갑니다.

오늘은 둘을 번갈아 씁니다. call 직전까지는 ni로 빠르게 가고, call 그 한 걸음만큼은 si로 들어가 무슨 일이 벌어지는지 봅니다.

2-4. -no-pie — 주소를 고정해 관찰하기 쉽게

기본 컴파일된 바이너리는 실행할 때마다 코드 주소가 바뀝니다(이 장치가 PIE — Step 187에서 정식으로 만납니다). 관찰 실습에서는 주소가 매번 바뀌면 책의 숫자와 비교하기 어려우니, 오늘은 -no-pie로 코드 주소를 0x40... 대역에 고정합니다.

실험실에서 측정을 위해 조건을 고정하는 것이지, 이 스위치가 공격 기법이라는 뜻이 아닙니다. 이름만 기억해 두면 Step 186~187에서 다시 만납니다.


3. 따라 하기

3-1. 실험용 프로그램 — 세 인자를 받는 add

입력 (follow.c)

#include <stdio.h>

int add(int a, int b, int c) {
    int s = a + b + c;
    return s;
}

int main(void) {
    int x = 10;
    int y = 20;
    int z = 30;
    int total = add(x, y, z);
    printf("total = %d\n", total);
    return 0;
}

컴파일과 어셈블리 소스 생성

gcc -g -O0 -no-pie follow.c -o follow
gcc -S -O0 -no-pie follow.c -o follow.s

읽는 법: -g는 디버깅 정보, -O0은 최적화 끄기(최적화를 켜면 add가 인라인으로 녹아 관찰할 대상이 사라집니다), -no-pie는 주소 고정. gcc -S는 컴파일 중간 산출물인 어셈블리 소스를 파일로 남깁니다.

3-2. 두 개의 어셈블리 — gcc -S와 gdb disas 대조

먼저 gcc -S가 만든 follow.s에서 main의 핵심부를 봅니다 (2026-09-09 실측, .cfi로 시작하는 줄은 디버깅용 부호라 생략했습니다):

main:
	pushq	%rbp
	movq	%rsp, %rbp
	subq	$16, %rsp
	movl	$10, -16(%rbp)
	movl	$20, -12(%rbp)
	movl	$30, -8(%rbp)
	movl	-8(%rbp), %edx
	movl	-12(%rbp), %ecx
	movl	-16(%rbp), %eax
	movl	%ecx, %esi
	movl	%eax, %edi
	call	add
	movl	%eax, -4(%rbp)
	...

같은 함수를 gdb에서 봅니다.

gdb -q ./follow
(gdb) disas main
Dump of assembler code for function main:
   0x000000000040115c <+0>:	endbr64
   0x0000000000401160 <+4>:	push   %rbp
   0x0000000000401161 <+5>:	mov    %rsp,%rbp
   0x0000000000401164 <+8>:	sub    $0x10,%rsp
   0x0000000000401168 <+12>:	movl   $0xa,-0x10(%rbp)
   0x000000000040116f <+19>:	movl   $0x14,-0xc(%rbp)
   0x0000000000401176 <+26>:	movl   $0x1e,-0x8(%rbp)
   0x000000000040117d <+33>:	mov    -0x8(%rbp),%edx
   0x0000000000401180 <+36>:	mov    -0xc(%rbp),%ecx
   0x0000000000401183 <+39>:	mov    -0x10(%rbp),%eax
   0x0000000000401186 <+42>:	mov    %ecx,%esi
   0x0000000000401188 <+44>:	mov    %eax,%edi
   0x000000000040118a <+46>:	call   0x401136 <add>
   0x000000000040118f <+51>:	mov    %eax,-0x4(%rbp)
   ...

(2026-09-09 실측. -no-pie로 컴파일했기에 주소가 0x40...으로 고정되어 있습니다.)

출력 읽는 법: 두 어셈블리는 같은 코드의 두 표기입니다. gcc -S는 AT&T 표기를 소스 파일 문법으로, gdb의 disas는 메모리 주소와 <+오프셋>을 붙여 보여 줍니다. 가운데 부분을 겹쳐 읽으면 이야기가 보입니다.

  • movl $10, -16(%rbp) — 10(0xa)을 스택의 -16(%rbp) 자리에. 지역 변수 x의 탄생입니다.
  • movl -8(%rbp), %edx / movl %ecx, %esi / movl %eax, %edi — z, y, x를 꺼내 rdx, rsi, rdi에 싣습니다. 인자 배달 준비입니다. 셋째 인자가 rdx에 간 것에 주목하세요.
  • call 0x401136 <add> — main+46에서 호출. 그리고 그 다음 명령이 main+51(0x40118f) 입니다. 이 주소를 기억하세요. 곧 스택에서 다시 만납니다.
  • mov %eax,-0x4(%rbp) — 돌아온 뒤 eax(반환값)를 total에 저장합니다.

예측: call이 실행되는 순간 스택 맨 위에는 어떤 값이 놓일까요? 2-2의 문장을 믿는다면 답은 정해져 있습니다. 3-4에서 검증합니다.

3-3. 함수 진입 — 인자 배달 확인

(gdb) b add
Breakpoint 1 at 0x401147: file follow.c, line 4.
(gdb) r
Starting program: .../follow

Breakpoint 1, add (a=10, b=20, c=30) at follow.c:4
4	    int s = a + b + c;
(gdb) info registers rdi rsi rdx
rdi            0xa                 10
rsi            0x14                20
rdx            0x1e                30

(2026-09-09 실측.)

출력 읽는 법: 약속대로입니다. 첫 인자 10(0xa)이 rdi, 둘째 20(0x14)이 rsi, 셋째 30(0x1e)이 rdx에 실려 도착했습니다. gdb는 16진수 옆에 십진수도 함께 보여 주니 16진수가 아직 낯설어도 괜찮습니다. b add 한 줄이 준 정보(a=10, b=20, c=30)와 레지스터의 내용이 일치하는 것 — 이 대조가 "규약의 검증"입니다.

3-4. 스택 맨 위의 비밀 — 리턴 주소 대조

add에 멈춰 있는 그 자리에서 스택 맨 위를 들여다봅니다.

(gdb) x/2gx $rsp
0x7fffffffe660:	0x00007fffffffe680	0x000000000040118f

(2026-09-09 실측. 0x7fff...로 시작하는 스택 주소는 실행할 때마다 달라집니다.)

출력 읽는 법: x/2gx $rsp는 "rsp가 가리키는 곳부터 8바이트(giant)짜리 두 칸을 16진수(x)로"입니다. 첫 칸(0x7fffffffe660)에는 지금 막 쌓인 것 — 0x40118f 가 있습니다. 3-2의 disas로 돌아가 보세요. 0x40118f는 call 바로 다음 명령인 main+51입니다.

call이 스택에 남긴 리턴 주소 = call 다음 명령의 주소. 예측이 사실로 확인되는 순간입니다. 함수가 ret할 때 rip가 갈 곳이 지금 스택 맨 위에 적혀 있는 것입니다. 이 자리가 나중에 공격의 표적이 됩니다.

3-5. call 한 걸음의 내부 — si로 들어가기

이번에는 call 명령 그 자체를 현미경으로 봅니다. main을 어드레스로 직접 찍어 멈춥니다.

(gdb) b *0x40118a
Breakpoint 1 at 0x40118a: file follow.c, line 12.
(gdb) r
Starting program: .../follow

Breakpoint 1, 0x000000000040118a in main () at follow.c:12
12	    int total = add(x, y, z);
(gdb) x/i $rip
=> 0x40118a <main+46>:	call   0x401136 <add>
(gdb) si
add (a=0, b=0, c=0) at follow.c:3
3	int add(int a, int b, int c) {
(gdb) info registers rip rsp
rip            0x401136            0x401136 <add>
rsp            0x7fffffffe668      0x7fffffffe668
(gdb) x/1gx $rsp
0x7fffffffe668:	0x000000000040118f

(2026-09-09 실측. b *0x40118a의 별표는 "이름이 아니라 메모리 주소로 멈춰라"라는 뜻입니다. 주소는 여러분의 disas 결과를 보고 맞추세요.)

출력 읽는 법: x/i $rip는 "rip가 가리키는 명령 하나를 보여 줘" — 지금 실행될 명령이 call임을 확인했습니다. si 한 번. 그 한 걸음에 세 가지가 동시에 일어났습니다.

  1. rip가 main+46에서 0x401136 — add의 시작 주소 로 점프했습니다.
  2. rsp가 8 줄었습니다(스택은 낮은 주소 쪽으로 자랍니다 — 8바이트 자리가 새로 생겼습니다).
  3. 그 새 자리(스택 맨 위)에 0x40118f — 리턴 주소가 놓였습니다.

call = "리턴 주소 push + rip 점프". 두 동작이 한 명령어에 압축된 것을 방금 눈으로 봤습니다. 참고로 si 직후 gdb가 보여 준 add (a=0, b=0, c=0)는 아직 인자가 스택에 복사되기 전이라 gdb가 값을 못 읽는 것이니 신경 쓰지 않아도 됩니다 — 레지스터의 진짜 인자는 3-3에서 이미 확인했습니다.

3-6. 반환의 순간 — rax에 담기는 합계

이제 add 안에서 ni로 한 줄씩 걸으며 덧셈이 진행되는 것을 따라가 봅니다. 먼저 add의 어셈블리 지도를 받습니다.

(gdb) disas add
Dump of assembler code for function add:
   0x0000000000401136 <+0>:	endbr64
   0x000000000040113a <+4>:	push   %rbp
   0x000000000040113b <+5>:	mov    %rsp,%rbp
   0x000000000040113e <+8>:	mov    %edi,-0x14(%rbp)
   0x0000000000401141 <+11>:	mov    %esi,-0x18(%rbp)
   0x0000000000401144 <+14>:	mov    %edx,-0x1c(%rbp)
=> 0x0000000000401147 <+17>:	mov    -0x14(%rbp),%edx
   0x000000000040114a <+20>:	mov    -0x18(%rbp),%eax
   0x000000000040114d <+23>:	add    %eax,%edx
   0x000000000040114f <+25>:	mov    -0x1c(%rbp),%eax
   0x0000000000401152 <+28>:	add    %edx,%eax
   0x0000000000401154 <+30>:	mov    %eax,-0x4(%rbp)
   0x0000000000401157 <+33>:	mov    -0x4(%rbp),%eax
   0x000000000040115a <+36>:	pop    %rbp
   0x000000000040115b <+37>:	ret

(2026-09-09 실측. => 화살표는 "지금 멈춰 있는 명령"입니다.)

출력 읽는 법: +8~+14에서 레지스터의 인자를 스택의 지역 자리로 내리고, +17~+28에서 더하고, +33에서 최종 결과를 rax 로 옮깁니다. b add로 멈춘 뒤 ni를 일곱 번 눌러 봅시다.

(gdb) ni        (×7 — 한 번에 한 명령씩)
...
(gdb) info registers rax
rax            0x3c                60
(gdb) finish
Run till exit from #0  add (a=10, b=20, c=30) at follow.c:6
0x000000000040118f in main () at follow.c:12
12	    int total = add(x, y, z);
Value returned is $1 = 60

(2026-09-09 실측.)

출력 읽는 법: 두 가지가 검증됐습니다. ① rax에 0x3c — 16진수로 60. 10 + 20 + 30의 결과가 약속대로 rax에 담겼습니다. ② finish가 함수를 끝까지 달려 멈춘 곳이 0x40118f, main의 12번째 줄 — 3-4에서 스택 맨 위에 적혀 있던 바로 그 주소입니다. 스택에 적힌 리턴 주소로 ret이 점프했기에 정확히 저기로 돌아온 것입니다.

가고 오는 길 전체가 숫자로 이어졌습니다. 인자는 레지스터로 가고, 돌아갈 주소는 스택에 남고, 결과는 rax로 돌아오고, 흐름은 스택의 주소를 따라 되돌아온다 — 오늘의 한 줄 요약입니다.

복습: 0x3c를 십진수로 암산해 보세요. 3×16 + 12 = 60. 막히면 python3 -c "print(0x3c)"가 계산기입니다.


4. 미션과 연습문제

미션 — 호출 추적 리포트

follow.c를 변형해 int sub3(int a, int b, int c) { return a - b - c; } 함수를 추가하고 main에서 add(x, y, z) 다음에 sub3(x, y, z)도 호출하게 만드세요. 그런 다음:

  1. gcc -g -O0 -no-pie로 컴파일하고 disas main에서 add를 call하는 명령의 주소와 "그 다음 명령의 주소"를 적는다
  2. gdb로 add와 sub3 진입 직후의 rdi·rsi·rdx를 각각 기록한다 (16진수와 십진수 함께)
  3. add 안에서 x/1gx $rsp로 스택 맨 위의 값을 적고, 1번에서 적은 "call 다음 주소"와 일치하는지 대조한다
  4. sub3 안에서도 같은 대조를 한다 (sub3을 call한 자리의 다음 주소가 sub3의 스택 맨 위에 있는가)
  5. 두 함수 각각에서 finish 직후 info registers rax로 반환값을 기록하고 십진수로 검산한다
  6. 위 관찰을 "가는 길(인자·리턴 주소) / 돌아오는 길(rax·ret)" 두 덩어리로 나눠 그림 한 장으로 정리한다

연습문제

문제 1. call 명령 하나가 실행될 때 일어나는 두 가지 일을 순서대로 말해 보세요.

문제 2. 3-4에서 add 진입 직후 스택 맨 위에는 0x40118f가 있었고, disas main에서 그 주소는 call 바로 다음 명령이었습니다. 이 둘의 관계를 한 문장으로 설명해 보세요.

문제 3. 3-6에서 finish가 멈춘 곳은 0x40118f였습니다. 왜 add의 끝에서 멈추지 않고 main의 한가운데에서 멈췄나요?

문제 4. nisi는 call을 만났을 때 어떻게 다르게 동작하나요? call의 내부를 관찰하려면 어느 쪽을 써야 하나요?


5. 모범 답안과 완료 기준

미션 모범 답안

추적 리포트의 예 (2026-09-09, Ubuntu 24.04, gcc 13.3.0, -g -O0 -no-pie 기준):

[지도] disas main: call add → 0x40118a <main+46>, 다음 명령 0x40118f <main+51>
       call sub3 → (주소는 소스에 따라 다름, 아래 대조용으로 기록)

[add 진입]   rdi=0xa(10) rsi=0x14(20) rdx=0x1e(30)
[add 스택]   x/1gx $rsp → 0x40118f  ← call 다음 주소와 일치 ✔
[add 반환]   finish 후 rax=0x3c(60)  ← 10+20+30 ✔

[sub3 진입]  rdi=0xa(10) rsi=0x14(20) rdx=0x1e(30)
[sub3 스택]  x/1gx $rsp → sub3를 call한 명령의 "다음 주소"와 일치 ✔
[sub3 반환]  finish 후 rax=0xffffffe2(-30)  ← 10-20-30 ✔
             (음수는 2의 보수로 표시됨 — gdb가 십진수도 함께 보여 줌)

검증하는 법: ① 각 함수에서 "call 다음 주소 = 스택 맨 위 값" 대조가 두 번 다 성공해야 합니다. ② 반환값 검산이 C 코드의 계산과 일치해야 합니다. ③ 그림에 화살표 네 개(인자 전달, 리턴 주소 push, rax 반환, ret 점프)가 모두 있어야 합니다. 주소 숫자 자체는 환경마다 달라도 됩니다 — 관계의 일치가 채점 기준입니다.

연습문제 해답

문제 1 해답. ① 돌아올 주소(call 바로 다음 명령의 주소, 리턴 주소)를 스택 맨 위에 밀어 넣고(push), ② rip를 호출된 함수의 시작 주소로 점프시킵니다. 3-5에서 rsp가 8 줄고 스택 맨 위에 0x40118f가 놓이며 rip가 0x401136(add)로 바뀐 것이 그 실황입니다.

문제 2 해답. call이 스택에 남긴 리턴 주소는 항상 "call 다음 명령의 주소"입니다. 그래야 함수가 ret할 때 호출 지점의 바로 다음부터 실행을 이어갈 수 있기 때문입니다. 스택 맨 위의 0x40118f가 main+51(call 다음)과 정확히 같았던 것은 우연이 아니라 call 명령의 정의입니다.

문제 3 해답. finish는 "현재 함수가 ret까지 실행하고 호출자에게 돌아간 직후"에 멈추는 명령이기 때문입니다. add가 ret하면 rip는 스택에서 꺼낸 리턴 주소 0x40118f로 가고, 그곳은 main의 12번째 줄에 해당합니다. 즉 finish의 멈춤 위치 자체가 "ret이 리턴 주소로 점프했다"는 증거입니다.

문제 4 해답. ni는 call을 한 걸음으로 치고 함수가 끝난 뒤로 건너뛰고, si는 함수 안으로 따라 들어갑니다. call이 스택과 rip에 무슨 일을 하는지 보려면(3-5처럼) si를 써야 합니다.

완료 기준 체크리스트

  • [ ] gcc -S의 어셈블리 소스와 gdb disas 출력이 같은 코드의 두 표기임을 안다
  • [ ] call의 두 동작(리턴 주소 push, rip 점프)을 설명할 수 있다
  • [ ] 함수 진입 직후 rdi·rsi·rdx에서 인자를 확인했다
  • [ ] 스택 맨 위의 리턴 주소가 call 다음 명령 주소와 일치함을 대조로 증명했다
  • [ ] si로 call 한 걸음을 따라 들어가 rip 점프를 관찰했다
  • [ ] ni로 진행하며 rax에 반환값이 담기는 순간을 포착했다
  • [ ] finish의 멈춤 위치가 리턴 주소와 같음을 확인했다
  • [ ] 미션: 호출 추적 리포트(대조 기록 + 그림)를 완성했다

6. 흔한 실수와 해결

벽 1. 책의 주소와 내 화면의 주소가 다르다

증상: 책은 call이 0x40118a인데 내 화면은 다른 주소입니다.
원인: 소스가 조금만 달라도(공백, 변수 이름) 코드 길이가 바뀌어 주소가 밀립니다. -no-pie를 빼먹으면 주소가 0x55... 대역으로 갑니다.
해결: 주소를 외우지 말고 관계를 보세요. "call 다음 명령의 주소 = 스택 맨 위의 값"이라는 일치만 확인되면 성공입니다. 자기 환경의 disas 출력에서 주소를 읽어 그 숫자로 대조하세요.

벽 2. 변수 이름과 줄 번호가 안 나온다

증상: b add로 멈춰도 이렇게만 나옵니다:

Breakpoint 1, 0x0000000000401136 in add ()

원인: -g 없이 컴파일했습니다.
해결: gcc -g -O0 -no-pie로 다시 컴파일하세요. 최적화 옵션(-O2 등)이 붙어도 함수가 인라인되어 멈출 곳이 사라질 수 있으니 -O0도 함께 확인하세요.

벽 3. ni를 눌렀는데 add 안으로 못 들어간다

증상: call에서 ni를 눌렀더니 그대로 다음 줄로 가 버립니다.
원인: ni는 call을 "한 걸음"으로 치고 함수를 건너뜁니다. 설계가 그렇습니다.
해결: call의 내부를 볼 때는 si를 쓰세요(3-5). 반대로 printf 같은 라이브러리 함수의 내부로 빠지고 싶지 않을 때는 ni가 정답입니다. 목적에 따라 갈라 쓰는 도구입니다.

벽 4. x 명령의 /2gx가 헷갈린다

증상: x/2gx $rsp의 슬래시 뒤가 암호 같습니다.
원인: 세 글자가 각각 뜻이 있습니다. 2 = 두 칸, g = 한 칸 8바이트(giant), x = 16진수.
해결: x/1gx(한 칸), x/4wx(4바이트짜리 네 칸)처럼 바꿔 가며 출력이 어떻게 달라지는지 직접 실험해 보세요. 한 번 바꿔 보면 평생 기억합니다.

*벽 5. b 주소에서 "No symbol" 에러가 난다

증상: b *0x40118a를 쳤더니 주소를 못 알아듣습니다.
원인: 별표를 빼먹었거나, 책의 주소를 내 환경 확인 없이 그대로 쳤을 수 있습니다. 별표 없는 b 0x40118a는 "이름이 0x40118a인 함수"를 찾으려다 실패합니다.
해결: disas main으로 내 환경의 call 주소를 먼저 확인하고, b *그주소 형식으로 찍으세요.


7. 정리

오늘의 개념

개념 한 줄 설명
호출 규약 인자는 rdi·rsi·rdx 순, 반환값은 rax — 함수 사이의 배달 약속
리턴 주소 call이 스택에 남기는 "돌아올 곳" = call 다음 명령의 주소
call 리턴 주소 push + rip 점프, 두 동작의 압축
ret 스택 맨 위 값을 꺼내 rip로 점프 — 호출자에게 귀환
ni / si 함수 호출을 건너뛰는 한 걸음 / 따라 들어가는 한 걸음
-no-pie 코드 주소를 고정하는 컴파일 옵션 (관찰 실습용)

오늘의 명령어

명령 하는 일
gcc -g -O0 -no-pie 고정 주소 디버깅용 컴파일
gcc -S 소스.c 어셈블리 소스(.s) 생성
disas 이름 함수의 어셈블리 지도 보기
b *주소 메모리 주소로 직접 멈춤 지점 찍기
x/2gx $rsp 스택 맨 위 8바이트 두 칸 보기
x/i $rip 지금 실행될 명령 하나 보기
finish 함수 끝까지 달려 호출자에서 멈추기

명령어보다 중요한 감각

오늘 실습의 본질은 명령어가 아니라 한 장의 그림입니다. 함수를 부른다는 것은 "돌아갈 주소를 스택에 쪽지로 남기고 떠나는 것", 돌아온다는 것은 "그 쪽지를 보고 돌아오는 것". 이 쪽지 — 리턴 주소 — 가 스택이라는, 프로그램의 데이터가 지나가는 길 한가운데 놓여 있다는 사실을 기억하세요.

Step 62에서 우리는 넘친 입력이 이웃 변수를 덮는 것을 봤습니다. 만약 그 입력이 변수를 지나 이 쪽지까지 닿는다면? 그 상상이 곧 Step 185~186의 내용이고, 오늘 여러분은 그 폭발물의 뇌관이 어디 있는지를 눈으로 확인한 것입니다.


전부 체크되면 Step 184 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.