Android NDK 입문 (3) - AddressSanitizer, 스택 카나리 알고 네이티브 크래시 미리 막기

2026. 5. 24. 00:07·모바일

얼마 전 운영중인 앱이 native 코드 문제로 런타임에 죽었다.

버퍼를 다루는 함수였다.

Ghidra로 디스어셈블해 들어가다 __stack_chk_fail 호출 경로를 발견했다.

스택 카나리가 buffer overflow를 잡아 죽인 거였다.

크래시는 한 곳에서 막는 게 아니라 여러 층에서 걸러진다.

카나리가 잡은 건 그중 한 층이었다.

이번 편은 나도 공부하면서 그 층들을 정리해본다.

미리 말하자면, 이번 편은 가볍게 읽고 넘어가도 된다. 이유는 이 것들을 본 순간 이미 당신의 앱은 죽어있다.

본론으로 들어가, 2편에서 본 크래시들(SIGSEGV, SIGABRT, SIGBUS 등)을 막는 방어는 세 층으로 나뉜다.

  • 1층은 컴파일러와 런타임이 자동으로 해주는 것. NDK 빌드 시 이미 켜져 있음.
  • 2층은 도구로 잡는 것. 내가 빌드에 추가해야 함.
  • 3층은 코드 작성 시점에 내가 의식해야 하는 것. 도구로도 못 막는 영역.

결국 이 방어 계층은 내 앱을 방어해주는게 아니라 시스템을 방어해 주는 방어다.

1층: 컴파일러와 런타임이 자동으로 해주는 것

NDK 빌드를 하면 내가 설정 안 해도 기본으로 켜지는 안전망이 있음.

Clang이 자동으로 켜는 것과 Android 플랫폼이 기본 제공하는 것임.

세 가지를 보자.

스택 카나리

도입부에서 내 앱을 죽인 게 이것임.

스택 카나리는 함수 안에서 스택 변수 영역과 리턴 주소 사이에 랜덤 값을 끼워 넣는 안전망임.

카나리라는 이름은 옛날 광부들이 갱도에 데려간 새에서 왔음.

새가 유독가스에 먼저 쓰러지면 광부에게 대피 신호였음.

이 값이 먼저 망가지면 overflow 신호인 것과 똑같음.

스택 메모리 배치는 이렇게 생겼음.

함수 안에서 buffer overflow가 나면 스택 변수에서 리턴 주소 방향으로 메모리를 침범함.

그 사이에 있는 카나리를 먼저 덮어쓰게 됨.

함수 종료 시 카나리 값이 처음과 다른지 확인함.

다르면 __stack_chk_fail을 호출해 즉시 종료시킴.

2편에서 SIGABRT 원인으로 언급한 Abort message: 'stack corruption detected'가 이것임.

최근 Android NDK/Clang 환경에서는 -fstack-protector-strong이 보통 기본 활성화되어 있음.

빌드 옵션을 신경 안 써도 적용됨.

다만 한계가 있음.

스택 영역만 보고 힙 overflow는 못 잡음.

함수 종료 시점에 검증하므로 함수 실행 중의 다른 메모리 손상은 막지 못함.

발생을 막는 게 아니라 발생 후 감지해 죽이는 방식임.

PIE / ASLR

PIE/ASLR은 프로세스를 시작할 때마다 코드와 메모리 영역의 주소를 무작위화하는 기법임.

특정 주소를 노린 공격을 막고 분석 비용을 올리는 기본 방어망임.

최근 Android NDK 환경에서는 보통 기본 활성화되어 있음.

내가 의식할 일은 거의 없음.

다만 backtrace에 매번 다른 주소가 찍히는 이유가 이것임.

FORTIFY_SOURCE

FORTIFY_SOURCE는 strcpy, memcpy, sprintf 같은 표준 함수의 경계 위반을 검사하는 기능임.

컴파일러가 목적지 버퍼 크기를 알 수 있을 때 더 안전한 버전으로 자동 대체함.

경계를 넘기면 이렇게 잡힘.

char buf[10];
strcpy(buf, very_long_string);  // FORTIFY가 길이 초과 감지 시 abort

Android libc(bionic)와 컴파일러 최적화 조건이 맞을 때 자동 적용됨.

logcat에 FORTIFY: strcpy: prevented write past end of buffer 같은 메시지와 함께 SIGABRT로 종료됨.

1층이 못 잡는 것

1층은 자동이라 편하지만 완전하지 않음.

  • 힙 overflow와 use-after-free는 못 잡음.
  • 정수 오버플로우가 작은 할당으로 이어지는 연쇄도 못 잡음.
  • ARM 정렬 위반, JNI Reference 누수, 논리 오류도 1층 밖임.

