Android Address Sanitizer 사용해보기

김병수·2026년 3월 4일
post-thumbnail

최근 진행하고 있는 프로젝트에서 Android 네이티브 라이브러리를 사용하고 있는데요.
네이티브 영역에서 exception이 발생해서 앱이 죽었는데, 정확히 어떤 이유로 exception이 발생했는지 알 수 없었던 경우가 종종 있었습니다.

이러한 문제를 해결하기 위해 Android Address Sanitizer를 적용했고, 그 결과 네이티브 영역에서 어떤 메모리에 잘못 접근해서 문제가 발생한 것인지 정확하게 알 수 있었습니다.
이 과정에서 Address Sanitizier 관련하여, 한국어로 작성된 글이 거의 없는 것 같아서 이번 기회에 샘플 프로젝트를 만들어서 ASan을 적용하기 위해 시도했던 방법들을 공유하려고 합니다.

Address Sanitizer?

Address Sanitizier(이하 ASan)는 네이티브 라이브러리에서 발생할 수 있는 메모리 오류를 발견하기 위한 도구입니다.

메모리가 해제된 포인터에 접근하거나 배열의 크기보다 더 큰 인덱스에 접근하거나 하는 등, 직접적으로 exception이 발생하는 문제를 잡아낼 때에도 사용하지만, exception이 발생하지는 않았지만 잘못된 메모리 주소에 접근하는 경우까지도 잡아내기 때문에 네이티브 라이브러리 디버깅에 아주 유용하게 사용할 수 있습니다.

Deprecated: As of 2023, ASan is unsupported. It is recommended to use HWASan instead. HWASan can be used on ARM64 devices running Android 14 (API level 34) or newer; or on Pixel devices running Android 10 (API level 29) by flashing a special system image. ASan may still be used but might have bugs.
출처: https://developer.android.com/ndk/guides/asan?hl=en

참고로 안드로이드 공식 문서에 따르면, Android 14 이상의 arm64-v8a 기기에서 ASan을 사용하면 버그가 발생할 수 있다고 하니, ASan을 사용하실 분들은 참고하시면 좋을 것 같습니다.

개발 환경

항목환경
PCMacbook M1 Pro
MobileAndroid API 34 AVD (arm64-v8a)
NDK27.0.12077973

How to apply ASan?

ASan을 사용하려면 다음과 같은 2가지 문제를 해결해야 합니다.

  1. 네이티브 라이브러리를 빌드할 때, ASan 옵션을 활성화한 상태로 빌드해야 합니다.
  2. 안드로이드 앱을 빌드할 때, asan 실행을 위한 .so 및 .sh 파일을 apk에 포함해야 합니다.

지금부터는 AndroidAddressSanitizier 샘플 프로젝트를 바탕으로 ASan 적용 방법을 자세하게 이야기해 보겠습니다.

샘플 프로젝트

실제 구현 내용을 보기 전에, 간단하게 샘플 프로젝트 구조부터 이야기 하려고 합니다.

샘플 프로젝트는 위의 이미지와 같이, 녹색으로 표시된 Android 앱 프로젝트와 노란색으로 표시된 NativeAccumulator 네이티브(C++) 프로젝트로 구성됩니다.

네이티브 프로젝트에는 아래와 같이, 고정된 크기의 배열에 값을 하나씩 추가하는 Accumulator 클래스가 작성되어 있습니다.

class Accumulator
{
private:
    int size;
    int idx;
    int *arr;

public:
    Accumulator();
    Accumulator(int size);
    ~Accumulator();

    int getSize();
    int getIdx();
    void push();
    std::string toString();
};

void Accumulator::push()
{
    static thread_local std::mt19937 rng(std::random_device{}());
    std::uniform_int_distribution<int> dist(0, 100);
    // 여기서 idx 범위가 size 이상인 경우에 Segmentation Fault 발생!
    arr[idx] = dist(rng);
    idx++;
}

