翻译声明 :本文由英文原文经 AI 翻译整理,可能存在表述或技术细节上的偏差。如有歧义,请以 英文原文及源码为准。
jnilog + gozinject:免 Frida 的 Android JNI 全表实时追踪
一个注入的 ARM64
.so即可 hook 完整的 AndroidJNINativeInterface(228 项)函数表,把 JNI 活动渲染成 带类型、带颜色 、带符号化 的实时追踪。jnilog负责追踪;gozinject在进程 fork 时刻 完成加载------不用ptrace、不用调试器 attach,并可对目标进程隐藏 VMA。
工具 角色 版本与形态 jnilog JNI 追踪载荷 1.1.0;Android/arm64 cgo 共享库;hook 全部 228 个 JNI 表项gozinject 加载器/注入器 1.0.0;Android/arm64 Go 注入器;root,zygote fork 时刻加载一句话:
gozinject趁应用原生反篡改尚未武装时,把libjnilog.so送进全新的应用进程;随后jnilog记录 JNI 边界上的每一次调用,并附带足以直接用于逆向的类型信息。目标应用
本次抓取目标是
myphotocom.allfasttranslate.transationtranslator("All Photo Translator",版本10.278.00),一款免费含广告的 OCR/翻译应用,是个相当真实的分析目标:
targetSdk 30,360 加固(libjiagu_64.so、com.stub.StubApp、com.qihoo.util.QHClassLoader)- 集成 Facebook / Firebase / AdMob SDK
- 首个可见 Activity 之前有大量原生引导(bootstrap)逻辑
一次冷启动在前 35 秒内产生了 5,599 个 JNI 事件。
追踪输出长什么样
冷启动早期,直接来自 logcat:
[FindClass] "android/os/Build$VERSION" -> android.os.Build$VERSION @ 0x72efb80fd0 [GetStaticIntField] android.os.Build$VERSION.SDK_INT: int -> 36 @ 0x72efb8106c [RegisterNatives] com.stub.StubApp {interface14(int): java.lang.String @0x72efb57f1c, mark(): void @0x72efbbc2fc, ...} [GetStaticFieldID] com.stub.StubApp.needX86Bridge: boolean -> 0x3056 @ 0x72efb5ce34 [GetStaticFieldID] android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE: int -> 0xac0e @ 0x72efc4aa3c [GetStaticFieldID] android.content.pm.PackageManager.GET_SIGNATURES: int -> 0xbe5a @ 0x72efbb3830不打开反汇编器,从上往下读就已经有价值:
SDK_INT暴露加固壳正在走的设备/ROM 判定路径,needX86Bridge是 Jiagu 的 x86 桥接决策,FLAG_DEBUGGABLE是一次反调试检查,GET_SIGNATURES开始一次签名/篡改校验。关于地址列 :上面每个调用方都是裸地址
0x72ef...------本阶段 Jiagu 引导逻辑运行在已解密的匿名内存里,没有文件映射的模块可作基准,裸地址就是最诚实的结果。一旦执行落入已映射的libjiagu_64.so,同一列就符号化为libjiagu_64.so!<offset>,给出稳定、与重定位无关的位置:每行
Call*Method都可能有独立的返回行,两者用单调递增的#id关联,因此多线程交错写入 logcat 仍可读,调用即便在返回前崩溃也依然可见。下面是应用读取 Android ID 并把它带入原生代码:
[CallStaticObjectMethodV] #63a android.provider.Settings$Secure::getString(...ApplicationContentResolver@e09d4c1, "android_id") @ 0x72efb59a30 [CallStaticObjectMethodV] #63a -> "79debbe244469315" [GetStringUTFChars] #63b ("79debbe244469315") @ 0x72efbaed2c重点不只是
getString()被调用了------返回值被配对、被渲染,紧接着就能看到它通过GetStringUTFChars跨入原生内存。输出是带类型的:jstring渲染为带引号的字符串,jclass渲染为点分类名,数组按上限截断显示元素,对象返回值被渲染出来而不是留下一个不透明句柄。
jnilog 工作原理
libjnilog.so= Go 运行时 + C hook 层,由go build -buildmode=c-shared构建。加载时其构造函数解析 ART/JNI 入口、找到存活的JavaVM,复制一份JNINativeInterface表、替换表项、临时改写页保护完成替换,此后每个线程的JNIEnv都路由经这张被 hook 的表。当前覆盖全表 228 项(方法调用 93、字段 36、查找 5、注册 2、引用 9、字符串 12、数组 44、异常 7、类/对象/缓冲区/其它 20);难以手写维护的几族用 X-macro 生成。热路径不回调 Go。 早期版本用 C→Go 的 cgo 回调,但在受保护应用上,每事件的 cgo 跨越所引发的 Go 调度活动本身就是一个完整性信号。当前改用一条二进制事件管道:
hook 入口 -> 栈上事件编码 -> AF_UNIX SOCK_DGRAM 发送 -> Go reader goroutine数据报上限
8192字节,字符串带长度前缀、分帧字节转义、截断回退到安全的 UTF-8 边界。消费端落后时,hook 丢弃事件而非阻塞应用线程,并周期性汇总丢弃计数。对象渲染也被安全地延迟:只有消费线程就绪才创建全局引用,所有权仅在数据报确实送达时才转交 Go,否则 hook 自行删除引用------否则泄漏的全局引用最终会耗尽 VM 的引用表。自给自足的 C 核心。 C hook 层在自身热路径上刻意避开可被重定向(reroutable)的 libc 调用:
str*/mem*、snprintf、malloc、mutex/futex、send/open/read/mprotect(内联svc #0系统调用)、以及dladdr/符号化(私有/proc/self/maps+.dynsym)全部仓库内自实现。一道readelf导入门禁强制这点------任何已迁移的 libc 符号一旦重新出现在动态导入里,构建即失败。这并不是说整个进程零 libc 痕迹(Go 运行时仍有冷启动导入),而是更窄也更有用的主张:执行在 JNI hook 入口上的那段 C 桥接,避开了加固壳与同进程日志器最常 hook 的那些 libc 符号。下面是真实抓取里的电话服务 hook 字符串,以及随后解密出的原生库加载级联:
gozinject 如何加载
gozinject不 attach 运行中的应用,而是在 fork 时刻捕获目标:
- 解析目标 UID 与主 Activity,清除该 UID 现有的
/proc/vma_hide条目。- 把载荷暂存到
/data/data/<pkg>/.org.chromium.<random>.tmp。- 字节级 patch zygote
libandroid_runtime.so里的android_os_Process_setArgV0,再用am start启动应用。- 匹配到的子进程在 fork 后、应用代码尚未完全运行时命中
setArgV0;一段 428 字节的桩按 zygote PID + 应用 UID 过滤,映射一块 256 KiB 的 RWX stage 并跳入。- 4 KiB 的 stage 恢复原始
setArgV0,按序dlopen每个载荷,删除暂存文件,经 mailbox 上报进度。- 注入器恢复被改写的页,把载荷
soinfo从链接器链表中摘除,并可选地对同 UID 的/proc/maps隐藏载荷/stage 的 VMA。
vma_hide是唯一需要内核协助的层。没有该模块时,注入与soinfo摘除仍然有效,但载荷映射在/proc/self/maps中仍可见。设想的威胁模型是运行在应用 UID 内的反篡改扫描器;root 权限的读取者按设计可绕过该过滤。
配置、构建与运行
配置默认读
/data/local/tmp/jnilog.json(或JNILOG_CONFIG);无配置即"记录一切"。include 列表为空表示全开,一旦填入functions/categories就切换到白名单模式,exclude始终优先;类别有methods / fields / lookups / strings / arrays / refs / exceptions。log_sinks当前默认在 logcat 之外再加一个异步文件 sink(带缓冲、周期 flush、受log_queue_size限流,溢出丢行而不阻塞 hook 线程)。tools/jnilogcfg是独立的 Go 模块,提供 TUI/CLI 编辑并经 adb 推送配置。
# 构建 jnilog cd /opt/github/jnilog export ANDROID_NDK_HOME=/path/to/android-ndk xmake b jnilog # 用 gozinject 注入并流式查看 cd /opt/github/gozinject xmake run --pkg=myphotocom.allfasttranslate.transationtranslator \ --lib=/opt/github/jnilog/dist/libjnilog.so \ --debug --logcat --logtag=JniLog当前 logcat tag 是
JNILogPayload;若你按旧 tag(如JniLog)过滤,可能载荷在正常工作,而你的过滤器把每一行追踪都藏了起来。
小结
对加固密集的 Android 目标,JNI 边界往往正是那些有趣事实变得具体的地方:包名、签名校验、设备 ID、权限查询、解密后的库加载、原生方法注册、字节数组载荷与框架调用。
jnilog把这条边界变成一份带类型的实时记录;gozinject通过尽早加载(无需对运行中进程ptraceattach)、并清理掉同进程反篡改最先检查的加载器痕迹,让这份记录在更难的目标上也成为可能。它既不是万能绕过,也不是对任意载荷行为的隐身保证,而是一条聚焦的分析流水线:尽早加载、隐藏加载器表面、精确记录 JNI,并让各种取舍始终可见。
代码: github.com/Arsylk/jnilog | 加载器: github.com/Arsylk/gozinject
仅供经授权的逆向工程、应用分析与安全研究使用。



