메모리 버그와 안전성, 그리고 도구
목표 학습 시간: 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'; // ✗ 힙에서 인접 블록의 헤더/데이터를 덮는다
- 스택 오버플로(버퍼): 지역 배열을 넘겨 쓰면 같은 프레임의 다른 변수, 저장된 레지스터, 심지어 복귀 주소(세션 3) 까지 덮을 수 있다. 복귀 주소가 덮이면
ret시 엉뚱한 곳으로 점프한다 — 이것이 고전적 스택 스매싱 공격의 원리이고, 그래서 카나리·NX·ASLR 같은 방어가 생겼다(4절). (여기서는 방어를 이해하기 위한 메커니즘 설명이며, 공격 방법이 아니다.) - 힙 오버플로: 인접 블록의 헤더(크기·플래그·free list 포인터)를 덮어 할당자를 교란한다.
2.5 범위 밖 읽기 (out-of-bounds read)
경계 밖을 읽으면 쓰레기 값을 얻거나, 인접 메모리의 내용이 새어 나간다(정보 누출 — 민감 데이터 유출의 원인).
2.6 초기화 안 된 읽기 (uninitialized read)
값을 넣기 전에 읽는다. malloc 메모리의 내용은 불정(세션 7)이고, 스택 지역변수도 자동 0이 아니다. 그래서 읽힌 값이 그때그때 달라 비결정적 버그가 된다.
int x; // 초기화 안 됨
if (x > 0) ... // ✗ x는 쓰레기, 실행마다 다른 분기
3. 왜 "가끔만" 터지나 — 비결정성의 메커니즘
UAF를 예로 들면:
free(p)직후, 그 메모리엔 옛 값이 아직 남아 있다 →*p읽기가 "맞는 듯" 보인다.- 그 블록이 다른
malloc에 재사용되어 새 값으로 덮이는 순간부터,*p는 엉뚱한 값을 주거나 충돌한다. - 즉 발현 여부가 할당/해제 타이밍, 할당자 상태, 메모리 레이아웃에 의존한다.
여기에 ASLR(세션 2, 실행마다 주소 무작위), 최적화 수준(디버그 빌드와 릴리스 빌드의 레이아웃 차이), 입력 타이밍까지 겹치면, "내 컴퓨터에선 되는데" "디버거 붙이면 안 터지는데" 같은 하이젠버그가 된다. 버퍼 오버플로도 같다 — 덮어쓴 옆 값이 당장 안 쓰이면 멀쩡하다가, 한참 뒤 그 값을 쓸 때 폭발한다. 원인-증상 거리가 비결정성과 디버깅 난이도의 뿌리다.
4. 시스템이 깐 방어선 (이전 세션과 연결)
이 버그들이 너무 위험해서, 컴파일러·OS·하드웨어가 여러 완화책(mitigation) 을 깔았다. 완화는 "버그를 없애는 것"이 아니라 "악용/조용한 손상을 어렵게 하는 것"이다.
- 스택 카나리(stack canary): 함수 프롤로그에서 복귀 주소 앞에 임의의 보초값(canary)을 심고, 에필로그에서 그 값이 그대로인지 검사한다. 버퍼 오버플로가 복귀 주소까지 덮으면 카나리가 먼저 망가지므로
ret전에 탐지해 프로그램을 중단시킨다. - ASLR(세션 2): 스택·힙·라이브러리 주소를 실행마다 무작위화해, 공격자가 덮어쓸 목표 주소를 예측하기 어렵게 한다.
- W^X / NX(세션 2·5): 쓰기 가능한 영역은 실행 불가로 표시해, 데이터로 주입한 코드를 실행하지 못하게 한다.
- 가드 페이지(세션 2·3): 스택 끝의 접근 금지 페이지로 스택 오버플로를 즉시 폴트로 잡는다.
4.1 (심화) 방어선의 사각지대 — 캐시 타이밍 사이드채널
위 방어들은 "잘못된 메모리 접근"을 막는다. 그런데 2018년 Spectre·Meltdown 은 전혀 다른 길을 열었다 — 아무 규칙도 어기지 않고, 캐시의 타이밍만으로 비밀을 빼낸다.
원리(개념만):
- CPU는 분기 결과가 확정되기 전 추측 실행(speculative execution) 으로 미리 일을 진행한다(세션 10의 비순차 실행과 한 갈래).
- 추측이 틀리면 결과는 되돌려지지만, 그 과정에서 건드린 메모리가 캐시에 남긴 부수효과는 안 지워진다.
- 공격자는 이후 여러 주소의 접근 시간을 재서(flush+reload), "어느 라인이 캐시에 올라왔나"로 추측 실행이 만진 비밀 값을 역추적한다.
즉 캐시 히트/미스의 시간 차(세션 1·4) 가 정보 채널이 된다. 막는 비용이 커서(추측 차단·캐시 격리) 성능 저하를 동반한다. 핵심 교훈: "접근이 캐시에 남긴 흔적"도 관측 가능한 상태이며, 성능 최적화(추측·캐싱)와 보안이 충돌할 수 있다.
이 방어들은 유용하지만 근본 해결은 아니다. 그래서 "버그를 일어나는 순간 잡는" 도구가 필요하다.
5. 도구 — 어떻게 잡는가 (내부 동작)
원인-증상 거리가 문제라면, 해법은 "잘못된 접근이 일어나는 바로 그 순간"을 포착하는 것이다. 두 대표 도구가 정확히 그걸 한다 — 다만 방식이 다르다.
5.1 AddressSanitizer (ASan) — 컴파일 타임 계측 + 섀도 메모리
컴파일 시 -fsanitize=address로 코드에 검사를 삽입(instrument) 한다. 핵심 메커니즘 두 가지:
- 섀도 메모리(shadow memory): 애플리케이션 메모리 8바이트마다 1바이트의 "섀도" 를 두어 "이 8바이트가 접근 가능한가"를 인코딩한다. 모든 메모리 접근 직전에 컴파일러가 삽입한 코드가 해당 섀도를 확인해, 접근 불가(poisoned) 영역을 건드리면 그 즉시 보고한다.
- 레드존(redzone) + 격리(quarantine): 각 할당의 앞뒤에 접근 금지 레드존을 둬 경계 밖 접근을 잡고, free된 메모리는 바로 재사용하지 않고 격리(quarantine) 해 한동안 poisoned로 유지한다 → UAF가 "재사용으로 가려지기 전에" 잡힌다.
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 위에서 실행하며 모든 메모리 연산을 가로채 검사한다. 재컴파일이 필요 없다(이미 빌드된 바이너리에 그대로 적용). 바이트마다 두 가지 메타데이터를 추적한다:
- A 비트(addressability): 이 바이트에 접근해도 되나? → 범위 밖/해제된 접근 탐지.
- V 비트(validity/definedness): 이 바이트가 초기화된 값인가? → 초기화 안 된 값 사용 탐지.
또 모든 malloc/free를 추적해 누수까지 보고한다. 강력하지만 10~50배 느리다(가상 CPU 위 실행).
5.3 그 외 새니타이저(세션 10과 연결)
- MSan(MemorySanitizer): 초기화 안 된 읽기에 특화.
- UBSan: 정수 오버플로·정렬 위반 등 일반 UB 탐지.
- TSan(ThreadSanitizer): 데이터 레이스 탐지(세션 10).
- 정적 분석/퍼징: clang-tidy·컴파일러 경고(정적), 그리고 무작위 입력을 퍼붓는 퍼징(fuzzing) 으로 ASan과 결합해 숨은 버그를 대량 발굴한다.
6. 실전 워크플로 + 실습
- 평소 테스트/CI: ASan(+UBSan) 빌드로 돌려 위반을 즉시 잡는다(빠르고 정확).
- 까다로운 누수·초기화: Valgrind로 정밀 추적.
- 입력 경계 버그: 퍼징 + ASan 조합.
실습(강력 권장): 위 6종 중 세 개(UAF·버퍼 오버플로·누수)를 일부러 심은 작은 C 프로그램을 만들고, ASan과 Valgrind로 각각 잡아 리포트의 "할당/해제/접근 삼각형"을 읽어보라. 도구가 가리키는 세 위치를 연결하는 연습이 곧 실전 디버깅력이다.
7. 흔한 오해 바로잡기
- ❌ "메모리 버그는 항상 크래시로 드러난다." → UB라 멀쩡히 동작하거나 한참 뒤·다른 곳에서 터질 수 있다(가장 위험한 시나리오).
- ❌ "ASan을 켜면 버그가 사라진다." → ASan은 탐지 도구다. 켜면 버그가 일어나는 순간 보고할 뿐, 고치는 건 개발자.
- ❌ "ASLR/카나리가 있으니 안전하다." → 완화책이지 해결책이 아니다. 버그 자체는 그대로다.
- ❌ "초기화 안 된 변수는 0이다." → 전역/정적(BSS)만 0. 지역변수·malloc 메모리는 불정.
- ❌ "Valgrind와 ASan은 같은 일을 한다." → 목표는 겹쳐도 방식이 다르다(가상 CPU·재컴파일 불필요 vs 컴파일 계측·빠름). 잡는 범위와 비용이 달라 상호 보완적.
8. 한 장 정리
- 메모리 버그는 UB이며, 원인-증상 거리가 멀어 비결정적(하이젠버그).
- 6종: 누수 · UAF/댕글링 · double free · 버퍼 오버플로(스택→복귀 주소까지, 힙→헤더) · 범위 밖 읽기 · 초기화 안 된 읽기.
- 시스템 방어: 카나리·ASLR·NX·가드 페이지(완화책, 근본 해결 아님).
- 도구는 위반의 순간을 잡는다 — ASan(섀도 메모리+레드존+격리, 빠름, 재컴파일), Valgrind(가상 CPU, A/V 비트, 재컴파일 불필요, 느림), 그리고 TSan(레이스)·퍼징.
- ASan 리포트의 할당/해제/접근 삼각형을 읽는 것이 실전 디버깅의 핵심.
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 — 동시성과 메모리 + 종합 (이어지는 파일)