1층은 기본 안전망이지 완전 보호가 아님.

더 잡고 싶으면 2층 도구를 추가해야 함.

2층: 도구로 잡는 것

내가 빌드에 추가해야 동작하는 도구들임.

디버그 빌드에 켜놓으면 1층보다 훨씬 많은 사고를 잡아줌.

단 성능 오버헤드가 있어 release 빌드엔 안 넣음.

두 가지를 보자.

AddressSanitizer (ASan)

ASan은 컴파일러가 메모리 접근마다 검사 코드를 삽입하는 도구임.

접근 가능 여부를 기록하는 shadow memory를 따로 유지함.

잘못된 접근이 일어나는 순간 즉시 abort시킴.

heap buffer overflow, use-after-free, double free, stack buffer overflow를 잡고 일부 메모리 누수도 탐지함.

ASan을 켜는 전통적인 방법은 CMakeLists.txt에 직접 플래그를 넣는 것임.

if(CMAKE_BUILD_TYPE STREQUAL "Debug")
    target_compile_options(your_lib PRIVATE
        -fsanitize=address
        -fno-omit-frame-pointer
    )
    target_link_options(your_lib PRIVATE -fsanitize=address)
endif()

다만 이 CMake 설정만으로는 부족함.

Android에서 ASan을 실제로 동작시키려면 ASan 런타임 라이브러리 패키징과 wrap.sh 설정 같은 추가 단계가 필요함.

이 수동 단계가 ASan 도입을 번거롭게 만드는 지점임.

지금은 더 간단한 길이 있음.

ASan은 2023년부로 deprecated 됐고, HWASan(Hardware-assisted ASan)이 대안으로 권장됨.

HWASan은 같은 종류의 메모리 버그를 잡으면서 오버헤드가 더 적음.

대신 ARM64 기기, Android 10 이상 같은 플랫폼 조건이 붙음.

HWASan은 NDK 27 이상에서 build.gradle만 고치면 됨.

CMakeLists.txt는 건드릴 필요가 없음.

android {
    defaultConfig {
        externalNativeBuild {
            cmake {
                arguments "-DANDROID_SANITIZE=hwaddress"
            }
        }
    }
}

wrap.sh도 런타임 라이브러리 패키징도 직접 안 해도 됨.

Android Gradle Plugin이 빌드 variant 설정만으로 처리해줌.

단 ANDROID_USE_LEGACY_TOOLCHAIN_FILE=false를 쓰는 프로젝트에서는 이 인자 방식이 동작하지 않음.

이제 크래시가 나면 logcat에 상세 정보가 찍힘.

ASan 출력에서 먼저 볼 건 SUMMARY 줄과 영역 설명임.

heap-buffer-overflow / WRITE of size 1 / 5 bytes after 10-byte region 형태로 무엇이 어떻게 어디서 잘못됐는지가 한 줄로 정리됨.

그 다음 backtrace에서 사고 위치를 봄.

allocated by 줄에서 그 메모리가 언제 할당됐는지를 같이 봄.

언제 어디서 할당된 메모리를 어디서 잘못 접근했는지까지 알려주는 것임.

일반 SIGSEGV가 어디서 죽었는지만 알려주는 것과 차이가 큼.

아쉬운 점은 디버그 빌드 전용이라는 점임.

UBSan

UBSan(UndefinedBehaviorSanitizer)은 산술 오버플로우, NULL 역참조, 정렬 위반 같은 위험한 코드 패턴을 런타임에 잡는 도구임.

target_compile_options(your_lib PRIVATE
    -fsanitize=undefined
    -fno-sanitize-recover=undefined
)
target_link_options(your_lib PRIVATE -fsanitize=undefined)

ASan이 메모리 접근 자체를 본다면, UBSan은 위험한 산술과 캐스팅을 봄.

둘 다 켜놓으면 보완 관계임.

도구를 디버그 빌드에 켜놓는 것 자체가 워크플로 정비임.

CI에 통합해두면 기존 코드에 숨어 있던 사고가 드러나기 시작함.

새로 짠 코드뿐 아니라 오래된 코드에서 우연히 안 터지던 버그도 잡힘.

도구가 잡는 사고는 원인 시점에 즉시 죽으니, 증상이 한참 뒤에 나타나는 사고보다 추적이 훨씬 쉬움.

3층: 코드 작성 시점에 의식해야 하는 것

