안드로이드 애플리케이션 비정상 종료 분석을 위한 상세 로그 수집 기법

안드로이드 환경, 특히 컨테이너나 가상화 기반의 솔루션을 개발하다 보면 애플리케이션이 시작되지 않거나 실행 중 갑자기 크래시가 발생하는 문제를 자주 마주하게 됩니다. 이때 adb logcat을 통해 확인되는 로그가 단 몇 줄에 불과하여 원인 파악에 어려움을 겪는 경우가 많습니다. 이러한 문제를 신속하게 진단하기 위해서는 애플리케이션의 비정상적인 동작 흔적을 복원하고 상세한 컨텍스트를 확보하는 것이 필수적입니다.

본 글에서는 PLT Hook 및 Inline Hook을 지원하는 ByteHook, ShadowHook와 Binder 통신을 감시하는 Binderceptor와 같은 오픈소스 도구를 활용하여 크래시 발생 시 다양한 레이어에서 디버깅 힌트를 수집하는 방법을 다룹니다.

1. 기본 크래시 로그의 한계

일반적인 프로세스 크래시 상황에서는 다음과 같이 매우 제한적인 로그만 남는 경우가 많습니다.

1024  1024 D AndroidRuntime: Shutting down VM
1024  1024 I Process : Sending signal. PID: 1024 SIG: 9

이러한 로그만으로는 크래시의 근본적인 원인을 파악하기 어렵습니다. 따라서 애플리케이션의 종료 시점을 포착하여 추가적인 스택 트레이스를 추출해야 합니다.

2. 프로세스 종료 시점 인터셉션 및 스택 덤프

Java 레이어와 Native 레이어에서 프로세스를 강제로 종료시키는 주요 함수들을 후킹하여, 종료 직전의 호출 스택을 기록합니다.

Java 레이어의 android.os.Processjava.lang.Runtime 클래스에서 시그널을 전송하거나 VM을 종료하는 네이티브 메서드를 타겟팅합니다.

// Java Layer Target Methods
public class Process {
    public static final native void sendSignal(int targetPid, int signalCode);
    public static final native void sendSignalQuiet(int targetPid, int signalCode);
}

public class Runtime {
    private static native void nativeExit(int statusCode);
}

Native 레이어에서는 C/C++ 표준 라이브러리의 종료 및 시그널 전송 함수를 후킹합니다.

// Native Layer Target Functions
void exit(int statusCode);
int kill(pid_t targetPid, int signalCode);

JNI 환경이 아닌 스레드에서 현재 스레드의 Java 스택 트레이스를 출력하기 위해 JNIEnv를 동적으로 연결하고 Thread.dumpStack()을 호출하는 유틸리티 함수를 구현합니다.

// Capture stack trace from the current thread in Native layer
void captureCurrentThreadStackTrace(JNIEnv* jniEnv) {
    jclass threadClass = jniEnv->FindClass("java/lang/Thread");
    if (threadClass == nullptr) return;
    
    jmethodID dumpStackMethod = jniEnv->GetStaticMethodID(threadClass, "dumpStack", "()V");
    if (dumpStackMethod == nullptr) return;
    
    jniEnv->CallStaticVoidMethod(threadClass, dumpStackMethod);
    
    // Clear any pending exceptions to prevent crashes
    if (jniEnv->ExceptionCheck()) {
        jniEnv->ExceptionClear();
    }
}

3. Throwable 및 VM 스택 트레이스 추적

예외가 발생했을 때 스택 트레이스가 채워지는 네이티브 메서드를 후킹하면, try-catch 블록에서 예외를 처리하거나 로깅하는 시점의 호출 스택을 확보할 수 있습니다.

// Throwable internal native methods
public class Throwable {
    private static native StackTraceElement[] nativeGetStackTrace(Object internalStackState);
    private static native Object nativeFillInStackTrace();
}

또한 Dalvik/ART VM의 스택에 접근하는 dalvik.system.VMStack의 메서드를 인터셉션하여 특정 스레드의 실행 흐름을 추적할 수 있습니다.

package dalvik.system;

public final class VMStack {
    native public static StackTraceElement[] getThreadStackTrace(Thread targetThread);
    native public static AnnotatedStackTraceElement[] getAnnotatedThreadStackTrace(Thread targetThread);
    native public static int fillStackTraceElements(Thread targetThread, StackTraceElement[] preallocatedElements);
}

4. 전역 예외 핸들러(UncaughtExceptionHandler) 커스터마이징

안드로이드 프레임워크는 기본적으로 프로세스마다 전역 예외 핸들러를 등록합니다. 이를 애플리케이션 초기화 단계에서 재정의하면, 처리되지 않은 예외(Uncaught Exception)가 발생했을 때 더 풍부한 로그를 남기거나 서버로 전송하는 로직을 추가할 수 있습니다. 단, 서드파티 SDK나 애플리케이션 자체에서 이미 핸들러를 설정했을 수 있으므로 체인(Call) 방식을 고려해야 합니다.

Thread.UncaughtExceptionHandler originalHandler = Thread.getDefaultUncaughtExceptionHandler();

Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
    @Override
    public void uncaughtException(Thread thread, Throwable throwable) {
        // 커스텀 로깅 및 크래시 데이터 수집 로직 추가
        Log.e("CrashCollector", "Unhandled exception in thread " + thread.getName(), throwable);
        
        // 기존 핸들러가 존재하면 위임
        if (originalHandler != null) {
            originalHandler.uncaughtException(thread, throwable);
        }
    }
});

