[Research] Address Sanitizer: Part 1 (KR)
Introduction

안녕하세요! 이번에 Hackyboiz에 신입으로 들어온 millet이라고 합니다!
워게임 돌이었던 시절 문제를 풀다가 ASan이라는 것에 대해 알게 되었는데요. 문제를 풀 당시에는 그냥 대충 보고 넘겼었지만(,,,ㅎㅎ) 연구글을 핑계 삼아 공부해보고자 주제로 가져오게 되었습니다. 오늘은 이 ASan이 무엇인지 그리고 어떻게 구현되었는지에 대해 살펴보겠습니다.
1. About Address Sanitizer
ASan이라고도 부르는 Address Sanitizer는 프로그램 실행 중 발생하는 메모리 오류를 동적으로 탐지하기 위한 런타임 오류 검출 도구입니다.
C/C++로 프로그램을 작성할 때 메모리 관련 버그를 잡아주는 코드를 자동으로 생성해주는 도구라고 생각하시면 돼요. 초기에는 구글에서 개발하고 관리되었지만 현재는 LLVM 프로젝트의 일부로서 관리되고 있습니다.
ASan은 여러 구현체로 제공됩니다! 저는 리눅스를 좋아하니 연구글에서는 GCC에서 제공하는 Sanitizer를 기준으로 설명하겠습니다. 대부분의 리눅스 배포판에서는 gcc를 설치할 때 관련 런타임 패키지도 같이 설치됩니다. (gcc에 해당하는 libasan 패키지가 같이 설치되는 형식이에요.)
$ (dpkg -l | grep libasan) && (gcc --version | head -1)
ii libasan6:amd64 11.4.0-1ubuntu1~22.04.2 amd64 AddressSanitizer -- a fast memory error detector
gcc (Ubuntu 11.4.0-1ubuntu1~22.04.2) 11.4.0
GCC에서 제공되는 ASan도 LLVM ASAN에서 사용하는 메모리 모델과 검사 기법을 동일하게 사용하기 때문에 개념적으로는 차이가 없답니다. 흐흐,,,

