Reversing Native Libraries

Reading time: 16 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++で書かれたnative librariesを使用することがある。マルウェア作成者もこれらのライブラリを悪用する。というのも、ELF shared objectsはDEX/OAT byte-codeよりもまだdecompileが難しいからだ。 このページは、Android .so ファイルのreversingを容易にする実践的なワークフローと、(2023–2025)での最近のツール改善に焦点を当てている。


Quick triage-workflow for a freshly pulled libfoo.so

  1. Extract the library
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. Identify architecture & protections
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. List exported symbols & JNI bindings
bash
readelf -s libfoo.so | grep ' Java_'     # dynamic-linked JNI
strings libfoo.so   | grep -i "RegisterNatives" -n   # static-registered JNI
  1. Load in a decompiler (Ghidra ≥ 11.0, IDA Pro, Binary Ninja, Hopper or Cutter/Rizin) and run auto-analysis. 新しいGhidraバージョンはAArch64 decompilerを導入し、PAC/BTI stubsやMTE tagsを認識するようになったため、Android 14 NDKでビルドされたライブラリの解析が大幅に改善された。
  2. Decide on static vs dynamic reversing: ストリップや難読化されたコードはしばしばinstrumentation(Frida, ptrace/gdbserver, LLDB)が必要になる。

Dynamic Instrumentation (Frida ≥ 16)

Fridaの16シリーズは、ターゲットがモダンなClang/LLD最適化を使用している場合に役立ついくつかのAndroid固有の改善をもたらした:

  • thumb-relocator は、LLDの過度なアライメント(--icf=all)によって生成される小さなARM/Thumb関数をhookできるようになった。
  • Android上でELF import slotsの列挙とリバインドが機能し、inline hooksが使えない場合にモジュール単位でのdlopen()/dlsym()パッチ適用を可能にする。
  • Android 14で--enable-optimizations付きでコンパイルされたアプリが使う新しいART quick-entrypointに対するJava hookingが修正された。

Example: enumerating all functions registered through RegisterNatives and dumping their addresses at runtime:

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 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.

プリロードされた .so によるプロセスローカル JNI テレメトリ (SoTap)

高度なインストルメンテーションが過剰だったりブロックされている場合でも、ターゲットプロセス内に小さなロガーをプリロードすることでネイティブレベルの可視性を確保できます。SoTap は同じアプリプロセス内の他の JNI (.so) ライブラリのランタイム挙動をログする軽量の Android ネイティブ (.so) ライブラリです(root 不要)。

Key properties:

  • 早期に初期化され、それをロードしたプロセス内の JNI/ネイティブ相互作用を観測します。
  • 書き込み可能な複数のパスにログを永続化し、ストレージが制限されている場合は Logcat にフェールバックします。
  • ソースでカスタマイズ可能: sotap.c を編集してログ内容を拡張/調整し、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:
smali
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 alignment is mandatory. A mismatch will raise UnsatisfiedLinkError and the logger won’t load.
  • Storage constraints are common on modern Android; if file writes fail, SoTap will still emit via Logcat.
  • Behavior/verbosity is intended to be customized; rebuild from source after editing sotap.c.

この手法は、プロセス開始時からネイティブの呼び出しフローを観察することが重要で、root/システム全体のフックが利用できない場合の malware triage and JNI debugging に有用です。


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

YearCVEAffected libraryNotes
2023CVE-2023-4863libwebp ≤ 1.3.1WebP 画像をデコードするネイティブコードから到達可能な Heap buffer overflow。いくつかの Android アプリが脆弱なバージョンをバンドルしています。APK 内に libwebp.so を見つけたらバージョンを確認し、exploit または patching を検討してください.
2024MultipleOpenSSL 3.x seriesいくつかの memory-safety および padding-oracle の問題。多くの Flutter & ReactNative バンドルは独自の libcrypto.so を同梱しています。

APK 内にサードパーティの .so ファイルを見つけたら、必ずハッシュを upstream advisories と突き合わせてください。モバイルでは SCA (Software Composition Analysis) が一般的でないため、古い脆弱なビルドが蔓延しています。


  • Pointer Authentication (PAC) & Branch Target Identification (BTI): Android 14 はサポートされる ARMv8.3+ シリコン上の system libraries で PAC/BTI を有効にします。Decompiler は現在 PAC 関連の疑似命令を表示します。動的解析では Frida が PAC を剥がした後に trampoline を注入しますが、カスタム trampoline は必要に応じて pacda/autibsp を呼ぶべきです。
  • MTE & Scudo hardened allocator: memory-tagging はオプトインですが、多くの Play-Integrity 対応アプリは -fsanitize=memtag でビルドされています。タグ違反を捕捉するには setprop arm64.memtag.dump 1adb shell am start ... を使用してください。
  • LLVM Obfuscator (opaque predicates, control-flow flattening): 商用パッカー(例: Bangcle, SecNeo)はネイティブコード(Java に限らない)を保護するケースが増えています。偽の control-flow や .rodata 内の暗号化された文字列ブロブを想定してください。

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

Highly protected apps often place root/emulator/debug checks in native constructors that run extremely early via .init_array, before JNI_OnLoad and long before any Java code executes. You can make those implicit initializers explicit and regain control by:

  • Removing INIT_ARRAY/INIT_ARRAYSZ from the DYNAMIC table so the loader does not auto-execute .init_array entries.
  • Resolving the constructor address from RELATIVE relocations and exporting it as a regular function symbol (e.g., INIT0).
  • Renaming JNI_OnLoad to JNI_OnLoad0 to prevent ART from calling it implicitly.

なぜ 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. INIT_ARRAYINIT_ARRAYSZ の DYNAMIC タグを削除します。セクションは削除しないでください。
  2. コンストラクタアドレスに GLOBAL DEFAULT FUNC シンボル INIT0 を追加し、手動で呼び出せるようにします。
  3. JNI_OnLoadJNI_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) による Patching

スクリプト: 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にしても効果はない:動的リンカが再配置によって再構成する。
  • INIT_ARRAY/INIT_ARRAYSZ を0にするとタグの不整合でローダが壊れる可能性がある。これらの DYNAMIC エントリをきれいに削除するのが確実な手段。
  • .init_array セクションを完全に削除するとローダがクラッシュしがち。
  • パッチ後は関数/レイアウトのアドレスが変わる可能性がある。パッチを再実行する必要がある場合は、パッチ済みファイルの .rela.dyn の加算値からコンストラクタを必ず再計算すること。

Bootstrapping a minimal ART/JNI to invoke INIT0 and JNI_OnLoad0

  • JNIInvocation を使い、スタンドアロンバイナリ内で小さな ART VM コンテキストを起動する。次に任意の Java コードより先に INIT0()JNI_OnLoad0(vm) を手動で呼ぶ。
  • ターゲットの APK/classes をクラスパスに含め、RegisterNatives がその Java クラスを見つけられるようにする。
INIT0 → JNI_OnLoad0 → Java メソッドを呼ぶ最小ハーネス(CMake と 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

よくある落とし穴:

  • コンストラクタのアドレスはリレイアウトによりパッチ適用後に変わることがあるため、最終バイナリの.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をサポートする