위의 코드에서 push 함수는 랜덤 정수를 배열 arr에 계속해서 추가합니다.
따라서 push 함수가 size번 이상 호출될 경우, 잘못된 메모리에 접근하여 Segmentation Fault가 발생합니다.

이 상태에서 빌드해서 앱을 실행하면, 아래와 같이 push 함수를 size보다 더 많이 실행했음에도 불구하고 앱이 강제 종료되지 않는 것을 확인할 수 있습니다.

size를 초과해서 접근한 메모리에 만약 중요한 데이터가 저장되어 있었다면, 나중에 원인을 알 수 없는 버그가 발생할 수도 있는데요.
이러한 경우를 예방하기 위해 이제부터는 ASan을 어떻게 적용했는지에 대하여 이야기해 보겠습니다.

네이티브 빌드하기

NativeAccumualtor 프로젝트를 빌드하기 위해, CMake 및 Ninja를 사용했습니다.
여기서 ASan 옵션 활성화 조건은 CMakeLists.txt 파일에 아래와 같은 내용을 추가하면 됩니다.

# ../NativeAccumulator/CMakeLists.txt

option(ENABLE_ASAN "Enable AddressSanitizer" OFF)

...

if(ENABLE_ASAN)
    target_compile_options(accumulator PRIVATE -fsanitize=address -fno-omit-frame-pointer)
    target_link_options(accumulator PRIVATE -fsanitize=address)
endif()

빌드 옵션으로 설정한 ENABLE_ASAN 값이 ON인 경우에는 ASan이 활성화되고, OFF인 경우에는 비활성화 되도록하여 필요에 따라 빌드할 수 있도록 작성했습니다.

그리고 사용할 NDK, CMake, Ninja 등의 경로를 수동 입력하지 않아도 빌드할 수 있도록, build_native_android.sh 파일을 작성했습니다.
이에 따라 ANDROID_NDK로 등록된 경로를 사용하여 네이티브 라이브러리 .a 파일을 빌드하도록 환경을 구축했습니다.

build_native_android.sh 파일을 정상적으로 실행했다면, 아래와 같은 경로에 .a 파일이 생성됩니다.

  • ../NativeAccumulator/build/android-arm64-v8a/libNativeAccumulator.a
  • ../NativeAccumulator/build/android-x86_64/libNativeAccumulator.a

이제 우리는 이 .a 파일을 사용하여, JNI 빌드를 해야 합니다.

JNI 빌드

Android 앱 프로젝트에는 :app 모듈과 :nativelib 모듈이 존재하며, JNI 빌드는 :nativelib 모듈이 담당합니다.

CMakeLists.txt 수정

JNI 빌드도 네이티브와 동일하게 CMake를 사용했기 때문에, 추가해야 하는 내용도 네이티브와 동일합니다.

# ../Android/nativelib/src/main/cpp/CMakeLists.txt

option(ENABLE_ASAN "Enable AddressSanitizer" OFF)

if(ENABLE_ASAN)
    target_compile_options(${CMAKE_PROJECT_NAME} PRIVATE -fsanitize=address -fno-omit-frame-pointer)
    target_link_options(${CMAKE_PROJECT_NAME} PRIVATE -fsanitize=address)
endif()

nativelib 모듈 build.gradle.kts 수정

JNI 빌드할 때에도 동일하게 ASan 및 필요한 빌드 옵션을 설정해야 합니다.

