네이티브 라이브러리 리버싱

Reading time: 11 minutes

tip

AWS 해킹 배우기 및 연습하기:HackTricks Training AWS Red Team Expert (ARTE)
GCP 해킹 배우기 및 연습하기: HackTricks Training GCP Red Team Expert (GRTE) Azure 해킹 배우기 및 연습하기: HackTricks Training Azure Red Team Expert (AzRTE)

HackTricks 지원하기

자세한 정보는 다음을 확인하세요: https://maddiestone.github.io/AndroidAppRE/reversing_native_libs.html

Android 앱은 보통 C 또는 C++로 작성된 네이티브 라이브러리를 성능이 중요한 작업에 사용합니다. 악성코드 제작자들도 ELF shared objects가 DEX/OAT byte-code보다 디컴파일하기 더 어렵기 때문에 이러한 라이브러리를 악용합니다. 이 페이지는 Android .so 파일 리버싱을 더 쉽게 만드는 실용적인 워크플로우와 최신 도구 개선사항(2023-2025)에 중점을 둡니다.


새로 추출한 libfoo.so에 대한 빠른 분류 워크플로우

  1. 라이브러리 추출
bash
# From an installed application
adb shell "run-as <pkg> cat lib/arm64-v8a/libfoo.so" > libfoo.so
# Or from the APK (zip)
unzip -j target.apk "lib/*/libfoo.so" -d extracted_libs/
  1. 아키텍처 및 보호 설정 확인
bash
file libfoo.so        # arm64 or arm32 / x86
readelf -h libfoo.so  # OS ABI, PIE, NX, RELRO, etc.
checksec --file libfoo.so  # (peda/pwntools)
  1. 내보낸 심볼 및 JNI 바인딩 나열
bash
readelf -s libfoo.so | grep ' Java_'     # dynamic-linked JNI
strings libfoo.so   | grep -i "RegisterNatives" -n   # static-registered JNI
  1. 디컴파일러에 로드 (Ghidra ≥ 11.0, IDA Pro, Binary Ninja, Hopper or Cutter/Rizin)하고 자동 분석을 실행합니다. 최근 Ghidra 버전은 AArch64 decompiler를 도입하여 PAC/BTI stubs와 MTE tags를 인식하고, Android 14 NDK로 빌드된 라이브러리 분석을 크게 개선했습니다.
  2. 정적 리버싱 vs 동적 리버싱 결정: stripped, obfuscated된 코드는 종종 계측(instrumentation)(Frida, ptrace/gdbserver, LLDB)이 필요합니다.

동적 계측 (Frida ≥ 16)

Frida 16 시리즈는 대상이 최신 Clang/LLD optimisations를 사용할 때 도움이 되는 여러 Android 특정 개선사항을 도입했습니다:

  • thumb-relocator는 이제 LLD의 aggressive alignment (--icf=all)로 생성된 작은 ARM/Thumb 함수를 hook할 수 있습니다.
  • Android에서 ELF import slots를 열거하고 재바인딩하는 것이 작동하여, inline hooks가 거부될 때 모듈별 dlopen()/dlsym() 패칭을 가능하게 합니다.
  • Android 14에서 앱이 --enable-optimizations로 컴파일될 때 사용되는 새로운 ART quick-entrypoint에 대한 Java hooking이 수정되었습니다.

예시: RegisterNatives를 통해 등록된 모든 함수를 열거하고 런타임에 그 주소를 덤프하기:

javascript
Java.perform(function () {
var Runtime = Java.use('java.lang.Runtime');
var register = Module.findExportByName(null, 'RegisterNatives');
Interceptor.attach(register, {
onEnter(args) {
var envPtr  = args[0];
var clazz   = Java.cast(args[1], Java.use('java.lang.Class'));
var methods = args[2];
var count   = args[3].toInt32();
console.log('[+] RegisterNatives on ' + clazz.getName() + ' -> ' + count + ' methods');
// iterate & dump (JNI nativeMethod struct: name, sig, fnPtr)
}
});
});

Frida는 PAC/BTI-enabled devices (Pixel 8/Android 14+)에서 frida-server 16.2 이상을 사용하면 별도 설정 없이 작동합니다 – 이전 버전은 inline hooks의 패딩을 찾지 못했습니다.

Process-local JNI telemetry via preloaded .so (SoTap)

전체 기능의 instrumentation이 과하거나 차단된 경우, 대상 프로세스 내부에 작은 로거를 preload하여 네이티브 수준의 가시성을 확보할 수 있습니다. SoTap은 동일 앱 프로세스 내 다른 JNI (.so) 라이브러리의 런타임 동작을 로깅하는 경량 Android native (.so) 라이브러리입니다 (no root required).

