逆向本地库
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
- 查看 订阅计划!
- 加入 💬 Discord 群组 或 Telegram 群组 或 在 Twitter 🐦 上关注我们 @hacktricks_live.
- 通过向 HackTricks 和 HackTricks Cloud GitHub 仓库提交 PR 来分享黑客技巧。
更多信息请查看: https://maddiestone.github.io/AndroidAppRE/reversing_native_libs.html
Android 应用可以使用本地库,通常用 C 或 C++ 编写,用于性能关键的任务。恶意软件作者也滥用这些库,因为 ELF shared objects 相比 DEX/OAT 字节码仍然更难反编译。
本页侧重于使逆向 Android .so 文件更容易的实用工作流和近期工具改进(2023-2025)。
针对刚拉出的 libfoo.so 的快速分级工作流
- 提取库
# 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/
- 识别架构与防护
file libfoo.so # arm64 or arm32 / x86
readelf -h libfoo.so # OS ABI, PIE, NX, RELRO, etc.
checksec --file libfoo.so # (peda/pwntools)
- 列出导出符号与 JNI 绑定
readelf -s libfoo.so | grep ' Java_' # dynamic-linked JNI
strings libfoo.so | grep -i "RegisterNatives" -n # static-registered JNI
- 在反编译器中加载 (Ghidra ≥ 11.0, IDA Pro, Binary Ninja, Hopper or Cutter/Rizin) 并运行自动分析。 较新的 Ghidra 版本引入了能识别 PAC/BTI stubs 和 MTE tags 的 AArch64 反编译器,大大改善了对使用 Android 14 NDK 构建的库的分析。
- 决定使用静态还是动态逆向: 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 注册的所有函数并在运行时导出它们的地址:
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):
- 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)
- 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 代码示例:
const-string v0, "sotap"
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
- 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
| Year | CVE | Affected library | Notes |
|---|---|---|---|
| 2023 | CVE-2023-4863 | libwebp ≤ 1.3.1 | 堆缓冲区溢出,可从解码 WebP 图像的本地代码触发。若 APK 中包含 libwebp.so,请检查其版本并尝试利用或修补。 |
| 2024 | Multiple | OpenSSL 3.x series | 存在若干内存安全和 padding-oracle 问题。许多 Flutter & ReactNative 打包会自带 libcrypto.so。 |
当你在 APK 中发现第三方 .so 文件时,务必将其哈希与上游通告进行交叉核验。SCA (软件成分分析) 在移动端并不常见,因此过时且易受攻击的构建非常普遍。
Anti-Reversing & Hardening trends (Android 13-15)
- 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_ARRAYSZfrom the DYNAMIC table so the loader does not auto-execute.init_arrayentries. - Resolving the constructor address from RELATIVE relocations and exporting it as a regular function symbol (e.g.,
INIT0). - Renaming
JNI_OnLoadtoJNI_OnLoad0to prevent ART from calling it implicitly.
Why this works on Android/arm64
- On AArch64,
.init_arrayentries are often populated at load time byR_AARCH64_RELATIVErelocations whose addend is the target function address inside.text. - The bytes of
.init_arraymay 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:
# 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_arrayvirtual address range; theaddendof thatR_AARCH64_RELATIVEis the constructor (e.g.,0xA34,0x954). - Disassemble around that address to sanity check:
objdump -D ./libnativestaticinit.so --start-address=0xA34 | head -n 40
Patch plan
- Remove
INIT_ARRAYandINIT_ARRAYSZDYNAMIC tags. Do not delete sections. - Add a GLOBAL DEFAULT FUNC symbol
INIT0at the constructor address so it can be called manually. - Rename
JNI_OnLoad→JNI_OnLoad0to stop ART from invoking it implicitly.
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,导出 INIT0,重命名 JNI_OnLoad→JNI_OnLoad0
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)
# 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.path覆盖所有RegisterNatives调用使用的类。 - 行为可能随 NDK/loader 版本而异;一个长期可靠的步骤是移除
INIT_ARRAY/INIT_ARRAYSZDYNAMIC 标记。
参考资料
- 学习 ARM 汇编: Azeria Labs – ARM Assembly Basics
- JNI & NDK 文档: Oracle JNI Spec · Android JNI Tips · NDK Guides
- 本地库调试: Debug Android Native Libraries Using JEB Decompiler
- Frida 16.x 更改日志 (Android hooking, tiny-function relocation) – frida.re/news
- NVD 对
libwebpoverflow CVE-2023-4863 的通告 – nvd.nist.gov - SoTap:轻量级的应用内 JNI (.so) 行为记录器 – github.com/RezaArbabBot/SoTap
- SoTap 发布 – github.com/RezaArbabBot/SoTap/releases
- 如何使用 SoTap? – t.me/ForYouTillEnd/13
- CoRPhone — JNI memory-only execution pattern and packaging
- Patching Android ARM64 library initializers for easy Frida instrumentation and debugging
- LIEF Project
- JNIInvocation
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
- 查看 订阅计划!
- 加入 💬 Discord 群组 或 Telegram 群组 或 在 Twitter 🐦 上关注我们 @hacktricks_live.
- 通过向 HackTricks 和 HackTricks Cloud GitHub 仓库提交 PR 来分享黑客技巧。
HackTricks