ASan(AddressSanitizer)을 켜고 프로그램을 돌리면 heap-buffer-overflow나 use-after-free가 발생한 순간 바로 크래시가 나면서 어느 함수의 몇 번째 줄에서, 어떤 malloc 호출로 할당된 메모리에 접근했는지까지 정확히 짚어주는 리포트가 뜬다. 일반적인 세그폴트는 침범한 메모리가 매핑되지 않은 페이지일 때만 감지되기 때문에, 힙 버퍼를 1바이트 넘어선 정도로는 아무 문제 없이 실행되다가 한참 뒤 다른 곳에서 데이터가 깨진 형태로 터지는 경우가 많다. ASan은 이런 “조용한” 메모리 오류를 접근 즉시 잡아내는데, 이게 가능한 이유는 컴파일 시점에 모든 메모리 접근 앞에 검사 코드를 끼워 넣고, 별도의 shadow memory에 각 바이트의 접근 가능 여부를 기록해두기 때문이다. 이 글에서는 shadow memory 인코딩 방식, 컴파일러 계측(instrumentation), redzone을 이용한 오버플로우 탐지, quarantine을 이용한 use-after-free 탐지까지 ASan이 실제로 어떻게 동작하는지 정리한다.
Shadow Memory — 8:1 매핑 구조
ASan은 프로세스의 가상 주소 공간을 애플리케이션이 실제로 쓰는 메인 메모리와, 그 상태를 기록하는 shadow memory 두 영역으로 나눈다. 메인 메모리 8바이트마다 shadow memory 1바이트가 대응되는 8:1 압축 비율을 쓰는데, 이 덕분에 shadow memory 자체의 오버헤드는 애플리케이션 메모리의 약 1/8 수준으로 억제된다. 특정 주소의 shadow 주소는 다음 공식으로 계산한다.
// 64비트
Shadow = (Mem >> 3) + 0x7fff8000;
// 32비트
Shadow = (Mem >> 3) + 0x20000000;shadow 바이트 하나는 대응하는 8바이트가 접근 가능한지를 9가지 값으로 표현한다.
| shadow 값 | 의미 |
|---|---|
| 0 | 대응하는 8바이트 전부 접근 가능(unpoisoned) |
| 음수 값 | 8바이트 전부 접근 불가(poisoned) — redzone이나 free된 영역 |
| 1 ~ 7 (k) | 앞의 k바이트만 접근 가능, 나머지는 접근 불가 |
1~7 값이 존재하는 이유는 malloc이 보통 8바이트 정렬된 청크를 반환하기 때문이다. 예를 들어 malloc(13)을 호출하면 실제로는 16바이트가 할당되고, shadow 값은 5(13 = 8 + 5)로 설정되어 “이 8바이트 블록 중 앞 5바이트까지만 유효한 데이터고 나머지 3바이트는 패딩이라 접근하면 안 된다”는 것을 1바이트만으로 정밀하게 표현한다.
컴파일러 계측 — 모든 메모리 접근 앞에 검사 코드 삽입
ASan의 핵심은 컴파일러가 소스 코드의 모든 로드/스토어 앞에 shadow memory를 확인하는 코드를 자동으로 끼워 넣는다는 점이다. *address = ... 같은 코드는 대략 다음과 같은 형태로 계측된다.
shadow_address = MemToShadow(address);
if (ShadowIsPoisoned(shadow_address)) {
ReportError(address, kAccessSize, kIsWrite);
}
*address = ...; // 원래 접근1바이트보다 큰 접근(예: 4바이트 정수 로드)은 8바이트 정렬 경계를 넘나들 수 있어 좀 더 정교한 검사가 필요하다. 접근하는 마지막 바이트의 오프셋((address & 7) + kAccessSize - 1)을 계산해 shadow 값과 비교함으로써, 8바이트 블록의 뒷부분 일부만 poisoned 상태여도 정확히 걸러낸다. 이 검사는 인라인 어셈블리 몇 줄 수준으로 짧기 때문에, 컴파일 시점에 프로그램 전체에 삽입돼도 실행 시간 오버헤드가 감당할 수 있는 수준(대략 1.5~3배)에 머무른다.
Redzone — 버퍼 오버플로우 탐지
메모리 접근이 shadow memory 검사를 통과하려면, 먼저 오버플로우가 일어날 수 있는 자리를 poisoned 상태로 막아둬야 한다. 이 역할을 하는 것이 redzone이다.
힙(heap)에서는 런타임 라이브러리가 malloc을 가로채, 사용자가 요청한 크기의 메모리 앞뒤에 여분의 redzone을 붙여 할당하고 그 redzone의 shadow 값을 poisoned로 설정한다. 사용자가 요청한 영역 바로 다음 바이트에 한 칸이라도 쓰면 곧바로 redzone을 건드리게 되어 heap-buffer-overflow로 잡힌다.
스택(stack)에서는 컴파일러가 함수 진입 시 지역 변수 주변에 redzone을 추가하고 shadow 값을 0xffffffff로 poison한 뒤, 함수를 빠져나갈 때 shadow 값을 다시 0으로 되돌려 스택 프레임을 재사용 가능한 상태로 만든다. 전역 변수(global)도 컴파일러의 정적 분석을 통해 동일한 방식으로 redzone이 붙는다.
#include <stdlib.h>
int main() {
int *array = (int *)malloc(10 * sizeof(int));
array[10] = 1; // 할당 범위를 1칸 벗어난 접근 -> redzone을 건드림
free(array);
return 0;
}$ clang -fsanitize=address -g heap_overflow.c -o heap_overflow
$ ./heap_overflow
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x...
WRITE of size 4 at 0x... thread T0
#0 0x... in main heap_overflow.c:5
0x... is located 0 bytes to the right of 40-byte region
allocated by thread T0 here:
#0 0x... in malloc
#1 0x... in main heap_overflow.c:4
리포트의 “0 bytes to the right of 40-byte region”이라는 문구가 바로 malloc이 반환한 40바이트 영역 바로 뒤 redzone을 건드렸다는 뜻이며, 이는 shadow memory 검사가 어느 지점에서 실패했는지를 그대로 사람이 읽을 수 있는 문장으로 번역한 것이다.
malloc/free 인터셉트와 Quarantine — Use-after-free 탐지
버퍼 오버플로우는 redzone만으로 잡을 수 있지만, use-after-free는 접근 자체가 할당됐던 영역 “안쪽”에서 일어나기 때문에 redzone만으로는 막을 수 없다. 이를 해결하기 위해 ASan은 free()도 함께 가로챈다. free()가 호출되면 실제로 메모리를 즉시 운영체제에 반환하거나 다음 malloc에 재사용하도록 내주지 않고, 해당 영역 전체의 shadow 값을 poisoned로 바꾼 뒤 quarantine이라는 별도의 큐에 잠시 보관한다.
quarantine에 들어간 메모리는 일정 기간(또는 quarantine 큐의 전체 크기 한도) 동안 malloc이 재사용하지 않는다. 그 사이에 이미 해제된 포인터로 접근이 들어오면 shadow memory 검사에서 poisoned로 걸려 use-after-free로 리포트된다. quarantine 크기를 늘리면 탐지 확률은 올라가지만 그만큼 메모리를 더 오래 붙들고 있어야 하므로, 오버헤드와 탐지력 사이의 트레이드오프가 존재한다.
주의사항
- ASan은 논문 기준 약 2배의 메모리 오버헤드와 1.5~3배의 실행 시간 오버헤드를 동반한다. redzone과 quarantine이 메모리를 추가로 점유하기 때문이며, 프로덕션 배포용 바이너리가 아니라 테스트/CI 빌드에서 사용하는 게 일반적이다.
- 8바이트 정렬 특성상 아주 작은 오버플로우(예: 구조체 패딩 안쪽으로의 접근)는 redzone이 아니라 같은 8바이트 블록 안의 유효 영역으로 취급돼 놓칠 수 있다. 이런 경우는
-fsanitize=address만으로는 못 잡고 MemorySanitizer나 UBSan 등 다른 도구와 함께 써야 한다. - 스택 redzone은 함수 프레임 단위로만 동작하므로, 이미 반환된 함수의 지역 변수 포인터를 참조하는 use-after-return은 기본 옵션으로는 잡히지 않는다. 이를 잡으려면
ASAN_OPTIONS=detect_stack_use_after_return=1을 별도로 켜야 한다. - quarantine 크기는
ASAN_OPTIONS=quarantine_size_mb=N으로 조절할 수 있다. 오래 걸리는 use-after-free 버그를 재현하려면 이 값을 키워보는 것도 방법이다.
마무리
ASan이 즉각적이고 정밀한 리포트를 낼 수 있는 이유는 결국 “모든 메모리 접근 앞에 shadow memory 확인 코드를 심어둔다”는 단순한 아이디어를 8:1 압축 인코딩과 redzone, quarantine으로 정교하게 구현했기 때문이다. 사용법만 알고 있어도 버그는 잡을 수 있지만, 이 원리를 알아두면 “왜 이 크기의 오버플로우는 안 잡히는지”, “왜 use-after-return은 기본으로 감지가 안 되는지” 같은 실무에서 부딪히는 예외 상황을 훨씬 빠르게 판단할 수 있다.