ASan은 현재 다음의 환경들에서 제공되고 있습니다.
| OS | x86 | x86_64 | ARM | ARM64 | MIPS | MIPS64 | PowerPC | PowerPC64 |
|---|---|---|---|---|---|---|---|---|
| Linux | O | O | O | O | O | O | ||
| OS X | O | O | ||||||
| iOS Simulator | O | O | ||||||
| FreeBSD | O | O | ||||||
| Android | O | O | O | O |
1.1 탐지 가능한 메모리 오류
ASan은 이름답게 웬만한 메모리 오류는 대부분 탐지가 가능합니다. 크게 컴파일 옵션과 런타임 플래그를 통해 탐지 범위와 정책을 설정할 수 있습니다. 대표적인 것들을 한 번 살펴볼까요?
Use After Free (Dangling Pointer Dereference)
Heap Buffer Overflow
Stack Buffer Overflow
Global Buffer Overflow
-fsanitize=address
Use After Return
ASAN_OPTIONS=detect_stack_use_after_return=1
- 스택 프레임을 별도의 가상 힙으로 이동하기 때문에 성능 오버헤드가 큼
Use After Scope
ASAN_OPTIONS=detect_stack_use_after_scope=1
Initialization Order Bugs
ASAN_OPTIONS=check_initialization_order=1
- 전역 객체 개념이 존재하는 C++ 전용
Memory Leaks
ASAN_OPTIONS=detect_leaks=1
- ASan이 아닌 LeakSanitizer의 책임
이외에도 추가적인 런타임 플래그들을 통해 더 많은 정책들을 설정할 수 있습니다. 너무 많기 때문에 이번 연구글에서는 기본 기능인 -fsanitize=address를 적용했을 경우에 대해 중점적으로 살펴보겠습니다!
또한 ASan의 경우 fast unwinder가 프레임 포인터 체인을 기반으로 동작하기 때문에 다음의 옵션을 적용하여 RBP 레지스터를 프레임 포인터로 사용하는 것이 권장됩니다.
-fno-omit-frame-pointer
2. ASan 감시 메커니즘
위에서 확인했듯이 -fsanitize=address만으로도 기본적인 탐지들이 가능합니다. 그렇다면 어떤 방식으로 해당 메모리 오류들을 탐지하는 걸까요?
ASan에서는 다음과 같이 크게 3개의 감시 메커니즘을 사용합니다.
Shadow Memory 기반 접근 감시
Poisoning / Unpoisoning을 통한 생명주기 추적
Redzone을 이용한 경계 감지
컴파일 타임 계측(Compile Time Instrumentation)을 위해 위의 메커니즘들이 컴파일 타임에 코드에 삽입됩니다. 기본적으로 메모리를 읽고 쓰고 할당하고 해제하는 모든 접근에 대해 삽입되기 때문에 정책에 위반되는 메모리 접근이 발생하면 오류 메시지를 출력되고 기본적으로는 프로그램이 즉시 중단됩니다.
이때 검사를 위한 조건문과 오류 보고 코드는 크게 다음의 지점에 삽입됩니다.
- 메모리 읽기/쓰기 명령 이전
- 동적 메모리 할당 시점
- 동적 메모리 해제 시점
- 함수 진입 시 스택 프레임 구성 지점
- 함수 반환 시 스택 프레임 해제 지점
- 전역 객체 초기화 및 종료 지점
byte *shadow_address = MemToShadow(address);
byte shadow_value = *shadow_address;
if (shadow_value) {
if (SlowPathCheck(shadow_value, address, kAccessSize)) {
ReportError(address, kAccessSize, kIsWrite);
}
}
하나씩 살펴볼까요?
2.1 Shadow Memory
ASan은 프로그램에서 사용하는 모든 메모리에 대해 쉐도우 메모리라는 별도의 영역을 만들어 메모리의 각 바이트에 대한 메타데이터를 기록합니다.
메모리 바이트에 대한 메타데이터가 저장될 주소는 다음과 같이 계산됩니다.
Shadow = (Mem >> 3) + SHADOW_OFFSET
이는 내부적으로 다음의 매크로를 통해 구현되어 있습니다.
llvm-project/compiler-rt/lib/asan/asan_mapping.h
# define MEM_TO_SHADOW(mem) \
((STRIP_MTE_TAG(mem) >> ASAN_SHADOW_SCALE) + (ASAN_SHADOW_OFFSET))
# define SHADOW_TO_MEM(mem) \
(((mem) - (ASAN_SHADOW_OFFSET)) << (ASAN_SHADOW_SCALE))
위의 코드를 보면 아시겠지만 가상 메모리 8바이트는 쉐도우 메모리 1바이트에 대응됩니다.
SHADOW_OFFSET은 플랫폼/아키텍처 별로 별도로 정의되어 있으며 x86-64 환경에서는 0x7fff8000 값을 사용합니다.
llvm-project/compiler-rt/lib/asan/asan_mapping.h
// Typical shadow mapping on Linux/x86_64 with SHADOW_OFFSET == 0x00007fff8000:
// || `[0x10007fff8000, 0x7fffffffffff]` || HighMem ||
// || `[0x02008fff7000, 0x10007fff7fff]` || HighShadow ||
// || `[0x00008fff7000, 0x02008fff6fff]` || ShadowGap ||
// || `[0x00007fff8000, 0x00008fff6fff]` || LowShadow ||
// || `[0x000000000000, 0x00007fff7fff]` || LowMem ||
...
# if ASAN_SHADOW_SCALE != 3
# error "Value below is based on shadow scale = 3."
# error "Original formula was: 0x7FFFFFFF & (~0xFFFULL << SHADOW_SCALE)."
# endif
# define ASAN_SHADOW_OFFSET_CONST 0x000000007fff8000
# endif
#endif
...
코드에서도 문서화된 것처럼 쉐도우 메모리와 프로그램이 사용하는 가상 메모리는 매핑 영역이 논리적으로 분리되어 있습니다. x86-64 기준으로는 다음과 같아요.
| 분류 | 매핑 영역 | 설명 |
|---|---|---|
| HighMem | [0x10007fff8000, 0x7fffffffffff] |
5바이트 이상으로 표현되는 가상 메모리 |
| HighShadow | [0x02008fff7000, 0x10007fff7fff] |
HighMem에 대응되는 Shadow Memory |
| ShadowGap | [0x00008fff7000, 0x02008fff6fff] |
Shadow Memory 간 간격 구간 |
| LowShadow | [0x00007fff8000, 0x00008fff6fff] |
LowMem에 대응되는 Shadow Memory |
| LowMem | [0x000000000000, 0x00007fff7fff] |
4바이트 이하로 표현되는 가상 메모리 |

- ShadowGap 영역은 Shadow Memory 간 간섭을 방해하기 위한 간격으로 매핑만 되고 읽기/쓰기 권한은 존재하지 않습니다.

기본적으로 쉐도우 메모리는 8:1로 매핑되기 때문에 정렬되지 않은 메모리 접근에 대해서는 잡아내지 못할 가능성이 있습니다. 예시 코드로 상황을 재현해 봅시다.
// g++ oob.cpp -o oob -fsanitize=address
int main(void)
{
int *x = new int[2]; // [0, 7]
int *y = (int *)((char *)x + 6); // [6, 9]
*y = 1; // OOB
delete[] x;
return 0;
}
x86-64 기준으로 x는 8바이트의 객체로 선언됩니다. 따라서 *y = 1;과 같은 접근은 명백한 OOB 접근이겠죠? 하지만 실행시켜봐도 ASan에서 감지되지 않습니다.
빌드된 바이너리를 통해 ASan이 어떤 코드를 삽입했는지 확인해보겠습니다. 역참조 하기 직전의 조건문을 보게 되면 우리가 위에서 확인했던 쉐도우 메모리 연산이 그대로 적용되어 있는 것을 볼 수 있습니다.