1층 자동 안전망과 2층 도구로도 안 잡히는 사고가 있음.

또는 도구가 잡긴 해도 애초에 안 만드는 게 나은 사고가 있음.

이런 건 코드를 짤 때 내가 의식하는 수밖에 없음.

네 가지를 보자.

JNI Reference 정리

Java/Kotlin 개발자가 native에서 가장 헷갈리는 지점임.

JNI에서 Java 객체 참조를 만들면 내가 명시적으로 정리해야 함.

// 위험: 반복문 안에서 누적
for (int i = 0; i < 10000; i++) {
    jstring s = (*env)->NewStringUTF(env, "x");
    // DeleteLocalRef 안 하면 Local Reference Table이 가득 참
}

// 안전
for (int i = 0; i < 10000; i++) {
    jstring s = (*env)->NewStringUTF(env, "x");
    (*env)->DeleteLocalRef(env, s);
}

Local Reference는 JNI 호출 프레임이 끝날 때 자동 정리됨.

다만 함수 안에서 많이 만들면 그 전에 한도를 초과함.

Local Reference Table은 크기 제한이 있어 반복문에서 계속 생성하면 overflow가 남.

Global Reference는 더 위험함.

NewGlobalRef로 만든 참조는 DeleteGlobalRef를 부르기 전까지 영원히 살아 있음.

정리 안 하면 앱 프로세스 종료 전까지 누수임.

Global Reference를 캐싱하고 정리하는 짝은 이렇게 둠.

static jclass g_my_class = NULL;

void cache_class(JNIEnv *env) {
    jclass local = (*env)->FindClass(env, "com/example/MyClass");
    g_my_class = (*env)->NewGlobalRef(env, local);
    (*env)->DeleteLocalRef(env, local);
}

void cleanup(JNIEnv *env) {
    if (g_my_class) {
        (*env)->DeleteGlobalRef(env, g_my_class);
        g_my_class = NULL;
    }
}

cleanup이 실제로 호출되는 경로에 있는지 확인하는 게 중요함.

함수만 있고 호출 경로가 없으면 그대로 누수임.

JNIEnv*를 잘못 다루는 것도 자주 터짐.

JNIEnv*는 스레드별로 다름.

한 스레드에서 받은 걸 다른 스레드에서 그대로 쓰면 안 됨.

native에서 새 스레드를 만들거나 다른 스레드에서 JNI를 호출할 때는 AttachCurrentThread로 그 스레드의 JNIEnv*를 얻음.

스레드가 끝나기 전에 DetachCurrentThread로 정리해야 함.

Use-after-free 방지

free한 메모리에 접근하면 안 됨.

기본 패턴은 free 후 포인터를 NULL로 만드는 것임.

free(buf);
buf = NULL;  // 다시 쓰려고 하면 NULL 역참조라 바로 드러남

다만 이건 같은 메모리를 가리키는 다른 포인터(alias)까지 막아주지는 못함.

char *a = malloc(64);
char *b = a;        // 두 포인터가 같은 메모리

free(a);
a = NULL;           // a는 정리됨
*b = 'X';           // b는 여전히 해제된 메모리를 가리킴, use-after-free

결국 해법은 소유자를 명확히 하는 것임.

누가 해제할지를 코드 구조로 표시함.

정수 오버플로우가 메모리 사고로 번지는 경우

겉보기엔 평범한 malloc 한 줄이 사고의 시작점이 될 수 있음.

void* allocate_array(uint32_t count) {
    void* buf = malloc(count * sizeof(int));
    return buf;
}

count가 크면 count * sizeof(int)가 32비트 unsigned 곱셈에서 wrap-around됨.

그 결과 malloc에 엉뚱하게 작은 값이 들어감.

호출자는 원래 의도한 큰 크기로 그 버퍼에 씀.

작은 버퍼에 큰 데이터를 쓰니 heap overflow임.

해법은 곱셈 결과를 검증하는 builtin을 쓰는 것임.

size_t total;
if (__builtin_mul_overflow(count, sizeof(int), &total)) {
    return NULL;  // 오버플로우 발생
}
void* buf = malloc(total);

__builtin_add_overflow, __builtin_sub_overflow도 같은 패턴임.

Java는 Math.multiplyExact가 같은 역할을 함.

native에서는 내가 의식해서 builtin을 부르지 않으면 컴파일러가 알아서 검사해주지 않음.

ARM 정렬과 바이트 버퍼 캐스팅

ARM 계열에서 정렬되지 않은 메모리 접근은 아키텍처와 명령어에 따라 동작이 갈림.