android {
    ...

    defaultConfig {
        ...
        
        ndk {
            // 필요한 ABI Filter를 추가합니다.
            abiFilters += listOf("arm64-v8a", "x86_64")
        }
    }

    buildTypes {
        debug {
            // JNI 레이어도 디버깅이 가능해야 하기 때문에 반드시 true로 설정해야 합니다.
            isJniDebuggable = true
            //  CMake 빌드 과정에서 사용할 빌드 옵션을 지정합니다.
            externalNativeBuild {
                cmake {
                    arguments += listOf(
                        "-DANDROID_ARM_MODE=arm",
                        "-DANDROID_STL=c++_shared",
                        "-DENABLE_ASAN=ON" // ASan 활성화
                    )
                }
            }
        }
        release {
            ...
            externalNativeBuild {
                cmake {
                    // debug 빌드 시에만 사용할 것이기 때문에, release 빌드 시에는 제외
                    arguments += "-DENABLE_ASAN=OFF" 
                }
            }
        }
    }
    externalNativeBuild {
        cmake {
            path("src/main/cpp/CMakeLists.txt")
            version = "3.22.1"
        }
    }
}

이렇게 하면, JNI 빌드까지는 모두 ASan 적용이 완료됩니다.
이제부터는 :app 모듈에서 apk를 빌드할 때에 적용해야 하는 내용에 대하여 설명하겠습니다.

apk 빌드

빌드 옵션 설정

android {
    ...
    
    defaultConfig {
        ...

        ndk {
            // 필요한 ABI Filter를 추가합니다.
            abiFilters += listOf("arm64-v8a", "x86_64")
        }
    }

    buildTypes {
        debug {
            // ASan을 사용하려면, 반드시 디버깅이 가능해야 합니다.
            isDebuggable = true
            packaging {
                jniLibs {
                    // ASan이 네이티브 라이브러리의 모든 코드를 알 수 있어야 하기 때문에, 
                    // 반드시 useLegacyPackaging 값을 true로 설정합니다.
                    // 이 값을 true로 설정해야 네이티브 라이브러리가 압축되지 않으며,
                    // 이에 따라 ASan이 정확히 어떤 코드에서 문제가 발생했는지 알 수 있습니다.
                    useLegacyPackaging = true
                }
            }
        }
    }
    ...
}

wrap.sh 파일 생성

ASan을 사용하려면, 안드로이드 앱이 실행될 때 가장 먼저 로드되는 네이티브 라이브러리가 ASan이 되어야 합니다. 이를 위해 안드로이드 앱이 실행될 때 가장 먼저 실행되는 스크립트 wrap.sh 파일을 사용합니다.

샘플 프로젝트에서는 :app 모듈 build.gradle.kts 파일에 다음과 같은 스크립트를 추가했습니다.
아래 스크립트는 jniLibs/abi 디렉토리에 각 abi에 알맞은 warp.sh 파일을 생성합니다.
warp.sh 파일의 내용은 writeText 함수 내의 파라미터에 해당하며, ASan 관련 옵션 설정과 asan.so 파일을 가장 먼저 로드할 수 있도록 하는 명령어(LD_PRELOAD)가 포함되어 있습니다.

val generateWrapScripts by tasks.registering {
    val arm64Dir = layout.projectDirectory.dir("src/debug/jniLibs/arm64-v8a")
    val x64Dir = layout.projectDirectory.dir("src/debug/jniLibs/x86_64")

    outputs.file(arm64Dir.file("wrap.sh"))
    outputs.file(x64Dir.file("wrap.sh"))

    doLast {
        val arm64Wrap = arm64Dir.file("wrap.sh").asFile
        val x64Wrap = x64Dir.file("wrap.sh").asFile

        arm64Wrap.parentFile.mkdirs()
        x64Wrap.parentFile.mkdirs()

        // arm64-v8a 에서 사용할 wrap.sh 파일 생성
        arm64Wrap.writeText(
            """
            #!/system/bin/sh
            export ASAN_OPTIONS=log_to_syslog=true,verbosity=1,print_stacktrace=1,allow_user_segv_handler=1,detect_leaks=0
            export LD_PRELOAD="${'$'}HERE/libclang_rt.asan-aarch64-android.so"
            "${'$'}@"
            """.trimIndent() + "\n"
        )
        // x86_64 에서 사용할 wrap.sh 파일 생성
        x64Wrap.writeText(
            """
            #!/system/bin/sh
            export ASAN_OPTIONS=log_to_syslog=true,verbosity=1,print_stacktrace=1,allow_user_segv_handler=1,detect_leaks=0
            export LD_PRELOAD="${'$'}HERE/libclang_rt.asan-x86_64-android.so"
            "${'$'}@"
            """.trimIndent() + "\n"
        )

        arm64Wrap.setExecutable(true)
        x64Wrap.setExecutable(true)
    }
}

