90% 的补环境工作发生在 JNI 层。一旦把它拆成"类型 / 取值 / 逻辑"三个维度,这件事就从"痛苦的试错"变成"流程化的操作"。你不再需要记住每个 case 怎么写,你只需要记住这三个问题该问谁。
上一篇把你留在了哪里
上一篇中我们说,补环境是角色扮演,所有外部交互落在四个通道里。其中 JNI 通道占工作量的 90%,是你要扎根最深的那一个。这一篇就专门讲它。
但我要先做一次小小的切换:前面六篇都是在讲"世界观"—— 为什么、是什么、该怎么想。从这篇开始,接下来几篇会非常操作化:给你看代码模板、工具命令、实战流程。
换句话说:前六篇让你明白"补环境是什么",接下来几篇让你明白"补环境怎么做"。
为什么 JNI 补环境"看起来"这么痛苦
刚入门的人写 JNI override 的典型场景是这样的:
- 看到一条报错,
UnsupportedOperationException: android/os/Build->getSerial()... - 跑去 AbstractJni 的源码里找:callStaticObjectMethodV? callObjectMethodV? 哪一个?
- 猜一个,写一个 switch case,复制一份 signature 字符串
- 返回一个
new StringObject(vm, "unknown")
每一次新报错都像从头做一遍。你没有"节奏感",没有"套路",整个过程全靠肌肉记忆。半年之后你可能会"熟能生巧",但那不是理解,那只是习惯。
真正的熟练不是"更快地试错",而是把每次报错分解成固定的三个问题,然后用固定的方式逐一回答。
三个维度:把一个 JNI 补环境请求拆成三个问题
任何一次 JNI override,本质上都在回答三个独立的问题:
JNI 补环境的三个维度 | | |
|---|
| 维度一:类型 | 我该用哪个返回类型?(Object?int?boolean?) | |
| 维度二:取值 | 返回什么具体的值?("HUAWEI" 还是 "Samsung"?) | |
| 维度三:逻辑 | | |
这三个维度互相独立。类型不对,取值再对也白搭;取值不对,类型正确也会让签名错;逻辑维度是高阶情况,只有少数方法属于这一类。
关键的简化:一旦你把这三个维度拆开,就可以逐个击破。不要再一次性想"我怎么补 getPackageInfo"。先回答"它该返回什么类型",再回答"该返回什么值",必要时再考虑"它背后要不要真的执行一段逻辑"。
下面我们把这三个维度逐个拆开。
维度一:返回什么类型 — 报错栈直接告诉你
这一维度是最容易的。你完全不需要猜,Unidbg 已经把答案写在报错栈里了。
栈顶方法名 = 返回类型
回忆第五篇的报错三段式:段二是 Unidbg 内部栈,栈顶那一帧一定是 AbstractJni.callXxxMethodV 或 AbstractJni.getXxxField 这样的方法。这个方法名里藏着完整的类型信息。
看几个例子:
栈顶方法名拆解三例:callObjectMethodV / callStaticIntMethodV / getStaticObjectField命名规则非常规整,把它拆开看就能秒懂:
| | |
|---|
| call | |
| Static | |
| Object / Int / Long / Boolean / Byte / Float / Double / Void | |
| Method | |
| V | 表示接收 va_list 形式的可变参数(Unidbg 内部用这个版本) |
实用记忆:
- 栈顶是
callXxxMethodV —— 你要 override 它
这一维度唯一需要注意的坑:callObjectMethodV 和 callObjectMethod 是两个不同的方法。Unidbg 内部几乎总是走 V 版本(因为 ARM 调用约定里参数走 va_list)。你 override 时认准带 V 的那个,不带 V 的版本可以完全无视。
六种返回模式的代码模板
明确了返回类型后,下一个问题是怎么构造这个返回值。Unidbg 给你准备了一套固定的"包装类",每个类型对应一个模板。背下这六个模板,你就能应付 95% 的 JNI override。
六种返回模式及代码模板模板 1:基本类型(int / long / boolean)— 直接返回数值
@Override
publicintcallIntMethodV(BaseVM vm, DvmObject<?> dvmObject,
DvmMethod dvmMethod, VaList vaList){
String signature = dvmMethod.getSignature();
switch (signature) {
// android/os/Build$VERSION 里的 SDK_INT, 直接返回整数
case"android/os/Build$VERSION->SDK_INT:I":
return29; // Android 10
// 屏幕宽度, 单位是像素
case"android/view/Display->getWidth()I":
return1080;
}
returnsuper.callIntMethodV(vm, dvmObject, dvmMethod, vaList);
}
模板 2:字符串 — 用 StringObject 包装
@Override
public DvmObject<?> callObjectMethodV(BaseVM vm, DvmObject<?> dvmObject,
DvmMethod dvmMethod, VaList vaList) {
String signature = dvmMethod.getSignature();
switch (signature) {
// 设备型号字符串, 必须用 StringObject 包装
case"android/os/Build->getModel()Ljava/lang/String;":
returnnew StringObject(vm, "MI 10");
// IMEI, 同样是字符串
case"android/telephony/TelephonyManager->getDeviceId()Ljava/lang/String;":
returnnew StringObject(vm, "864394010105011");
}
returnsuper.callObjectMethodV(vm, dvmObject, dvmMethod, vaList);
}
模板 3:字节数组 — 用 ByteArray 包装
case"com/example/Native->getSignBytes()[B":
// 签名字节数组, 必须包成 ByteArray
byte[] signBytes = newbyte[] { 0x30, (byte) 0x82, 0x01, (byte) 0xE3, ... };
returnnew ByteArray(vm, signBytes);
模板 4:自定义对象 — ProxyDvmObject 最灵活
case"android/content/pm/PackageManager->getPackageInfo(Ljava/lang/String;I)"
+ "Landroid/content/pm/PackageInfo;":
// 伪造一个 PackageInfo 对象, 利用 JDK 反射 + 动态代理
// ProxyDvmObject 内部会根据后续方法调用动态响应
try {
Object packageInfo = new Object() {
public String packageName = "com.example.app";
publicint versionCode = 123;
public String versionName = "1.2.3";
public Object[] signatures = new Object[] { fakeSignature };
};
return ProxyDvmObject.createObject(vm, packageInfo);
} catch (Exception e) {
thrownew IllegalStateException(e);
}
模板 5:JDK 真实类 — resolveClass + newObject
case"java/util/HashMap-><init>()V":
// HashMap 这种 JDK 自带的类, 直接让 Unidbg 用反射绑定真实 Java 类
// 后续所有方法调用会自动走 JDK 的 HashMap 实现
return vm.resolveClass("java/util/HashMap").newObject(new HashMap<>());
模板 6:装箱类型 — DvmInteger / DvmBoolean
case"android/os/Build->getSomeIntegerField()Ljava/lang/Integer;":
// 注意签名返回的是 Ljava/lang/Integer; (装箱类型)
// 不是 int, 所以要用 DvmInteger 包装
return DvmInteger.valueOf(vm, 42);
速查表:
| | |
|---|
I | | return 1; |
Ljava/lang/String; | StringObject | new StringObject(vm, "...") |
[B | ByteArray | new ByteArray(vm, bytes) |
Landroid/xxx/Xxx; | ProxyDvmObject.createObject | |
Ljava/util/HashMap; | resolveClass().newObject() | |
Ljava/lang/Integer; / Ljava/lang/Boolean; 等装箱类 | DvmInteger.valueOf | |
认清这六个模板,维度一这件事就结束了。
维度二:返回什么值 — 信息收集工具箱
维度一解决了"用什么形式包装",维度二解决"装进去的具体内容是什么"。
这个维度才是真正的核心。因为:
- 值错了 → 代码不崩,但最终签名 / 加密结果是错的,你看不到任何报错
这也是第六篇反复强调的"真正危险的是补错了不知道"。所以你不能拍脑袋猜 "HUAWEI",你需要从真机上取真实值。
下面这张图是你所有取值工具的全景:
JNI 取值工具箱按照"简单先用"的原则,工具箱从轻到重排列:
工具一:adb shell — 适合设备全局信息
最轻量。凡是 android.os.Build 这种全设备共享的属性,直接用 adb shell getprop 就能拿到。
# 获取设备型号, 对应 Build->getModel()
adb shell getprop ro.product.model
# 输出: MI 10
# 获取厂商, 对应 Build->getManufacturer()
adb shell getprop ro.product.manufacturer
# 输出: Xiaomi
# 获取 Android 版本, 对应 Build$VERSION->RELEASE
adb shell getprop ro.build.version.release
# 输出: 11
# 获取 API level, 对应 Build$VERSION->SDK_INT
adb shell getprop ro.build.version.sdk
# 输出: 30
# 获取屏幕分辨率, 对应 Display->getRealSize
adb shell wm size
# 输出: Physical size: 1080x2340
# 获取屏幕密度, 对应 Display->getDensity
adb shell wm density
# 输出: Physical density: 440
# 查看当前 SIM 卡运营商, 对应 TelephonyManager->getSimOperatorName
adb shell dumpsys telephony.registry | grep mServiceState
适用场景:凡是可以从系统属性直接读出来的,优先 adb shell。不需要写代码,不需要注入,几秒钟就搞定。
局限:getprop 只能拿 Android 系统属性,拿不到 App 内部状态(SharedPreferences、类字段),也拿不到需要权限的值(IMEI、硬件 ID)。
工具二:Frida — 适合动态拿单个方法的返回值
当某个方法的返回值是运行时才算出来的(例如 MessageDigest.digest() 的结果、getApplicationInfo().metaData.getString("API_KEY")),adb 就无能为力了。这时候上 Frida。
场景 A:Hook 法 — 被动抓取
等 App 自己调到这个方法,你就能看到真实返回值:
// Frida hook 脚本, 被动等 App 调用 getDeviceId 时打印返回值
Java.perform(function() {
var TelephonyManager = Java.use('android.telephony.TelephonyManager');
// 重载需要用 overload 指定参数签名, 避免多个同名方法匹配冲突
TelephonyManager.getDeviceId.overload().implementation = function() {
var result = this.getDeviceId(); // 先调真实方法, 拿到真实返回
console.log('[*] getDeviceId() => ' + result);
return result; // 返回给 App, 不影响正常运行
};
});
场景 B:主动 Call — 不等调用
如果这个方法 App 暂时不会调(比如只在登录时调),你可以主动 Call 它:
// Frida 主动 Call: 不等 App 自己调, 直接在 loadLibrary 后拿到 Context, 主动调方法
Java.perform(function() {
// 获取当前 App 的 Application 实例
var currentApplication = Java.use('android.app.ActivityThread')
.currentApplication();
var context = currentApplication.getApplicationContext();
// 主动调 PackageManager.getPackageInfo 拿签名
var pm = context.getPackageManager();
// GET_SIGNATURES = 64, 告诉 PM 把签名信息一起返回
var pi = pm.getPackageInfo(context.getPackageName(), 64);
// 把签名字节打印成 hex 字符串, 方便复制到 Unidbg 代码里
var signatures = pi.signatures.value;
for (var i = 0; i < signatures.length; i++) {
var bytes = signatures[i].toByteArray();
var hex = '';
for (var j = 0; j < bytes.length; j++) {
// 处理 Java byte 可能为负的情况
hex += ('0' + (bytes[j] & 0xFF).toString(16)).slice(-2);
}
console.log('[*] signature[' + i + '] hex = ' + hex);
}
});
适用场景:抓单个方法、单个字段的返回值,精确可控。
局限:一次只能抓一个。如果你要补 30 个 JNI 回调,写 30 段 Frida 脚本会很累。这时候上下一个工具。
工具三:r0tracer — 批量 trace 一整个类
r0tracer 是 Frida 之上的一个批量 trace 框架。它的核心能力是:给一个类名,自动 hook 这个类的所有方法和字段,打印调用顺序 / 入参 / 返回值。
// r0tracer 批量 trace 示例, 一次抓一整个类
// 运行后会自动 hook TelephonyManager 所有公开方法
var ClassName = 'android.telephony.TelephonyManager';
classMethodsTracer(ClassName);
// 或者一次 trace 一个包下的所有类
var PackageName = 'android.os.Build';
classAllMethodsTracer(PackageName);
输出长这样:
*** entered android.telephony.TelephonyManager.getDeviceId
arg[0]: undefined
*** exiting android.telephony.TelephonyManager.getDeviceId
retval: 864394010105011
*** entered android.telephony.TelephonyManager.getSubscriberId
arg[0]: undefined
*** exiting android.telephony.TelephonyManager.getSubscriberId
retval: 460001234567890
你只需要让 App 正常走一次流程,r0tracer 就会把 SO 访问的所有方法 / 字段及其返回值都列出来。把这些值直接粘贴到 Unidbg 的 switch case 里,就完成了整个类的补环境。
实用技巧:关掉调用栈,只留值
r0tracer 默认会打印每次调用的 Java 调用栈,在 trace 几十个方法时控制台会刷屏。打开 r0tracer.js,找到类似:
console.log(Thread.backtrace(this.context,
Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n'));
把这一行注释掉,或者在 onEnter / onLeave 里注释掉 backtrace 的打印逻辑。输出就会变得极其干净:只剩方法名、入参、返回值,一眼就能扫完。
适用场景:一次补一整个类、一整个包、一个复杂样本的首轮"摸底"。
工具选择原则:
需要一个全局属性(型号、版本、分辨率)?
→ adb shell getprop / wm / dumpsys
需要一个动态方法的返回值(digest、签名字节、API Key)?
→ Frida Hook 或主动 Call
需要一次搞定一整个类 / 一整个包的所有方法?
→ r0tracer 批量 trace
所有都拿不到, 或者方法是 App 私有的?
→ 上 JADX 看 Java 层代码, 照实现抄过来(见维度三)
维度三:返回什么逻辑 — 当一个"值"不够用
前两个维度的假设是:SO 调一个 Java 方法,想要一个确定的返回值。你只要把这个值伪造对就行。
但**有时候 SO 调的不是一个"getter",它调的是一段"动作"**。
举几个典型:
- SO 调
SimpleDateFormat.format(new Date()):它需要一个格式化后的时间字符串,但你事先不知道 SO 什么时候调这个方法,也不知道会传什么时间进来。写死返回 "2024-01-01" 可能让后面的签名算法直接崩(因为第二次调会拿到同样的假时间,算出同样的签名)。 - SO 调
HashMap.put(key, value) 再 HashMap.get(key):它期望这是一个真实的 Map,存进去的东西能取出来。写死返回一个空 HashMap 不行,因为 get 拿不到值。 - SO 调
SharedPreferences.getString("some_key", "default"):你不知道 SO 会读哪些 key,手动写 case 来应付所有 key 很累。 - SO 调
Base64.encodeToString(bytes, flags):你得真的把 bytes 做一次 Base64 编码再返回。
这些情况下你**没法只"返回一个值"**,你得"执行一段逻辑"。
四种处理策略
按照投入成本从低到高排列,先试简单的:
策略 1:JDK 内置类 → 直接绑定
如果这个类是 Java 标准库自带的(java.util.HashMap、java.util.ArrayList、java.text.SimpleDateFormat、java.lang.StringBuilder 等),就让 Unidbg 把它绑定到真实的 JDK 类上。你不需要写任何 case,后续所有方法调用会自动走 JDK 实现。
// 在 callObjectMethodV 里: SO new 一个 HashMap
case"java/util/HashMap-><init>()V":
// 返回一个真实的 JDK HashMap 实例, 后续 put/get 全部由 JDK 处理
return vm.resolveClass("java/util/HashMap").newObject(new HashMap<>());
// 或者更优雅: 在 setJni 之前, 直接让整个类走 JDK
vm.resolveClass("java/util/HashMap");
优点:零代码,JDK 保证行为完全正确。适用:java/util/*、java/text/*、java/lang/* 几乎所有工具类。
策略 2:App 自定义类 → JADX 反编译后复制逻辑
如果这个类是 App 自己写的(com.example.app.utils.Encryptor),JDK 没有它,Android Framework 也没有它。这时候上 JADX:
1. 用 JADX 打开 APK
2. 搜索类名, 找到对应的 Java 源码
3. 把实现逻辑原样复制到 Unidbg 代码里
4. override 对应方法时, 调你复制过来的 Java 实现
// 假设 JADX 反编译后发现 Encryptor.encrypt 只是一个 AES 封装
// 直接把这段 Java 逻辑搬到 Unidbg 项目里做成工具类
case"com/example/app/utils/Encryptor->encrypt([B[B)[B":
DvmObject<?> arg0 = vaList.getObjectArg(0); // data
DvmObject<?> arg1 = vaList.getObjectArg(1); // key
byte[] data = (byte[]) arg0.getValue();
byte[] key = (byte[]) arg1.getValue();
// 调你自己的 Java 复刻实现
byte[] result = MyEncryptor.encrypt(data, key);
returnnew ByteArray(vm, result);
优点:逻辑和真机完全一致,结果一定对。代价:要读懂 JADX 反编译出来的 Java 代码。对复杂的类来说这一步最耗时。适用:App 自己实现的 Util / Helper / Encoder 类。
策略 3:Framework 类 → 手动简化实现
Android Framework 自带的类(SharedPreferences、Base64、PackageInfo)JDK 里没有,App 里也没有。这时候需要你手动简化一个实现。
"简化"的意思是:你不需要 100% 复刻 Framework 的行为,只要让 SO 拿到想要的结果就行。
// Base64 的手动实现: 直接用 JDK 自带的 java.util.Base64 当后端
case"android/util/Base64->encodeToString([BI)Ljava/lang/String;":
DvmObject<?> bytesArg = vaList.getObjectArg(0);
int flags = vaList.getIntArg(1);
byte[] bytes = (byte[]) bytesArg.getValue();
// Android 的 Base64 flag 分为 DEFAULT / NO_WRAP / URL_SAFE 等
// 这里只处理最常见的 NO_WRAP (flag=2)
String encoded;
if ((flags & 2) != 0) { // NO_WRAP
encoded = java.util.Base64.getEncoder()
.withoutPadding()
.encodeToString(bytes);
} else {
encoded = java.util.Base64.getEncoder().encodeToString(bytes);
}
returnnew StringObject(vm, encoded);
优点:绕过了 Framework 依赖,实现透明可控。代价:要理解 Framework 类的关键行为。适用:android/util/Base64、android/content/SharedPreferences、android/os/Bundle 等。
策略 4:复杂逻辑 → 降级返回 null
有时候某个方法太复杂、太边缘,你实在不想花时间 —— 直接返回 null 或空值试一下。很多 SO 拿到 null 会走默认分支,不影响最终结果。
// getSomeRarelyUsedInfo 返回 null 试试, 看 SO 会不会崩
case"android/content/pm/PackageManager->getSomeRarelyUsedInfo()Ljava/lang/Object;":
returnnull;
原则:先返回 null 跑一下,如果 SO 能过,这就是最省力的选择;如果 SO 报 NPE 或者拿到 null 后走了错误分支,再回来补策略 1/2/3。
策略 4 的核心思维:不要过度补环境。很多时候你想的"SO 需要这个值"其实是错的,它根本不依赖这个值。节省下来的时间应该花在真正关键的补环境上。
验证闭环 — 跑通不等于跑对
整个流程结束后,千万不要以为"没报错 = 成功"。按照第六篇说的思路,你必须走一次值对照:
- 真机跑同样的输入,用 Frida hook native 入口打印输出
- 不一致 → 哪个值伪造错了?回到 r0tracer 输出去对比
只有对照通过的补环境才是真的补环境。不对照,你永远不知道哪个 "unknown" 其实不该是 "unknown"。
三维度拆解:为什么这个模型有效
回顾整篇文章,我们做的事情其实只有一个:把一个模糊的任务("补环境")拆成三个独立的子任务。
分层简化:把"补环境"拆成可管理的子任务这种分层简化的思维在所有工程问题里都通用:一个看起来巨大的问题,往往只是三四个独立小问题的组合。把它们拆开,每个都变得可管理。
接下来几篇会继续把剩下三个通道 —— 文件访问(第八篇)、系统调用(第九篇)、库函数(第十篇)—— 也用同样的模式化思路讲一遍。你会发现每一个通道都有自己的"维度拆解",而一旦拆开,补环境这件事就不再神秘。