Devin.KR

C++ 메모리 버그 디버깅 - UBSan, 표준 라이브러리 검사, 정적 분석과 ASan (C++ 객체와 자원 관리 12장)

개발자 조회 1

이 장에서 배우는 것

이 책에서 본 버그 대부분은 조용히 틀렸다. 매달린 참조는 0을 출력했고(1장), 재할당 뒤 옛 참조는 빈 문자열을 출력했고(6장), 사라진 캡처는 7777을 출력했다(7장). 프로그램은 죽지 않았고 종료 코드도 0이었다. 이런 정의되지 않은 동작은 테스트를 통과하고 배포된 뒤, 다른 컴파일러나 최적화 옵션에서 갑자기 모습을 바꾼다. 이 장에서는 그런 버그를 도구로 잡는 방법을 정리한다.

  • UBSan(-fsanitize=undefined)으로 정수 넘침, 잘못된 시프트, 배열 범위 밖 접근을 실행 중에 잡는다.
  • libc++ 검사 모드로 vector·string_view의 범위 밖 접근을 잡는다.
  • clang 정적 분석기(--analyze)로 해제 후 사용, 누수, 이중 해제를 실행 없이 찾는다.
  • ASan(-fsanitize=address)의 역할과 사용법을 정리하고, 이 책의 검증 환경에서 ASan이 동작하지 않은 기록을 그대로 남긴다.

문제 상황

로봇 휠 엔코더의 틱 수를 int에 누적하는 코드가 있다. 몇 시간 동안은 멀쩡하다가, 누적값이 int 최댓값(약 21억)을 넘는 순간 음수가 되어 로봇이 "뒤로 21km를 갔다"고 판단한다. 같은 코드에 비트 마스크를 만드는 시프트와, 게인 표를 인덱스로 읽는 코드도 있다. 세 버그 모두 컴파일 경고 없이 통과하고, 실행해도 멈추지 않는다.

#include <cstdint>
#include <iostream>

int total_ticks(int start, int steps, int per_step) {
    int total = start;
    for (int i = 0; i < steps; ++i) total += per_step;
    return total;
}

std::uint32_t make_mask(int bit) { return 1u << bit; }

int lookup(int index) {
    int gains[4] = {10, 20, 30, 40};
    return gains[index];
}

