UBSan으로 C 미정의 동작 런타임에 잡기 — gcc·clang 실측 비교

정수 오버플로나 배열 범위 밖 접근처럼 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)
옵션gccclang잡는 것
-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%, 부호 없는 루프에서는 측정되지 않았다.

참고

답글 남기기