c/c++ 개발자들은 버퍼 오버플로우, 댕글링 포인터 등의 메모리 오류, data race, dead lock 등의 멀티 스레드 오류를 자주 접하게 된다. 이런 버그들은 바로 크래시가 나지 않고 나중에 엉뚱한 곳에서 터지기 때문에 찾기가 매우 어려워 이런 문제를 빠르게 찾아내는 것은 항상 골칫거리였다. google이 opensource로 공개한 sanitizer 동적 분석 도구는 c/c++ 개발자들이 효율적으로 문제를 찾아내어 개발 효율성을 높이는 데 도움을 준다.
Sanitizer란
sanitizer는 google이 오픈소스로 공개한 동적 코드 분석 도구로 Clang 3.1과 GCC 4.8 부터 Clang과 GCC에 통합되었다. 프로그래머가 런타임에 프로그램의 메모리 오류와 멀티스레드 오류를 빠르고 정확하게 찾아낼 수 있도록 도와준다.
Sanitizer 도구 모음에는 다음이 포함된다.
- AddressSanitizer (ASan): 버퍼 오버플로우, 해제된 메모리 접근, 널포인터 역참조 등의 메모리 오류 감지
- LeakSanitizer (LSan): 메모리 누수 탐지
- ThreadSanitizer (TSan): 멀티 스레드 data race, deadlock 탐지
- UndefinedBehaviorSanitizer (UBSan): 정의되지 않은 동작 탐지
- MemorySanitizer (MSan): 초기화되지 않은 메모리 접근 탐지
코드 구현 관점에서 모든 sanitizer는 두단계로 동작한다.
- 컴파일 타임 계측
- 런타임 라이브러리
1단계: 컴파일 타임 계측
// 원래 코드
arr[i] = 10;
// Sanitizer가 변환한 코드 (개념적)
if (is_memory_valid(&arr[i])) {
arr[i] = 10;
} else {
report_error("잘못된 메모리 접근!");
}
2단계: 런타임 라이브러리
- malloc, free 같은 함수를 가로채서 자체 버전으로 대체
- 할당된 메모리 주변에 red zone 설정
- 해제된 메모리를 바로 재사용하지 않고 격리 구역에 보관
ASan을 예로들면
- ASan은 컴파일 시 모든 메모리 읽기/쓰기 구문 앞에 코드를 삽입하여, 각 메모리 접근에 해당하는 shadow memory (일반 메모리 상태를 기록하기 위한 추가 메모리) 의 상태를 확인하여 해당 메모리 접근이 합법적인지 검사한다. 또한 스택 변수와 전역 변수 주변에 red zone으로 추가 메모리를 할당하여 메모리 버퍼 오버플로우를 탐지한다.
- ASan 런타임 라이브러리는 malloc/free, operator new/delete 등의 메모리 할당 함수 구현을 대체한다. 이로써 어플리케이션의 모든 메모리 할당은 ASan 이 구현한 메모리 할당자가 담당하게 된다. ASan 메모리 할당자는 할당된 힙 메모리 주변에 추가 메모리를 할당하여 힙 메모리 오버플로우를 탐지하고 해제된 메모리를 격리 구역에 우선 배치하여 heap-use-after-free와 같은 힙 메모리 오류를 탐지한다.
실제로 ASan 라이브러리는 malloc, free, operator new/delete, memcpy, memmove, strcpy, strcat, pthread_create 등 매우 많은 라이브러리 함수의 구현을 대체한다.
사용법은 다음과 같다.
# ASan 사용 (메모리 오류 검사)
g++ -fsanitize=address -g mycode.cpp -o mycode
./mycode
# TSan 사용 (데이터 경쟁 검사)
g++ -fsanitize=thread -g mycode.cpp -o mycode
./mycode
그렇다면 sanitizer는 어떻게 malloc/free와 같은 함수 구현을 대체할 수 있을까?
답은 sanitizer의 interceptor 개념이다.
Symbol Interposition
interceptor는 sanitizier가 malloc, free, memcpy 같은 표준 라이브러리 함수를 가로채는 개념이다.
(1) 프로그램이 malloc() 호출
(2) 원래: libc의 malloc 실행
-> Sanitizer 적용 시: ASan의 malloc 실행 (메모리 상태 추적)
이렇게 함수를 가로채서 sanitizer가 메모리 할당/해제를 추적할 수 있게 된다.
그럼 어떻게 어플리케이션에서 libc의 malloc 구현을 내가 구현한 버전으로 대체할 수 있을까?
이것이 Sanitizer가 하는 일의 핵심이다.
- 가장 간단한 방법은 어플리케이션에서 동일한 이름의 malloc 함수를 정의하는 것이다.
- 또 다른 방법은 우리의 malloc 함수를 libmymalloc.so 에 구현한 다음, 어플리케이션을 실행하기 전에 환경 변수 LD_PRELOAD=/path/to/libmymalloc.so 를 설정하는 것이다.
위의 두 방법이 동작하는 이유는 symbol interposition 때문이다. 동적 링커가 심볼(함수 이름)을 찾는 순서가 정해져 있기 때문이다.
ELF specification 5장 "Program Loading and Dynamic Linking" 에서 다음과 같이 언급한다.
심볼 참조를 해석할 때 동적 링커는 너비 우선 탐색(breadth-first search) 방식으로 심볼 테이블을 검사합니다.
즉, 먼저 실행 파일 전체의 심볼 테이블을 확인하고, 그 다음 DT_NEEDED 항목들의 심볼 테이블을 (순서대로) 확인하며,
그 다음 2단계 DT_NEEDED 항목들을 확인하는 식으로 진행됩니다.
- 1순위: 실행 파일 자체
- 2순위: 직접 링크된 .so 파일들 (순서대로)
- 3순위: 그 .so 파일들이 의존하는 .so 파일들
동적 링커는 심볼 참조를 바인딩할 때 너비 우선 탐색 순서로 심볼을 찾는다.
만약 하나의 심볼이 여러 컴포넌트(executable, shared object) 에 정의되어 있다면 동적 링커는 가장 먼저 발견한 정의를 선택한다.
만약 다음과 같은 의존구조일때 심볼 탐색 순서는 test-symbind → libW.so → libX.so → libc.so.6 → libw.so → libx.so 이다.
test-symbind
├── libW.so
│ ├── libw.so
│ └── libc.so.6
├── libX.so
│ ├── libx.so
│ └── libc.so.6
└── libc.so.6
// main.c
extern int W(), X();
int main() { return (W() + X()); }
// W.c
extern int b();
int a() { return (1); }
int W() { return (a() - b()); }
// w.c
int b() { return (2); }
// X.c
extern int b();
int a() { return (3); }
int X() { return (a() - b()); }
// x.c
int b() { return (4); }
$ gcc -o libw.so -shared w.c
$ gcc -o libW.so -shared W.c -L. -lw -Wl,-rpath=.
$ gcc -o libx.so -shared x.c
$ gcc -o libX.so -shared X.c -L. -lx -Wl,-rpath=.
$ gcc -o test-symbind main.c -L. -lW -lX -Wl,-rpath=.
실행 파일과 동적 라이브러리 간의 의존 관계는 다음과 같다.
test-symbind
/ | \
↓ ↓ ↓
libW.so libX.so libc.so.6
/ \ / \
↓ ↓ ↓ ↓
libw.so libc.so.6 libx.so libc.so.6
- test-symbind 실행 파일은 libW.so, libX.so, libc.so.6에 의존
- libW.so 는 libw.so와 libc.so.6에 의존
- libX.so는 libc.so.6와 libx.so에 의존
함수 a() 는 두군데 정의되어있다. (W.c, X.c) 하지만 탐색 순서에서 libW.so가 libX.so 보다 먼저 나오기 때문에 W.c 의 a()가 호출된다.
Sanitizer에서는 -fsanitize=address 옵션이 libasan.so를 의존 라이브러리 목록의 맨 앞에 배치하기 때문에 libc.so 의 malloc 보다 libasan.so 의 malloc 이 먼저 발견되어 sanitizer 버전이 선택된다.
Symbol Interposition 실제 동작 확인
앞서 설명한대로, 심볼 탐색 순서는 test-symbind → libW.so → libX.so → libc.so.6 → libw.so → libx.so 이다.
동적 링커는 LD_DEBUG 환경 변수를 통해 디버그 정보를 출력할 수 있다.
LD_DEBUG="symbols:bindings"를 설정하여 test-symbind의 symbol binding 과정을 확인해보면
$ LD_DEBUG="symbols:bindings" ./test-symbind
1884890: symbol=a; lookup in file=./test-symbind [0]
1884890: symbol=a; lookup in file=./libW.so [0]
1884890: binding file ./libW.so [0] to ./libW.so [0]: normal symbol `a'
1884890: symbol=b; lookup in file=./test-symbind [0]
1884890: symbol=b; lookup in file=./libW.so [0]
1884890: symbol=b; lookup in file=./libX.so [0]
1884890: symbol=b; lookup in file=/lib/x86_64-linux-gnu/libc.so.6 [0]
1884890: symbol=b; lookup in file=./libw.so [0]
1884890: binding file ./libW.so [0] to ./libw.so [0]: normal symbol `b'
1884890: symbol=a; lookup in file=./test-symbind [0]
1884890: symbol=a; lookup in file=./libW.so [0]
1884890: binding file ./libX.so [0] to ./libW.so [0]: normal symbol `a'
1884890: symbol=b; lookup in file=./test-symbind [0]
1884890: symbol=b; lookup in file=./libW.so [0]
1884890: symbol=b; lookup in file=./libX.so [0]
1884890: symbol=b; lookup in file=/lib/x86_64-linux-gnu/libc.so.6 [0]
1884890: symbol=b; lookup in file=./libw.so [0]
1884890: binding file ./libX.so [0] to ./libw.so [0]: normal symbol `b'
함수 a는 libW.so와 libX.so 둘 다 정의되어있지만 최종적으로 a에 대한 모든 참조는 libW.so 의 함수 a 구현에 바인딩된다.
함수 b는 libw.so와 libx.so 둘 다에 정의되어있지만 libw.so가 먼저이기 때문에 최종적으로 libw.so 의 함수 b 구현에 바인딩된다.
이제 앞서 언급한 두가지 malloc 대체 방식이 왜 동작하는지 이해할 수 있다.
ASan 검증
다음 코드를 GCC와 Clang에서 ASan을 활성화하여 컴파일해보자.
// test.cpp
#include <iostream>
int main() {
std::cout << "Hello AddressSanitizer!\n";
}
GCC 에서의 동작
gcc는 기본적으로 런타임 라이브러리를 동적 링크하므로 다음 명령어로 test-gcc-asan 이 의존하는 동적 라이브러리를 확인할 수 있다.
$ g++ -fsanitize=address test.cpp -o test-gcc-asan
$ objdump -p test-gcc-asan | grep NEEDED
NEEDED libasan.so.5
NEEDED libstdc++.so.6
NEEDED libm.so.6
NEEDED libgcc_s.so.1
NEEDED libc.so.6
test-gcc-asan 이 의존하는 동적 라이브러리 중 libasan.so 의 순서가 libc.so.6 보다 앞선다는 것을 확인할 수 있다.
링크 시 -fsanitize=address 옵션은 libasan.so 를 프로그램의 첫번째 의존 라이브러리로 만든다.
LD_DEBUG="bindings" 환경 변수를 설정하여 test-gcc-asan의 symbol binding 과정을 확인해보자.
$ LD_DEBUG="bindings" ./test-gcc-asan
3309213: binding file /lib/x86_64-linux-gnu/libc.so.6 [0] to /usr/lib/x86_64-linux-gnu/libasan.so.5 [0]: normal symbol `malloc' [GLIBC_2.2.5]
3309213: binding file /lib64/ld-linux-x86-64.so.2 [0] to /usr/lib/x86_64-linux-gnu/libasan.so.5 [0]: normal symbol `malloc' [GLIBC_2.2.5]
3309213: binding file /usr/lib/x86_64-linux-gnu/libstdc++.so.6 [0] to /usr/lib/x86_64-linux-gnu/libasan.so.5 [0]: normal symbol `malloc' [GLIBC_2.2.5]
동적 링커가 libc.so.6, ld-linux-x86-64.so, libstdc++.so의 malloc 참조를 모두 libasan.so 의 malloc 구현에 바인딩한 것을 확인할 수 있다.
Clang 의 동작
clang은 기본적으로 ASan 런타임 라이브러리를 정적 링크하므로 test-clang-asan 이 의존하는 동적 라이브러리는 확인하지 않고 바로 symbol binding 과정을 살펴보자.
$ clang++ -fsanitize=address test.cpp -o test-clang-asan
$ LD_DEBUG="bindings" ./test-clang-asan
3313022: binding file /lib/x86_64-linux-gnu/libc.so.6 [0] to ./test-clang-asan [0]: normal symbol `malloc' [GLIBC_2.2.5]
3313022: binding file /lib64/ld-linux-x86-64.so.2 [0] to ./test-clang-asan [0]: normal symbol `malloc' [GLIBC_2.2.5]
3313022: binding file /usr/lib/x86_64-linux-gnu/libstdc++.so.6 [0] to ./test-clang-asan [0]: normal symbol `malloc' [GLIBC_2.2.5]
마찬가지로 동적 링커가 libc.so.6, ld-linux-x86-64.so.2, libstdc++.so의 malloc 참조를 모두 test-clang-asan의 malloc 구현에 바인딩한것을 확인할 수 있다.
References
'System Programming > Kernel' 카테고리의 다른 글
| xv6 #1 booting in xv6 (0) | 2025.05.05 |
|---|