네이티브 라이브러리 역분석

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++로 작성된 네이티브 라이브러리(.so)를 성능이 중요한 작업에 사용할 수 있습니다. 악성코드 제작자들도 이러한 라이브러리를 악용합니다. ELF shared objects는 여전히 DEX/OAT 바이트코드보다 디컴파일하기 어렵습니다. 이 페이지는 Android .so 파일의 역분석을 더 쉽게 만드는 실용적인 워크플로와 최근(2023–2025) 도구 개선 사항에 중점을 둡니다.


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

  1. 라이브러리 추출
# 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. 아키텍처 및 보호 설정 식별
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 바인딩 나열
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).

Dynamic Instrumentation (Frida ≥ 16)

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

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

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

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 will work out of the box on PAC/BTI-enabled devices (Pixel 8/Android 14+) as long as you use frida-server 16.2 or later – earlier versions failed to locate padding for inline hooks.

Dumping runtime-decrypted native libraries from memory (Frida soSaver)

When a protected APK keeps native code encrypted or only maps it at runtime (packers, downloaded payloads, generated libs), attach Frida and dump the mapped ELF directly from process memory.

soSaver workflow (Python host + TS/JS Frida agent):

  • Hooks dlopen and android_dlopen_ext to detect load-time library mapping and performs an initial sweep of already loaded modules.
  • Periodically scans the process memory mappings for ELF headers to catch modules loaded through non-standard mappers that never hit the loader APIs.
  • Reads each module in blocks from memory and streams the bytes through Frida messages to the host; if a region cannot be read, it falls back to reading from the on-disk path when available.
  • Saves the reconstructed .so files and prints per-module extraction stats, providing artifacts for static RE.

Run (root + frida-server, Python ≥3.8, uv):

git clone https://github.com/TheQmaks/sosaver.git
cd sosaver && uv sync
source .venv/bin/activate    # .venv\Scripts\activate on Windows

# target by package or PID; choose output/verbosity
sosaver com.example.app
sosaver 1234 -o /tmp/so-dumps --debug

이 방법은 “only decrypted in RAM” 보호를 복구된 라이브 맵핑 이미지로 우회하여, 파일시스템 복사본이 난독화되어 있거나 없어도 IDA/Ghidra에서 오프라인 분석이 가능합니다.

사전 로드된 .so를 통한 프로세스 로컬 JNI 텔레메트리 (SoTap)

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

Key properties:

  • 초기에 초기화되어 로드한 프로세스 내의 JNI/native 상호작용을 관찰합니다.
  • 여러 쓰기 가능한 경로에 로그를 지속 저장하고 저장 공간이 제한될 때는 Logcat으로 우아하게 폴백합니다.
  • Source-customizable: edit sotap.c to extend/adjust what gets logged and rebuild per ABI.

Setup (repack the APK):

  1. Drop the proper ABI build into the APK so the loader can resolve libsotap.so:
  • lib/arm64-v8a/libsotap.so (for arm64)
  • lib/armeabi-v7a/libsotap.so (for arm32)
  1. Ensure SoTap loads before other JNI libs. Inject a call early (e.g., Application subclass static initializer or onCreate) so the logger is initialized first. Smali snippet example:
const-string v0, "sotap"
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
  1. Rebuild/sign/install, run the app, then collect logs.

Log paths (checked in order):

/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을 통해 출력합니다.
  • 동작/출력 수준(verbosity)은 사용자화하도록 설계되어 있습니다; sotap.c를 편집한 후 소스에서 재빌드하세요.

이 접근법은 malware triage 및 JNI 디버깅에서 유용합니다. 프로세스 시작 시점의 네이티브 호출 흐름을 관찰해야 하고 루트/시스템 전역 훅을 사용할 수 없을 때 특히 중요합니다.


See also: in‑memory native code execution via JNI