주요 특징:

  • 초기에 초기화되며, 그것을 로드하는 프로세스 내부의 JNI/native 상호작용을 관찰합니다.
  • 여러 쓰기 가능한 경로에 로그를 저장하고, 저장소가 제한될 때는 Logcat으로 우아하게 폴백합니다.
  • Source-customizable: sotap.c를 편집해 로깅 항목을 확장/조정하고 ABI별로 재빌드하세요.

설정 (repack the APK):

  1. 적절한 ABI 빌드를 APK에 넣어 로더가 libsotap.so를 해석할 수 있게 합니다:
  • lib/arm64-v8a/libsotap.so (for arm64)
  • lib/armeabi-v7a/libsotap.so (for arm32)
  1. SoTap이 다른 JNI 라이브보다 먼저 로드되도록 하세요. 로거가 먼저 초기화되도록 호출을 초기에 주입합니다(예: Application subclass static initializer 또는 onCreate). Smali 스니펫 예:
smali
const-string v0, "sotap"
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
  1. 재빌드/서명/설치 후 앱을 실행하고 로그를 수집하세요.

로그 경로 (확인 순서):

/data/user/0/%s/files/sotap.log
/data/data/%s/files/sotap.log
/sdcard/Android/data/%s/files/sotap.log
/sdcard/Download/sotap-%s.log
# If all fail: fallback to Logcat only

Notes and troubleshooting:

  • ABI 정렬은 필수입니다. 불일치하면 UnsatisfiedLinkError가 발생하고 로거가 로드되지 않습니다.
  • 현대 Android에서는 저장 공간 제약이 흔합니다. 파일 쓰기가 실패하면 SoTap은 여전히 Logcat을 통해 출력합니다.
  • 동작/출력 수준은 사용자 맞춤을 염두에 둔 것입니다; sotap.c를 편집한 후 소스에서 재빌드하세요.

이 접근법은 프로세스 시작부터 네이티브 호출 흐름을 관찰하는 것이 중요하지만 루트/시스템 전체 훅을 사용할 수 없는 경우에 악성코드 트리아지 및 JNI 디버깅에 유용합니다.


See also: in‑memory native code execution via JNI

일반적인 공격 패턴은 런타임에 원시 shellcode blob을 다운로드하여 JNI 브리지를 통해 디스크에 ELF를 쓰지 않고 메모리에서 직접 실행하는 것입니다. 자세한 내용과 즉시 사용할 수 있는 JNI 스니펫은 다음을 참조하세요:

In Memory Jni Shellcode Execution


APK에서 찾아볼 가치가 있는 최근 취약점

연도CVE영향받는 라이브러리설명
2023CVE-2023-4863libwebp ≤ 1.3.1WebP 이미지를 디코딩하는 네이티브 코드에서 도달 가능한 힙 버퍼 오버플로가 존재합니다. 여러 Android 앱이 취약한 버전을 번들링합니다. APK 안에 libwebp.so가 보이면 버전을 확인하고 익스플로잇 또는 패치 시도를 해보세요.
2024MultipleOpenSSL 3.x series여러 메모리 안전성 및 padding-oracle 이슈가 보고되었습니다. 많은 Flutter & ReactNative 번들들이 자체 libcrypto.so를 포함합니다.

APK 내부의 서드파티 .so 파일을 발견하면 항상 그 해시를 업스트림 권고문과 대조하세요. 모바일에서는 SCA(Software Composition Analysis)가 드물어 구식의 취약한 빌드가 만연합니다.


안티 리버싱 및 하드닝 트렌드 (Android 13-15)

  • Pointer Authentication (PAC) & Branch Target Identification (BTI): Android 14는 지원되는 ARMv8.3+ 실리콘에서 시스템 라이브러리에 PAC/BTI를 활성화합니다. 디컴파일러는 이제 PAC 관련 유사 명령어를 표시하며, 동적 분석용으로 Frida는 PAC를 제거한 후 트램폴린을 주입하지만, 사용자 정의 트램폴린은 필요한 경우 pacda/autibsp를 호출해야 합니다.
  • MTE & Scudo hardened allocator: 메모리 태깅은 선택적이지만 Play-Integrity를 고려한 많은 앱이 -fsanitize=memtag로 빌드합니다; 태그 폴트를 캡처하려면 setprop arm64.memtag.dump 1adb shell am start ...를 사용하세요.
  • LLVM Obfuscator (opaque predicates, control-flow flattening): 상용 패커(예: Bangcle, SecNeo)는 점점 더 Java뿐 아니라 네이티브 코드도 보호합니다; .rodata에 가짜 제어 흐름이나 암호화된 문자열 블롭이 있을 것으로 예상하세요.

