🧠 메모리 교재 · 09_메모리버그와도구

메모리 버그와 안전성, 그리고 도구

목표 학습 시간: 105분 + 복습 15분 · 전공 수준 정독용

이 챕터를 마치면: (1) 주요 메모리 버그 클래스를 메커니즘 수준에서 구분하고, (2) 왜 이들이 비결정적(하이젠버그)인지 설명하고, (3) 시스템이 깐 방어(카나리·ASLR·NX·가드 페이지)의 원리를 알고, (4) ASan과 Valgrind가 어떻게 버그를 잡는지 내부 동작으로 설명할 수 있다.


0. 학습 지도 (105분)

구간 내용
1 8 메모리 버그가 특별한 이유
2 30 버그 클래스 6종 (메커니즘)
3 12 왜 "가끔만" 터지나
4 15 시스템의 방어선
5 30 도구의 내부 동작: ASan · Valgrind · 그 외
6 10 워크플로 + 실습
복습 15 인출

1. 메모리 버그가 특별한 이유

대부분의 버그는 "틀린 값"을 낳는다. 메모리 버그는 다르다 — 미정의 동작(undefined behavior, UB) 을 낳는다. C/C++ 표준은 잘못된 메모리 접근의 결과를 규정하지 않으므로, 컴파일러·할당자·하드웨어 상태에 따라 아무 일이나 일어날 수 있다(멀쩡히 동작, 엉뚱한 값, 즉시 크래시, 한참 뒤 크래시, 보안 취약점). 그리고 메모리 버그의 본질적 어려움은:

원인과 증상이 시공간적으로 멀리 떨어져 있다.

엉뚱한 곳에 쓴 값이 한참 뒤 다른 코드가 그 값을 읽을 때 터진다. 그래서 크래시 지점은 진짜 원인과 무관할 때가 많다. 이것이 메모리 버그 디버깅을 악명 높게 만든다.


2. 버그 클래스 6종 — 메커니즘으로

2.1 메모리 누수(leak)

할당한 메모리의 마지막 참조를 잃어 free하지 못한 상태. 프로그램이 오래 돌수록 메모리가 야금야금 새어 결국 고갈된다(OOM). 짧은 프로그램에선 종료 시 OS가 회수하므로 무해해 보이지만, 서버·장기 실행 프로세스에선 치명적이다.

void f(void) {
    int *p = malloc(100);
    // ... return 경로에서 free(p)를 빠뜨림 → p가 사라지며 그 블록은 영영 미아
}

2.2 댕글링 포인터 / use-after-free (UAF)

이미 수명이 끝난 메모리(반환된 스택 프레임=세션 3, 또는 free된 힙)를 가리키는 포인터를 계속 사용.

int *p = malloc(sizeof(int));
free(p);
*p = 5;        // ✗ UAF: 이미 반납한 메모리에 쓴다 (p는 댕글링)

가장 위험한 클래스 중 하나다. free된 블록이 다른 malloc재사용된 뒤 그 자리를 건드리면, 두 논리적 객체가 같은 메모리를 공유해 데이터가 조용히 오염된다.

2.3 double free

같은 블록을 두 번 free. 할당자는 free 시 그 블록을 free list 등 내부 구조에 되돌리는데(세션 7), 또 free하면 그 구조가 중복·모순 상태가 된다. 이후 malloc/free가 엉뚱한 포인터를 주거나 힙이 깨진다.

free(p);
free(p);       // ✗ 할당자 메타데이터 손상

2.4 버퍼 오버플로/언더플로 (out-of-bounds write)

할당 범위 에 쓴다. 두 무대가 있다.

int a[4];
a[4] = 9;      // ✗ 0~3만 유효. a[4]는 옆 메모리(스택의 인접 변수/구조)를 덮는다

char *buf = malloc(8);
buf[10] = 'x'; // ✗ 힙에서 인접 블록의 헤더/데이터를 덮는다

2.5 범위 밖 읽기 (out-of-bounds read)

경계 밖을 읽으면 쓰레기 값을 얻거나, 인접 메모리의 내용이 새어 나간다(정보 누출 — 민감 데이터 유출의 원인).

