手搓JniForward:UnidbgJNI转发真实AndroidART的探索

admin 2026-07-01 05:40:47 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文探讨了在Unidbg模拟器中实现JNI转发至真实AndroidART环境的混合执行方案。核心思路是PC端运行SO的Native指令,当遇到java/*类JNI调用时,通过adb将JSON格式请求发送至手机Agent执行,结果按类型返回。文章详细阐述了对象跨进程传递的Handle机制、路由分发策略以及JSON协议设计,解决了传统补环境方法维护困难的问题,为移动安全分析提供了可操作的技术路径。 综合评分: 85 文章分类: 移动安全,逆向分析,安全工具,Android安全,红队


cover_image

手搓 JniForward:Unidbg JNI 转发真实 Android ART 的探索

r8e8cd8 r8e8cd8

看雪学苑

2026年6月30日 17:59 上海

在小说阅读器读本章

去阅读

论坛里偶尔能看到「SO 在 PC 模拟、JNI 丢真机跑」的说法,当时没看太懂中间怎么接。后来手写补环境补烦了,自己撸了一版。

  • Unidbg 跑 SO,Native 一般没问题,一回调 JNI 就卡:TreeMap 遍历、String.getBytesgetPackageManager 等,全得 AbstractJni 里 switch。
  • 换 App 就复制改一轮;Iterator 状态、字符编码差一点点,签名就歪。
  • 想搞明白的是:Native 继续在 Unidbg 里跑,JNI 里哪些该扔真机、怎么传、怎么接回来。

下面先简单说下整体思路,再写实现细节和踩坑。

整体思路

分工:PC 跑 SO,手机跑该真算的那部分 Java

  • SO 的 Native 指令仍在 Unidbg/Unicorn 里执行。
  • 遇到 java/* 这类 JNI(TreeMapString.getBytes 等),PC 不在 AbstractJni 里硬编返回值,而是经 adb 发一行 JSON 到手机 Agent,在真机 ART 里调完,再把结果传回。
  • PC 和手机之间用 JSON 一行一问一答,方便写协议、对 log。

对象跨进程:只传编号,不传指针

  • Unidbg 里 SO 拿到的 Java 引用,本质是 **DvmObject**(模拟器里的「Java 对象壳」)。
  • 真机 Agent 里对应的是 **jobject** / 普通 Java 对象。
  • 这两者都不能塞进 Socket,所以线上只传 **Handle(整数编号)**,两边各一张表:编号 → 真对象

举个短例子(SO 遍历 TreeMap):

  • PC 发 JSON,让手机 new TreeMap({"width":"1080"}),手机登记 1001,回 {HANDLE, 1001}
  • PC 收到后造一个 **DvmObject 壳**,里面不写真 Map,只记「远程编号 = 1001」——SO 以为手里有个 Java 对象,继续跑。
  • SO 调 entrySet(),PC 再发 JSON:对 HANDLE 1001 调 TreeMap->entrySet;手机查表找到真 TreeMap,调完登记 1002 回传。
  • PC 又造一个新 **DvmObject 壳**(编号 1002),SO 接着调 iteratorhasNextnext……Iterator 的状态一直在手机那张表里,PC 只传递编号。

要点:PC 的 DvmObject 多半是「指向手机的遥控器」;手机 Handle 表里才是 TreeMapIterator 本体。同一条链里编号对得上就行,PC 的 1003 和手机的 1003 不要求是同一个内存对象。

手机算完之后怎么传回来?按返回值类型来

一次 JNI = 一次 JSON 往返。手机 invoke 完,按 JNI 返回值是什么类型,在 JSON 里带不同的 result,不是固定只传 Handle:

  • 返回 Java 对象

    (Map、Iterator、Entry…)→ HANDLE 编号,PC 造 DvmObject 壳,下次 JNI 再把这个编号发回去。

  • 返回 boolean / int 等

    → BOOLI32 等,把值直接传回来。

  • 返回 byte[]

    → BYTES(Base64),比如 String.getBytes()

  • 返回 String

    → STRING,PC 包成 StringObject

  • void / null

    → NULL 或空 result。

对象还要链式调用(next 再 getKey),就靠 Handle 来回指;字符串、字节数组 把值传回来就行,SO 在 Native 里直接用,通常不必再登记成 Handle。

分流:不是全部 JNI 都上网

  • 独立 Agent 是单独安装的 App,没有目标 App 的 Context / ClassLoader。

  • java/javax/

    → 发手机(JDK 类,ART 算得准)。

  • android/*

    、包名签名、com.xxx 业务类 → 留 PC 补环境。

  • 以后若接 Frida / Xposed 模块(跑在目标进程里),这条边界可以往后挪,协议不用改。

#

几种做法(简单对比)

本质都是:PC 截 JNI → 发请求 → 真机 ART 执行 → 结果回传

差别在执行端放哪、要少写多少补环境。

  • 纯 Unidbg 手写补环境

    — 不连手机,全在 AbstractJni 里 switch;简单,JNI 多了难维护。

  • Unidbg + 独立 Agent APK + adb(我用的)

    — PC 跑 SO,java/* 发 JSON 到自写 App;包名、业务类仍 PC 补;不 root、不注入目标 APK。

  • Frida / Xposed 当 Agent

    — 跑在目标 App 进程里,能补的环境更多(Context、业务类、包名等);JSON 协议可以共用,但注入/框架 有可能被检测,我还没做到这一步。

#

整体架构

#

PC(Unidbg)
  libxxx.so 在 Unicorn 里跑
  SO 调 JNI
    ↓
  【代理层】拦住所有 Jni 回调,统一进 Router
    ↓
  【Router.dispatch】打包成一次 JniCall(方法名 + 参数)
    ↓
  【路由策略】看 signature 前缀
    ├─ java/*、javax/*  ──► 【远程执行器】编 JSON → adb → 手机
    └─ android/*、业务类  ──► 【本地执行器】反射调 AbstractJni 补环境
手机 Agent
  127.0.0.1:8765 收 JSON → 真机 invoke → 回一行 result(HANDLE/BOOL/BYTES…)

Router 在架构里干什么(和上面对应)

Router 本身不算 JNI、也不连手机,只做三件事:

  • 统一入口

    — SO 每次 JNI 都先到 dispatch(方法名, 参数),不再散落到各处 switch。

  • 打包

    — 压成 JniCall,后面无论本地还是远程,格式一致。

  • 分拣

    — 查 signature:

  • java/util/Iterator->hasNext()Z

    → 远程 → 发手机 → 回来 BOOL

  • java/util/Iterator->next()...

    → 远程 → 回来 HANDLE → PC 造 DvmObject 壳

  • android/app/Application->getPackageManager()...

    → 本地 → 进本地补环境

  • acceptMethod

    → 本地(只问接不接,不是最终执行)

分拣之后是两条执行链,互斥、只走一条

JniCall
  → 路由:isRemote?
       否 → LocalJniExecutor → 反射调 AbstractJni(补环境 switch)
       是 → RemoteJniExecutor → JSON 往返 → JniArgBridge 译回 DvmObject/boolean/bytes

所以架构图里「代理 → Router → 分叉」就是:先进 Router 再决定本地算还是手机算;控制台 [local] / [remote] 就是这次走了哪条叉。

Native 指令只在 PC;需要真 JVM 的 JNI 才走右边那条叉。

Router 到底是干什么的、怎么分发

可以把它想成快递分拣中心:SO 每次调 JNI,都先到 Router,Router 不自己算,只负责「这单该本地送还是发手机」。

第一层:统一入口(代理)

Unidbg 原来直接调 AbstractJni。我在外面包了一层动态代理:任何 callObjectMethodfindClass……先进代理,代理只做一件事:

return router.dispatch(方法名, 参数数组);

这样不用改 Unidbg 源码,用户还是 vm.setJni(this)

第二层:打包(JniCall)

Router 收到「方法名 + 参数」,压进一个小结构:

public Object dispatch(String op, Object... args) {
return executor.execute(new JniCall(op, args));
}

op 比如 "callObjectMethodV"args 里是 [vm, dvmObject, signature, varArg]

为什么要打包? 后面发 JSON 时,整包 JniCall 转 args 就行,Router 本身不关心本地还是远程。

第三层:选执行器(Hybrid)

真正「分发」发生在 HybridJniExecutor

public Object execute(JniCall call) throws Throwable {
if (!路由策略.isRemote(call)) {
return 本地执行器.execute(call);   // 还是调 AbstractJni
    }
try {
return 远程执行器.execute(call);   // 发 JSON 到手机
    } catch (IOException e) {
if (允许回退) return 本地执行器.execute(call);
throw e;
    }
}

路由策略怎么判? 从参数里找出 JNI 的 signature 字符串(形如 java/util/Iterator->next()Ljava/lang/Object;):

  • 以 java/javax/ 开头 → 发手机(框架类,真机 ART 算最准)

  • 以 android/com/org/ 开头 → 留 PC(包名、系统 API、业务类)

  • acceptMethod

    acceptField → 永远 PC(只是 Unidbg 问「该方法是否由补环境接管」,不是真执行)

判完以后,两条路:

本地路LocalJniExecutor 用反射找到 Jni 接口上对应方法,调原来的实现类——和没加转发前一模一样,补环境 switch 还写在这。

远程路RemoteJniExecutor 把 JniCall 编成 JSON,Socket 发到 8765,等手机回一行 JSON,再解码塞回 Unidbg。

第四层:日志(可选)

外面再包一层「打印执行器」,控制台就会看到:

JNI >> [remote] callObjectMethod java/util/Iterator->next()Ljava/lang/Object;
JNI >> [local]  callObjectMethod android/app/Application->getPackageManager()...

[remote] / [local] 就是 Router 分发结果的直观体现。

一句话:Router = 统一进门 → 打包成 JniCall → 按类名前缀分拣 → 本地反射 or 远程 JSON。

我是怎么一步步做的

第 1 步:先想直接改 AbstractJni —— 不行

最开始很直接的想法,把 Unidbg 里每个 JNI  override 改成一行转发,例如:

@Override
public&nbsp;DvmObject<?>&nbsp;callObjectMethod(BaseVM vm,&nbsp;DvmObject<?>&nbsp;o,
String&nbsp;signature,&nbsp;VarArg&nbsp;varArg) {
return&nbsp;(DvmObject<?>) router.dispatch("callObjectMethod", vm, o, signature, varArg);
}

findClasscallBooleanMethod 等几十个方法都要这么改。能跑,但:

  • 动的是框架源码,以后升级 Unidbg merge 很痛苦;
  • 本地 / 远程写死在改过的类里,不好切换。

后来改成 不动 AbstractJni,外面用动态代理包 Jni 接口,再只做 Router + 本地反射,跑通 SO 确认和改之前结果一致。

第 2 步:定 JSON 协议 + PC 端假 Agent

在 PC 再起一个监听 8765 的小程序,用真 Java 处理 TreeMap/Iterator,跟未来手机 Agent 同一套 JSON。协议定成 一行 JSON 一问一答,方便 log 里直接 grep。

遇到的问题:Unidbg 回调名带 V,Agent 只认不带 V 的 op

Unidbg 的 Jni 接口里,处理可变参数的方法名末尾会多一个 V(表示 VarArg),比如 callObjectMethodVcallBooleanMethodV。我在 PC 侧用动态代理转发时,method.getName() 拿到的就是这个带 V 的名字,原样写进 JSON 的 op 字段。

假 Agent 分发器是按「不带 V 的标准名」写的 switch,两边对不上:

// PC 侧:代理里 method.getName() 拿到的名字
request.setOp("callObjectMethodV"); &nbsp;&nbsp;// &nbsp;JSON 里 op 带 V 不行

// Agent 侧
switch&nbsp;(req.getOp()) {
case&nbsp;"callObjectMethod": &nbsp;&nbsp;// 只注册了这条
return&nbsp;invokeObjectMethod(req);
default:
throw&nbsp;new&nbsp;UnsupportedOperationException("unknown op: "&nbsp;+ req.getOp());
}

表现就是:Socket 通了、JSON 也 parse 成功,但 Agent 回 ok:false,或直接抛 unknown op: callObjectMethodV。跟参数翻译无关,纯粹是 op 名字不一致

PC 发 JSON 前,把末尾的 V 剥掉即可:

private&nbsp;static&nbsp;String&nbsp;normalizeOp(String&nbsp;op) {
if&nbsp;(op.endsWith("V") && op.length() >&nbsp;1) {
return&nbsp;op.substring(0, op.length() -&nbsp;1);
&nbsp; &nbsp; }
return&nbsp;op;
}
// callObjectMethodV &nbsp;→ callObjectMethod
// callBooleanMethodV → callBooleanMethod

这样 PC 和 Agent 只维护一套 op 分支,不用为每个 xxxV 再复制一份。

第 3 步:参数翻译(最难,具体代码 AI 帮着改了几轮)

Unidbg 里 SO 调 JNI 时,参数不是普通 Java 对象,而是一堆框架类型:DvmObjectVarArgVaListStringObject……PC 要把这些「翻译成 JSON 能发的形式」再发出去,手机算完还要「翻译回来」塞给 Unidbg。我主要理规则(什么发 HANDLE、什么发 MAP),具体反射和编码 AI 帮着改了几轮。

第 4 步:分流策略 —— 为什么「全扔手机」不行

跑通 PC 假 Agent 之后,很自然地想:既然真机 ART 算得准,干脆所有 JNI 都发手机,PC 一个 switch 都不用写。

试下来不行,原因是:独立 Agent 是一个自己安装的 APK,不在目标 App 进程里。

所以分流不能是「能发就发」,而是 按类名前缀划边界

  • java/javax/

    → 发手机。JDK 框架类,跟哪个 App 无关,ART 算 TreeMap、String、Iterator 最靠谱。

  • android/、目标包名、com/ 业务类

    → 留 PC 补环境。要么需要 Android 上下文,要么需要目标 App 的 ClassLoader,独立 Agent 给不了。

  • acceptMethod / acceptField

    → 永远 PC。这只是 Unidbg 问「该方法是否由补环境接管」,不是真执行,发手机没意义。

  • 这里应该还有更好的优化方法。

第 5 步:真机 Agent APK

前面 PC 假 Agent 就是在电脑上开了一个「小型 Java 服务」,监听 8765,收到 JSON 就调 TreeMap、回 JSON。第五步做的事很简单:把这段逻辑原样搬进手机里。

可以把它想成在手机上装了一个「专职接电话的 App」:

  • 装 APK

    — 就是一个普通 Android 应用,不用 root,不用改目标 App。

  • 开前台 Service

    — Android 后台杀进程很凶,Service 不挂前台通知,RPC 跑着跑着就被系统掐了;所以必须常驻通知栏,相当于跟系统说「我在干活,别杀我」。

  • 手机内部监听 8765

    — Service 在真机里 bind 127.0.0.1:8765,等 PC 通过 adb 隧道把请求送过来。

  • adb 隧道

    — PC 和手机是两个设备,不能直接 Socket 连。用 adb forward tcp:8765 tcp:8765 的意思是:PC 访问本机 8765 端口,由 adb 把连接转发到手机上的 8765。可以把它想成 USB 里挖了一条专用管道

第 6 步:删 java/ 补环境 TreeMap/Iterator 的 override 全删,只留 vm.setJni(this) 和 android/* 伪装,签名和纯 PC 跑一致才算通。

关键代码讲解

动态代理:所有 JNI 先进 Router

// 包在 AbstractJni 外面
return (Jni) Proxy.newProxyInstance(
&nbsp; &nbsp; Jni.class.getClassLoader(),
&nbsp; &nbsp; new Class<?>[]{ Jni.class&nbsp;},
&nbsp; &nbsp; (proxy, method, args) -> router.dispatch(method.getName(), args)
);

本地执行:反射调原来的类

public&nbsp;Object&nbsp;execute(JniCall call)&nbsp;throws&nbsp;Throwable {
// 方法名 + 参数数量 + 参数类型在 Jni 接口里找 Method
Methodmethod&nbsp;=&nbsp;resolveMethod(call.getOp(), call.getArgs());
return&nbsp;method.invoke(delegate, call.getArgs());
}

本地处理的是「Router 分拣错了或不该上网的」,最终仍进本地 switch 补环境。

远程执行:一整条 JNI 变成一次 Socket 往返

public&nbsp;Object&nbsp;execute(JniCall call)&nbsp;throws&nbsp;Throwable {
BaseVMvm&nbsp;=&nbsp;从参数里取出vm;
&nbsp; &nbsp; RpcValue[] args = 把JniCall参数翻译成JSON可发的形式;
Stringop&nbsp;=&nbsp;归整方法名(call.getOp()); &nbsp;// callObjectMethodV → callObjectMethod
RpcRequestrequest&nbsp;=new&nbsp;RpcRequest(请求序号++, op, args);

try&nbsp;(Socketsocket&nbsp;=new&nbsp;Socket(host, port)) {
&nbsp; &nbsp; &nbsp; &nbsp; 写出一条JSON加换行;
&nbsp; &nbsp; &nbsp; &nbsp; 读回一行JSON;
return&nbsp;把JSON结果变回Unidbg能用的返回值(vm, response, signature);
&nbsp; &nbsp; }
}

参数翻译(核心难点)

// DvmObject 里如果是 远程编号 占位→ 发 HANDLE
if&nbsp;(value&nbsp;instanceof&nbsp;RemoteHandleMarker) {
return&nbsp;RpcValue.handle(marker.getId());
}
// 如果是 Map 初始数据 → 发 MAP,让手机 new TreeMap
if&nbsp;(value&nbsp;instanceof&nbsp;Map) {
return&nbsp;RpcValue.map(copy);
}
// VarArg:反射读内部 args 列表,逐个翻译

结果翻译:HANDLE 变回 DvmObject 占位

public&nbsp;static&nbsp;DvmObject<?>&nbsp;wrapRemoteHandle(BaseVM vm, int handleId,&nbsp;String&nbsp;signature) {
// 从 signature 看出返回值类型,比如 Iterator.next → Map$Entry
String&nbsp;className =&nbsp;inferClassName(signature);
DvmClass&nbsp;type&nbsp;= vm.resolveClass(className);
// 造一个「壳」,里面只存远程编号,真对象在手机
return&nbsp;new&nbsp;RemoteHandleDvmObject(type,&nbsp;new&nbsp;RemoteHandleMarker(handleId));
}

壳的类型不能设成占位符类本身,必须按 signature 推断真实 Java 类型,否则后面 JNI 类型全乱。

手机 Agent:读 JSON、调 Java、写 JSON

// Service 里启动,bind 127.0.0.1:8765
Stringline&nbsp;=&nbsp;reader.readLine();
RpcRequestreq&nbsp;=&nbsp;解析JSON(line);
RpcResponseresp&nbsp;=&nbsp;dispatcher.dispatch(req); &nbsp;// 按 op + signature 调真 TreeMap/Iterator
writer.write(编码JSON(resp));
writer.newLine();
writer.flush();

手机 Agent 与 PC MockAgent 的 JniOpDispatcher 采用 按 JNI signature 硬编码 的 if/switch:收到 JSON 后根据 op + signature 在真机执行对应 Java 调用。未实现的 signature 会在 logcat 中报 UnsupportedOperationException,再按需增加分支。

当前仅针对示例 SO 已实现的远程调用(Map 遍历签名链,非框架全集):

  • ping

  • TreeMap.entrySet

    Set.iterator / Iterator.hasNext|next / Entry.getKey|getValue

  • String.getBytes

receiver:参数为 MAP 时在手机 new TreeMap<>(map) 并登记编号;为 HANDLE 时查表取对象。换其他 SO 时,Router/JSON 可复用,Agent 需按实际 java/* 调用扩展上述列表。

JSON 协议长什么样

请求idopargs(数组,每项 type+value

成功idok:trueresultresultType(小写 handle/boolean/bytes/string)

失败ok:falseexceptionmessage

类型:NULL、BOOL、I32、STRING、HANDLE(大写)、BYTES(Base64)、MAP

收发(一行一问一答):

writer.write(JSON字符串);
writer.newLine();
writer.flush();
String&nbsp;line = reader.readLine();

#

配置项(启动 Hybrid 时用)

  • jni.forward.mode=local

    — 不加转发,原版 Unidbg

  • jni.forward.mode=hybrid

    — 强制 java/* 走手机

  • jni.forward.mode=auto

    — 启动时 ping 8765,通则 Hybrid(默认)

  • jni.forward.host

    port — 默认 127.0.0.1:8765

  • jni.forward.fallback=true

    — 手机连不上时回退 PC 本地,开发时用

用户代码前后

以前:几十行补 TreeMap/Iterator。

现在

vm.setJni(this); &nbsp;&nbsp;// 框架里自动包代理 + Hybrid
// java/* 不用 override 了
// android/* 包名、getPackageManager 仍在 PC 补

#

手机 Agent 工程(功能说明)

  • 独立 Android 工程,Gson 解析 JSON,不依赖 Unidbg。

  • 前台 Service 保活,否则后台被杀 RPC 断。

  • RpcServer:accept 线程 + 线程池,每个连接处理一条 JSON。

  • HandleRegistry:编号从 1000 自增。

  • JniOpDispatcher:和 PC 假 Agent 同一套 signature 分支。

#

联调顺序

工程脚本与 快速上手.md 一致,在项目根目录分步执行。

前置

  • 手机安装 JNI Forward Agent APK,开启 USB 调试(第 3 步真机用;可先装好)
  • 确认 examples\vipshop\assets\ 下有 wph.apklibkeyinfo.so
  • 缺 libz.so 时从 Android SDK 复制到 unidbg-android/src/main/resources/android/sdk23/lib64/

第 1 步:框架自测

run-jni-remote-verify.cmd

成功标志:

[PASS] JniForward smoke tests:&nbsp;4/4
[PASS] JniForward remote tests:&nbsp;4/4

第 2 步:MockAgent + 示例

窗口 A(保持运行):

run-mock-agent.cmd

看到 listening on 127.0.0.1:8765 即可。

窗口 B(依次两条):

compile-example.cmd
run-hybrid-example.cmd

成功标志:

call&nbsp;native&nbsp;method&nbsp;result: d4887cba12109a9e007e8f1d9628f301f9384676

日志里应有 JNI >> [remote] ...

【图3】

第 3 步:真机 Hybrid

  • 打开手机上 JNI Forward Agent

  • 关掉 PC 上的 MockAgent

    (8765 不能冲突)

窗口 A

run-phone-setup.cmd

窗口 B

compile-example.cmd
run-hybrid-example.cmd

成功标志:pong + 签名 70dc3d08... + JNI >> [remote](来自手机)。


#

项目的不足

  • ## JNI 操作覆盖不全

  • 目前 mainly 支持对象方法、布尔方法;NewObjectGetByteArrayElements 等尚未做成 JSON op,不少场景仍须在 PC 侧手写补环境。

  • Agent 按 signature 硬编码

    手机端用 if/switch 逐条对齐 PC 假 Agent,缺一条补一条;未做成「解析 signature → 反射 invoke」,维护成本高,异常与重载也难统一处理。

  • 独立 APK 补不全目标 App 上下文

    执行端不在目标进程内,android/*、包名、业务类仍依赖 PC 补环境;Frida / Xposed 尚未接入,想进一步减少补环境只能换执行端,协议虽可复用但工程未做。

完整工程见 jni-forward-unidbg

https://github.com/r8e8cd8/jni-forward-unidbg

(仅供参考:环境、机型、示例 SO 各异,不一定能直接跑通,主要提供实现思路与目录结构)

#

看雪ID:r8e8cd8

https://bbs.kanxue.com/user-home-1012883.htm

*本文为看雪论坛优秀文章,由 r8e8cd8 原创,转载请注明来自看雪社区

第十届安全开发者峰会【议题征集】-欢迎投稿

往期推荐

ret2dlresolve分析

ELF GOT Hook 实战

面向复现的逆向工程实践:Hermes 在设备刷写、提权与 Frida 魔改中的自动化能力验证

把 .o 变成 .ko:GKI 安全特性的铁幕

实战APP全流程分析(检测绕过/登录分析/视频解锁/native加密/广告绕过)

球分享

球点赞

球在看

点击阅读原文查看更多


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:看雪学苑 r8e8cd8 r8e8cd8《手搓 JniForward:Unidbg JNI 转发真实 Android ART 的探索》

评论:0   参与:  0