int main(int argc, char**) {
    std::cout << "ticks = " << total_ticks(2'147'483'000, 10, 100) << std::endl;
    std::cout << "mask = " << make_mask(31 + argc) << std::endl;
    std::cout << "gain = " << lookup(3 + argc) << std::endl;
}

main(int argc, char**)에서 argc(인자 없이 실행하면 1)를 더한 것은, 컴파일러가 값을 미리 계산해 버리지 못하게 하려는 장치다. 실제 코드에서 값이 입력이나 센서에서 오는 상황을 흉내 낸 것이다.

완성 코드

이 장의 "완성 코드"는 코드가 아니라 빌드 명령이다. 같은 소스를 목적에 따라 다른 옵션으로 빌드한다.

# 1) 평소 개발: 경고를 모두 켠다
clang++ -std=c++20 -Wall -Wextra app.cpp -o app

# 2) 정적 분석: 실행하지 않고 경로를 따라가며 검사
clang++ -std=c++20 --analyze app.cpp

# 3) UBSan: 정의되지 않은 동작을 실행 중에 보고
clang++ -std=c++20 -Wall -Wextra -g -fsanitize=undefined app.cpp -o app_ub

# 4) libc++ 검사 모드: 표준 컨테이너의 범위 검사
clang++ -std=c++20 -Wall -Wextra -g -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG app.cpp -o app_hard

# 5) ASan + UBSan: 메모리 오류까지 (Linux 권장, 아래 환경 기록 참고)
clang++ -std=c++20 -Wall -Wextra -g -fsanitize=address,undefined -fno-omit-frame-pointer app.cpp -o app_asan

GCC의 libstdc++라면 4번에 해당하는 옵션은 -D_GLIBCXX_ASSERTIONS다(C++ 표준 라이브러리 구현마다 다르다).

컴파일 경고와 정적 분석은 실행 전에, UBSan 과 libc++ 검사 모드와 ASan 은 검사 빌드를 실행할 때 버그를 잡는다. 도구마다 잡는 버그가 다르므로 함께 쓴다.

줄별 해설

-g

디버그 정보를 넣는다. sanitizer 보고서에 파일:줄:열이 찍히려면 필요하다. 검사 빌드에는 항상 붙인다.

-fsanitize=undefined

컴파일러가 정의되지 않은 동작이 될 수 있는 연산(부호 있는 정수 덧셈, 시프트, 고정 배열 인덱스, 널 포인터 역참조 등) 앞에 검사 코드를 넣는다. 실행 중에 위반이 생기면 한 줄 보고를 출력하고 기본으로는 계속 실행한다. 속도 저하가 작아서 테스트 빌드에 항상 켜 두기 좋다.

-fno-sanitize-recover=undefined

첫 위반에서 프로그램을 멈추게 한다. 자동 테스트에서 위반을 "실패"로 만들려면 이 옵션을 쓴다.

-D_LIBCPP_HARDENING_MODE=...DEBUG

libc++의 operator[], front(), string_view 인덱스 등에 범위 검사를 켠다. UBSan은 C 배열의 범위는 보지만 std::vectoroperator[]는 보지 못한다. 이 빈틈을 메운다. 최근 libc++에는 비용이 작은 FAST, EXTENSIVE 단계도 있어서 배포 빌드에 켜 두는 팀도 있다.

--analyze

clang 정적 분석기다. 프로그램을 실행하지 않고, 함수 안의 가능한 실행 경로를 따라가며 "이 경로에서는 해제된 메모리를 읽는다"를 찾는다. 실행되지 않는 오류 경로까지 볼 수 있는 대신, 함수 경계를 넘는 복잡한 흐름은 놓칠 수 있다.

실제 실행 결과

UBSan

$ clang++ -std=c++20 -Wall -Wextra -g -fsanitize=undefined ubsan_demo.cpp -o ubsan_demo && ./ubsan_demo
ubsan_demo.cpp:6:43: runtime error: signed integer overflow: 2147483600 + 100 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ubsan_demo.cpp:6:43 
ticks = -2147483296
ubsan_demo.cpp:10:46: runtime error: shift exponent 32 is too large for 32-bit type 'unsigned int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ubsan_demo.cpp:10:46 
mask = 1
ubsan_demo.cpp:14:12: runtime error: index 4 out of bounds for type 'int[4]'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ubsan_demo.cpp:14:12 
gain = 72990100

UBSan 보고는 파일 이름과 줄·열, runtime error 표시, 잘못된 연산과 실제 값, 그 이유로 이루어진다. ubsan_demo.cpp 실제 출력의 첫 줄을 나눈 그림이다.

세 버그가 모두 정확한 위치와 함께 보고되었다. 보고 뒤의 출력(ticks = -2147483296, mask = 1, gain = 75627924)이 바로 경고 없이 빌드했을 때 조용히 나왔을 값이다. 특히 시프트 결과 1은 "32번 비트"가 아니라 0번 비트를 켠 것처럼 보여, 엉뚱한 레지스터 비트를 건드리는 펌웨어 버그가 된다. 멈추게 하면 이렇게 된다.

$ clang++ -std=c++20 -Wall -Wextra -g -fsanitize=undefined -fno-sanitize-recover=undefined ubsan_demo.cpp -o ubsan_stop && ./ubsan_stop
ubsan_demo.cpp:6:43: runtime error: signed integer overflow: 2147483600 + 100 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ubsan_demo.cpp:6:43 
ticks = /bin/bash: line 1: 44292 Abort trap: 6           ./ubsan_stop

첫 위반에서 종료했다(종료 코드 134). ticks = 까지만 찍힌 것은 출력 문장의 앞부분을 이미 보낸 뒤 total_ticks 안에서 멈췄기 때문이다.

libc++ 검사 모드

#include <iostream>
#include <string_view>
#include <vector>

int main() {
    std::vector<double> joints{0.1, 0.2, 0.3};
    std::cout << "joints[2] = " << joints[2] << std::endl;
    std::string_view name = "elbow";
    std::cout << "name[4] = " << name[4] << std::endl;
    std::cout << "joints[3] = " << joints[3] << std::endl;
}
$ clang++ -std=c++20 -Wall -Wextra -g -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG hardening_demo.cpp -o hardening_demo && ./hardening_demo
joints[2] = 0.3
name[4] = w
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/c++/v1/__vector/vector.h:403: assertion __n < size() failed: vector[] index out of bounds
joints[3] = /bin/bash: line 1: 44299 Abort trap: 6           ./hardening_demo

joints[3](원소는 3개)에서 "vector[] index out of bounds" 검사에 걸려 멈췄다. 검사가 없었다면 이 줄은 옆 메모리의 값을 조용히 출력했을 것이다. 6장의 "v[i]의 범위를 믿는다" 실수를 개발 빌드에서 잡는 방법이다.

정적 분석기

#include <cstring>
#include <iostream>

struct Packet {
    int id;
    char payload[32];
};

Packet* make_packet(int id, const char* text) {
    Packet* p = new Packet{id, {}};
    std::strncpy(p->payload, text, sizeof(p->payload) - 1);
    return p;
}

void send(Packet* p) {
    std::cout << "send " << p->id << '\n';
    delete p;
}

void resend_after_free() {
    Packet* a = make_packet(1, "hello");
    send(a);
    std::cout << "재전송 " << a->id << '\n';
}

bool early_return(bool busy) {
    Packet* b = make_packet(2, "world");
    if (busy) return false;
    send(b);
    return true;
}

void double_send() {
    Packet* c = make_packet(3, "again");
    send(c);
    send(c);
}

int main() {
    resend_after_free();
    early_return(false);
    double_send();
}
$ clang++ -std=c++20 --analyze analyze_demo.cpp
analyze_demo.cpp:23:34: warning: Use of memory after it is freed [cplusplus.NewDelete]
   23 |     std::cout << "재전송 " << a->id << '\n';
      |                               ^~~~~
analyze_demo.cpp:28:22: warning: Potential leak of memory pointed to by 'b' [cplusplus.NewDeleteLeaks]
   28 |     if (busy) return false;
      |                      ^~~~~
analyze_demo.cpp:36:5: warning: Use of memory after it is freed [cplusplus.NewDelete]
   36 |     send(c);
      |     ^~~~~~~
3 warnings generated.

세 함수의 버그가 모두 보고되었다. resend_after_freesend 안에서 해제된 패킷을 다시 읽고(해제 후 사용), early_returnbusy가 참인 경로에서 패킷을 놓치고(누수), double_send는 두 번째 send에서 이미 해제된 패킷을 넘긴다. 정적 분석기는 send의 본문까지 따라가서 delete가 있다는 것을 알아냈다. main에서 early_return(false)만 부르므로 누수 경로는 실행 테스트로는 절대 드러나지 않는다. 정적 분석의 강점이다.

ASan: 검증 환경 기록

ASan(AddressSanitizer)은 모든 힙·스택 메모리 옆에 "그림자 메모리"를 두고, 해제된 메모리나 범위 밖 메모리에 접근하는 순간 멈추며 어디서 접근했는지, 그 메모리를 누가 할당하고 누가 해제했는지 세 개의 호출 스택을 보여 준다. 1장, 6장, 7장, 8장의 버그는 모두 ASan이 잡는 종류다. 게임·로봇 회사의 C++ 테스트 빌드에서 가장 널리 쓰이는 도구다.

그런데 이 책의 예제를 검증한 Mac(macOS 26.6, Apple clang 17)에서는 ASan으로 빌드한 프로그램이 시작 단계에서 멈춰 끝나지 않았다. 아래는 해제 후 사용 예제를 평소처럼 빌드한 결과와 ASan으로 빌드한 결과다.

#include <iostream>

int main() {
    int* speeds = new int[4]{1, 2, 3, 4};
    delete[] speeds;
    std::cout << speeds[0] << std::endl;
}
$ clang++ -std=c++20 -Wall -Wextra asan_probe.cpp -o plain_uaf && ./plain_uaf
7
$ clang++ -std=c++20 -Wall -Wextra -g -fsanitize=address,undefined asan_probe.cpp -o asan_probe && ./asan_probe
(검증 스크립트 메모: 30초 동안 끝나지 않아 강제 종료함)

평소 빌드는 해제된 배열을 읽어 원래 값(1)도 아닌 7을 출력하고 정상 종료했다. ASan 빌드는 30초 동안 아무것도 출력하지 않아 검증 스크립트가 강제로 끝냈다. 진단 옵션(ASAN_OPTIONS=verbosity=2)으로 보면 그림자 메모리 영역을 찾는 초기화 단계(FindDynamicShadowStart)에서 진행하지 못했다. 같은 환경에서 ThreadSanitizer도 시작하자마자 비정상 종료했다. 반면 UBSan은 그림자 메모리를 쓰지 않아 정상 동작했다. 운영체제와 툴체인 조합의 문제로 보이며, 이 책에는 실제로 얻지 못한 ASan 보고서를 지어내 싣지 않았다.

ASan을 쓰려면 다음 환경을 권한다.

  • Linux(Ubuntu 등)의 clang 또는 gcc: 위 5번 명령 그대로 쓴다. Windows라면 WSL2 안의 Linux도 된다.
  • Linux에서는 ASan에 누수 검사(LeakSanitizer)가 기본으로 포함되어, 프로그램 종료 시 해제하지 않은 메모리를 보고한다. macOS에서는 누수 검사가 기본으로 켜져 있지 않고, 지원 여부도 툴체인과 CPU에 따라 다르다.
  • 보고서를 읽을 때는 맨 위의 ERROR: AddressSanitizer: heap-use-after-free 같은 종류 이름, 그 아래 첫 번째 스택(내 코드에서 가장 위 줄), 그리고 freed by thread 스택(누가 해제했나) 순서로 본다.

표 12-1. 이 책의 버그를 잡는 도구

버그(장)경고정적 분석실행 중 검사
지역 변수 참조 반환(1장)잡음잡음ASan(use-after-return 옵션)
멤버 초기화 순서(2장)잡음--
얕은 복사 이중 해제(4장)-잡음(검증함)ASan, 할당기의 비정상 종료
재할당 뒤 옛 참조(6장)--ASan(heap-use-after-free)
vector 범위 밖(6장)--libc++ 검사 모드(검증함), ASan
참조 캡처 수명(7장)--ASan(stack-use-after-return)
임시 문자열 view(8장)잡음(-Wdangling-gsl)-ASan
정수 넘침·시프트(12장)--UBSan(검증함)

표의 "검증함"은 이 책의 검증 환경에서 실제 출력을 확인한 칸이다. ASan 칸은 도구의 공식 기능 설명에 근거한 것이고 이 환경에서 실행 확인은 하지 못했다.

표 12-2. 검사 빌드 도구의 비용과 쓰는 곳

도구실행 속도메모리쓰는 곳
경고(-Wall -Wextra)영향 없음영향 없음모든 빌드
정적 분석빌드 시간 증가영향 없음CI, 코드 리뷰 전
UBSan조금 느림거의 없음테스트 빌드, 때로 배포 빌드 일부
libc++ 검사 모드단계에 따라 작음~보통영향 없음개발 빌드, FAST 단계는 배포도
ASan보통 2배 안팎 느림크게 늘어남테스트 빌드, 퍼징

실무에서 자주 틀리는 것

1. 검사 빌드를 한 번도 돌리지 않는다

도구가 있어도 CI에 넣지 않으면 쓰지 않은 것과 같다. 단위 테스트를 UBSan(+ 가능하면 ASan) 빌드로도 돌리는 작업을 CI에 하나 추가하는 것이 가장 효과가 크다.

2. 보고를 출력만 하고 넘어간다

UBSan은 기본으로 계속 실행하므로 로그에 묻힌다. 테스트에서는 -fno-sanitize-recover=undefined로 실패하게 만든다.

3. 넘침을 막으려고 unsigned로 바꾼다

unsigned 넘침은 정의된 동작(0으로 돌아감)이라 UBSan이 보고하지 않을 뿐, 틱 수가 0으로 돌아가는 버그는 그대로다. 필요한 범위를 담는 타입(std::int64_t)을 쓰거나 넘침을 직접 검사한다.

4. 검사 빌드와 배포 빌드를 섞는다

sanitizer로 빌드한 라이브러리와 일반 빌드를 한 프로그램에 섞어 링크하면 보고가 누락되거나 링크가 실패한다. CMake에서는 11장의 ENABLE_SANITIZERS처럼 옵션 하나로 전체를 같이 켠다.

연습 문제

  1. std::uint32_t u = UINT32_MAX; u += 1;-fsanitize=undefined로 빌드해 실행하면 보고가 나오는가? u의 값은?
  2. total_ticks의 누적 변수를 long long으로 바꾸면 total_ticks(2'147'483'000, 10, 100)에 해당하는 계산 결과는 얼마인가?
  3. hardening_demo.cpp에서 검사 모드 없이도 범위 밖 접근을 예외로 알 수 있게 하려면 joints[3]을 무엇으로 바꾸는가? 두 방법의 차이는?

정답

  1. 보고가 나오지 않고 u는 0이다. 부호 없는 정수의 넘침은 C++에서 정의된 동작(2의 32제곱으로 나눈 나머지)이라 -fsanitize=undefined 기본 검사 대상이 아니다. clang에는 이것까지 보고하는 -fsanitize=unsigned-integer-overflow가 따로 있다. 검증 프로그램을 UBSan으로 빌드해 보고 없이 0이 되는 것을 확인했다.
  2. 2,147,483,000 + 10 × 100 = 2,147,484,000이다. long long은 최소 64비트라 넘치지 않는다. 검증 프로그램에서 확인했다.
  3. joints.at(3)으로 바꾸면 std::out_of_range 예외가 던져진다(검증 프로그램에서 확인). at()은 표준이 보장하는 동작이라 어떤 빌드에서도 검사하고, 예외로 복구할 수 있다. 검사 모드는 operator[] 코드를 고치지 않고 빌드 옵션만으로 검사를 켜며, 위반 시 복구 없이 프로그램을 멈춘다. 입력에서 온 인덱스는 at()이나 명시적 검사, 내부 논리 오류는 검사 모드로 잡는 것이 일반적인 역할 분담이다.

이 책은 "누가 이 자원의 주인인가, 언제 사라지는가"라는 질문 하나로 참조에서 sanitizer까지 왔다. 다음 단계인 『RAII·템플릿·동시성 설계 심화』에서는 같은 질문을 스레드와 설계 수준으로 넓힌다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.