逆向本地库

Reading time: 14 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 字节码仍然更难反编译。 本页侧重于使逆向 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 版本引入了能识别 PAC/BTI stubs 和 MTE tags 的 AArch64 反编译器,大大改善了对使用 Android 14 NDK 构建的库的分析。
  2. 决定使用静态还是动态逆向: stripped、obfuscated 的代码通常需要 instrumentation(Frida, ptrace/gdbserver, LLDB)。

动态 instrumentation (Frida ≥ 16)

Frida 的 16 系列带来了若干 Android 特定的改进,当目标使用现代 Clang/LLD 优化时这些改进很有帮助:

  • thumb-relocator 现在可以 hook tiny ARM/Thumb functions,这些函数由 LLD 的激进对齐(--icf=all)生成。
  • 在 Android 上枚举并重新绑定 ELF import slots 已可行,当内联 hook 被拒绝时,允许对每个模块做 dlopen()/dlsym() 级别的修补。
  • 修复了针对新的 ART quick-entrypoint 的 Java hooking,该 entrypoint 在使用 --enable-optimizations 在 Android 14 上编译应用时被使用。

示例:枚举通过 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 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)

当全功能的 instrumentation 过度或被阻止时,你仍然可以通过在目标进程内预加载一个小型 logger 来获得本地级别的可见性。SoTap 是一个轻量级的 Android native (.so) 库,用于记录同一应用进程内其他 JNI (.so) 库的运行时行为(无需 root)。

主要特性:

  • 尽早初始化,并观察加载它的进程内的 JNI/native 交互。
  • 使用多个可写路径持久化日志,在存储受限时优雅地回退到 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 代码示例:
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.

This approach is useful for malware triage and JNI debugging where observing native call flows from process start is critical but root/system-wide hooks aren’t available.


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.1堆缓冲区溢出,可从解码 WebP 图像的本地代码触发。若 APK 中包含 libwebp.so,请检查其版本并尝试利用或修补。
2024MultipleOpenSSL 3.x series存在若干内存安全和 padding-oracle 问题。许多 Flutter & ReactNative 打包会自带 libcrypto.so

当你在 APK 中发现第三方 .so 文件时,务必将其哈希与上游通告进行交叉核验。SCA (软件成分分析) 在移动端并不常见,因此过时且易受攻击的构建非常普遍。


  • Pointer Authentication (PAC) & Branch Target Identification (BTI): Android 14 在支持的 ARMv8.3+ 硬件上对系统库启用了 PAC/BTI。反编译器现在会显示与 PAC 相关的伪指令;对于动态分析,Frida 在去除 PAC 后注入 trampolines,但你的自定义 trampolines 在必要时应该调用 pacda/autibsp
  • MTE & Scudo hardened allocator: memory-tagging 是可选的,但许多关注 Play-Integrity 的应用在构建时使用 -fsanitize=memtag;使用 setprop arm64.memtag.dump 1 加上 adb shell am start ... 来捕获 tag fault。
  • LLVM Obfuscator (opaque predicates, control-flow flattening): 商业 packer(例如 Bangcle、SecNeo)越来越多地保护 native 代码,而不仅仅是 Java;在 .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.

Why this works on Android/arm64

  • On AArch64, .init_array entries are often populated at load time by R_AARCH64_RELATIVE relocations whose addend is the target function address inside .text.
  • The bytes of .init_array may look empty statically; the dynamic linker writes the resolved address during relocation processing.

Identify the constructor target

  • Use the Android NDK toolchain for accurate ELF parsing on AArch64:
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
  • Find the relocation that lands inside the .init_array virtual address range; the addend of that R_AARCH64_RELATIVE is the constructor (e.g., 0xA34, 0x954).
  • Disassemble around that address to sanity check:
bash
objdump -D ./libnativestaticinit.so --start-address=0xA34 | head -n 40

Patch plan

  1. Remove INIT_ARRAY and INIT_ARRAYSZ DYNAMIC tags. Do not delete sections.
  2. Add a GLOBAL DEFAULT FUNC symbol INIT0 at the constructor address so it can be called manually.
  3. Rename JNI_OnLoadJNI_OnLoad0 to stop ART from invoking it implicitly.

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 无济于事:动态链接器会通过重定位重新填充它。
  • INIT_ARRAY/INIT_ARRAYSZ 设置为 0 可能会因标记不一致而破坏加载器。干净地移除那些 DYNAMIC 条目是可靠的手段。
  • 彻底删除 .init_array 节通常会导致加载器崩溃。
  • 打补丁后,函数/布局地址可能会发生偏移;如果需要重新运行补丁,务必在被修改的文件上根据 .rela.dyn 的 addends 重新计算构造函数。

引导一个最小的 ART/JNI 以调用 INIT0 和 JNI_OnLoad0

  • 使用 JNIInvocation 在独立二进制中启动一个小型 ART VM 上下文。然后在任何 Java 代码之前手动调用 INIT0()JNI_OnLoad0(vm)
  • 在 classpath 中包含目标 APK/classes,使得任何 RegisterNatives 能找到其 Java 类。
用于调用 INIT0 → JNI_OnLoad0 → Java 方法 的最小化 harness(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.path 覆盖所有 RegisterNatives 调用使用的类。
  • 行为可能随 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