구형 ARM에서는 SIGBUS로 죽는 경우가 흔함.

ARM64에서는 일반 load/store는 조용히 동작하다 atomic이나 SIMD 명령어에서 죽기도 함.

// 위험
uint8_t buf[10];
uint32_t value = *(uint32_t*)(buf + 1);  // buf+1은 4의 배수가 아닐 수 있음

해법은 memcpy로 복사하는 것임.

uint32_t value;
memcpy(&value, buf + 1, sizeof(value));

memcpy는 바이트 단위 복사로 정의돼 정렬 안 된 주소에서도 안전함.

현대 컴파일러는 작은 크기의 memcpy를 단일 load 명령으로 최적화해 성능 손실도 거의 없음.

네트워크 패킷이나 파일 포맷 파싱, GetByteArrayElements로 받은 데이터를 C struct로 캐스팅할 때 자주 만남.

어느 사고가 어느 층에서 잡히나

여기까지를 한 장으로 정리하면 이렇게 됨.

사고 1층 자동 2층 도구 3층 의식
스택 buffer overflow 카나리 잡음 ASan 잡음 길이 검증
힙 buffer overflow 못 잡음 ASan 잡음 경계 검사
Use-after-free 못 잡음 ASan 잡음 소유자 명확히
Double free 일부 잡음 ASan 잡음 소유자 명확히
JNI Reference 누수 못 잡음 못 잡음 Delete 짝 맞추기
정수 오버플로우 못 잡음 UBSan/ASan builtin 검증
ARM 정렬 위반 못 잡음 UBSan 잡음 memcpy 패턴
표준 함수 경계 위반 FORTIFY 잡음 ASan 잡음 안전한 API

위쪽 층에서 잡히는 사고는 코드를 잘못 짜도 자동으로 보호됨.

아래쪽 층은 내가 의식하지 않으면 그대로 사고가 됨.

도입부의 카나리 케이스가 1층에서 걸린 경우였음.

만약 그게 힙 overflow였다면 1층은 그냥 통과했을 것임.

결국 사용자 단말에서 터졌을 것임.

마무리 메모

복잡하다. 그리고, 이런 문제는 항상 운영에서만 나온다. 방어 해주는건 고마운데, 제발 테스트 단계에서좀 나오라고...

참고 자료

  1. AddressSanitizer (Android NDK 공식 문서) - https://developer.android.com/ndk/guides/asan

'모바일' 카테고리의 다른 글

Android NDK 입문 (4) - native 크래시 디버깅 방법, addr2line과 Ghidra  (0) 2026.05.24
Android NDK 입문 (2) - SIGSEGV, SIGABRT, SIGBUS 네이티브 크래시 시그널 정리  (0) 2026.05.23
Android NDK 입문 (1) - NDK를 왜 쓰는가, JNI와 네이티브 개발 기초  (0) 2026.05.23
Google Play 데이터 보안: AdMob 사용 시 "기기 또는 기타 ID 선언되지 않음" 경고 해결  (0) 2026.05.23
Android 개발자 인증 정리 - Play Console 등록 가이드  (0) 2026.04.19
'모바일' 카테고리의 다른 글
  • Android NDK 입문 (4) - native 크래시 디버깅 방법, addr2line과 Ghidra
  • Android NDK 입문 (2) - SIGSEGV, SIGABRT, SIGBUS 네이티브 크래시 시그널 정리
  • Android NDK 입문 (1) - NDK를 왜 쓰는가, JNI와 네이티브 개발 기초
  • Google Play 데이터 보안: AdMob 사용 시 "기기 또는 기타 ID 선언되지 않음" 경고 해결
João Jin
João Jin
모바일 · 보안 · AI 기록
  • João Jin
    João Jin - 모바일 · 보안 · AI
    João Jin
  • 전체
    오늘
    어제
    • 분류 전체보기 (30)
      • 프로젝트 (3)
      • 개발기 (8)
      • 모바일 (8)
      • 보안 (2)
      • AI (8)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • GitHub
    • X
  • 공지사항

  • 인기 글

  • 태그

    MCP 보안
    안드로이드 NDK
    FastAPI
    Docker
    머신러닝
    Android
    mcp-fence
    JNI
    LLM 보안
    Native
    model context protocol
    MLFlow
    LINE WORKS
    MCP
    ndk
    AI 에이전트 보안
    MLOps
    온디바이스AI
    Docker Compose
    AI
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
João Jin
Android NDK 입문 (3) - AddressSanitizer, 스택 카나리 알고 네이티브 크래시 미리 막기
상단으로

티스토리툴바