안드로이드 환경, 특히 컨테이너나 가상화 기반의 솔루션을 개발하다 보면 애플리케이션이 시작되지 않거나 실행 중 갑자기 크래시가 발생하는 문제를 자주 마주하게 됩니다. 이때 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.Process와 java.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로의 크래시 보고 인터셉션
애플리케이션에서 크래시가 발생하면 IActivityManager의 handleApplicationCrash 메서드를 통해 시스템 서비스(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, ...);