초기 네이티브 이니셜라이저(.init_array)와 JNI_OnLoad 비활성화로 조기 계측 확보 (ARM64 ELF)

강하게 보호된 앱들은 종종 .init_array를 통해 매우 초기 단계에서 실행되는 네이티브 생성자에 루트/에뮬레이터/디버그 체크를 배치합니다. 이는 JNI_OnLoad보다 훨씬 앞서 Java 코드가 실행되기 전에 발생합니다. 이러한 암묵적 이니셜라이저를 명시적으로 바꿔 제어권을 회복할 수 있습니다:

  • DYNAMIC 테이블에서 INIT_ARRAY/INIT_ARRAYSZ를 제거하여 로더가 .init_array 항목을 자동 실행하지 않도록 합니다.
  • RELATIVE 재배치에서 생성자 주소를 해결하고 이를 일반 함수 심볼(예: INIT0)로 내보냅니다.
  • ART가 암묵적으로 호출하지 않도록 JNI_OnLoad의 이름을 JNI_OnLoad0로 변경합니다.

왜 Android/arm64에서 동작하는가

  • AArch64에서는 .init_array 항목이 종종 로드 시 R_AARCH64_RELATIVE 재배치에 의해 채워지며, addend가 .text 내부의 대상 함수 주소입니다.
  • .init_array의 바이트는 정적으로는 비어 보일 수 있습니다; 동적 링커가 재배치 처리 중에 해결된 주소를 씁니다.

생성자 대상 식별

  • AArch64에서 정확한 ELF 파싱을 위해 Android NDK toolchain을 사용하세요:
bash
# Adjust paths to your NDK; use the aarch64-linux-android-* variants
readelf -W -a ./libnativestaticinit.so | grep -n "INIT_ARRAY" -C 4
readelf -W --relocs ./libnativestaticinit.so
  • .init_array 가상 주소 범위 안에 떨어지는 재배치를 찾으세요; 해당 R_AARCH64_RELATIVEaddend가 생성자입니다(예: 0xA34, 0x954).
  • 주소 주변을 디스어셈블해 정합성을 확인하세요:
bash
objdump -D ./libnativestaticinit.so --start-address=0xA34 | head -n 40

패치 계획

  1. DYNAMIC 태그에서 INIT_ARRAYINIT_ARRAYSZ를 제거합니다. 섹션을 삭제하지 마세요.
  2. 생성자 주소에 GLOBAL DEFAULT FUNC 심볼 INIT0를 추가해 수동으로 호출할 수 있게 합니다.
  3. JNI_OnLoad의 이름을 JNI_OnLoad0으로 변경해 ART가 암묵적으로 호출하지 않도록 합니다.

Validation after patch

bash
readelf -W -d libnativestaticinit.so.patched | egrep -i 'init_array|fini_array|flags'
readelf -W -s libnativestaticinit.so.patched | egrep 'INIT0|JNI_OnLoad0'

LIEF (Python)로 패치하기

스크립트: INIT_ARRAY/INIT_ARRAYSZ 제거, INIT0 내보내기, JNI_OnLoad→JNI_OnLoad0 이름 변경
python
import lief

b = lief.parse("libnativestaticinit.so")

# Locate .init_array VA range
init = b.get_section('.init_array')
va, sz = init.virtual_address, init.size

# Compute constructor address from RELATIVE relocation landing in .init_array
ctor = None
for r in b.dynamic_relocations:
if va <= r.address < va + sz:
ctor = r.addend
break
if ctor is None:
raise RuntimeError("No R_*_RELATIVE relocation found inside .init_array")

# Remove auto-run tags so loader skips .init_array
for tag in (lief.ELF.DYNAMIC_TAGS.INIT_ARRAYSZ, lief.ELF.DYNAMIC_TAGS.INIT_ARRAY):
try:
b.remove(b[tag])
except Exception:
pass

# Add exported FUNC symbol INIT0 at constructor address
sym = lief.ELF.Symbol()
sym.name = 'INIT0'
sym.value = ctor
sym.size = 0
sym.binding = lief.ELF.SYMBOL_BINDINGS.GLOBAL
sym.type = lief.ELF.SYMBOL_TYPES.FUNC
sym.visibility = lief.ELF.SYMBOL_VISIBILITY.DEFAULT