하지만 매핑이 8:1로 되기 때문에 v4 + 6 >> 3 연산의 결과는 결국 v4 >> 3 연산과 같아지기 때문에 동일한 쉐도우 메모리를 확인하게 되어 OOB를 잡지 못하게 됩니다. 이러한 구조적 한계로 인해 메모리 정렬이 맞지 않는 경계에서는 검사가 제대로 이루어지지 않는 것이죠!
매핑 스케일을 더 줄인다면 검사가 더 엄격하게 이루어지겠지만 벡터 명령에서의 비효율성과 ASan의 초기 설계와의 차이 등으로 인한 문제 때문에 현재는 -fsanitize=alignment와 같이 정렬 위반을 검사하는 추가적인 sanitizer (이 옵션의 경우 Undefined Behavior Sanitizer, UBSan)를 적용함으로써 해결하는 것이 권장됩니다.
-fsanitize=alignment 옵션을 주고 다시 한 번 빌드해보면…
$ g++ oob.cpp -o oob_aligned -fsanitize=address -fsanitize=alignment
$ ./oob_aligned
oob.cpp:7:8: runtime error: store to misaligned address 0x502000000016 for type 'int', which requires 4 byte alignment
0x502000000016: note: pointer points here
be be be be be be 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
^
이번에는 OOB 접근을 잘 잡는 것을 확인할 수 있네요!
바이너리를 확인해보면 UBSan에 의해 자료형에 대한 검사 함수가 추가된 것을 확인할 수 있습니다. 요거는… 다음 기회에 다뤄보도록 할게요,,,

