크래프톤 정글 (컴퓨터 시스템: CSAPP)/3장 프로그램의 기계수준 표현

컴퓨터 시스템 : CSAPP 3장 정리 - 3.10 기계수준 프로그램에서 제어와 데이터의 결합

고웅 2025. 4. 7. 08:47

📦 3.10 머신 수준 프로그램에서 제어와 데이터 결합하기

Combining Control and Data in Machine-Level Programs

이 장에서는 제어 흐름(control flow)데이터 구조(data structures)가 머신 수준에서 어떻게 상호작용하며 동작하는지를 탐구한다. 지금까지는 이를 별도로 배웠지만, 실제 프로그램에서는 두 요소가 복잡하게 얽혀 있다.

주요 주제:

  1. 포인터(pointer)의 심화 이해 – C 언어에서 가장 강력하면서도 혼란스러운 개념 중 하나.
  2. gdb 디버거 사용법 – 머신 수준 프로그램의 실행 과정을 자세히 들여다보는 도구.
  3. 버퍼 오버플로우 – 보안 취약점의 주요 원인 중 하나.
  4. 스택 프레임의 크기가 실행마다 달라지는 경우의 구현 방식.

🧠 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가 변조되었는지 확인하여 스택 손상 감지.

🔍 작동 방식

  1. canary 값을 movq %fs:40, %rax로 읽음.
  2. 스택에 저장.
  3. 함수 종료 시 xorq 명령으로 비교.
  4. 변조 시 __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 : 함수 vframe에 대한 스택 프레임 구조

📉 스택 구조 (그림 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 명령어로 스택 정리.