# Place symbol in .text index
text = b.get_section('.text')
for idx, sec in enumerate(b.sections):
if sec == text:
sym.shndx = idx
break
b.add_dynamic_symbol(sym)

# Rename JNI_OnLoad -> JNI_OnLoad0 to block implicit ART init
j = b.get_symbol('JNI_OnLoad')
if j:
j.name = 'JNI_OnLoad0'

b.write('libnativestaticinit.so.patched')

주의사항 및 실패한 접근법 (이식성)

  • .init_array 바이트를 0으로 만들거나 섹션 길이를 0으로 설정해도 도움이 되지 않습니다: 동적 링커가 relocations를 통해 이를 다시 채웁니다.
  • INIT_ARRAY/INIT_ARRAYSZ를 0으로 설정하면 태그 불일치로 인해 로더가 깨질 수 있습니다. 해당 DYNAMIC 엔트리를 깔끔히 제거하는 것이 신뢰할 수 있는 방법입니다.
  • .init_array 섹션을 완전히 삭제하면 로더가 충돌하는 경향이 있습니다.
  • 패치 후에는 함수/레이아웃 주소가 이동할 수 있으니, 패치를 다시 실행해야 하는 경우 패치된 파일의 .rela.dyn addends에서 항상 생성자(constructor)를 재계산하세요.

INIT0와 JNI_OnLoad0를 호출하기 위한 최소 ART/JNI 부트스트랩

  • JNIInvocation을 사용해 단독 바이너리에서 작은 ART VM 컨텍스트를 띄웁니다. 그런 다음 어떤 Java 코드보다 먼저 INIT0()JNI_OnLoad0(vm)를 수동으로 호출하세요.
  • 타깃 APK/classes를 classpath에 포함시켜 RegisterNatives가 해당 Java 클래스를 찾을 수 있게 하세요.
INIT0 → JNI_OnLoad0 → Java method를 호출하기 위한 최소 하니스 (CMake and C)
cmake
# CMakeLists.txt
project(caller)
cmake_minimum_required(VERSION 3.8)
include_directories(AFTER ${CMAKE_SOURCE_DIR}/include)
link_directories(${CMAKE_SOURCE_DIR}/lib)
find_library(log-lib log REQUIRED)
add_executable(caller "caller.c")
add_library(jenv SHARED "jnihelper.c")
target_link_libraries(caller jenv nativestaticinit)
c
// caller.c
#include <jni.h>
#include "jenv.h"
JavaCTX ctx;
void INIT0();
void JNI_OnLoad0(JavaVM* vm);
int main(){
char *jvmopt = "-Djava.class.path=/data/local/tmp/base.apk"; // include app classes
if (initialize_java_environment(&ctx,&jvmopt,1)!=0) return -1;
INIT0();                   // manual constructor
JNI_OnLoad0(ctx.vm);       // manual JNI init
jclass c = (*ctx.env)->FindClass(ctx.env, "eu/nviso/nativestaticinit/MainActivity");
jmethodID m = (*ctx.env)->GetStaticMethodID(ctx.env,c,"stringFromJNI","()Ljava/lang/String;");
jstring s = (jstring)(*ctx.env)->CallStaticObjectMethod(ctx.env,c,m);
const char* p = (*ctx.env)->GetStringUTFChars(ctx.env,s,NULL);
printf("Native string: %s\n", p);
cleanup_java_env(&ctx);
}
bash
# Build (adjust NDK/ABI)
cmake -DANDROID_PLATFORM=31 \
-DCMAKE_TOOLCHAIN_FILE=$HOME/Android/Sdk/ndk/26.1.10909125/build/cmake/android.toolchain.cmake \
-DANDROID_ABI=arm64-v8a ..
make

일반적인 함정:

  • 생성자 주소는 재배치(re-layout)로 인해 패치 후 변경됩니다; 최종 바이너리에서 항상 .rela.dyn으로부터 재계산하세요.
  • -Djava.class.pathRegisterNatives 호출에서 사용되는 모든 클래스를 포함하는지 확인하세요.
  • 동작은 NDK/loader 버전에 따라 달라질 수 있습니다; 일관되게 신뢰할 수 있던 단계는 INIT_ARRAY/INIT_ARRAYSZ DYNAMIC 태그를 제거하는 것이었습니다.

참고자료

tip

AWS 해킹 배우기 및 연습하기:HackTricks Training AWS Red Team Expert (ARTE)
GCP 해킹 배우기 및 연습하기: HackTricks Training GCP Red Team Expert (GRTE) Azure 해킹 배우기 및 연습하기: HackTricks Training Azure Red Team Expert (AzRTE)

HackTricks 지원하기