2.2 Poisoning / Unpoisoning
쉐도우 메모리는 8바이트 메모리에 대한 정보를 1바이트에 저장한다고 확인했습니다. ASan에서는 요 1바이트를 9개의 형태로 변환하여 메모리 접근을 관리합니다.
이때 접근 가능한 메모리 영역을 unpoison, 접근 불가능한 메모리 영역을 poison이라는 상태로 표현합니다. 이때 ‘접근’은 읽기/쓰기를 구분하지 않습니다!
- 엄밀하게 ASan의 오류 보고 함수에서는 읽기는
load, 쓰기는store로 구분되긴 하지만 unpoison/poison 관점에서는 읽기/쓰기를 ‘접근’의 개념으로 바라봅니다.
| Shadow Byte | 의미 | 상태 |
|---|---|---|
0 |
8바이트 모두 접근 가능 | unpoison |
< 0 |
8바이트 모두 접근 불가능 | poison |
k (1~7) |
앞에서부터 k 바이트만 접근 가능 |
부분 unpoison |
프로세스가 시작되면 ASAN에 의해 쉐도우 메모리 영역이 매핑되고 기본 상태는 모두 poison으로 초기화됩니다. 이후 미리 사용되는 메모리 영역을 예측할 수 있는 메모리만 unpoison으로 변경하고 우리가 흔히 아는 main(메인 스레드)이 호출되게 됩니다.
표로 정리하면 다음과 같이 표현할 수 있겠네요!
| 영역 | 메인 스레드 실행 전 초기화 여부 | 프로세스 동작 중 변경 여부 |
|---|---|---|
| 전역/정적 변수 | O (unpoison) | X |
| 스택 프레임 (main) | O (부분적으로 unpoison) | 함수 호출, 반환 시 갱신 |
| 힙 | X (poison) | 별도의 인터셉트 함수로 관리 |
| TLS | O (unpoison) | X |
힙 메모리처럼 동적으로 할당되는 메모리는 ASan에서 관리하기 위해 별도의 인터셉트 함수로 변경되어 프로그램에 삽입됩니다.
llvm-project/compiler-rt/lib/interception/interception.h
#define INTERCEPTOR(ret_type, func, ...) \
DEFINE_REAL(ret_type, func, __VA_ARGS__) \
DECLARE_WRAPPER(ret_type, func, __VA_ARGS__) \
extern "C" INTERCEPTOR_ATTRIBUTE ret_type WRAP(func)(__VA_ARGS__)
llvm-project/compiler-rt/lib/asan/asan_malloc_linux.cpp
...
INTERCEPTOR(void, free, void *ptr) {
if (DlsymAlloc::PointerIsMine(ptr))
return DlsymAlloc::Free(ptr);
GET_STACK_TRACE_FREE;
asan_free(ptr, &stack);
}
...
INTERCEPTOR(void*, malloc, uptr size) {
if (DlsymAlloc::Use())
return DlsymAlloc::Allocate(size);
GET_STACK_TRACE_MALLOC;
return asan_malloc(size, &stack);
}
...
poison/unpoison은 별도의 API를 통해 명시적으로 설정할 수 있습니다.
llvm-project/compiler-rt/include/sanitizer/asan_interface.h
#if __has_feature(address_sanitizer) || defined(__SANITIZE_ADDRESS__)
#define ASAN_POISON_MEMORY_REGION(addr, size) \
__asan_poison_memory_region((addr), (size))
#define ASAN_UNPOISON_MEMORY_REGION(addr, size) \
__asan_unpoison_memory_region((addr), (size))
#else
#define ASAN_POISON_MEMORY_REGION(addr, size) \
((void)(addr), (void)(size))
#define ASAN_UNPOISON_MEMORY_REGION(addr, size) \
((void)(addr), (void)(size))
#endif
명시적인 poison의 경우 런타임 플래그를 통해 동작 여부를 설정할 수 있답니다.
ASAN_OPTIONS=allow_user_poisoning=0
명시적인 unpoison, poison은 메모리에 대한 접근 시기를 설정하는 데에 유용하게 쓸 수 있습니다. 예제 코드를 통해 확인해보겠습니다!
// g++ unpoison.cpp -o unpoison -fsanitize=address
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sanitizer/asan_interface.h>
void *cus_alloc(size_t size)
{
void *p = malloc(size);
ASAN_POISON_MEMORY_REGION(p, size);
return p;
}
void activate_p(void *p, size_t size)
{
ASAN_UNPOISON_MEMORY_REGION(p, size);
}
int main(void)
{
char *p = (char *)cus_alloc(8);
activate_p(p, 6);
strcpy(p, "millet");
puts(p);
free(p);
return 0;
}
ASan에서 재선언된 malloc()의 경우 기본적으로 할당받은 메모리를 unpoison으로 관리합니다. 예제 코드의 경우는 일단 ASAN_POISON_MEMORY_REGION으로 메모리 접근 불가로 변경한 후 사용할 때만 unpoison으로 변경하기 때문에 더욱 안전하게 메모리를 관리할 수 있다는 장점이 있습니다.
만약 명시적인 unpoison이 없다면 다음과 같이 오류를 내뿜으면서 프로그램은 종료됩니다.
./unpoison
=================================================================
==1381294==ERROR: AddressSanitizer: use-after-poison on address 0x502000000010 at pc 0x7f0804f052c3 bp 0x7ffcc58dca60 sp 0x7ffcc58dc208
WRITE of size 7 at 0x502000000010 thread T0
#0 0x7f0804f052c2 in __interceptor_memcpy ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:827
#1 0x55577d55a300 in main (/root/asan/unpoison+0x1300)
#2 0x7f0804998d8f in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#3 0x7f0804998e3f in __libc_start_main_impl ../csu/libc-start.c:392
#4 0x55577d55a1a4 in _start (/root/asan/unpoison+0x11a4)
0x502000000010 is located 0 bytes inside of 8-byte region [0x502000000010,0x502000000018)
allocated by thread T0 here:
#0 0x7f0804f7f887 in __interceptor_malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:145
#1 0x55577d55a284 in cus_alloc(unsigned long) (/root/asan/unpoison+0x1284)
#2 0x55577d55a2e1 in main (/root/asan/unpoison+0x12e1)
#3 0x7f0804998d8f in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
SUMMARY: AddressSanitizer: use-after-poison ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:827 in __interceptor_memcpy
Shadow bytes around the buggy address:
0x0a047fff7fb0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0a047fff7fc0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0a047fff7fd0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0a047fff7fe0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0a047fff7ff0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x0a047fff8000: fa fa[f7]fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0a047fff8010: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0a047fff8020: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0a047fff8030: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0a047fff8040: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0a047fff8050: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
Shadow gap: cc
==1381294==ABORTING
use-after-poison이라고 잘 알려주네요!
2.3 Redzone
poison과 unpoison이 메모리의 상태와 시간적인 생명주기를 관리한다면 redzone은 검사 대상의 주변 공간(경계)을 어떻게 보호하기 위한 메커니즘입니다. 메모리 객체의 실제 사용 영역 주변에 접근이 금지된 공간을 배치하는데 이 공간을 바로 redzone이라고 부릅니다.