ASan .so 파일 복사

wrap.sh 파일 준비가 완료되었다면, ASan .so 파일을 apk에 포함해야 합니다.
ASan .so 파일은 Android NDK 디렉토리 하위에 위치해 있기 때문에, 이를 jniLibs 디렉토리로 복사해야 합니다.
이를 위해 샘플 프로젝트에서는 아래와 같은 코드를 :app 모듈 build.gradle.kts 파일에 추가했습니다.

// 시스템 환경 변수에 등록된 ANDROID_NDK 경로를 가져옵니다.
val ndkDir = System.getenv("ANDROID_NDK")
    ?: error("ANDROID_NDK is not set")

// 사용 중인 PC에 따라, ASan 라이브러리가 위치한 경로가 달라지기 때문에 분기 처리합니다.
val hostTag = when {
    OperatingSystem.current().isMacOsX -> "darwin-x86_64"
    OperatingSystem.current().isLinux -> "linux-x86_64"
    OperatingSystem.current().isWindows -> "windows-x86_64"
    else -> error("Unsupported host OS")
}

// ASan .so 파일을 찾는 함수입니다.
fun findAsanRuntime(abi: String): File {
    // abi 별로 알맞은 경로를 정의합니다.
    val pattern = when (abi) {
        "arm64-v8a" -> "**/lib/linux/libclang_rt.asan-aarch64-android.so"
        "x86_64" -> "**/lib/linux/libclang_rt.asan-x86_64-android.so"
        else -> error("Unsupported ABI for ASan runtime: $abi")
    }
    // 정의한 경로와 매칭되는 파일을 반환합니다.
    val matches = fileTree("$ndkDir/toolchains/llvm/prebuilt/$hostTag/lib64") {
        include(pattern)
    }.files
    require(matches.size == 1) {
        "Expected 1 ASan runtime for $abi, found ${matches.size}: $matches"
    }
    return matches.first()
}

// ASan .so 파일을 jniLibs 디렉토리로 복사하는 스크립트입니다.
val copyAsanRuntime by tasks.registering(Copy::class) {
    duplicatesStrategy = DuplicatesStrategy.INCLUDE
    destinationDir = layout.projectDirectory.dir("src/debug/jniLibs").asFile
    val arm64 = findAsanRuntime("arm64-v8a")
    val x64 = findAsanRuntime("x86_64")

    println("Copying ASan runtimes to $destinationDir")
    from(arm64) {
        into("arm64-v8a")
        rename { "libclang_rt.asan-aarch64-android.so" }
    }

    from(x64) {
        into("x86_64")
        rename { "libclang_rt.asan-x86_64-android.so" }
    }
}

이렇게 추가한 wrap.sh 파일 생성 및 ASan .so 파일 복사 스크립트는 반드시 앱 빌드 전에 실행해야 합니다.
따라서 :app 모듈 build.gradle.kts 파일에 빌드 의존성을 추가해서, 빌드 전에 반드시 1회 호출되도록 구현했습니다.

// generateWrapScripts 태스크를 preBuild에 의존성으로 추가
tasks.named("preBuild") {
    dependsOn(generateWrapScripts, copyAsanRuntime)
}

결과

위와 같은 방법으로 ASan을 활성화한 상태로 동일한 앱을 실행하면, 아래 이미지와 같이 자세한 에러 로그를 확인할 수 있습니다.

Github

profile
주니어 개발자

0개의 댓글