정수 오버플로나 배열 범위 밖 접근처럼 C의 미정의 동작(undefined behavior)은 대개 크래시 없이 조용히 지나간다. 컴파일 경고로 잡히는 것은 상수로 접근하는 뻔한 경우뿐이고, 인덱스나 시프트 값이 런타임에 결정되면 경고가 나오지 않는다. UBSan(UndefinedBehaviorSanitizer)은 이런 연산 지점마다 검사 코드를 심어 실제로 규칙을 어기는 순간 파일·행·값을 찍어준다. 이 글에서는 gcc 13.3과 clang 18에서 UBSan이 무엇을 잡고 무엇을 못 잡는지, 보고 후 계속 진행하는 기본 동작을 어떻게 중단으로 바꾸는지, 계측 비용이 얼마나 되는지를 실측으로 정리한다.
$ gcc --version | head -1
gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0
$ clang --version | head -1
Ubuntu clang version 18.1.3 (1ubuntu1)
무엇을 잡는가
검사 대상을 한 파일에 모아놓고 인자로 골라 실행한다. 모든 값이 런타임에 결정되므로 컴파일 경고로는 잡히지 않는다.
#include <stdio.h>
#include <limits.h>
#include <stdlib.h>
int add_ovf(int a, int b) { return a + b; }
int shift_big(int v, int s) { return v << s; }
int div_zero(int a, int b) { return a / b; }
int oob(void) { int t[4] = {1,2,3,4}; int i = 5; return t[i]; }
int null_deref(int *p) { return *p; }
int main(int argc, char **argv)
{
int which = argc > 1 ? atoi(argv[1]) : 0;
switch (which) {
case 1: printf("add_ovf = %d\n", add_ovf(INT_MAX, 1)); break;
case 2: printf("shift_big= %d\n", shift_big(1, 33)); break;
case 3: printf("div_zero = %d\n", div_zero(7, 0)); break;
case 4: printf("oob = %d\n", oob()); break;
case 5: printf("null = %d\n", null_deref(NULL)); break;
default: puts("usage: ./ub 1..5");
}
return 0;
}$ gcc -O1 -fsanitize=undefined -fno-omit-frame-pointer -g -o ub ub.c
$ for i in 1 2 3 4 5; do echo "--- case $i ---"; ./ub $i; done
--- case 1 ---
ub.c:5:43: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
add_ovf = -2147483648
--- case 2 ---
ub.c:6:44: runtime error: shift exponent 33 is too large for 32-bit type 'int'
shift_big= 0
--- case 3 ---
ub.c:7:44: runtime error: division by zero
--- case 4 ---
ub.c:8:76: runtime error: index 5 out of bounds for type 'int [4]'
ub.c:8:76: runtime error: load of address 0x7ffee8264114 with insufficient space for an object of type 'int'
0x7ffee8264114: note: pointer points here
02 00 00 00 00 00 00 00 00 c1 bb ef fa 20 2c 07 60 4d 0a e8 7b 5a 00 00 dd 50 26 e8 fe 7f 00 00
^
oob = 0
--- case 5 ---
ub.c:9:42: runtime error: load of null pointer of type 'int'
범위 밖 접근은 인덱스 위반과 “객체 크기가 부족한 주소에서 로드” 두 건으로 찍히고, 바이트 덤프까지 붙는다. clang은 같은 코드에 SUMMARY 한 줄을 더 붙인다.
$ clang -O1 -g -fsanitize=undefined -o ub ub.c
--- case 1 ---
ub.c:5:43: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ub.c:5:43
--- case 4 ---
ub.c:8:75: runtime error: index 5 out of bounds for type 'int[4]'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ub.c:8:75
기본은 보고하고 계속 진행한다
가장 중요한 함정이다. UBSan은 기본적으로 메시지만 찍고 실행을 이어가며 종료 코드도 0이어서, CI에서 종료 코드만 보면 전부 통과로 보인다.
$ for i in 1 2 4 5; do ./ub $i >/dev/null 2>&1; echo "case $i 종료코드=$?"; done
case 1 종료코드=0
case 2 종료코드=0
case 4 종료코드=0
case 5 종료코드=139 <- 널 참조는 SIGSEGV로 죽어서 139
$ ./ub 3 >/dev/null 2>&1; echo "case 3 종료코드=$?"
case 3 종료코드=136 <- 0으로 나누기는 SIGFPE로 죽어서 136
-fno-sanitize-recover=all을 주면 첫 위반에서 멈춘다. gcc는 이때 종료 코드 1로 끝낸다.
$ gcc -O1 -fsanitize=undefined -fno-sanitize-recover=all -g -o ub_abort ub.c
$ ./ub_abort 1
ub.c:5:43: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
$ ./ub_abort 1 >/dev/null 2>&1; echo $?
1
환경변수로도 조절한다. print_stacktrace=1은 호출 경로를 함께 찍어 어디서 들어온 값인지 바로 보여준다.
$ UBSAN_OPTIONS=print_stacktrace=1 ./ub 1
ub.c:5:43: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
#0 0x5f5a77b8c4b9 in add_ovf ub.c:5
#1 0x5f5a77b8c4b9 in main ub.c:15
#2 0x723fb9e2a1c9 (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9)
#3 0x723fb9e2a28a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a)
#4 0x5f5a77b8c1c4 in _start (ub+0x11c4)
add_ovf = -2147483648
| 설정 | 효과 |
|---|---|
| 기본 | 메시지 출력 후 계속 진행, 종료 코드 0 |
-fno-sanitize-recover=all | 첫 위반에서 종료(gcc는 종료 코드 1) |
UBSAN_OPTIONS=halt_on_error=1 | 런타임에 같은 효과 |
UBSAN_OPTIONS=print_stacktrace=1 | 호출 스택 출력 |
-fsanitize-trap=undefined | 런타임 라이브러리 없이 ud2 트랩, SIGILL로 즉사 |
트랩 모드: 런타임 라이브러리 없이
-fsanitize-trap=undefined는 메시지를 못 찍는 대신 libubsan에 링크하지 않는다. 임베디드나 커널처럼 런타임을 못 쓰는 환경에서 쓰는 모드다.
$ gcc -O1 -fsanitize=undefined -fsanitize-trap=undefined -g -o ub_trap ub.c
$ ./ub_trap 1 >/dev/null 2>&1; echo $?
132 <- SIGILL
$ gcc -O1 -g -o ub_plain ub.c # 비교용 일반 빌드
$ for b in ub ub_abort ub_trap ub_plain; do \
printf "%-9s %7s bytes libubsan=%s\n" "$b" "$(stat -c%s $b)" "$(ldd $b | grep -c ubsan)"; done
ub 22616 bytes libubsan=1
ub_abort 22016 bytes libubsan=1
ub_trap 20008 bytes libubsan=0
ub_plain 20160 bytes libubsan=0
계측 비용
검사가 들어갈 연산이 있어야 비용이 생긴다. 부호 있는 산술과 배열 인덱싱을 1억 번 돌리되 실제 위반은 없는 코드로 측정했다.
#include <stdio.h>
#include <stdlib.h>
/* 부호 있는 산술 + 배열 인덱싱: UBSan이 실제로 검사를 삽입하지만 UB는 발생하지 않는다 */
int main(int argc, char **argv)
{
long n = argc > 1 ? atol(argv[1]) : 100000000L;
int tbl[256];
for (int i = 0; i < 256; i++) tbl[i] = i;
int acc = 0;
for (long i = 1; i <= n; i++) {
int idx = (int)(i & 0xFF);
acc = (acc + tbl[idx] * 3) % 1000003; /* 곱·합·나머지 전부 검사 대상 */
}
printf("acc=%d\n", acc);
return 0;
}# 3회 중 최저값, -O2
b2_plain 0.26 s
b2_ubsan 0.28 s
b2_trap 0.27 s
$ gcc -O2 -S -o - bench2.c | grep -c '__ubsan'
0
$ gcc -O2 -fsanitize=undefined -S -o - bench2.c | grep -c '__ubsan'
11
검사 호출이 11곳 들어갔는데도 이 루프에서는 8% 정도다. 반대로 부호 없는 산술만 쓰는 루프는 검사 대상이 아예 없어 차이가 측정되지 않았다.
# 같은 루프를 unsigned로 바꿔 돌린 경우 (acc = acc * 31u + ...)
b_plain 0.21 s
b_ubsan 0.21 s
b_trap 0.21 s
b_asan 0.21 s
UBSan이 못 잡는 것
strict aliasing 위반
타입 규칙 위반은 UBSan의 검사 대상이 아니다. -O2에서 값이 틀어지는 코드인데도 아무 보고가 없다.
#include <stdio.h>
int reload(int *ip, float *fp) { *ip = 1; *fp = 0.0f; return *ip; }
int main(void){ int s=0; printf("reload=%d\n", reload(&s,(float*)&s)); return 0; }$ gcc -O2 -fsanitize=undefined -o nc nocatch.c && ./nc
reload=1 <- 메모리에는 0이 들어 있는데 보고 없음
힙 경계 위반
스택 배열의 범위 밖 접근은 잡지만 malloc 영역은 ASan의 몫이다.
#include <stdlib.h>
#include <stdio.h>
int main(void){ volatile int *a = malloc(4*sizeof(int)); a[4] = 7; printf("%d\n", a[4]); free((void*)a); return 0; }$ gcc -O0 -g -fsanitize=undefined -o ho_ub heapoob.c && ./ho_ub
7 <- 보고 없음
$ gcc -O0 -g -fsanitize=address -o ho_as heapoob.c && ./ho_as
=================================================================
==11217==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x502000000020 ...
WRITE of size 4 at 0x502000000020 thread T0
#0 0x582551e4f2a1 in main heapoob.c:3
clang 전용 검사
clang에는 표준상 정의된 동작이지만 보통 버그인 것들을 잡는 그룹이 더 있다.
#include <stdio.h>
#include <limits.h>
int main(void)
{
unsigned u = UINT_MAX;
u = u + 1; /* 표준상 정의된 동작, 하지만 보통 버그다 */
printf("unsigned wrap = %u\n", u);
int big = 300;
unsigned char c = big; /* 암묵적 축소 변환 */
printf("narrowed = %u\n", c);
return 0;
}$ clang -O1 -g -fsanitize=undefined -o co1 clangonly.c && ./co1
unsigned wrap = 0
narrowed = 44 <- 둘 다 보고 없음
$ clang -O1 -g -fsanitize=integer -o co2 clangonly.c && ./co2
clangonly.c:6:11: runtime error: unsigned integer overflow: 4294967295 + 1 cannot be represented in type 'unsigned int'
clangonly.c:10:23: runtime error: implicit conversion from type 'int' of value 300 (32-bit, signed) to type 'unsigned char' changed the value to 44 (8-bit, unsigned)
| 옵션 | gcc | clang | 잡는 것 |
|---|---|---|---|
-fsanitize=undefined | 있음 | 있음 | 정수 오버플로, 시프트, 0 나누기, 널 참조, 스택 범위 밖 등 |
-fsanitize=integer | 없음 | 있음 | 부호 없는 래핑 + 암묵적 변환까지 |
-fsanitize=implicit-conversion | 없음 | 있음 | 값이 바뀌는 암묵적 변환만 |
-fsanitize=address | 있음 | 있음 | 힙/스택/전역 버퍼 오버플로, use-after-free |
-fsanitize-trap=undefined | 있음 | 있음 | 메시지 없이 트랩(런타임 라이브러리 불필요) |
주의사항
| 상황 | 증상 | 대응 |
|---|---|---|
| CI에서 종료 코드만 확인 | 위반이 있어도 0이라 통과로 보인다 | -fno-sanitize-recover=all 또는 UBSAN_OPTIONS=halt_on_error=1 |
-O0으로만 빌드 | 최적화가 켜질 때만 나타나는 경로를 못 본다 | -O1 이상으로도 돌린다. 행 번호는 -g로 확보 |
-fsanitize-trap으로 메시지 기대 | SIGILL만 나고 아무 설명이 없다 | 원인 파악 단계에서는 트랩 모드를 쓰지 말고 일반 UBSan으로 |
| 타입 퍼닝 버그를 UBSan으로 찾으려 함 | strict aliasing 위반은 검사 대상이 아니다 | -Wstrict-aliasing=2(gcc) 경고와 코드 리뷰로 잡는다 |
| 힙 오버플로를 UBSan으로 찾으려 함 | malloc 영역 위반은 보고되지 않는다 | ASan을 쓴다. UBSan과 ASan은 함께 켤 수 있다 |
| ASan과 UBSan을 운영 빌드에 켜둠 | 메모리 사용량과 실행 시간이 늘고 공격면도 커진다 | 테스트·CI 빌드에만 켜고, 운영 하드닝은 트랩 모드를 검토한다 |
| 오버헤드를 일반화 | 검사 대상 연산 비중에 따라 0%에서 수 배까지 벌어진다 | 자기 워크로드로 직접 재본다 |
| UBSan 통과를 안전 보증으로 해석 | 계측된 경로를 실제로 지나가야만 잡힌다 | 커버리지가 낮은 코드에는 의미가 약하다. 퍼징과 함께 쓴다 |
마무리
-fsanitize=undefined -g -O1로 빌드하면 정수 오버플로·시프트·0 나누기·널 참조·스택 범위 밖을 파일·행·값과 함께 잡는다.- 기본 동작이 “보고하고 계속”이고 종료 코드가 0이므로, CI에서는
-fno-sanitize-recover=all을 반드시 함께 준다. -fsanitize-trap=undefined는libubsan없이 동작하지만 메시지가 없어 원인 파악용으로는 부적합하다.- strict aliasing 위반과 힙 경계 위반은 UBSan 범위 밖이다. 각각 gcc 경고와 ASan이 담당한다.
- clang의
-fsanitize=integer는 표준상 합법인 부호 없는 래핑과 축소 변환까지 잡아준다. - 계측 비용은 검사 대상 연산 비중에 달려 있다. 부호 있는 산술 루프에서 8%, 부호 없는 루프에서는 측정되지 않았다.