运行时与 Hook
运行时与 Hook
这一页讲的是 JSXHook 最常用、也最先接触到的那层运行时环境。你可以把它理解成一句话:
脚本被加载进目标 App 进程以后,JSXHook 会先把一批“当前环境对象 + Hook 能力 + 调试辅助函数”注入给你,你写的脚本就是基于这层环境跑起来的。
如果你想直接翻应用内置手册里的运行时示例,可以看这里:内置手册全量示例 - 运行时与 Hook。
如果你是第一次接触 JSXHook
先记住下面 4 件事就够了:
- 你的脚本不是在浏览器里跑,也不是在 Node.js 里跑,而是在 被 Hook 的 Android 进程里 跑。
- 你最常用的环境对象是
lpparam、context、activity、classLoader。 - 你最常用的能力是
hook、hookAll、hookctor、replace。 - 新手先别急着一上来就写复杂 Hook,先写一段“确认脚本真的跑起来了”的日志脚本。
第一个能确认环境正常的脚本
把下面这段先跑通:
//@process=main
log(`package=${lpparam.packageName}`);
log(`process=${lpparam.processName}`);
log(`loader=${lpparam.classLoader}`);
log(`activity=${activity}`);
log(`context=${context}`);
log(`modulePath=${suparam.modulePath}`);
你先不要嫌它“没做事”。这段脚本的价值很高,因为它能一次帮你确认下面几件事:
- 作用域
scope有没有配对上目标包名。 - 当前是不是你想要的那个进程。
- 运行时上下文有没有正确注入。
- 后面如果 Hook 不生效,问题到底出在“脚本没跑”还是“Hook 写错了”。
运行时里已经给你的对象
lpparam
lpparam 是最核心的 Hook 上下文对象,最常用的字段有:
lpparam.packageName:当前目标包名。lpparam.processName:当前进程名。lpparam.classLoader:当前进程的类加载器。lpparam.appInfo:目标应用的ApplicationInfo。
最常见的用法有两个:
log(`package=${lpparam.packageName}`);
log(`process=${lpparam.processName}`);
hook({
class: "com.example.target.LoginManager",
classloader: lpparam.classLoader,
method: "login",
params: ["java.lang.String", "java.lang.String"]
});
suparam
suparam 是模块启动参数,最常见的是拿模块路径:
log(`modulePath=${suparam.modulePath}`);
如果你后面要读模块自身的资源或文件,这个对象会比较有用。
context / activity
context:当前可用的 AndroidContext。activity:当前尽力解析出的Activity,有时可能是null。
最常见的误区是:不要默认 activity 一定存在。
如果你的脚本运行时目标页面还没起来,或者当前不是界面进程,activity 为 null 很正常。
例如:
if (activity) {
toastLog(`current activity=${activity.getClass().getName()}`);
} else {
log("activity is null, use context-first logic");
}
classLoader
classLoader 通常等于 lpparam.classLoader。日常写法里,两种都常见:
const Toast = imports("android.widget.Toast");
const UserManager = XposedHelpers.findClass(
"com.example.target.UserManager",
classLoader
);
如果你不知道该传哪个类加载器,新手默认优先用 lpparam.classLoader,最稳。
XposedHelpers / XposedBridge / DexFinder 等桥接类
运行时还会把一些 Java 侧桥接对象直接放进脚本环境里,最常用的是:
XposedHelpersXposedBridgeDexFinderDexKitBridge
例如:
const UserManager = XposedHelpers.findClass(
"com.example.target.UserManager",
lpparam.classLoader
);
const manager = XposedHelpers.callStaticMethod(UserManager, "getInstance");
XposedBridge.log(`manager=${manager}`);
//@process=... 进程过滤
脚本顶部支持用注释声明要跑在哪些进程里:
//@process=main
log(`main process=${lpparam.processName}`);
常见写法:
//@process=all
//@process=com.tencent.mobileqq:MSF
//@process=com.tencent.mobileqq,com.tencent.mobileqq:MSF
什么时候一定要写这个?
- 目标 App 有主进程、推送进程、工具进程时。
- 你只想在界面进程里调 UI 或页面对象时。
- 你发现同一段 Hook 打了很多次日志时。
新手建议:
- 不确定时先用
//@process=main。 - 真要覆盖所有进程,再切到
//@process=all。
Hook 系列到底怎么选
这块是最常问、也最容易混的地方。先给你一个最短结论:
- 已知“类名 + 方法名 + 精确参数签名”时,用
hook(...) - 方法重载很多,你想一次都拦住时,用
hookAll(...) - 想拦构造函数时,用
hookctor(...) - 想直接接管返回值时,用
replace(...)
先记住一个很重要的现状:当前 hook / hookAll / hookctor / replace 都是“注册型 API”。
你调用它们的目的,是把拦截规则装进去;JS 侧不要指望拿到一个可继续链式调用的返回句柄,当前实现最终返回的都是 null。
hook(options)
这是日常最常用、也最推荐优先掌握的写法。
适合“我已经知道类名、方法名、参数签名,现在想观察、改参数、改返回值、甚至提前拦截”的场景。
hook(options) 最稳的最小模板
hook({
class: "com.example.target.LoginManager",
classloader: lpparam.classLoader,
method: "login",
params: ["java.lang.String", "java.lang.String"],
before(it) {
log(`before args=${JSON.stringify(it.args)}`);
},
after(it) {
log(`after result=${it.result}`);
}
});
options 字段总表
| 字段 | 能填什么 | 说明 |
|---|---|---|
class | 类全名字符串,或 Class 对象 | 例如 com.example.UserManager,也可以先 findClass(...) / imports(...) 后再传类对象 |
classloader | ClassLoader 对象 | 字段名就是全小写的 classloader,不是 classLoader |
method | 方法名字符串,或 java.lang.reflect.Method | 传 Method 对象时,会直接 Hook 这一个方法,class 和 params 会失去意义 |
params | 参数类型数组,或省略 / [] | 不写或写空数组表示“无参方法” |
before(it) | 函数 | 原方法执行前回调 |
after(it) | 函数 | 原方法执行后回调,不管原方法正常返回、抛异常,还是被 before 提前拦掉,都会走到这里 |
class / method / params 到底可以怎么填
class 最常见有两种填法:
class: "com.example.target.LoginManager"
const LoginManager = findClass("com.example.target.LoginManager", lpparam.classLoader);
hook({
class: LoginManager,
method: "login",
params: ["java.lang.String", "java.lang.String"]
});
method 也有两种真实有效的填法:
method: "login"
const LoginManager = findClass("com.example.target.LoginManager", lpparam.classLoader);
const method = LoginManager.getDeclaredMethod(
"isVip"
);
hook({
method: method,
after(it) {
log(`result=${it.result}`);
}
});
params 这一项当前真正支持的值,建议你按下面这几类理解:
| 写法 | 例子 | 表示什么 |
|---|---|---|
| 基础类型字符串 | "int"、"long"、"boolean"、"double"、"float"、"char"、"byte"、"short" | 对应 Java 基础类型 |
| 字符串类型简写 | "String"、"string" | 对应 java.lang.String |
| 完整类名 | "java.lang.CharSequence"、"android.content.Context" | 对应普通 Java / Android 类 |
| 数组类型 | "byte[]"、"java.lang.String[]"、"int[]" | 对应数组参数 |
Class 对象 | java.lang.String.class、findClass("...", loader) 的结果 | 适合你已经先拿到了类对象 |
| 导入后的类代理 | imports("java.lang.String") 的返回值 | 运行时会先解成真实 Class 再用 |
这里有两个很容易误会的点:
params不是模糊匹配,不支持“任意类型占位符”这种概念params里塞null不表示“这个位置任意”,它只会被忽略;如果你不确定签名,就先用hookAll(...)
hook(options) 最常见的 6 种用法
1. 只看参数和返回值
hook({
class: "com.example.target.LoginManager",
classloader: lpparam.classLoader,
method: "login",
params: ["java.lang.String", "java.lang.String"],
before(it) {
log(`username=${it.args[0]}`);
},
after(it) {
log(`login result=${it.result}`);
}
});
这类写法最适合起步,因为你先确认“它有没有命中、参数长什么样、返回了什么”,后面再谈修改。
2. 在进入原方法前改参数
hook({
class: "com.example.target.LoginManager",
classloader: lpparam.classLoader,
method: "login",
params: ["java.lang.String", "java.lang.String"],
before(it) {
it.args[0] = String(it.args[0]).trim();
it.args[1] = "123456";
log(`patched args=${JSON.stringify(it.args)}`);
}
});
当前实现里,before 阶段对 it.args[index] 的修改,会真正带进原方法执行。
所以“把空格去掉”“把测试账号密码替换掉”“临时改 URL / token 入参”都适合在这里做。
3. 让原方法照常执行,但在 after 里改最终返回值
hook({
class: "com.example.target.LoginManager",
classloader: lpparam.classLoader,
method: "isVip",
params: [],
after(it) {
if (it.result === false) {
it.setResult(true);
}
}
});
这个模式很常用,因为它保留了原方法的大部分执行过程,只是在最后一步改结果。
比如你想让流程继续走原逻辑,但最终判断改成“成功 / 已登录 / 是会员”,就很适合这样写。
4. 在 before 里直接拦掉原方法
hook({
class: "com.example.target.LoginManager",
classloader: lpparam.classLoader,
method: "isVip",
params: [],
before(it) {
it.setResult(true);
}
});
这里一定要注意:要拦掉原方法,请用 it.setResult(...),不要只写 it.result = ...。
原因很简单:
it.result = true只是改了字段值it.setResult(true)才会同时把内部的“提前返回”标记打开
也就是说:
- 想“改最终结果但原方法还是跑”时,可以在
after里用setResult(...) - 想“原方法别跑了,直接返回”时,要在
before里用setResult(...)
5. 主动让它抛一个你指定的异常
const IllegalStateException = imports("java.lang.IllegalStateException");
hook({
class: "com.example.target.LoginManager",
classloader: lpparam.classLoader,
method: "login",
params: ["java.lang.String", "java.lang.String"],
before(it) {
if (String(it.args[0]) === "debug_user") {
it.setThrowable(new IllegalStateException("blocked by JSXHook"));
}
}
});
这个写法适合做:
- 强制阻断某个入口
- 模拟异常分支
- 验证上层页面在失败时会怎么处理
同样地,这里也推荐用 it.setThrowable(...),不要只给 it.throwable = ... 赋值。
6. 看实例方法里的 thisObject
hook({
class: "com.example.target.ProfileManager",
classloader: lpparam.classLoader,
method: "getProfile",
params: [],
after(it) {
log(`thisObject=${it.thisObject}`);
printFields(it.thisObject, "\n");
}
});
这类写法最适合“我不只是想知道参数,我还想看看这个对象当前内部有哪些字段值”。
如果你 Hook 的是静态方法,要记住一件事:it.thisObject 通常就是 null。
这不是 Hook 失败,而是因为静态方法本来就没有实例 this。
hook(...) 的位置参数写法
虽然推荐优先写配置对象,但源码里当前还兼容下面两种位置参数形式:
hook("类名", classLoader, "方法名", ...参数类型, beforeFn, afterFn)
hook(methodObject, beforeFn, afterFn)
几个一定要记住的规则:
- 对
hook("类名", ...)这种位置参数写法来说,最后两个参数永远按before/after解释 - 所以前面的才是参数类型列表
- 如果你只想写
after,前面的before位置要显式塞一个null - 当前
hook(...)的首参不支持直接传Class对象;想传类对象时,用配置对象写法
例如,无参方法只写 after 时,要这么放:
hook(
"com.example.target.LoginManager",
lpparam.classLoader,
"isVip",
null,
function(it) {
log(`after result=${it.result}`);
}
);
如果你只想写 before,就反过来:
hook(
"com.example.target.LoginManager",
lpparam.classLoader,
"isVip",
function(it) {
log("before only");
},
null
);
如果你已经拿到了 Method 对象,写法也一样:
const StringCls = imports("java.lang.String");
const method = StringCls.class.getDeclaredMethod("length");
hook(method, null, function(it) {
log(`String.length() => ${it.result}`);
});
hookAll(options)
当你只知道方法名,不确定具体走的是哪个重载时,hookAll 会非常省事。
hookAll(options) 最常用模板
hookAll({
class: "android.widget.Toast",
classloader: lpparam.classLoader,
method: "makeText",
before(it) {
log(`${it.method} | args=${JSON.stringify(it.args)}`);
}
});
hookAll(options) 真实会匹配什么
它当前的匹配规则,建议你记成一句人话:
在你给的这个类里,找“名字完全等于某个方法名”的所有声明方法,把它们全部 Hook。
也就是说:
- 它匹配的是“方法名完全相等”,不是模糊匹配,不是正则
- 它 Hook 的是这个类自己
declaredMethods里的重载 - 它不会顺着父类继续找
所以如果你写:
hookAll({
class: "com.example.target.ChildManager",
method: "login"
});
但真正的 login(...) 在父类里,不在 ChildManager 自己声明的方法列表里,那这条 hookAll 可能就打不中。
这种情况更适合回到 hook(...),按精确签名去找,因为 hook(...) 当前会沿着父类往上找。
hookAll(options) 适合什么时候用
- 你只知道方法名,不确定签名
- 你怀疑一个方法有很多重载,想先全部打出来
- 你想先看
it.method打印出的真实签名,再决定回去写哪条精确hook(...)
hookAll(options) 常见例子
1. 先把所有重载都打日志
hookAll({
class: "android.widget.Toast",
classloader: lpparam.classLoader,
method: "makeText",
before(it) {
log(`hit overload=${it.method}`);
}
});
这类写法最适合做第一轮摸底。
2. 拿 DexKit 搜到的方法名,先全拦一遍
const hit = matches[0];
const clazz = hit.getClassInstance(lpparam.classLoader);
hookAll(clazz, hit.name, function(it) {
log(`hookAll -> ${it.method}`);
});
这里 hit.name 这种来自 Java 侧的字符串包装值,当前也可以更自然地直接传,桥接层会先做解包再转字符串。
hookAll(...) 的位置参数写法
hookAll("类名", classLoader, "方法名", beforeFn, afterFn)
hookAll(clazz, "方法名", beforeFn, afterFn)
这里比 hook(...) 轻松一点,因为 hookAll 不需要你再放参数类型。
如果你只想写一个回调,默认就是 before。
还有一个源码层面的细节要知道:
当前 hookAll({ ... }) 配置对象里的 classloader 字段,并不参与这一步的类解析。
如果你必须指定一个特殊 loader,最稳的写法有两个:
- 先自己拿到
Class对象,再传给hookAll(clazz, ...) - 或者改用位置参数写法
hookAll("类名", customLoader, "方法名", ...)
hookctor(options)
这是专门拦截构造函数的入口。
如果你的目标是“对象在 new 的那一刻拿到了什么参数、构造完以后字段是什么样”,就该用它。
兼容别名 hookcotr(...),但文档里统一推荐你写 hookctor(...)。
hookctor(options) 最常用模板
hookctor({
class: "com.example.target.UserInfo",
classloader: lpparam.classLoader,
params: ["java.lang.String"],
before(it) {
log(`ctor args=${JSON.stringify(it.args)}`);
},
after(it) {
log(`instance=${it.thisObject}`);
}
});
hookctor(options) 适合做什么
- 看对象创建时到底塞进来了什么参数
- 在构造函数执行前改参数
- 在对象刚创建完时立刻看字段
- 定位“某个对象是谁 new 出来的”
hookctor(options) 常见例子
1. 记录构造参数
hookctor({
class: "com.example.target.UserInfo",
classloader: lpparam.classLoader,
params: ["java.lang.String", "int"],
before(it) {
log(`ctor args=${JSON.stringify(it.args)}`);
}
});
2. 改构造参数
hookctor({
class: "com.example.target.UserInfo",
classloader: lpparam.classLoader,
params: ["java.lang.String", "int"],
before(it) {
it.args[0] = "debug_user";
it.args[1] = 9999;
}
});
3. 对象构造完以后立刻看字段
hookctor({
class: "com.example.target.UserInfo",
classloader: lpparam.classLoader,
params: ["java.lang.String"],
after(it) {
log(`instance=${it.thisObject}`);
printFields(it.thisObject, "\n");
}
});
对构造函数来说,一个很好记的经验是:
before更适合看和改入参after更适合拿新实例it.thisObject
hookctor(...) 的位置参数写法
hookctor("类名", classLoader, ...参数类型, beforeFn, afterFn)
hookctor(clazz, ...参数类型, beforeFn, afterFn)
hookcotr(...) // 兼容别名
这里和 hook(...) 一样,最后两个参数固定是 before / after。
所以如果你是无参构造函数,又只想写 after,记得补一个 null 占位:
hookctor(
"com.example.target.UserInfo",
lpparam.classLoader,
null,
function(it) {
log(`instance=${it.thisObject}`);
}
);
再补一个对精确文档很重要的细节:
当前 hookctor({ ... }) 配置对象里的 classloader 字段,不参与这一步的类和参数类型解析。
如果你必须明确指定别的 loader,优先用下面两种方式:
hookctor("类名", customLoader, ...参数类型, beforeFn, afterFn)- 先自己拿到
Class对象,再hookctor(clazz, ...参数类型, beforeFn, afterFn)
replace(options)
replace 的定位不是“观察”,而是“完整接管这条方法”。
它不会先跑原方法再回来给你改结果,而是直接把原方法替换成你的 JS 回调。
replace(options) 最常用模板
replace({
class: "com.example.target.LoginManager",
classloader: lpparam.classLoader,
method: "isVip",
params: [],
replace(it) {
log("force return true");
return true;
}
});
replace(options) 字段总表
| 字段 | 能填什么 | 说明 |
|---|---|---|
class | 类全名字符串,或 Class 对象 | 和 hook(options) 一样 |
classloader | ClassLoader 对象 | 字段名同样是 classloader |
method | 方法名字符串,或 java.lang.reflect.Method | 传 Method 对象时,class / params 不再重要 |
params | 参数类型数组,或省略 / [] | 规则和 hook(options) 一样 |
replace(it) | 函数 | 这里的返回值,就是这次方法调用最终要返回给上层的值 |
replace(options) 最常见的 4 种用法
1. 直接固定返回值
replace({
class: "com.example.target.LoginManager",
classloader: lpparam.classLoader,
method: "isVip",
params: [],
replace(it) {
return true;
}
});
2. 用自己的条件逻辑决定返回什么
replace({
class: "com.example.target.UserManager",
classloader: lpparam.classLoader,
method: "getDisplayName",
params: ["java.lang.String"],
replace(it) {
const userId = String(it.args[0]);
if (userId === "10001") {
return "debug_admin";
}
return `guest_${userId}`;
}
});
3. 临时把一段复杂逻辑替换成最小可控逻辑
replace({
class: "com.example.target.SignManager",
classloader: lpparam.classLoader,
method: "buildNonce",
params: [],
replace(it) {
log("use fixed nonce for debugging");
return "nonce_debug_123456";
}
});
4. 已经拿到 Method 对象时直接替换
const StringCls = imports("java.lang.String");
const method = StringCls.class.getDeclaredMethod("length");
replace(method, function(it) {
return 999;
});
replace(...) 的位置参数写法
replace("类名", classLoader, "方法名", ...参数类型, replaceFn)
replace(methodObject, replaceFn)
这里有两个容易踩的点:
replace(...)的位置参数首参当前只支持“类名字符串”或“Method对象”- 当前不支持
replace(clazz, "方法名", ...)这种把Class直接放在第一个参数位的写法
如果你已经有 Class 对象,又想写得清楚,还是优先回到配置对象风格。
什么时候该用 replace,什么时候该用 hook
- 想先看参数、看返回值、看调用链:优先
hook - 想让原方法照常跑,只在最后改结果:优先
hook + after + it.setResult(...) - 想原方法完全别跑:优先
replace,或者hook + before + it.setResult(...)
这 4 个 Hook API 的真实写法总表
当前源码里,能稳定使用的形式可以记成下面这张表:
| API | 配置对象写法 | 位置参数写法 | 备注 |
|---|---|---|---|
hook | hook({ ... }) | hook("类名", loader, "方法名", ...参数类型, beforeFn, afterFn)、hook(methodObject, beforeFn, afterFn) | 位置参数首参不支持直接传 Class |
hookAll | hookAll({ ... }) | hookAll("类名", loader, "方法名", beforeFn, afterFn)、hookAll(clazz, "方法名", beforeFn, afterFn) | 匹配当前类中同名的全部声明方法 |
hookctor | hookctor({ ... }) | hookctor("类名", loader, ...参数类型, beforeFn, afterFn)、hookctor(clazz, ...参数类型, beforeFn, afterFn) | hookcotr 只是兼容别名 |
replace | replace({ ... }) | replace("类名", loader, "方法名", ...参数类型, replaceFn)、replace(methodObject, replaceFn) | 位置参数首参不支持直接传 Class |
如果你是第一次接触 JSXHook,这里仍然建议你把重心放在配置对象写法上,因为:
- 可读性最高
- 最不容易把回调位置放错
- 后面回头维护时最容易看懂
这些字符串入口现在会先解包再使用
当你走“类名字符串 / 方法名字符串 / 字段名字符串”这类入口时,桥接层当前会先做一次“解包 -> 转字符串”,再去继续处理。
最直接的好处就是:
像 DexKit、反射、Java 返回对象里带出来的这些“看起来像字符串、但其实外面还包了一层”的值,现在更自然地就能直接喂进去。
最常见的受益场景就是:
matches[0].namematches[0].declaredClassNamemethodData.namefieldData.name
例如:
const hit = matches[0];
hookAll(
hit.declaredClassName,
lpparam.classLoader,
hit.name,
function(it) {
log(`hit ${hit.name}`);
}
);
这里的重点不是这个写法有多帅,而是你不用再手动担心“这个名字是不是 Rhino 包装对象、是不是还得自己拆一下”。
Kotlin 的 isXxx 布尔属性现在按真正的属性值暴露给 JS
如果一个 Kotlin / Java 对象上存在真实的 Kotlin 布尔属性,名字刚好是:
isConstructorisMethodisStaticInitializerisArray
那当前运行时会把它们当成真正的布尔属性暴露给脚本,而不是误当成一个可调用对象。
最常见的受益对象,就是 DexKit 结果:
const hit = methodHits.firstOrNull();
if (hit && hit.isMethod) {
log(`normal method=${hit.name}`);
}
const classHit = classHits.firstOrNull();
if (classHit && classHit.isArray) {
log(`array class=${classHit.name}`);
}
这里要记两条就够了:
- 正确写法是
hit.isMethod - 不要写成
hit.isMethod()
但普通 Java 方法不会被误伤。
例如 new java.io.File(".").isFile() 这种,本来就是方法,还是照常要加括号调用。
Hook 回调里的 it 到底能做什么
不管你是用 hook、hookAll、hookctor,还是 replace,回调里拿到的 it 本质上都是同一个方向上的“方法调用现场”。
it 里最重要的字段和方法
| 名字 | 什么时候最常用 | 作用 |
|---|---|---|
it.method | before / after 都常用 | 当前命中的 Method 或 Constructor |
it.thisObject | 实例方法、构造函数 after | 当前实例对象;静态方法通常是 null |
it.args | before 最常用 | 当前参数数组;改它会影响原方法实际收到的参数 |
it.result | after 最常用 | 当前返回值 |
it.throwable | after 或主动拦截时 | 当前异常对象 |
it.getResult() | after | 读返回值 |
it.setResult(value) | before / after | 设定返回值;在 before 里还能直接阻断原方法 |
it.getThrowable() | after | 读异常 |
it.hasThrowable() | after | 判断这次调用是不是抛异常了 |
it.setThrowable(error) | before / after | 设定异常;在 before 里能直接阻断原方法并抛出指定异常 |
it.getResultOrThrowable() | after | 要么拿结果,要么直接把异常抛出来 |
before 和 after 的时机要怎么理解
当前执行顺序可以理解成这样:
- 先进入
before(it) - 如果
before里没有提前setResult(...)/setThrowable(...),原方法才会真正执行 - 不管是原方法正常返回、原方法抛异常,还是你在
before里提前拦掉,最后都会进入after(it)
这意味着几个很实用的结论:
- 想改入参,优先在
before - 想提前阻断原方法,优先在
before - 想看原方法真正跑出来的结果,优先在
after - 想吞掉原本抛出的异常,通常在
after里判断hasThrowable()再setResult(...)
it 的 5 个高频操作例子
1. 用 it.method 看真实命中的签名
hookAll({
class: "android.widget.Toast",
method: "makeText",
before(it) {
log(`hit=${it.method}`);
}
});
这在 hookAll(...) 第一轮摸底时特别有用,因为你想知道的往往不是“有没有命中”,而是“究竟命中了哪个重载”。
2. 用 it.args 改参数
hook({
class: "com.example.target.ProfileManager",
classloader: lpparam.classLoader,
method: "updateProfile",
params: ["com.example.target.Profile"],
before(it) {
const profile = it.args[0];
setField(profile, "vip", true);
}
});
3. 用 it.setResult(...) 提前返回
hook({
class: "com.example.target.FeatureGate",
classloader: lpparam.classLoader,
method: "isFeatureEnabled",
params: ["java.lang.String"],
before(it) {
if (String(it.args[0]) === "vip_feature") {
it.setResult(true);
}
}
});
4. 用 it.hasThrowable() 判断异常,再改成兜底返回值
hook({
class: "com.example.target.TokenStore",
classloader: lpparam.classLoader,
method: "readToken",
params: [],
after(it) {
if (it.hasThrowable()) {
log(`readToken failed=${it.getThrowable()}`);
it.setResult("");
}
}
});
这类写法特别适合做“先别让它崩,我先把页面跑起来继续看别的逻辑”。
5. 用 it.getResultOrThrowable() 明确看最终状态
hook({
class: "com.example.target.SignManager",
classloader: lpparam.classLoader,
method: "buildSign",
params: ["java.lang.String"],
after(it) {
try {
log(`final=${it.getResultOrThrowable()}`);
} catch (error) {
log(`throwable=${error}`);
}
}
});
关于 it.result = ... 和 it.throwable = ... 的一个硬提醒
你当然能直接改字段,但新手阶段强烈建议优先用方法,不要直接写字段:
- 想改结果,用
it.setResult(...) - 想抛异常,用
it.setThrowable(...)
原因是这些方法除了改值,还会顺手把内部状态一起处理好。
尤其在 before 阶段,你如果只是写:
it.result = true;
那原方法大概率还是会继续执行。
真正的“到这就别往下跑了”是:
it.setResult(true);
Hook 里最常见的参数类型怎么读
很多人真正卡住的地方,不是 hook(...) 不会写,而是:
it.args[0]明明拿到了,但不知道它是什么- 明明是个 Java 对象,却不知道怎么继续往里读
- 明明参数是
List、Map、byte[],但日志打出来看不懂
这一节你可以当成“参数拆解速查表”。
先记住一个总原则:
- 先看参数类型和外观
- 再决定是按对象、集合、字节数组,还是嵌套对象去处理
- 不确定时,先
log(...),再printFields(...),最后才做精确字段读取
普通对象参数:先 printFields(...),再 getField(...)
这是最常见的一类。
比如方法参数是:
ProfileLoginRequestOrderInfoSessionInfo
这类对象你不要一上来就猜字段名。
最稳的顺序永远是:
- 先确认对象确实在
it.args[index] - 再
printFields(obj, "\n") - 看清字段名以后,再
getField(...)/setField(...)
第一步:先看对象长什么样
hook({
class: "com.example.target.ProfileManager",
classloader: lpparam.classLoader,
method: "updateProfile",
params: ["com.example.target.Profile"],
before(it) {
const profile = it.args[0];
log(`profile=${profile}`);
printFields(profile, "\n");
}
});
第二步:确认字段名以后,精确读取
hook({
class: "com.example.target.ProfileManager",
classloader: lpparam.classLoader,
method: "updateProfile",
params: ["com.example.target.Profile"],
before(it) {
const profile = it.args[0];
log(`name=${getField(profile, "name")}`);
log(`vip=${getField(profile, "vip")}`);
}
});
第三步:需要时再改字段
hook({
class: "com.example.target.ProfileManager",
classloader: lpparam.classLoader,
method: "updateProfile",
params: ["com.example.target.Profile"],
before(it) {
const profile = it.args[0];
setField(profile, "vip", true);
}
});
List / 集合参数:优先用 callMethod(...) 去拿大小和元素
很多参数表面上看是“一个对象”,实际上里面装的是:
java.util.ListArrayList- 其他 Java 集合
这类值最稳的思路,不是先 JSON.stringify(...),而是直接按 Java 集合来读:
size()get(index)
先看集合有几个元素
hook({
class: "com.example.target.UserManager",
classloader: lpparam.classLoader,
method: "saveUsers",
params: ["java.util.List"],
before(it) {
const users = it.args[0];
log(`user size=${callMethod(users, "size")}`);
}
});
再取第一个元素继续看
hook({
class: "com.example.target.UserManager",
classloader: lpparam.classLoader,
method: "saveUsers",
params: ["java.util.List"],
before(it) {
const users = it.args[0];
const first = callMethod(users, "get", 0);
log(`first=${first}`);
printFields(first, "\n");
}
});
为什么这里推荐 callMethod(...)
因为你现在面对的是 Java 集合,不是浏览器里的原生 JS 数组。
用 Java 自己的 size()、get(index),通常最稳,也最接近目标对象真实行为。
Map / Bundle / 键值参数:优先用 get(...) / getString(...) 这类取值方法
这一类也很常见。比如参数是:
MapHashMapBundle- 某种内部 options 对象
这类值真正有用的信息通常不是“它的内部字段名”,而是“键值里存了什么”。
参数是 Map
hook({
class: "com.example.target.RequestBuilder",
classloader: lpparam.classLoader,
method: "build",
params: ["java.util.Map"],
before(it) {
const map = it.args[0];
log(`token=${callMethod(map, "get", "token")}`);
log(`userId=${callMethod(map, "get", "userId")}`);
}
});
参数是 Bundle
hook({
class: "com.example.target.EntryActivity",
classloader: lpparam.classLoader,
method: "onCreate",
params: ["android.os.Bundle"],
before(it) {
const bundle = it.args[0];
if (!bundle) {
return;
}
log(`token=${callMethod(bundle, "getString", "token")}`);
log(`scene=${callMethod(bundle, "getString", "scene")}`);
}
});
不知道有哪些 key 时怎么办
先别急着精确 get("xxx"),优先先把整体外观摸出来:
hook({
class: "com.example.target.RequestBuilder",
classloader: lpparam.classLoader,
method: "build",
params: ["java.util.Map"],
before(it) {
const map = it.args[0];
log(`map=${map}`);
}
});
如果你已经知道它不是普通 Map,而是包装对象,那就回到“普通对象参数”的路线:
printFields(...)getField(...)setField(...)
byte[] 参数:先判断它是文本还是二进制
这是最容易把新手卡住的一类。
你看到参数签名是:
byte[]
不要本能地把它当成字符串。
先问自己两个问题:
- 这段字节更像文本内容,还是纯二进制数据?
- 我现在是想看“长度和特征”,还是想看“真正文本内容”?
第一步:先看长度和 Base64
hook({
class: "com.example.net.Request",
classloader: lpparam.classLoader,
method: "setBody",
params: ["byte[]"],
before(it) {
const bytes = it.args[0];
log(`body len=${bytes.length}`);
log(`body base64=${crypto.base64.encode(bytes)}`);
}
});
为什么这里推荐先看 Base64:
- 不容易因为乱码把日志搞花
- 文本和二进制都能稳定打印
- 你后面拿去对比、保存、重放都方便
第二步:如果你怀疑它其实是文本,再尝试按字符串解
hook({
class: "com.example.net.Request",
classloader: lpparam.classLoader,
method: "setBody",
params: ["byte[]"],
before(it) {
const bytes = it.args[0];
const StringClass = findClass("java.lang.String");
const text = this["new"](StringClass, bytes, "UTF-8");
log(`body text=${text}`);
}
});
这个写法特别适合:
- JSON body
- 表单 body
- 明文协议内容
但如果你一解出来就是乱码,不一定是你代码错了,也可能是:
- 它根本不是文本
- 它不是 UTF-8
- 它是压缩、加密或 protobuf 之类的二进制结构
第三步:需要改 byte[] 时,先想清楚你改的是文本还是原始字节
如果你改的是一小段固定字节,最直接的是:
hook({
class: "com.example.net.Request",
classloader: lpparam.classLoader,
method: "setBody",
params: ["byte[]"],
before(it) {
const bytes = it.args[0];
bytes[0] = 65;
bytes[1] = 66;
bytes[2] = 67;
}
});
如果你改的是完整文本内容,通常更稳的做法不是“一个字节一个字节改”,而是先另外准备好你真正要的 byte[],再整体替换进去。
参数里再套对象:一层一层往里拆
真实项目里很常见的是这种情况:
it.args[0]不是最终值- 它里面还有
request request里面还有headerheader里面才有你真正关心的字段
这种时候,思路一定要慢,不要一口气猜到底。
第一步:先看第一层
hook({
class: "com.example.target.RequestManager",
classloader: lpparam.classLoader,
method: "send",
params: ["com.example.target.RequestWrapper"],
before(it) {
const wrapper = it.args[0];
printFields(wrapper, "\n");
}
});
第二步:取出子对象,再看第二层
hook({
class: "com.example.target.RequestManager",
classloader: lpparam.classLoader,
method: "send",
params: ["com.example.target.RequestWrapper"],
before(it) {
const wrapper = it.args[0];
const request = getField(wrapper, "request");
printFields(request, "\n");
}
});
第三步:最后再去读真正目标字段
hook({
class: "com.example.target.RequestManager",
classloader: lpparam.classLoader,
method: "send",
params: ["com.example.target.RequestWrapper"],
before(it) {
const wrapper = it.args[0];
const request = getField(wrapper, "request");
log(`url=${getField(request, "url")}`);
log(`headers=${getField(request, "headers")}`);
}
});
这类场景最容易犯的错,就是第一眼看到 wrapper 就开始乱猜 url、body、headers 在不在它身上。
更稳的做法永远是:先看一层,再进一层。
一条最实用的参数排查顺序
如果你现在每次看到 it.args[0] 都有点发怵,那就先照着这条顺序来:
log(it.args[index])看它大概是什么- 如果是普通对象,先
printFields(...) - 如果像集合,先
callMethod(obj, "size")/callMethod(obj, "get", 0) - 如果像
Map/Bundle,优先试get(...)/getString(...) - 如果是
byte[],先看length和Base64 - 如果还是看不清,再往内层对象继续拆
你把这套顺序用熟以后,Hook 命中之后“下一步该怎么读参数”,就基本不会再乱了。
Hook 实战套路
前面那一大段解决的是“API 到底怎么写”。
这一段解决的是另一个更实际的问题:
我现在手里只有一个大概方向,接下来该怎么一步一步把目标方法抓出来、看清楚、再改掉。
如果你现在还不熟,最建议你照着下面这些套路练。它们比单独背 API 更有用。
套路 1:先用 hookAll(...) 摸清重载,再切回精确 hook(...)
这是新手最该熟的一条路线。
很多时候你知道:
- 类大概对
- 方法名大概对
- 但参数签名拿不准
这时别一上来死磕精确签名,先把所有同名重载打一遍。
第一步:先摸清到底命中了哪个重载
hookAll({
class: "com.example.target.LoginManager",
classloader: lpparam.classLoader,
method: "login",
before(it) {
log(`hit method=${it.method}`);
log(`args=${JSON.stringify(it.args)}`);
}
});
你这一步最想看的是:
it.method打出来的完整签名- 每个位置的参数大概是什么
- 到底是一个重载,还是很多个重载都在跑
第二步:确定真正目标以后,回到精确 hook(...)
假设你从日志里看到真正走的是:
com.example.target.LoginManager#login(java.lang.String, java.lang.String)
那就换成:
hook({
class: "com.example.target.LoginManager",
classloader: lpparam.classLoader,
method: "login",
params: ["java.lang.String", "java.lang.String"],
before(it) {
log(`username=${it.args[0]}`);
log(`password=${it.args[1]}`);
}
});
为什么后面一定建议切回精确 hook(...):
- 日志更干净
- 性能压力更小
- 不容易把别的重载也一起改坏
- 后面你回看脚本时,也更容易知道自己到底拦的是哪一条
套路 2:抓登录、搜索、提交这类入口,先看简单参数,再看对象字段
很多业务入口方法并不是:
login(String, String)
而是这种:
login(LoginRequest)
updateProfile(Profile)
submitOrder(OrderInfo, boolean)
这时你不要一看到对象参数就发愁,顺序应该是:
- 先确认
it.args[0]里真的是你关心的对象 - 再用
getField(...)/printFields(...)看里面有哪些值 - 最后才决定要不要改字段
先读取对象字段
hook({
class: "com.example.target.ProfileManager",
classloader: lpparam.classLoader,
method: "updateProfile",
params: ["com.example.target.Profile"],
before(it) {
const profile = it.args[0];
log(`name=${getField(profile, "name")}`);
log(`vip=${getField(profile, "vip")}`);
}
});
再进一步,直接改对象字段
hook({
class: "com.example.target.ProfileManager",
classloader: lpparam.classLoader,
method: "updateProfile",
params: ["com.example.target.Profile"],
before(it) {
const profile = it.args[0];
setField(profile, "vip", true);
setField(profile, "name", "debug_user");
}
});
参数是复杂 Java 对象时,为什么别太迷信 JSON.stringify
像 JSON.stringify(it.args) 这种写法,对简单字符串、数字、布尔值很方便;
但如果参数里是复杂 Java 对象,打出来的内容未必有你想象中那么详细。
这时更稳的思路是:
- 先
log(it.args[0])看对象类型 - 再
printFields(it.args[0], "\n")看字段 - 已知字段名以后,再
getField(...)精确拿值
比如:
hook({
class: "com.example.target.ProfileManager",
classloader: lpparam.classLoader,
method: "updateProfile",
params: ["com.example.target.Profile"],
before(it) {
log(`arg0=${it.args[0]}`);
printFields(it.args[0], "\n");
}
});
套路 3:改布尔结果、状态码、文本结果,优先从 after(it) 下手
如果你的目标是:
- 判断强制成功
- 会员状态强制为真
- 角色名、昵称、token 之类的返回值改掉
那大多数时候,先别急着 replace(...),先试 after(it)。
因为这种写法的优点是:
- 原方法照常执行
- 你还能看到它原本算出来的结果
- 出问题时更容易判断是“原逻辑就错了”,还是“你改坏了”
改布尔返回值
hook({
class: "com.example.target.LoginManager",
classloader: lpparam.classLoader,
method: "isVip",
params: [],
after(it) {
log(`origin result=${it.result}`);
it.setResult(true);
}
});
改状态码
hook({
class: "com.example.target.ResultParser",
classloader: lpparam.classLoader,
method: "getCode",
params: [],
after(it) {
if (it.result !== 0) {
it.setResult(0);
}
}
});
改文本返回值
hook({
class: "com.example.target.UserManager",
classloader: lpparam.classLoader,
method: "getDisplayName",
params: [],
after(it) {
log(`origin name=${it.result}`);
it.setResult("debug_admin");
}
});
什么时候说明你该考虑 replace(...),而不是继续用 after(it):
- 原方法本身很重,跑一次代价太高
- 原方法一执行就会发请求、落库、删数据
- 你根本不想让它继续跑
套路 4:想直接阻断原方法,就在 before(it) 里提前返回
有些目标不是“让结果改掉”,而是“原逻辑别执行”。
比如:
- 不想发真实请求
- 不想走风控
- 不想触发弹窗
- 不想落盘写数据
这时最直接的办法,就是在 before 里提前 setResult(...)。
直接拦掉一个判断方法
hook({
class: "com.example.target.FeatureGate",
classloader: lpparam.classLoader,
method: "isFeatureEnabled",
params: ["java.lang.String"],
before(it) {
if (String(it.args[0]) === "vip_feature") {
it.setResult(true);
}
}
});
拦掉一个本来会弹窗的方法
hook({
class: "com.example.target.DialogManager",
classloader: lpparam.classLoader,
method: "showRiskDialog",
params: ["android.app.Activity"],
before(it) {
log("skip risk dialog");
it.setResult(null);
}
});
你可以把这类写法理解成:
before + setResult(...)是“原方法别跑,直接给我这个结果”replace(...)是“这一整条方法从现在开始由我全权接管”
套路 5:原方法会抛异常时,先把流程救活再继续调
逆向和调试里有一个很常见的现实问题:
- 你已经命中目标了
- 但它一跑就抛异常
- 结果页面直接断掉,后面的链路看不到
这种时候,先别急着彻底修逻辑。
第一步通常是:让它先别崩,至少把页面和后续流程继续跑起来。
原方法抛异常时,改成兜底返回值
hook({
class: "com.example.target.TokenStore",
classloader: lpparam.classLoader,
method: "readToken",
params: [],
after(it) {
if (it.hasThrowable()) {
log(`readToken error=${it.getThrowable()}`);
it.setResult("");
}
}
});
原方法抛异常时,改成固定对象或固定状态
hook({
class: "com.example.target.UserStatusManager",
classloader: lpparam.classLoader,
method: "getLoginState",
params: [],
after(it) {
if (it.hasThrowable()) {
it.setResult(1);
}
}
});
这套思路特别适合:
- 先让 UI 正常显示
- 先让链路继续往后跑
- 后面再回过头慢慢查异常根因
套路 6:对象是在哪儿 new 出来的,优先用 hookctor(...)
当你已经知道某个对象很关键,但还不知道:
- 谁创建了它
- 创建时传了什么
- 创建完以后字段是什么
那就别先去追一大圈业务方法,直接先拦构造函数。
看构造参数
hookctor({
class: "com.example.target.SessionInfo",
classloader: lpparam.classLoader,
params: ["java.lang.String", "java.lang.String"],
before(it) {
log(`session ctor args=${JSON.stringify(it.args)}`);
}
});
看刚创建好的实例内容
hookctor({
class: "com.example.target.SessionInfo",
classloader: lpparam.classLoader,
params: ["java.lang.String", "java.lang.String"],
after(it) {
log(`instance=${it.thisObject}`);
printFields(it.thisObject, "\n");
}
});
这个套路在几类对象上尤其好用:
Request/RequestBuilderUserInfo/ProfileSession/Token/AccountConfig/Options
因为这类对象往往一旦构造出来,后面就会被层层传递。
你在最开始的构造点把它看清楚,后面很多链路都会容易很多。
套路 7:什么时候用 replace(...) 最划算
很多人一开始会把 replace(...) 当成“高级版 hook”。
更准确一点的理解应该是:
replace(...) 适合那些你根本不想让原方法再跑的地方。
很适合 replace(...) 的场景
- 生成随机串、nonce、签名这种你想固定住的逻辑
- 简单判断函数,你只想永远返回一个值
- 本地格式化函数,你想快速换成最小实现
例如:
replace({
class: "com.example.target.SignManager",
classloader: lpparam.classLoader,
method: "buildNonce",
params: [],
replace(it) {
return "nonce_debug_fixed";
}
});
不太建议一上来就 replace(...) 的场景
- 你还没看清原方法参数和返回值
- 原方法里有你还想观察的中间逻辑
- 你还不确定真正命中的就是这一个重载
这种时候,更稳的顺序通常是:
- 先
hookAll(...) - 再精确
hook(...) - 最后确认真的要彻底接管时,再改成
replace(...)
一条最实用的 Hook 工作流
如果你现在还没形成自己的调试节奏,先照着这条走,基本不会太乱:
- 先确认进程和脚本真的跑起来了
- 用
hookAll(...)摸方法名和重载 - 改成精确
hook(...) - 先只看参数、返回值、对象字段
- 再决定是改参数、改结果,还是提前拦截
- 只有在“原方法根本不想跑”时,才切到
replace(...)
5 个完整业务案例
前面的例子大多是“单个 API 怎么用”。
这一节换个角度,直接按真实调试任务来写。
下面这些案例里的类名、方法名、字段名,当然是示例占位;
你真正照着做时,要把它们替换成你自己项目里已经确认过的名字。
最简单的替换原则就是:
class换成你实际命中的类method换成你实际命中的方法params换成你用hookAll(...)或日志确认出来的那组签名
案例 1:抓登录参数,再把会员判断改掉
什么时候最适合用这套
- 你想看登录时到底传了什么用户名、密码、token
- 你还想顺手把会员状态、成功状态、权限判断改掉
- 你已经大概知道
LoginManager、UserManager这类业务类名
推荐操作顺序
- 先
hookAll(login)看真正命中的是哪个重载 - 再切回精确
hook(login),只抓你要的那一个 - 最后去 Hook
isVip()/hasPermission()/getRole()这类结果方法
第一步:先摸清登录入口签名
hookAll({
class: "com.example.target.LoginManager",
classloader: lpparam.classLoader,
method: "login",
before(it) {
log(`login hit=${it.method}`);
log(`login args=${JSON.stringify(it.args)}`);
}
});
第二步:换成精确 Hook,只抓你真正要的登录方法
hook({
class: "com.example.target.LoginManager",
classloader: lpparam.classLoader,
method: "login",
params: ["java.lang.String", "java.lang.String"],
before(it) {
log(`username=${it.args[0]}`);
log(`password=${it.args[1]}`);
},
after(it) {
log(`login result=${it.result}`);
}
});
第三步:把会员判断直接改成真
hook({
class: "com.example.target.UserManager",
classloader: lpparam.classLoader,
method: "isVip",
params: [],
after(it) {
log(`origin vip=${it.result}`);
it.setResult(true);
}
});
如果它不是布尔值,而是状态码
有的 App 不是 isVip(): boolean,而是:
getVipLevel(): intgetUserType(): inthasPermission(String): boolean
这种时候改法也一样,只是你要改成对方业务真正认的值:
hook({
class: "com.example.target.UserManager",
classloader: lpparam.classLoader,
method: "getVipLevel",
params: [],
after(it) {
it.setResult(9);
}
});
这一类案例最容易踩的坑
- 只抓到了登录按钮点击,没抓到真正登录方法
hookAll(...)日志太多,看花了眼,没有及时切回精确hook(...)- 会员判断改错了方法,结果前端显示变了,但真正接口权限没变
所以这里最重要的经验是:
登录入口和会员判断,通常不是同一个方法,要分开看。
案例 2:从请求对象构造函数,一路追到真正发包的方法
什么时候特别适合这样做
- 你已经知道某个请求对象很关键,但还不知道它是谁创建的
- 你想知道 URL、body、headers、参数对象是在什么时候装进去的
- 你发现单独 Hook 发送方法时,拿到的对象已经太复杂了
这时候很实用的一条路线就是:
- 先
hookctor(...)看请求对象创建时塞了什么 - 再 Hook
send(...)/execute(...)/enqueue(...)看它最终怎么发出去
第一步:先拦构造函数
hookctor({
class: "com.example.net.Request",
classloader: lpparam.classLoader,
params: ["java.lang.String", "byte[]"],
before(it) {
log(`request ctor url=${it.args[0]}`);
log(`request ctor body=${it.args[1]}`);
},
after(it) {
log(`request instance=${it.thisObject}`);
printFields(it.thisObject, "\n");
}
});
这一步你通常能看清三件事:
- 构造函数参数里有没有直接带 URL
- 请求体是不是一开始就准备好了
- 构造完以后对象内部究竟有哪些字段
第二步:再去拦真正发包的方法
hook({
class: "com.example.net.HttpClient",
classloader: lpparam.classLoader,
method: "send",
params: ["com.example.net.Request"],
before(it) {
const request = it.args[0];
log(`send url=${getField(request, "url")}`);
log(`send method=${getField(request, "method")}`);
log(`send headers=${getField(request, "headers")}`);
}
});
如果字段名还不确定
别急着上 getField(...),先在发送前回调里把整个对象字段打出来:
hook({
class: "com.example.net.HttpClient",
classloader: lpparam.classLoader,
method: "send",
params: ["com.example.net.Request"],
before(it) {
const request = it.args[0];
printFields(request, "\n");
}
});
你看见真实字段名以后,再回头补成:
urlmethodheadersbodyparams
这种精确读取就行。
这套案例最实用的价值
它能帮你判断:
- URL 是构造时就定死的,还是发送前才拼出来的
- body 是原始字节、JSON 文本,还是另一个包装对象
- 你后面到底该改构造参数,还是改发送前对象字段
案例 3:页面一开就崩,先把异常吞掉,让流程继续跑
什么时候该优先这么做
- 你刚命中目标方法,它一执行就抛异常
- 页面、列表、详情页根本进不去
- 你现在的第一目标不是“马上修复”,而是“先把链路看完”
这个时候特别实用的一招就是:
先兜底,先别崩,让后面的逻辑继续走。
先看是哪条方法在抛
hook({
class: "com.example.target.TokenStore",
classloader: lpparam.classLoader,
method: "readToken",
params: [],
after(it) {
if (it.hasThrowable()) {
log(`readToken error=${it.getThrowable()}`);
}
}
});
再把它改成安全兜底值
hook({
class: "com.example.target.TokenStore",
classloader: lpparam.classLoader,
method: "readToken",
params: [],
after(it) {
if (it.hasThrowable()) {
log(`readToken error=${it.getThrowable()}`);
it.setResult("");
}
}
});
如果返回的不是字符串,而是布尔值 / 数字
那就把兜底值换成对应类型:
hook({
class: "com.example.target.UserStatusManager",
classloader: lpparam.classLoader,
method: "isLogin",
params: [],
after(it) {
if (it.hasThrowable()) {
it.setResult(false);
}
}
});
hook({
class: "com.example.target.ResultParser",
classloader: lpparam.classLoader,
method: "getCode",
params: [],
after(it) {
if (it.hasThrowable()) {
it.setResult(0);
}
}
});
这里要特别有意识地想一件事
你给的兜底值,必须是“上层代码能吃下去”的。
比如:
- 返回
String的方法,常见兜底值是"" - 返回
boolean的方法,常见兜底值是false - 返回
int的方法,常见兜底值是0
如果方法真实返回的是复杂对象,那就不要想当然地塞 null,除非你已经确认上层能接受 null。
案例 4:同时拦风控判断和风险弹窗
什么时候这一类很常见
- 你明明已经改了某个状态判断,但页面还是会弹风险提示
- 你想让流程继续走,不想被弹窗打断
- 你怀疑“判断逻辑”和“弹窗逻辑”分散在两条方法里
这类问题经常不是单点方法能解决,而是两层:
- 一层负责判断“你是不是风险用户”
- 一层负责把风险弹窗显示出来
先改掉风控判断
hook({
class: "com.example.target.RiskManager",
classloader: lpparam.classLoader,
method: "isRiskUser",
params: ["java.lang.String"],
after(it) {
log(`origin risk=${it.result}`);
it.setResult(false);
}
});
再把弹窗方法直接拦掉
hook({
class: "com.example.target.DialogManager",
classloader: lpparam.classLoader,
method: "showRiskDialog",
params: ["android.app.Activity"],
before(it) {
log("skip risk dialog");
it.setResult(null);
}
});
为什么这里是 setResult(null)
因为很多“显示弹窗”的方法本身返回的就是:
voidDialogAlertDialog
如果你确认它本来就是无返回值或你不关心返回对象,setResult(null) 往往就够用了。
这类问题为什么经常要双 Hook
因为只改判断不一定够:
- 有的页面已经提前缓存了“要弹窗”的状态
- 有的弹窗不是根据最新判断出来的,而是其他地方直接触发的
反过来,只拦弹窗也不一定够:
- 后面的流程可能还会因为“风险用户”状态而被卡住
所以这类问题,最稳的是“判断方法 + 显示方法”一起看。
案例 5:把 nonce / 随机串固定住,方便重复复现
什么时候这一招特别有价值
- 你在调签名、发包、重放请求
- 每次生成的 nonce、timestamp、随机串都不一样
- 你很难对比两次请求差异到底来自哪一步
这时候很适合把“随机源头”先固定住。
直接替换 nonce 生成方法
replace({
class: "com.example.target.SignManager",
classloader: lpparam.classLoader,
method: "buildNonce",
params: [],
replace(it) {
return "nonce_debug_fixed";
}
});
同时把签名输入输出也记下来
hook({
class: "com.example.target.SignManager",
classloader: lpparam.classLoader,
method: "buildSign",
params: ["java.lang.String", "java.lang.String"],
before(it) {
log(`sign arg0=${it.args[0]}`);
log(`sign arg1=${it.args[1]}`);
},
after(it) {
log(`sign result=${it.result}`);
}
});
这一类案例的调试目标是什么
不是立刻“破解签名”,而是先把环境变稳定:
- 同样输入,尽量得到同样输出
- 同样请求,尽量只剩一个变量在变化
等你把随机项先固定住,再去比对:
- header
- body
- 时间戳
- sign 输入参数
就会容易很多。
这 5 个案例你该怎么练
如果你现在还是刚上手,别五个一起扑。
最推荐的练法是:
- 先练案例 1,熟悉
hookAll -> hook -> after setResult - 再练案例 2,熟悉
hookctor和对象字段观察 - 再练案例 3,学会
hasThrowable()和兜底 - 最后再练案例 4、案例 5 这种“组合型场景”
你把这几类练熟以后,绝大部分 App 里的 Hook 起手式,其实都差不多了。
常用运行时函数统一去哪里看
为了避免和 全局函数清单 重复,这一页不再把常用全局函数逐条展开两遍。
现在分工是:
- 这页负责“运行时环境、Hook 入口、排错思路”
- 全局函数清单 负责“函数参数、默认值、返回值、失败行为”
如果你已经确认脚本跑起来了,下面这些函数请直接去专页看详细规则:
- 日志与退出:
log / print / toast / toastLog / printStackTrace / exit - 定时器与等待:
setTimeout / clearTimeout / setInterval / clearInterval / Task / sleep - Shell 与项目加载:
shell / require / getProjectDir / loadDex - 宿主弹窗与小工具:
confirm / showGlobalDialog / setClip / getClip / random / getAppLanguage
最常见的运行时排错组合,记住这一套就够了:
log(`package=${lpparam.packageName}`);
log(`process=${lpparam.processName}`);
printStackTrace();
injectActivity(...)
把脚本片段注入当前页面上下文,非常适合做页面内快速实验。
当前最常用的是两种形式:
injectActivity(scriptText)
injectActivity(context, scriptText)
怎么理解这两个形式:
injectActivity(scriptText):直接用当前能拿到的页面 / 宿主上下文去拉起注入页injectActivity(context, scriptText):你自己明确指定一个Context再拉起
如果你现在手里已经有一个更稳的 Activity 或 Context,第二种写法更可控。
injectActivity(`
const Toast = imports("android.widget.Toast");
Toast.makeText(activity, "Hello from injected page", 0).show();
`);
在注入脚本里,默认可用:
thisactivitycontextimportsimportClass
例如:
injectActivity(`
const Toast = importClass("android.widget.Toast");
Toast.makeText(context, "Injected", 0).show();
`);
如果你传进去的脚本文本是空字符串,或者当前连可用的 Context 都拿不到,这次调用就不会真正打开注入页。
其他常用全局函数
像下面这些函数,本身确实经常在运行时脚本里用到:
confirm(...)/showGlobalDialog(...)setClip()/getClip()random()getAppLanguage()
但为了不和 全局函数清单 形成双份说明,这里只保留入口提醒。
如果你要看参数字段、别名、默认值、回调写法,统一去那一页。
一个稍完整的新手示例
下面这段脚本把“进程过滤 + 环境自检 + Hook 日志”串在一起,适合第一次自己抄一遍:
//@process=main
log(`package=${lpparam.packageName}`);
log(`process=${lpparam.processName}`);
hook({
class: "android.widget.Toast",
classloader: lpparam.classLoader,
method: "makeText",
params: [
"android.content.Context",
"java.lang.CharSequence",
"int"
],
before(it) {
log(`toast text=${it.args[1]}`);
}
});
这段脚本适合拿来练这三件事:
- 确认脚本真的进入了主进程。
- 确认你写的签名能命中。
- 学会从
it.args里读参数。
常见问题
“脚本一条日志都没有”
优先检查:
- 项目
scope是否写对。 - 当前脚本是否被启用。
//@process=...是否把自己过滤掉了。
“activity 是 null”
这不一定是错。很多场景下脚本运行时就是拿不到当前页面对象。
你要么改成依赖 context 的写法,要么等页面真正起来以后再处理。
“Hook 没命中”
最常见是这几类原因:
- 类名写错。
- 参数签名写错。
- 用错进程。
- 用错类加载器。
新手排查顺序建议是:
- 先用
hookAll(...)看能不能命中这个方法名。 - 再慢慢收窄到精确签名。
“require() 找不到文件”
它是项目内模块加载,路径应该相对当前项目根目录去理解。
不确定时先配合 getProjectDir("你的相对路径") 打一条日志看最终路径。
想继续学字段读写、反射调用、构造对象这些内容,下一站读 反射与 Java 类操作。
想学项目结构和 main.js / init.js 怎么配,接着读 项目系统。