2.6 초기화 안 된 읽기 (uninitialized read)

값을 넣기 전에 읽는다. malloc 메모리의 내용은 불정(세션 7)이고, 스택 지역변수도 자동 0이 아니다. 그래서 읽힌 값이 그때그때 달라 비결정적 버그가 된다.

int x;          // 초기화 안 됨
if (x > 0) ...  // ✗ x는 쓰레기, 실행마다 다른 분기

3. 왜 "가끔만" 터지나 — 비결정성의 메커니즘

UAF를 예로 들면:

  1. free(p) 직후, 그 메모리엔 옛 값이 아직 남아 있다 → *p 읽기가 "맞는 듯" 보인다.
  2. 그 블록이 다른 malloc재사용되어 새 값으로 덮이는 순간부터, *p는 엉뚱한 값을 주거나 충돌한다.
  3. 즉 발현 여부가 할당/해제 타이밍, 할당자 상태, 메모리 레이아웃에 의존한다.

여기에 ASLR(세션 2, 실행마다 주소 무작위), 최적화 수준(디버그 빌드와 릴리스 빌드의 레이아웃 차이), 입력 타이밍까지 겹치면, "내 컴퓨터에선 되는데" "디버거 붙이면 안 터지는데" 같은 하이젠버그가 된다. 버퍼 오버플로도 같다 — 덮어쓴 옆 값이 당장 안 쓰이면 멀쩡하다가, 한참 뒤 그 값을 쓸 때 폭발한다. 원인-증상 거리가 비결정성과 디버깅 난이도의 뿌리다.


4. 시스템이 깐 방어선 (이전 세션과 연결)

이 버그들이 너무 위험해서, 컴파일러·OS·하드웨어가 여러 완화책(mitigation) 을 깔았다. 완화는 "버그를 없애는 것"이 아니라 "악용/조용한 손상을 어렵게 하는 것"이다.

4.1 (심화) 방어선의 사각지대 — 캐시 타이밍 사이드채널

위 방어들은 "잘못된 메모리 접근"을 막는다. 그런데 2018년 Spectre·Meltdown 은 전혀 다른 길을 열었다 — 아무 규칙도 어기지 않고, 캐시의 타이밍만으로 비밀을 빼낸다.

원리(개념만):

  1. CPU는 분기 결과가 확정되기 전 추측 실행(speculative execution) 으로 미리 일을 진행한다(세션 10의 비순차 실행과 한 갈래).
  2. 추측이 틀리면 결과는 되돌려지지만, 그 과정에서 건드린 메모리가 캐시에 남긴 부수효과는 안 지워진다.
  3. 공격자는 이후 여러 주소의 접근 시간을 재서(flush+reload), "어느 라인이 캐시에 올라왔나"로 추측 실행이 만진 비밀 값을 역추적한다.

캐시 히트/미스의 시간 차(세션 1·4) 가 정보 채널이 된다. 막는 비용이 커서(추측 차단·캐시 격리) 성능 저하를 동반한다. 핵심 교훈: "접근이 캐시에 남긴 흔적"도 관측 가능한 상태이며, 성능 최적화(추측·캐싱)와 보안이 충돌할 수 있다.

이 방어들은 유용하지만 근본 해결은 아니다. 그래서 "버그를 일어나는 순간 잡는" 도구가 필요하다.


5. 도구 — 어떻게 잡는가 (내부 동작)

원인-증상 거리가 문제라면, 해법은 "잘못된 접근이 일어나는 바로 그 순간"을 포착하는 것이다. 두 대표 도구가 정확히 그걸 한다 — 다만 방식이 다르다.

5.1 AddressSanitizer (ASan) — 컴파일 타임 계측 + 섀도 메모리

컴파일 시 -fsanitize=address로 코드에 검사를 삽입(instrument) 한다. 핵심 메커니즘 두 가지:

ASan 리포트는 세 위치의 삼각형을 찍어준다: ① 잘못 접근한 코드 위치, ② 그 메모리가 할당된 위치, ③ (UAF면) 해제된 위치. 이 삼각형을 읽는 것이 실전 디버깅의 핵심이다. 비용: 약 2배 느려지고 메모리도 더 쓰며, 재컴파일이 필요하다.

