컴퓨터 시스템 : CSAPP 3장 정리 - 3.10 기계수준 프로그램에서 제어와 데이터의 결합
📦 3.10 머신 수준 프로그램에서 제어와 데이터 결합하기
Combining Control and Data in Machine-Level Programs
이 장에서는 제어 흐름(control flow)과 데이터 구조(data structures)가 머신 수준에서 어떻게 상호작용하며 동작하는지를 탐구한다. 지금까지는 이를 별도로 배웠지만, 실제 프로그램에서는 두 요소가 복잡하게 얽혀 있다.
주요 주제:
- 포인터(pointer)의 심화 이해 – C 언어에서 가장 강력하면서도 혼란스러운 개념 중 하나.
- gdb 디버거 사용법 – 머신 수준 프로그램의 실행 과정을 자세히 들여다보는 도구.
- 버퍼 오버플로우 – 보안 취약점의 주요 원인 중 하나.
- 스택 프레임의 크기가 실행마다 달라지는 경우의 구현 방식.
🧠 3.10.1 포인터 이해하기
Understanding Pointers
C 언어의 핵심이자 머신 수준에서도 매우 중요한 개념인 포인터(pointer)에 대해 깊이 있는 분석을 제공한다.
🧬 포인터는 '타입'을 가진다
int *ip; // int 값을 가리키는 포인터
char **cpp; // char 포인터를 가리키는 포인터
- 포인터의 타입은 역참조 시 어떤 크기와 해석을 해야 하는지를 결정한다.
- void *는 타입이 명확하지 않은 일반 포인터.
🧭 포인터는 '주소값'을 가진다
- 포인터의 실제 값은 메모리 주소이며, 그 주소에 있는 데이터를 참조할 수 있다.
- NULL 포인터는 어떤 데이터도 가리키지 않음을 의미.
🔧 주소 연산자: &
- 변수 등의 주소를 구할 때 사용.
- 컴파일된 어셈블리 코드에서는 보통 leaq 명령어로 구현됨.
int x = 10;
int *px = &x; // x의 주소를 저장
📥 역참조 연산자: *
- 포인터가 가리키는 주소에 있는 값을 읽거나 쓸 수 있다.
int y = *px; // x의 값을 y에 저장
*px = 20; // x의 값을 20으로 변경
🧮 배열과 포인터는 밀접한 관계
- a[i]는 *(a + i)로 해석된다.
- 배열 이름은 포인터처럼 동작.
int a[4] = {1, 2, 3, 4};
int *p = a;
printf("%d\n", *(p + 2)); // 3
🔀 포인터 캐스팅: 타입만 바뀐다
(char *)p + 7 // 바이트 단위 이동
(int *)(p + 7) // 주소는 p+7, int*로 해석
(int *)p + 7 // int 단위로 7개 건너뜀 ⇒ 바이트로는 p+28
- 포인터 산술 연산은 포인터 타입에 따라 이동 크기가 달라짐.
🧩 함수 포인터도 존재
int (*fp)(int, int *);
fp = fun;
int result = fp(3, &y);
- fp는 fun이라는 함수의 시작 주소를 값으로 가지는 포인터.
- 함수 이름 자체도 해당 함수의 시작 주소를 나타냄.
🧪 3.10.2 현실 세계에서: gdb 디버거 사용법
Life in the Real World: Using the gdb Debugger
gdb는 GNU에서 제공하는 강력한 디버깅 도구로, 머신 수준 프로그램의 실행을 분석하고 제어할 수 있게 해 준다. 책에서는 코드만 보고 프로그램의 동작을 추론하려 하지만, gdb를 사용하면 직접 실행을 관찰하면서 제어할 수 있다.
🧰 기본 실행 흐름
linux> gdb prog
- prog는 분석 대상 실행 파일.
- 보통은 분석하고자 하는 함수 진입점 근처나 특정 주소에 breakpoint를 설정한다.
- breakpoint가 걸리면 프로그램이 멈추고 사용자가 명령을 통해 레지스터 값, 메모리 상태 등을 조사할 수 있다.
- 단일 명령 실행(stepi) 또는 다음 breakpoint까지 실행(continue)도 가능.
🛠 주요 명령어 요약 (그림 3.39 기준)
▶ 실행 제어
명령어 | 설명 |
run | 프로그램 실행 |
kill | 프로그램 중단 |
quit | gdb 종료 |
🎯 브레이크포인트 설정
명령어 | 설명 |
break multstore | 함수 multstore 진입 지점에서 중단 |
break *0x400540 | 특정 주소에서 중단 |
delete | 브레이크포인트 삭제 |
⏯ 단일 실행
명령어 | 설명 |
stepi | 한 명령어 실행 |
stepi 4 | 4개 명령어 실행 |
nexti | 함수 진입은 생략하고 다음 명령으로 진행 |
continue | 다음 브레이크포인트까지 실행 |
finish | 현재 함수 반환 시점까지 실행 |
🔎 코드와 데이터 조사
어셈블리 코드 확인
- disas – 현재 함수 어셈블리 확인
- disas multstore – 특정 함수 어셈블리 확인
레지스터 및 메모리 확인
명령어 | 설명 |
info registers | 모든 레지스터 상태 |
print $rax | rax 값 출력 |
print *(long *) 0x주소 | 해당 주소에 있는 값 출력 |
기타 유용한 정보
- info frame – 현재 스택 프레임 정보
- help – 명령어 도움말
💡 팁: GUI를 선호한다면?
- ddd라는 도구는 gdb에 그래픽 인터페이스를 추가한 확장 도구로, 코드 라인별 디버깅, 변수 시각화 등을 지원.
🛡️ 3.10.3 경계를 벗어난 메모리 참조와 버퍼 오버플로우
Out-of-Bounds Memory References and Buffer Overflow
C 언어는 배열 참조에 대해 **경계 검사(bound checking)**를 하지 않기 때문에, 다음과 같은 문제가 발생할 수 있다:
- 스택에 저장된 지역 변수들이 상태 정보(예: 저장된 레지스터, 반환 주소 등)와 함께 존재.
- 배열의 경계를 벗어난 쓰기는 이 상태 정보를 손상시킬 수 있음.
- 이후 프로그램이 손상된 상태 정보로 레지스터를 복원하거나 ret 명령을 실행하면, 심각한 오류가 발생.
💣 버퍼 오버플로우(Buffer Overflow)
가장 흔한 형태의 스택 손상은 버퍼 오버플로우에서 발생한다.
📌 시나리오:
char buf[64];
gets(buf); // ← 여기서 문제 발생 가능
- gets()는 입력 길이를 제한하지 않기 때문에, 64바이트 이상의 문자열이 들어오면 스택 메모리를 침범함.
- 예제에서 buf 다음에 저장되어 있던 반환 주소(return address) 등이 덮여 쓸 수 있음.
- 이런 문제는 다음 그림으로 설명된다:
스택 프레임 구조 (echo 함수 예시)
[호출자 스택 프레임]
...
[buf = %rsp][6][5][4][3][2][1][0]
[저장된 상태: 반환 주소 등] ← 이 영역이 덮이면 문제 발생
🧨 악의적인 활용: Exploit Code Injection
공격자는 버퍼 오버플로우를 이용해 프로그램에 의도하지 않은 코드를 실행하게 만들 수 있다.
예: 공격 문자열에 실행 가능한 코드(exploit code)와 그 위치를 가리키는 주소를 포함.
ret 명령 실행 시, 프로그램은 공격자가 지정한 코드로 점프하게 됨.
사례:
1988년 인터넷 웜은 fingerd라는 데몬의 버퍼 오버플로우 취약점을 이용해 수많은 컴퓨터를 감염시킴.
🛡️ 3.10.4 버퍼 오버플로우 공격 차단하기
Thwarting Buffer Overflow Attacks
버퍼 오버플로우는 너무나도 널리 퍼져 있고 치명적인 보안 문제이기 때문에, 최신 컴파일러와 운영체제는 이를 자동으로 방지하는 다양한 메커니즘을 제공한다. 이 절에서는 Linux용 gcc에서 제공하는 주요 방어 기술을 소개한다.
🎲 스택 랜덤화 (Stack Randomization)
🧠 아이디어
- 공격자가 악성 코드 + 해당 코드의 주소를 함께 삽입해 시스템을 장악.
- 과거에는 스택 주소가 매우 예측 가능했기 때문에 한 번 만든 공격 문자열로 여러 시스템 공격 가능.
🔐 방어 전략
- 프로그램 실행마다 스택 위치를 무작위로 할당.
- alloca() 등을 이용해 0에서 n 바이트 사이를 랜덤하게 할당해 이후 스택 주소를 변화시킴.
- 이를 통해 모든 실행마다 주소가 달라짐 → 공격자 예측 어려워짐.
🕊️ 스택 보호자 (Stack Protector)
🧠 아이디어
- 지역 버퍼와 저장된 상태 사이에 무작위 canary 값을 삽입.
- 함수 종료 시 이 canary가 변조되었는지 확인하여 스택 손상 감지.
🔍 작동 방식
- canary 값을 movq %fs:40, %rax로 읽음.
- 스택에 저장.
- 함수 종료 시 xorq 명령으로 비교.
- 변조 시 __stack_chk_fail 호출 → 프로그램 종료
movq %fs:40, %rax // canary 읽기
movq %rax, 8(%rsp) // 스택에 저장
...
movq 8(%rsp), %rax // 다시 읽음
xorq %fs:40, %rax // 비교
je .L9 // 같으면 정상 종료
call __stack_chk_fail // 다르면 오류 종료
- 이 기능은 gcc가 자동으로 삽입하며, -fno-stack-protector 옵션으로 비활성화 가능.
🚫 실행 가능한 코드 제한 (Non-Executable Memory)
- 공격자가 악성 코드를 삽입해 실행하지 못하도록, 실행 가능한 메모리 구역을 제한함.
- 일반적으로 코드 섹션만 실행 가능하고, 스택이나 힙은 읽기/쓰기 전용.
- 운영체제의 가상 메모리 보호 기법으로 구현됨.
📐 3.10.5 가변 크기 스택 프레임 지원
Supporting Variable-Size Stack Frames
지금까지 본 대부분의 함수는 고정된 크기의 스택 프레임을 사용했다. 즉, 컴파일 시점에 필요한 저장 공간의 크기를 미리 알 수 있었다. 하지만, 어떤 함수는 실행 중 결정되는 크기의 지역 저장소를 필요로 한다.
📦 가변 크기 스택이 필요한 경우
- alloca() 함수를 호출하여 동적으로 스택에 저장 공간을 할당할 경우
- long *p[n]; 처럼 크기가 실행 중에 결정되는 지역 배열을 선언할 경우
이런 경우 컴파일러는 스택 프레임의 크기를 미리 결정할 수 없기 때문에 특별한 관리 방식이 필요하다.
🧭 해결 방법: 프레임 포인터 사용
- %rbp 레지스터를 **프레임 포인터(frame pointer)**로 사용
- 함수가 시작되면:
pushq %rbp ; 이전 프레임 포인터 저장
movq %rsp, %rbp ; 현재 스택 위치를 프레임 포인터로 설정
- 지역 변수는 %rbp 기준으로 오프셋으로 접근함 (ex: i는 -8(%rbp))
📋 예제 코드: 가변 배열 p[n] 사용
long vframe(long n, long idx, long *q) {
long i;
long *p[n];
p[0] = &i;
for (i = 1; i < n; i++)
p[i] = q;
return *p[idx];
}
📉 스택 구조 (그림 3.44 기준)
주소 ↓
[8n bytes] ← p 배열 저장 영역
[8 bytes] ← unused padding
[8 bytes] ← 변수 i
[8 bytes] ← 저장된 %rbp
[8 bytes] ← return address
🔁 배열 초기화 루프의 어셈블리 분석
- %rcx = p 배열의 시작 주소
- %rax = 반복 변수 i
- %rdx = 인자로 받은 포인터 q
movq %rdx, (%rcx,%rax,8) ; p[i] = q
addq $1, %rax
movq %rax, -8(%rbp) ; i 값을 스택에 저장
🧹 함수 종료 시 스택 프레임 정리
- leave 명령어는 다음을 수행:
-
assembly
movq %rbp, %rsp ; 스택 포인터를 프레임 시작으로 이동
popq %rbp ; 이전 프레임 포인터 복구
🧩 요약: 3.10 머신 수준에서의 제어와 데이터 결합
이 절에서는 제어 흐름(Control Flow)과 데이터 구조(Data Structures)가 머신 수준에서 어떻게 결합되어 작동하는지를 다룬다. 특히, 포인터, 디버깅, 버퍼 오버플로우, 보안, 동적 스택 프레임 등이 주요 주제이다.
🧠 포인터 이해하기
- 포인터는 타입과 주소값을 가진다.
- *와 & 연산자를 통해 값을 읽고 주소를 참조.
- 배열과 포인터는 긴밀히 연결되며, a[i] == *(a + i) 형태.
- 함수 포인터로 함수 자체도 포인터처럼 사용할 수 있음.
🧪 gdb 디버거 사용법
- gdb는 머신 수준에서 프로그램을 직접 관찰하고 조작할 수 있는 강력한 도구.
- break, stepi, info registers, disas 등의 명령을 통해 실행 흐름과 메모리 상태를 분석 가능.
💣 버퍼 오버플로우
- gets()와 같이 입력 길이 제한 없는 함수는 스택에 저장된 반환 주소 등을 침범 가능.
- 이로 인해 프로그램 제어 흐름을 의도하지 않은 코드로 변경할 수 있음.
🛡️ 보안 기법
- 스택 랜덤화: 매 실행마다 스택 위치를 바꿔 공격을 어렵게 함.
- 스택 보호자 (canary): 스택 손상 여부를 감지해 프로그램 종료.
- 비실행 메모리 영역: 스택, 힙에 있는 악성 코드 실행을 차단.
📐 가변 크기 스택 프레임
- alloca()나 가변 길이 배열 사용 시 스택 크기를 런타임에 결정.
- 프레임 포인터(%rbp)를 활용해 변수 위치 추적 및 복구 가능.
- 함수 종료 시 leave 명령어로 스택 정리.