redzone에 해당하는 영역은 항상 poison 상태로 유지됩니다. 따라서 경계를 넘어서는 읽기/쓰기나 논리적으로 잘못된 접근은 redzone에 대한 접근으로 이어지게 되고 이를 통해 힙 버퍼 오버플로우나 스택 버퍼 오버플로우를 감지할 수 있게 됩니다.
redzone은 크게 힙, 스택, 전역 버퍼에 배치되며 모두 shadow memory의 poison으로 표현됩니다.
| Shadow Byte | 의미 | 상태 |
|---|---|---|
0xfa |
Heap left redzone | poison |
0xf1 |
Stack left redzone | poison |
0xf2 |
Stack mid redzone | poison |
0xf3 |
Stack right redzone | poison |
0xf5 |
Stack after return | poison |
0xf9 |
Global redzone | poison |
0xca |
Left alloca redzone | poison |
0xcb |
Right alloca redzone | poison |
예제를 통해 힙에 삽입되는 redzone을 확인해보겠습니다.
// g++ redzone.cpp -o redzone -fsanitize=address
#include <stdlib.h>
int main(void)
{
char *p = (char *)malloc(6);
p[6] = 'm';
free(p);
return 0;
}
x86-64 환경에서 ptmalloc2는 청크를 16바이트 단위로 할당합니다. malloc(6)이 호출되면 ptmalloc2는 총 32바이트의 공간을 할당해주게 되는데요. 이 중 user data 영역의 6바이트를 제외한 공간은 접근이 발생해서는 안되는 공간입니다.
...
0x5555555551d1 <main+0008> sub rsp, 0x10
0x5555555551d5 <main+000c> mov edi, 0x6
0x5555555551da <main+0011> call 0x5555555550b0 <malloc@plt>
→ 0x5555555551df <main+0016> mov QWORD PTR [rbp-0x8], rax
...
gef➤ x/4bx (($rax >> 3) + 0x7fff8000) - 2
0xa047fff8000: 0xfa 0xfa 0x06 0xfa
메모리 할당 후를 확인해보면 위와 같습니다. 실제로 사용되어야 하는 공간의 shadow byte 값만 0x06으로 설정되어 있고 나머지 공간은 Heap left redzone을 나타내는 0xfa로 설정된 것을 확인할 수 있습니다.
따라서 다음의 조건문에 의해 잘못된 메모리 접근이 감지되게 됩니다.

이번에는 스택 버퍼 오버플로우의 예제를 통해 확인해보겠습니다.
// g++ redzone.cpp -o redzone -fsanitize=address
#include <stdlib.h>
int main(void)
{
char sp[16] = {0, };
sp[17] = 'i';
return 0;
}
빌드된 후 ASan에 의해 재작성된 main() 함수는 다음과 같습니다.
int __fastcall main(int argc, const char **argv, const char **envp)
{
unsigned __int64 v3; // rbx
__int64 v4; // rax
_BYTE *v5; // rdx
__int64 v6; // rax
char v7; // si
_BYTE v9[88]; // [rsp+0h] [rbp-70h] BYREF
unsigned __int64 cnry; // [rsp+58h] [rbp-18h]
v3 = (unsigned __int64)v9;
if ( _TMC_END__ )
{
v4 = __asan_stack_malloc_0(64, argv, envp);
if ( v4 )
v3 = v4;
}
v5 = (_BYTE *)(v3 + 0x60);
*(_QWORD *)v3 = 0x41B58AB3;
*(_QWORD *)(v3 + 8) = "1 32 16 4 sp:5";
*(_QWORD *)(v3 + 16) = main;
v6 = v3 >> 3;
*(_DWORD *)(v6 + 0x7FFF8000) = 0xF1F1F1F1;
*(_DWORD *)(v6 + 0x7FFF8004) = 0xF3F30000;
cnry = __readfsqword(0x28u);
if ( *(_WORD *)(((v3 + 32) >> 3) + 0x7FFF8000) )
v6 = __asan_report_store16(v3 + 32);
*((_QWORD *)v5 - 8) = 0;
*((_QWORD *)v5 - 7) = 0;
v7 = *(_BYTE *)(((unsigned __int64)(v5 - 47) >> 3) + 0x7FFF8000);
if ( v7 != 0 && (((unsigned __int8)v5 - 64 + 17) & 7) >= v7 )
v6 = __asan_report_store1(v5 - 47);
*(v5 - 47) = 'i';
if ( v9 == (_BYTE *)v3 )
{
*(_QWORD *)(v6 + 0x7FFF8000) = 0;
}
else
{
*(_QWORD *)v3 = 0x45E0360E;
*(_QWORD *)(v6 + 0x7FFF8000) = 0xF5F5F5F5F5F5F5F5LL;
**(_BYTE **)(v3 + 0x38) = 0;
}
return 0;
}
워후,,, 뭔가 많은 코드가 삽입되었네요. 복잡해보이긴 하지만 위에서 살펴보았던 개념들을 적용해서 본다면 크게 어렵지는 않을 것 같습니다. 하나씩 뜯어볼까요?
ASan 스택 프레임 설정
v9은 컴파일러가 설정해놓은 스택 프레임 버퍼의 최상단을 나타냅니다. 이것을 ASan에서 관리하기 위해 v3 변수로 옮기는 동작이 발생합니다.
v3 = (unsigned __int64)v9;
if ( _TMC_END__ )
{
v4 = __asan_stack_malloc_0(64, argv, envp);
if ( v4 )
v3 = v4;
}
_TMC_END__라는 값을 확인하고 스택을 다시 할당하는 로직이 존재하는데 이것은 스택 재사용을 감지하기 위한ASAN_OPTIONS=detect_stack_use_after_return에 따라 결정되는 값입니다.