gcc -fsanitize=address -g bug.c -o bug && ./bug
# ==ERROR== AddressSanitizer: heap-use-after-free on address 0x...
#   WRITE of size 4 at 0x... (잘못 접근 위치 + 스택)
#   freed by thread T0 here: ...   (해제 위치)
#   previously allocated here: ... (할당 위치)

5.2 Valgrind (Memcheck) — 동적 이진 계측 + 가상 CPU

프로그램을 가상 CPU 위에서 실행하며 모든 메모리 연산을 가로채 검사한다. 재컴파일이 필요 없다(이미 빌드된 바이너리에 그대로 적용). 바이트마다 두 가지 메타데이터를 추적한다:

또 모든 malloc/free를 추적해 누수까지 보고한다. 강력하지만 10~50배 느리다(가상 CPU 위 실행).

5.3 그 외 새니타이저(세션 10과 연결)


6. 실전 워크플로 + 실습

실습(강력 권장): 위 6종 중 세 개(UAF·버퍼 오버플로·누수)를 일부러 심은 작은 C 프로그램을 만들고, ASan과 Valgrind로 각각 잡아 리포트의 "할당/해제/접근 삼각형"을 읽어보라. 도구가 가리키는 세 위치를 연결하는 연습이 곧 실전 디버깅력이다.


7. 흔한 오해 바로잡기


8. 한 장 정리


9. 복습 (15분) — 답을 가리고

Q1. 메모리 버그가 일반 버그와 다른 두 가지 본질은?

① 미정의 동작(UB)이라 결과가 규정되지 않음(아무 일이나 가능). ② 원인과 증상이 시공간적으로 멀어 크래시 지점이 진짜 원인과 무관할 때가 많다.

Q2. UAF가 "가끔만" 터지는 이유를 메커니즘으로.

free 직후엔 옛 값이 남아 멀쩡해 보이다가, 그 블록이 다른 malloc에 재사용돼 덮이는 순간 오작동한다. 발현이 할당/해제 타이밍·할당자 상태·레이아웃·ASLR·최적화에 의존해 비결정적.

Q3. 스택 버퍼 오버플로가 복귀 주소를 덮을 수 있는 이유와, 이를 노린 방어책은?

지역 배열과 복귀 주소가 같은 스택 프레임에 있어(세션 3), 배열을 넘겨 쓰면 복귀 주소까지 덮을 수 있다. 방어: 스택 카나리(복귀 주소 앞 보초값 검사), NX(주입 코드 실행 차단), ASLR(주소 예측 방해).

Q4. ASan의 섀도 메모리와 레드존/격리는 각각 무엇을 잡나?

섀도 메모리: 8바이트당 1바이트로 접근 가능 여부를 인코딩, 접근 직전 검사해 위반 즉시 탐지. 레드존: 할당 앞뒤 금지 영역으로 경계 밖 접근 탐지. 격리: free된 메모리를 한동안 재사용 안 해 UAF가 가려지기 전에 잡음.

Q5. Valgrind가 추적하는 A 비트와 V 비트는?

A(addressability): 이 바이트에 접근해도 되나(범위 밖/해제 탐지). V(validity): 이 바이트가 초기화된 값인가(초기화 안 된 사용 탐지).

Q6. ASan과 Valgrind의 방식·비용 차이를 한 줄씩.

ASan: 컴파일 시 검사 코드 삽입, 빠름(2배), 재컴파일 필요. Valgrind: 가상 CPU에서 실행하며 모든 연산 가로챔, 재컴파일 불필요, 느림(1050배).

Q7. 세션 3·7과의 연결을 한 줄씩.

세션 3: 스택 프레임에 복귀 주소가 데이터로 저장되어 버퍼 오버플로의 표적이 된다. 세션 7: free된 블록은 할당자 free list로 돌아가 재사용되며, 그 재사용 타이밍이 UAF의 비결정성을 만든다. double free는 그 free list를 손상시킨다.


다음: 세션 10 — 동시성과 메모리 + 종합 (이어지는 파일)