A common attack pattern is to download a raw shellcode blob at runtime and execute it directly from memory through a JNI bridge (no on‑disk ELF). Details and ready‑to‑use JNI snippet here:

In Memory Jni Shellcode Execution


Recent vulnerabilities worth hunting for in APKs

연도CVEAffected library메모
2023CVE-2023-4863libwebp ≤ 1.3.1네이티브 코드에서 WebP 이미지를 디코드할 때 도달 가능한 힙 버퍼 오버플로우. 여러 Android 앱이 취약한 버전을 번들로 포함합니다. APK 내부에 libwebp.so가 보이면 버전을 확인하고 익스플로잇 시도 또는 패치 적용을 고려하세요.
2024MultipleOpenSSL 3.x series여러 메모리 안전성 및 패딩-오라클 계열 문제. 많은 Flutter & ReactNative 번들들이 자체 libcrypto.so를 포함하여 배포됩니다.

APK 내부에서 서드파티 .so 파일을 발견하면, 항상 해당 해시를 업스트림 권고문과 교차 확인하세요. SCA (Software Composition Analysis)는 모바일에서 드물기 때문에 오래된 취약한 빌드가 널리 퍼져 있습니다.


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

Neutralizing early native initializers (.init_array) and JNI_OnLoad for early instrumentation (ARM64 ELF)

높이 보호된 앱들은 종종 매우 초기 단계에 .init_array를 통해 실행되는 네이티브 생성자(constructor)에 루트/에뮬레이터/디버그 검사를 배치합니다. 이들은 JNI_OnLoad보다 훨씬 이전, Java 코드 실행 이전에 동작합니다. 이런 암묵적 초기화자들을 명시적으로 바꿔 제어권을 회복할 수 있습니다:

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

Android/arm64에서 이 방법이 작동하는 이유

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

생성자 대상 식별

  • AArch64에서 정확한 ELF 파싱을 위해 Android NDK 툴체인을 사용하세요:
# 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).
  • 해당 주소 주변을 역어셈블하여 확인하세요:
objdump -D ./libnativestaticinit.so --start-address=0xA34 | head -n 40

패치 계획

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

Validation after patch

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 제거, export 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’)

</details>

메모 및 실패한 접근법(이식성)
- `.init_array` 바이트를 0으로 만들거나 섹션 길이를 0으로 설정해도 도움이 되지 않습니다: the dynamic linker가 relocations을 통해 이를 다시 채웁니다.
- `INIT_ARRAY`/`INIT_ARRAYSZ`를 0으로 설정하면 태그 불일치로 인해 loader가 손상될 수 있습니다. 해당 DYNAMIC 항목들을 깔끔하게 제거하는 것이 신뢰할 수 있는 방법입니다.
- `.init_array` 섹션을 완전히 삭제하면 loader가 충돌하는 경향이 있습니다.
- 패치 후에는 함수/레이아웃 주소가 이동할 수 있습니다; 패치를 다시 실행해야 하는 경우 패치된 파일에서 `.rela.dyn` addends로부터 생성자(constructor)를 항상 재계산하세요.

최소한의 ART/JNI 초기화로 INIT0 및 JNI_OnLoad0 호출하기
- JNIInvocation을 사용해 독립 실행 바이너리에서 작은 ART VM 컨텍스트를 구동하세요. 그런 다음 어떤 Java 코드보다 먼저 수동으로 `INIT0()`와 `JNI_OnLoad0(vm)`를 호출합니다.
- 타깃 APK/classes를 classpath에 포함시켜 `RegisterNatives`가 Java 클래스를 찾을 수 있게 하세요.

<details>
<summary>최소 허니스 (CMake 및 C) — INIT0 → JNI_OnLoad0 → Java 메서드 호출</summary>
```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)
// 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);
}
# 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

일반적인 함정:

  • 생성자 주소는 리레이아웃 때문에 패치 후 변경됩니다; 최종 바이너리에서 .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 지원하기