스택 프레임 메타데이터 기록
스택 프레임에 대한 별도의 메타데이터를 기록하는 코드입니다. 여기서는 스택 프레임의 현재 상태와 프로그램이 의도하는 프레임 내 객체 레이아웃 등을 볼 수 있습니다.
v5 = (_BYTE *)(v3 + 0x60);
*(_QWORD *)v3 = 0x41B58AB3;
*(_QWORD *)(v3 + 8) = "1 32 16 4 sp:5";
*(_QWORD *)(v3 + 16) = main;
0x41B58AB3: 스택 프레임을 식별하기 위한 magic number. 여기서는 스택 프레임이 유효하다는 의미로 사용됩니다.llvm-project/compiler-rt/lib/asan/asan_internal.h
static const uptr kCurrentStackFrameMagic = 0x41B58AB3; static const uptr kRetiredStackFrameMagic = 0x45E0360E;"1 32 16 4 sp:5": 스택 객체 1개, 오프셋 32, 크기 16, 내부 이름 sp:5main: 어떤 함수의 프레임인지 기록
Stack redzone 설정
실제로 사용될 16바이트에 대한 shadow byte 2개를 unpoison으로 설정한 후 양쪽 영역에 대해 각각 Stack left redzone과 Stack right redzone을 설정하는 것을 확인할 수 있습니다.
v6 = v3 >> 3;
*(_DWORD *)(v6 + 0x7FFF8000) = 0xF1F1F1F1;
*(_DWORD *)(v6 + 0x7FFF8004) = 0xF3F30000;
객체 접근 전 검사 → 접근 로직
이 부분은 위에서 확인했던 shadow memory 검사와 동일합니다. 이때 검사를 시작하는 오프셋은 스택 프레임 메타데이터의 정보와 동일한 +32 지점인 것을 확인할 수 있습니다.
if ( *(_WORD *)(((v3 + 32) >> 3) + 0x7FFF8000) )
v6 = __asan_report_store16(v3 + 32);
*((_QWORD *)v5 - 8) = 0;
*((_QWORD *)v5 - 7) = 0;
v7 = *(_BYTE *)(((unsigned __int64)(v5 - 47) >> 3) + 0x7FFF8000);
if ( v7 != 0 && (((unsigned __int8)v5 - 64 + 17) & 7) >= v7 )
v6 = __asan_report_store1(v5 - 47);
*(v5 - 47) = 'i';
뭔가 연산이 복잡하지만 결국 배열이 존재하는 +32 지점이라는 것을 알 수 있습니다. (__asan_report_storeX() 함수는 다음 단락에서 확인해보겠습니다.)
함수 반환 전 정리
만약 v9과 v3이 가라키는 값이 동일하다면 진짜 스택이니 다른 함수 호출을 위해 unpoison으로 변경해주는 것을 확인할 수 있습니다.
그렇지 않다면 ASan에서 할당한 fake stack의 경우일텐데 이 경우에는 kRetiredStackFrameMagic에 해당하는 0x45E0360E으로 magic number 값을 변경하고 Stack after return에 해당하는 0xf5로 poision을 설정하네요! 개념을 보고 나니 코드를 이해하는 건 쉬운 것 같습니다. 호호호
if ( v9 == (_BYTE *)v3 )
{
*(_QWORD *)(v6 + 0x7FFF8000) = 0;
}
else
{
*(_QWORD *)v3 = 0x45E0360E;
*(_QWORD *)(v6 + 0x7FFF8000) = 0xF5F5F5F5F5F5F5F5LL;
**(_BYTE **)(v3 + 0x38) = 0;
}
return 0;
}
3. 오류 보고
3.1 오류 보고용 함수
검사 로직에서 통과하지 못했을 경우 호출되는 보고 함수는 다음과 같이 정의되어 있습니다.
llvm-project/compiler-rt/lib/asan_abi_asan_abi_shim.cpp
// Functions concerning memory load and store reporting
void __asan_report_load1(uptr addr) {
__asan_abi_report_load_n((void *)addr, 1, true);
}
void __asan_report_load2(uptr addr) {
__asan_abi_report_load_n((void *)addr, 2, true);
}
void __asan_report_load4(uptr addr) {
__asan_abi_report_load_n((void *)addr, 4, true);
}
void __asan_report_load8(uptr addr) {
__asan_abi_report_load_n((void *)addr, 8, true);
}
void __asan_report_load16(uptr addr) {
__asan_abi_report_load_n((void *)addr, 16, true);
}
void __asan_report_load_n(uptr addr, uptr size) {
__asan_abi_report_load_n((void *)addr, size, true);
}
void __asan_report_store1(uptr addr) {
__asan_abi_report_store_n((void *)addr, 1, true);
}
void __asan_report_store2(uptr addr) {
__asan_abi_report_store_n((void *)addr, 2, true);
}
void __asan_report_store4(uptr addr) {
__asan_abi_report_store_n((void *)addr, 4, true);
}
void __asan_report_store8(uptr addr) {
__asan_abi_report_store_n((void *)addr, 8, true);
}
void __asan_report_store16(uptr addr) {
__asan_abi_report_store_n((void *)addr, 16, true);
}
void __asan_report_store_n(uptr addr, uptr size) {
__asan_abi_report_store_n((void *)addr, size, true);
}
딱 직관적이게 읽기는 load, 쓰기는 store, 그리고 마지막에 몇 바이트인지를 나타내는 형태로 정의되어 있는 것을 볼 수 있습니다. 래핑된 함수인 __asan_abi_report_XXX_n은 최종적으로 ReportGenericError() 함수를 호출하게 됩니다.
llvm-project/compiler-rt/lib/asan/asan_report.cpp
void __asan_report_error(uptr pc, uptr bp, uptr sp, uptr addr, int is_write,
uptr access_size, u32 exp) {
ENABLE_FRAME_POINTER;
bool fatal = flags()->halt_on_error;
ReportGenericError(pc, bp, sp, addr, is_write, access_size, exp, fatal);
}
void ReportGenericError(uptr pc, uptr bp, uptr sp, uptr addr, bool is_write,
uptr access_size, u32 exp, bool fatal) {
if (__asan_test_only_reported_buggy_pointer) {
*__asan_test_only_reported_buggy_pointer = addr;
return;
}
if (!fatal && SuppressErrorReport(pc)) return;
ENABLE_FRAME_POINTER;
// Optimization experiments.
// The experiments can be used to evaluate potential optimizations that remove
// instrumentation (assess false negatives). Instead of completely removing
// some instrumentation, compiler can emit special calls into runtime
// (e.g. __asan_report_exp_load1 instead of __asan_report_load1) and pass
// mask of experiments (exp).
// The reaction to a non-zero value of exp is to be defined.
(void)exp;
ScopedInErrorReport in_report(fatal);
ErrorGeneric error(GetCurrentTidOrInvalid(), pc, bp, sp, addr, is_write,
access_size);
in_report.ReportError(error);
}
fatal 값에 의해 에러 보고 후 종료시킬지 여부가 결정됩니다. 물론 이 값 또한 컴파일 옵션과 런타임 플래그에 의해 결정됩니다.
# Activate ASAN Recovery Mode
-fsanitize-recover=address
# Runtime Flag
ASAN_OPTIONS=halt_on_error=0
redzone에서 예제로 사용했던 코드에 getchar()를 추가한 뒤 확인해보면 다음과 같이 오류가 발생해도 프로그램이 종료되지 않는 것을 확인할 수 있습니다.
$ g++ redzone.cpp -o redzone -fsanitize=address -fsanitize-recover=address && ASAN_OPTIONS=halt_on_error=0 ./redzone
=================================================================
==1481048==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7fff00408791 at pc 0x5593d35b02fa bp 0x7fff00408750 sp 0x7fff00408740
WRITE of size 1 at 0x7fff00408791 thread T0
...
$ echo $?
0
3.2 오류 해석
오류는 크게 요약, 콜 스택, 메모리 정보, Shadow Bytes 상태 순으로 출력됩니다.
바로 직전의 예제를 실행시켜 출력된 오류 메시지를 기준으로 각 항목을 해석해 보겠습니다!
$ ./redzone
=================================================================
==1670382==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7fffaec08e11 at pc 0x5591367762fa bp 0x7fffaec08dd0 sp 0x7fffaec08dc0
WRITE of size 1 at 0x7fffaec08e11 thread T0
#0 0x5591367762f9 in main (/root/asan/redzone+0x12f9)
#1 0x7fe1e1807d8f in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#2 0x7fe1e1807e3f in __libc_start_main_impl ../csu/libc-start.c:392
#3 0x559136776144 in _start (/root/asan/redzone+0x1144)
Address 0x7fffaec08e11 is located in stack of thread T0 at offset 49 in frame
#0 0x559136776218 in main (/root/asan/redzone+0x1218)
This frame has 1 object(s):
[32, 48) 'sp' (line 6) <== Memory access at offset 49 overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
(longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-overflow (/root/asan/redzone+0x12f9) in main
Shadow bytes around the buggy address:
0x100075d79170: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d79180: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d79190: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d791a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d791b0: 00 00 00 00 00 00 00 00 00 00 00 00 f1 f1 f1 f1
=>0x100075d791c0: 00 00[f3]f3 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d791d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d791e0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d791f0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d79200: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d79210: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
Shadow gap: cc
==1670382==ABORTING
오류 개요
오류의 유형은 stack-buffer-overflow이며 1바이트 쓰기에 대해 thread T0에서 발생하였음을 알 수 있습니다. 그 밑에는 콜 스택이 출력되어 오류가 발생한 함수 호출 경로를 확인할 수 있습니다.
- 발생한 위치:
0x5591367762fa
==1670382==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7fffaec08e11 at pc 0x5591367762fa bp 0x7fffaec08dd0 sp 0x7fffaec08dc0
WRITE of size 1 at 0x7fffaec08e11 thread T0
#0 0x5591367762f9 in main (/root/asan/redzone+0x12f9)
#1 0x7fe1e1807d8f in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#2 0x7fe1e1807e3f in __libc_start_main_impl ../csu/libc-start.c:392
#3 0x559136776144 in _start (/root/asan/redzone+0x1144)
Address 0x7fffaec08e11 is located in stack of thread T0 at offset 49 in frame
#0 0x559136776218 in main (/root/asan/redzone+0x1218)
메모리 프레임 정보
sp라는 버퍼는 스택 프레임에서 [32, 48), 즉 16바이트만큼 유효하지만 접근한 위치는 49라고 표시해줍니다.
따라서 해당 로그를 보고 OOB 쓰기가 발생하였음을 알 수 있습니다.
This frame has 1 object(s):
[32, 48) 'sp' (line 6) <== Memory access at offset 49 overflows this variable
Shadow Bytes 상태
실제 접근이 가능한 지점은 0x100075d791c0부터 0x100075d791c1인데 =>로 표시된 지점에 Stack right redzone을 의미하는 0xf3로 표시된 poison 영역에 접근했다는 것이 [ ]로 표시되고 있습니다. 이것을 통해 전반적인 메모리 접근 권한 상태를 확인할 수 있습니다.
Shadow bytes around the buggy address:
0x100075d79170: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d79180: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d79190: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d791a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d791b0: 00 00 00 00 00 00 00 00 00 00 00 00 f1 f1 f1 f1
=>0x100075d791c0: 00 00[f3]f3 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d791d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d791e0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d791f0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d79200: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100075d79210: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
오류 보고를 통해 오류 위치, 원인, 메모리 상태가 명확하게 제공되어 편하긴 하네요!
마치며
여기까지 Address Sanitizer에 대해 가볍게 확인해보았습니다! ASan을 사용하면 메모리 오류를 매우 높은 정확도로 탐지할 수 있지만 Shadow Memory 검사, redzone 삽입, 런타임에서의 빈번한 검사 등으로 인해 메모리 사용량이 증가하고 성능에도 상당한 오버헤드가 걸린다는 단점 또한 존재합니다. 이러한 특성으로 인해 ASan은 일반적으로 프로덕션 환경에서 직접적으로 사용되기보다는 개발 단계, 테스트 환경, 퍼징과 취약점 분석 과정에서 주로 사용된다고 합니다.
최근에는 HWASan, UBSan, TSan, LSan처럼 Sanitizer들의 종류들이 다양해지며 역할도 세분화되었고 선택지도 많아졌지만 여전히 ASan은 메모리 안전성 검증에 있어 기본적이고 핵심적인 도구이기 때문에 알아두면 좋을 것 같습니다. Chrome 개발에서도 아직 사용된다고 하니까요. (구글이 만든거니까)
다음 연구글에서는 좀 더 다양한 Sanitizer를 다뤄보도록 하겠습니다. 글 읽어주셔서 감사합니다!! 다음에 뵐게요! 🫂
Reference
- https://learn.microsoft.com/ko-kr/cpp/sanitizers/asan?view=msvc-170
- https://github.com/google/sanitizers/wiki/AddressSanitizerUseAfterReturn
- https://clang.llvm.org/docs/AddressSanitizer.html
- https://github.com/llvm/llvm-project/tree/main
- https://github.com/google/sanitizers/issues/100
- https://research.google/pubs/addresssanitizer-a-fast-address-sanity-checker/
- https://arxiv.org/pdf/2506.05022v2
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.