5. ActivityManagerService로의 크래시 보고 인터셉션

애플리케이션에서 크래시가 발생하면 IActivityManagerhandleApplicationCrash 메서드를 통해 시스템 서비스(AMS)로 크래시 정보가 전달됩니다. Java Reflection과 java.lang.reflect.Proxy를 활용하여 이 Binder 인터페이스를 프록싱하면, 시스템으로 전송되는 CrashInfo 객체의 상세 내용을 미리 가로채어 로깅할 수 있습니다.

// IActivityManager AIDL definition
interface IActivityManager {
    void handleApplicationCrash(IBinder appBinder, ApplicationErrorReport.ParcelableCrashInfo crashReport);
}

// CrashInfo structure
public class ApplicationErrorReport implements Parcelable {
    public static class CrashInfo {
        public String exceptionClassName;
        public String exceptionMessage;
        public String throwFileName;
        public int throwLineNumber;
        public String stackTrace;
        
        public void dump(Printer printer, String logPrefix) {
            printer.println(logPrefix + "Exception: " + exceptionClassName);
            printer.println(logPrefix + "Message: " + exceptionMessage);
            printer.println(logPrefix + "Location: " + throwFileName + ":" + throwLineNumber);
            printer.println(logPrefix + "Trace: " + stackTrace);
        }
    }
}

6. Binder IPC 통신 및 시스템 서비스 호출 추적

크래시 직전 애플리케이션이 어떤 시스템 서비스와 통신했는지 파악하는 것은 문제의 맥락을 이해하는 데 큰 도움이 됩니다. Binderceptor와 같은 도구를 이용하면 Binder 트랜잭션을 감시하고 타겟 서비스의 인터페이스와 호출된 메서드를 매핑할 수 있습니다.

1024  1024 W BinderTrace : Target: 0x4998c63, Code: 3, Interface: android.content.pm.IPackageManager
1024  1024 W BinderTrace : Target: 0xf5603d3, Code: 2, Interface: android.os.IServiceManager
1024  1024 W BinderTrace : Target: 0x87d3933, Code: 56, Interface: android.view.IWindowManager

Binder 트랜잭션 코드(Code)는 AIDL 파일의 메서드 순서 및 TRANSACTION_ 상수와 매칭됩니다. 리플렉션을 사용하여 Stub 클래스의 상수 값을 추출하고 정렬하면 실제 호출된 메서드를 역추적할 수 있습니다.

public static void extractBinderTransactionCodes(String interfaceClassName) {
    Log.d("BinderParser", "Parsing interface: " + interfaceClassName);
    try {
        Class stubClass = Class.forName(interfaceClassName + "$Stub");
        Method[] methods = stubClass.getDeclaredMethods();
        List<TransactionMapping> mappings = new ArrayList<>();

        for (Method method : methods) {
            if ("asBinder".equals(method.getName())) continue;
            
            try {
                String fieldName = "TRANSACTION_" + method.getName();
                Field transactionField = stubClass.getDeclaredField(fieldName);
                transactionField.setAccessible(true);
                int transactionCode = transactionField.getInt(null);
                
                mappings.add(new TransactionMapping(transactionCode, method.toGenericString()));
            } catch (NoSuchFieldException | IllegalAccessException ignored) {
            }
        }
        
        // Sort by transaction code to match AIDL definition order
        Collections.sort(mappings, Comparator.comparingInt(m -> m.code));
        
        for (TransactionMapping mapping : mappings) {
            Log.d("BinderParser", "Code " + mapping.code + ": " + mapping.signature);
        }
    } catch (ClassNotFoundException e) {
        Log.e("BinderParser", "Stub class not found", e);
    }
}

7. JNI 호출 및 파일 시스템 접근 모니터링

Native 코드에서 Java 레이어의 클래스나 메서드를 조회할 때 사용하는 JNIEnv의 함수들을 후킹하면 JNI 호출 경로를 추적할 수 있습니다.

// Target JNIEnv functions for JNI tracing
jclass FindClass(JNIEnv* env, const char* name);
jmethodID GetMethodID(JNIEnv* env, jclass clazz, const char* name, const char* sig);
jmethodID GetStaticMethodID(JNIEnv* env, jclass clazz, const char* name, const char* sig);
jint RegisterNatives(JNIEnv* env, jclass clazz, const JNINativeMethod* methods, jint nMethods);

마지막으로, 애플리케이션이 크래시 직전 어떤 파일을 읽거나 쓰려고 했는지 확인하기 위해 POSIX 파일 I/O 함수들을 후킹합니다. 안드로이드의 Bionic libc는 다양한 버전과 아키텍처에 따라 여러 파생 함수를 제공하므로, 호환성을 위해 주요 변형 함수들을 모두 타겟팅해야 합니다.

// Target File I/O functions in libc
int open(const char* path, int oflag, ...);
int openat(int fd, const char* path, int oflag, ...);
int open64(const char* path, int oflag, ...);
int openat64(int fd, const char* path, int oflag, ...);

태그: Android CrashAnalysis PLTHook InlineHook JNI

5월 23일 13:02에 게시됨