native Native Hook 与 FFI
native Native Hook 与 FFI
native 是 ScriptX 现在这一套 native 能力的统一入口,别名还有:
$nativeso
它不只是 native.hook(...)。
当前这套模块已经把下面几组能力都接进来了:
- 模块 / 符号 / 地址信息查询
- 受限内存读取、映射范围查看、模式扫描
native.hook(...)/native.replace(...)native.invoke(...)native.function(...)native.callback(...)native.buffer(...)native.struct(...)
如果你第一次接触这块,先记住 12 条:
- 这是目标进程里的 native 能力,不是桌面 Node.js 那种本机 FFI。
- 大多数能力要求脚本跑在 Hook 宿主进程里;无宿主运行里很多会直接不可用。
native.observe(...)当前就是native.hook(...)的兼容别名。native.hook(...)用来拦截原生函数调用。native.replace(...)用来直接用 JS 实现替换原生函数。native.invoke(...)用来主动调用一个 native 函数。native.function(...)是“先创建一个可复用的函数句柄,再多次调用”。native.callback(...)是“从 JS 生成一个可传给 native 的函数指针”。native.buffer(...)/native.struct(...)负责安全地组织参数和输出缓冲区。- 当前不提供任意地址写内存这类完全放开的危险接口。
- 大部分地址、偏移、指针都推荐用十六进制字符串。
- 真正最容易炸进程的,不是语法写错,而是签名猜错。
先分清几组能力
| 你现在要做的事 | 更该看哪组 API |
|---|---|
| 想看看当前进程加载了哪些 so、某个地址落在哪个模块里 | listModules / findModule / addressInfo / symbolize |
| 想看某个模块的导入导出符号 | listExports / listImports |
| 想看内存映射、按模式扫描模块 | ranges / scan |
| 想 Hook 某个 native 函数 | hook / replace / listHooks |
| 想主动调用 native 函数做验证 | invoke / function |
| 想把 JS 回调传给 native | callback |
| 想安全地准备指针参数 / 输出缓冲区 | buffer / struct |
native.observationOnly
当前固定值是:
false
这说明现在这套模块已经不只是“只观察不修改”的只读探测器,而是完整的功能型 native hook 运行时。
native.abi()
读取当前目标进程 ABI。
返回值
string- 常见值:
arm64-v8a、armeabi-v7a
例子
log("abi = " + native.abi());
native.pointerSize()
读取当前进程指针宽度。
返回值
number- 常见值:
4或8
例子
log("pointerSize = " + native.pointerSize());
native.moduleBase(module)
取已加载模块的基址。
参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
module | string | 是 | 模块名,如 libtarget.so |
返回值
- 指针十六进制字符串
例子
const base = native.moduleBase("libdemo.so");
log("base = " + base);
native.resolve(module, symbol)
按模块名 + 符号名解析当前进程里已加载的符号地址。
返回值
- 指针十六进制字符串
例子
const address = native.resolve("libdemo.so", "check_login");
log("check_login = " + address);
native.listModules(options?)
列出当前进程已加载模块。
常见用途
- 先看目标 so 到底是否已经加载
- 看模块名、基址、路径,确认后面到底该 hook 哪个
例子
const modules = native.listModules();
log(JSON.stringify(modules.slice(0, 5), null, 2));
native.findModule(module)
按模块名查单个已加载模块。
返回值
- 模块对象
- 或
null
例子
const moduleInfo = native.findModule("libdemo.so");
if (moduleInfo) {
log(JSON.stringify(moduleInfo, null, 2));
}
native.addressInfo(address)
查看某个地址所在的模块、区段和映射信息。
适合场景
- 你已经拿到一个地址,想知道它到底落在哪
- 你排查
address方式为什么不可调用
例子
const info = native.addressInfo("0x7a12345678");
log(JSON.stringify(info, null, 2));
native.backtrace(options?)
抓当前线程的 native 回溯。
适合场景
- 你在 Hook 回调里想看调用栈
- 你需要把当前返回地址们符号化
例子
const bt = native.backtrace();
log(JSON.stringify(bt, null, 2));
native.symbolize(addresses, options?)
把一批地址转成可读的符号信息。
例子
const frames = native.symbolize([
"0x7a12345678",
"0x7a12349990"
]);
log(JSON.stringify(frames, null, 2));
native.listExports(module, options?)
列出某个模块导出的符号。
参数
第一参数必须是模块名:
const exportsList = native.listExports("libdemo.so");
适合场景
- 先找有没有可直接 hook 的导出函数
- 先确认符号名写没写错
native.listImports(module, options?)
列出某个模块导入的符号。
适合场景
- 看它依赖了哪些 libc / libart / 第三方导入
- 分析某个 so 调到了哪些外部函数
例子
const importsList = native.listImports("libdemo.so");
log(JSON.stringify(importsList.slice(0, 10), null, 2));
options 里这些值要写清楚
| 字段 | 类型 | 默认值 | 可填值 | 说明 |
|---|---|---|---|---|
query | string | "" | 任意字符串 | 过滤关键字;源码会先 trim(),空字符串就等于不过滤 |
kind | string | "all" | all / plt / got / absolute / tls / other | 只看某一类导入项 |
offset | number | 0 | >= 0 | 分页起始偏移 |
limit | number | 128 | 1 .. 256 | 每次最多返回多少条 |
ignoreCase | boolean | false | true / false | query 是否忽略大小写 |
kind 每个值分别是什么意思
| 值 | 适合什么时候看 |
|---|---|
all | 先把这个 so 的全部导入扫一遍 |
plt | 更关心通过 PLT 跳转出去的函数 |
got | 更关心 GOT 相关导入 |
absolute | 在排查绝对重定位类导入 |
tls | 在看线程局部存储相关导入 |
other | 想把不落在前面几类里的导入也翻出来看看 |
这组参数还有几个容易忽略的细节
kind当前会先转小写,所以PLT、plt、Plt都能识别,最终按小写值处理。query传" memcpy "这种值时,前后空格会先被去掉。offset不能小于0。limit不能超过256,不是无限翻页。ignoreCase默认就是false,不写时按大小写敏感过滤。
分页筛选例子
const page = native.listImports("libdemo.so", {
kind: "plt",
query: "mem",
ignoreCase: true,
offset: 0,
limit: 32
});
log(JSON.stringify(page, null, 2));
native.ranges(options?)
查看已加载模块的内存映射范围。
常用参数
| 字段 | 类型 | 可填值 | 说明 |
|---|---|---|---|
module | string | 模块名 | 只看某个模块 |
permissions | string | 例如 r-xp、r--p | 精确权限过滤 |
offset | number | 0 起 | 分页偏移 |
limit | number | 最大 512 | 每页条数 |
例子
const page = native.ranges({
module: "libtarget.so",
permissions: "r-xp",
offset: 0,
limit: 128
});
权限值怎么写
必须是严格 4 位形式:
[r-][w-][x-][ps]
比如:
r-xpr--prw-p
native.scan(options)
在一个已加载模块内部按字节模式扫描。
常用参数
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
module | string | 是 | 模块名 |
pattern | string | 是 | 形如 FD 7B ?? A9 的模式串 |
permissions | string | 否 | 只扫某种可读映射 |
maxBytes | number | 否 | 默认 4 MiB,上限 16 MiB |
maxMatches | number | 否 | 默认 128,上限 1024 |
timeoutMillis | number | 否 | 默认 1000,上限 10000 |
permissions 不是随便写字符串
当前源码要求它必须匹配这个格式:
[r-][w-][x-][ps]
也就是总共 4 位,分别表示:
| 位置 | 可填值 | 含义 |
|---|---|---|
| 第 1 位 | r / - | 是否可读。native.scan(...) 最终要求这里必须是 r |
| 第 2 位 | w / - | 是否可写 |
| 第 3 位 | x / - | 是否可执行 |
| 第 4 位 | p / s | 私有映射 / 共享映射 |
常见合法例子:
r-xpr--prw-p
pattern 也有固定格式
它不是正则,而是“空格分隔的十六进制字节串”,并支持 ?? 通配:
FD 7B ?? A9
00 00 80 D2
当前规则是:
- 只能写十六进制双字节,比如
FD、7B - 通配只能写成完整的
?? - 字节之间用空格分开
- 总字节数必须在
1..64之间
例子
const result = native.scan({
module: "libtarget.so",
pattern: "FD 7B ?? A9",
maxBytes: 4 * 1024 * 1024,
maxMatches: 128,
timeoutMillis: 1000
});
log(JSON.stringify(result, null, 2));
结果怎么理解
成功结果里最值得先看的通常是:
matchestotalMatchestruncatedtimedOutbudgetExhaustedscannedBytes
失败时不会把脚本整个炸停,而是返回:
{
ok: false,
matches: [],
error: {
code: "MODULE_NOT_FOUND",
message: "..."
}
}
native.observeModules(options, callbacks?)
监听模块加载变化。
适合场景
- 某个 so 还没加载,你想等它出现
- 你要做“模块一加载就开始后续处理”的逻辑
相关管理接口
native.listModuleObservers()native.moduleObserverStats()native.closeModuleObservers()
例子
const observer = native.observeModules({
module: "libtarget.so"
}, {
onMatch(event) {
log(JSON.stringify(event));
}
});
native.listModuleObservers()
列出当前脚本运行时拥有的模块观察器记录。
返回值
返回数组。当前最值得先看的字段有:
idownerstatemoduleeventsincludeExistingincludeFailedhits
例子
log(JSON.stringify(native.listModuleObservers(), null, 2));
native.moduleObserverStats()
看模块观察器的占用和命中统计。
返回值
当前最值得先看的字段有:
capacityusedavailableownernativeOwnerCapacitynativeOwnersUsedhits
例子
const stats = native.moduleObserverStats();
log(`used=${stats.used}, hits=${stats.hits}`);
native.closeModuleObservers()
关闭当前脚本运行时拥有的全部模块观察器。
返回值
number
表示这次尝试关闭了多少个观察器句柄。
它不会去碰别的脚本运行时拥有的观察器。
例子
const closed = native.closeModuleObservers();
log(`closed observers=${closed}`);
native.read(address, size)
读取任意地址上的受限字节。
参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
address | string / number | 是 | 目标地址,推荐十六进制字符串 |
size | number | 是 | 读取字节数,当前上限 256 KiB |
返回值
number[]- 每个元素是
0 ~ 255
例子
const bytes = native.read("0x7a12345678", 32);
log(JSON.stringify(bytes));
native.readCString(address, maxBytes?)
按 C 字符串方式读取 UTF-8 文本。
参数规则
maxBytes省略时默认4096- 最大不超过
64 KiB
例子
const text = native.readCString(call.args[0], 128);
log(text);
native.invoke(options)
主动调用一个 native 函数。
什么时候用它
- 你已经通过静态分析确认了函数签名
- 你想做受控动态验证
- 你不想先挂 Hook,只想直接试调一次
目标写法三选一
| 形式 | 必填字段 | 说明 |
|---|---|---|
| 模块 + 符号 | module + symbol | 当前已加载模块里按符号名找 |
| 模块 + 偏移 | module + offset | 按 ELF 虚拟偏移计算地址 |
| 绝对地址 | address | 直接调用当前进程地址 |
目标字段的组合规则非常严格
当前实现不是“差不多能猜出来就帮你猜”,而是严格按下面这套判断:
| 你传的组合 | 结果 |
|---|---|
只传 address | 合法 |
address + symbol | 非法 |
address + offset | 非法 |
module + symbol | 合法 |
module + offset | 合法 |
module + symbol + offset | 非法 |
| 什么都不传 | 非法 |
可以直接把它记成两句:
address和module二选一,不能同时出现。- 一旦走
module分支,就必须在symbol和offset里再二选一。
签名写法
signature: {
returnType: "int32",
argumentTypes: ["int32", "pointer"]
}
signature 当前的硬要求
signature必须是对象signature.returnType必填signature.argumentTypes必填args.length必须和signature.argumentTypes.length完全一致- 最多支持
16个参数
类型名不只有一种拼法
| 规范写法 | 兼容别名 |
|---|---|
pointer | ptr、integer、int、uint、long |
bool | boolean |
int8 | i8 |
uint8 | u8 |
int16 | i16 |
uint16 | u16 |
int32 | i32 |
uint32 | u32 |
int64 | i64 |
uint64 | u64 |
float | f32 |
double | f64 |
initialErrno
你还可以额外传:
| 字段 | 类型 | 默认值 | 作用 |
|---|---|---|---|
initialErrno | number | 0 | 先把这次调用起始 errno 设成指定值,再进入真正的 native 调用 |
常用标量类型
pointerboolint8/uint8int16/uint16int32/uint32int64/uint64floatdoublevoid
例子
const result = native.invoke({
module: "libdemo.so",
symbol: "target",
signature: {
returnType: "int32",
argumentTypes: ["int32", "pointer"]
},
args: [100, "0x1234"]
});
if (!result.ok) {
log(result.error.code + ": " + result.error.message);
} else {
log("value=" + result.value + ", errno=" + result.errno);
}
返回结果长什么样
成功时重点看:
okvaluerawValueerrnoaddresstargetreturnTypeabidurationMillis
失败时重点看:
ok: falseerror.codeerror.message
一个典型用法:读返回指针上的字符串
const result = native.invoke({
module: "libdemo.so",
symbol: "get_version_name",
signature: {
returnType: "pointer",
argumentTypes: []
},
args: []
});
if (result.ok && result.value !== "0x0000000000000000") {
log(native.readCString(result.value, 256));
}
native.function(options)
创建一个可重复调用的 native 函数句柄。
为什么不用每次都 invoke(...)
如果你要反复调同一个 native 函数,native.function(...) 会更顺:
- 创建时先把目标和签名固定下来
- 后面只需要
handle.call(args)
创建句柄时的规则
- 目标写法和
native.invoke(...)完全一样,也是module + symbol/module + offset/address三选一 signature必填- 这里不传
args initialErrno是“句柄默认值”,后面每次call(args, initialErrno?)还能单独覆盖- 单个运行时当前最多保留
128个打开的函数句柄
例子
const add = native.function({
module: "libdemo.so",
symbol: "add_i32",
signature: {
returnType: "int32",
argumentTypes: ["int32", "int32"]
},
initialErrno: 0
});
try {
const result = add.call([40, 2]);
if (!result.ok) {
throw new Error(result.error.code + ": " + result.error.message);
}
log(result.value); // 42
} finally {
add.close();
}
句柄属性
| 属性 | 说明 |
|---|---|
id | 句柄 id |
target | 目标标签 |
returnType | 返回类型名 |
argumentTypes | 参数类型名数组 |
句柄方法
| 方法 | 说明 |
|---|---|
call(args, initialErrno?) | 调用函数 |
state() | ready / unavailable / closed |
address() | 当前解析到的地址,或 null |
calls() | 已尝试调用次数 |
lastError() | 上一次结构化错误 |
close() | 关闭句柄 |
call(args, initialErrno?) 最容易踩的地方
args必须是数组args.length必须和signature.argumentTypes.length一致- 零参数函数既可以写
call(),也可以写call([]) - 第二个参数
initialErrno只覆盖这一次调用
native.listFunctions()
列出当前脚本运行时打开的 native 函数句柄。
返回值
返回数组。每项当前最值得先看的字段有:
idownertargetaddressstatereturnTypeargumentTypescallslastError
例子
log(JSON.stringify(native.listFunctions(), null, 2));
native.functionStats()
看当前函数句柄池的占用情况。
返回值
当前最值得先看的字段有:
capacityusedavailableownerstatescalls
这里的 capacity 当前就可以直接理解成“这个运行时最多能开多少个 native.function(...) 句柄”,目前是 128。
例子
const stats = native.functionStats();
log(`used=${stats.used}, calls=${stats.calls}`);
native.closeFunctions()
关闭当前脚本运行时拥有的全部函数句柄。
返回值
number
表示这次尝试关闭了多少个仍处于打开状态的函数句柄。
例子
const closed = native.closeFunctions();
log(`closed functions=${closed}`);
native.callback(options)
从 JS 生成一个可传回 native 的函数指针。
什么时候用
- 某个 native API 要你传函数指针
- 你要在
native.invoke(...)或 Hook 里把回调地址传进去
创建回调时必须满足什么
signature必填signature.returnType必填signature.argumentTypes必填implementation(call)必填- 参数类型的支持范围和
native.invoke(...)/native.function(...)一样 - 仍然最多支持
16个参数
例子
const callback = native.callback({
signature: {
returnType: "int32",
argumentTypes: ["int32", "pointer"]
},
implementation(call) {
log(JSON.stringify(call.args));
call.setErrno(0);
return call.args[0] + 1;
}
});
const pointer = callback.address();
try {
native.invoke({
module: "libdemo.so",
symbol: "run_callback",
signature: {
returnType: "int32",
argumentTypes: ["pointer", "int32"]
},
args: [pointer, 41]
});
} finally {
callback.close();
callback.waitUntilClosed(3000);
}
回调句柄方法
| 方法 | 说明 |
|---|---|
address() | 当前回调地址 |
state() | open / closing / closed |
close() | 请求关闭 |
waitUntilClosed(timeoutMillis) | 等待正在飞行中的调用退干净 |
implementation(call) 里最常做的几件事
- 读
call.args call.setErrno(value)- 按签名返回一个 JS 值
- 必要时配合
native.buffer(...)/native.readCString(...)读写指针参数
native.listCallbacks()
列出当前脚本运行时拥有的 native 回调句柄。
返回值
返回数组。每项当前最值得先看的字段有:
idowneraddressstatereturnTypeargumentTypeshits
例子
log(JSON.stringify(native.listCallbacks(), null, 2));
native.callbackStats()
看当前回调句柄池的占用与命中统计。
返回值
当前最值得先看的字段有:
capacityusedavailableownedownerstates
capacity / used / available 这三个字段表示回调句柄池当前剩多少空位;owned 更偏“当前这个脚本运行时自己占了多少个”,和全局 used 不是一个口径。
例子
const stats = native.callbackStats();
log(`used=${stats.used}, owned=${stats.owned}`);
native.closeCallbacks()
关闭当前脚本运行时拥有的全部 native 回调句柄。
返回值
number
表示这次尝试关闭的句柄数量。
它不会去关闭别的脚本运行时拥有的回调。
例子
const closed = native.closeCallbacks();
log(`closed callbacks=${closed}`);
native.buffer(options)
分配一段当前脚本拥有的受控 native 缓冲区。
最常见用途
- 给
pointer参数准备输入缓冲区 - 给 native 函数准备输出缓冲区
- 用
readCString()/view(...)/struct(...)继续读写
分配例子
const name = native.buffer({
size: 128,
initial: {
type: "cstring",
value: "ScriptX"
}
});
initial 当前支持两种
cstring
const text = native.buffer({
size: 64,
initial: {
type: "cstring",
value: "hello",
offset: 0
}
});
bytes
const bytes = native.buffer({
size: 8,
initial: {
type: "bytes",
value: [0, 127, 128, 255],
offset: 2
}
});
缓冲区配额别忽略
当前这套托管缓冲区不是无限开的,比较实用的记法是:
| 限制项 | 当前值 |
|---|---|
| 单块缓冲区最大大小 | 1 MiB |
| 同一运行时同时打开的缓冲区数量 | 64 |
| 同一运行时同时打开的缓冲区总字节数 | 8 MiB |
也就是说:
native.buffer({ size: 2 * 1024 * 1024 })这种单块超1 MiB的会直接失败- 你就算每块都很小,开到总量超
8 MiB也会失败 close()掉一个 buffer 后,对应的数量和字节额度会立刻归还
缓冲区属性
| 属性 | 说明 |
|---|---|
id | 缓冲区 id |
size | 总容量,字节数 |
缓冲区方法
| 方法 | 说明 |
|---|---|
state() | open / closed |
address() | 打开时返回基址 |
read(offset?, size?) | 读字节数组 |
write(bytes, offset?) | 写字节数组 |
fill(value, offset?, size?) | 填充指定字节值 |
writeCString(value, offset?) | 写 UTF-8 + \\0 |
readCString(offset?, maxBytes?) | 读 C 字符串 |
view(options) | 创建类型视图 |
close() | 关闭缓冲区 |
例子:输出缓冲区
const output = native.buffer({ size: 256 });
try {
const result = native.invoke({
module: "libdemo.so",
symbol: "write_report",
signature: {
returnType: "int32",
argumentTypes: ["pointer", "uint32"]
},
args: [output, output.size]
});
if (result.ok && result.value >= 0) {
log(output.readCString(0, output.size));
}
} finally {
output.close();
}
native.listBuffers()
列出当前脚本运行时仍然打开的托管缓冲区。
返回值
返回数组。每项当前最值得先看的字段有:
idaddresssizestate
例子
log(JSON.stringify(native.listBuffers(), null, 2));
native.bufferStats()
看当前缓冲区占用情况。
返回值
当前最值得先看的字段有:
countbytesmaxCountmaxBufferBytesmaxTotalBytes
可以直接把它们理解成:
| 字段 | 含义 |
|---|---|
count | 当前打开的缓冲区数量 |
bytes | 当前打开的缓冲区总字节数 |
maxCount | 允许同时打开的最大缓冲区数量,当前就是 64 |
maxBufferBytes | 单块 buffer 上限,当前就是 1 MiB |
maxTotalBytes | 当前运行时所有 buffer 总额度,当前就是 8 MiB |
例子
const stats = native.bufferStats();
log(`buffers=${stats.count}, bytes=${stats.bytes}`);
native.closeBuffers()
关闭当前脚本运行时拥有的全部托管缓冲区。
返回值
number
表示这次尝试关闭了多少个缓冲区。
常见用法就是在一段临时实验脚本最后做一次兜底释放。
例子
const closed = native.closeBuffers();
log(`closed buffers=${closed}`);
buffer.view(options)
把一个缓冲区按指定标量类型映射成“类型化视图”。
常用字段
| 字段 | 说明 |
|---|---|
type | 标量类型,如 int32、double、pointer |
offset | 起始字节偏移 |
length | 元素个数 |
endian | native / little / big |
type 和 endian 可填值说全一点
type
| 规范写法 | 兼容别名 |
|---|---|
bool | boolean |
int8 | i8 |
uint8 | u8 |
int16 | i16 |
uint16 | u16 |
int32 | i32 |
uint32 | u32 |
int64 | i64 |
uint64 | u64 |
float | f32 |
double | f64 |
pointer | ptr |
endian
| 写法 | 含义 |
|---|---|
native | 跟当前进程本机字节序走 |
little / le | 小端 |
big / be | 大端 |
offset / length 的默认行为
offset默认0length省略时,会自动按“剩余容量 / 单元素字节数”算出能铺满多少个元素- 如果你手填的
offset + length * elementSize超出 buffer 容量,会直接报错
视图属性
| 属性 | 说明 |
|---|---|
type | 视图类型 |
offset | 字节偏移 |
length | 元素数 |
elementSize | 单元素字节数 |
byteLength | 总字节数 |
endian | 当前字节序 |
视图方法
| 方法 | 说明 |
|---|---|
get(index) | 读一个元素 |
set(index, value) | 写一个元素 |
toArray() | 转整个数组 |
setAll(values, startIndex?) | 批量写 |
fill(value, startIndex?, count?) | 连续填充 |
例子
const buffer = native.buffer({ size: 16 });
const view = buffer.view({
type: "int32",
length: 4
});
view.set(0, 10);
view.set(1, 20);
log(JSON.stringify(view.toArray()));
buffer.close();
native.struct(options)
定义一个可复用的 C 结构布局。
例子
const Result = native.struct({
name: "Result",
fields: [
{ name: "status", type: "int32" },
{ name: "flags", type: "uint32" },
{ name: "total", type: "int64" },
{ name: "score", type: "double" },
{ name: "next", type: "pointer" }
]
});
布局选项
| 字段 | 必填 | 说明 |
|---|---|---|
fields | 是 | 字段数组,当前非空,最多 128 个字段 |
name | 否 | 诊断名;默认 "anonymous",最多 128 个字符,且不能带 NUL |
endian | 否 | native / little / le / big / be |
pack | 否 | 1 / 2 / 4 / 8 / 16 |
size | 否 | 显式记录大小;最终必须在“已用最小字节数 .. 1 MiB”之间 |
结构布局的硬限制
| 限制项 | 当前值 |
|---|---|
| 字段数量上限 | 128 |
单字段 count 上限 | 65536 |
| 单个结构布局总大小上限 | 1 MiB |
| 结构名最大长度 | 128 |
| 嵌套深度上限 | 8 |
name / pack / endian 这些值再说具体一点
name
- 不写时默认是
"anonymous" - 最长
128个字符 - 不能带
\0
pack
只认这 5 个值:
124816
endian
| 写法 | 含义 |
|---|---|
native | 跟当前进程本机字节序走 |
little / le | 小端 |
big / be | 大端 |
字段选项
| 字段 | 必填 | 说明 |
|---|---|---|
name | 是 | 字段名;必须是安全的 JS 标识符,不能重名,也不能占用保留名 |
type | 是 | 标量类型,或者另一个 native.struct(...) 返回的布局对象 |
count | 否 | 固定数组长度;默认 1,允许范围 1..65536 |
offset | 否 | 显式偏移;不能和前一个字段重叠 |
字段名不是随便写
当前字段名要同时满足:
- 形如普通 JS 字段名,比如
status、nextPtr、_flags - 不能重复
- 不能用保留名,例如
__proto__、constructor、prototype
type 到底能填什么
这里当前支持两类:
| 写法 | 含义 |
|---|---|
"int32"、"pointer"、"double" 这类标量类型名 | 普通标量字段 |
另一个 native.struct(...) 返回的布局对象 | 嵌套结构字段 |
也就是说你可以这样写:
const Header = native.struct({
name: "Header",
fields: [
{ name: "magic", type: "uint32" },
{ name: "version", type: "uint16" }
]
});
const Packet = native.struct({
name: "Packet",
fields: [
{ name: "header", type: Header },
{ name: "bodySize", type: "uint32" }
]
});
offset / size 两个最容易出错的点
offset一旦显式传了,不能回头压住前一个字段,否则会被判定成字段重叠。size如果显式传了,不能小于当前字段排完后的最小占用字节数,也不能超过1 MiB。
布局只读属性
| 属性 | 说明 |
|---|---|
name | 布局名 |
size | 单记录字节数 |
alignment | 对齐值 |
pack | 打包策略 |
endian | 字节序 |
fields | 已解析字段元数据 |
结构体视图
通过 layout.view(buffer, options?) 把布局绑定到具体缓冲区上:
const output = native.buffer({ size: Result.size });
const result = Result.view(output);
layout.view(buffer, options?) 也有前提
- 第一个参数必须是 ScriptX 自己分配出来的
native.buffer(...) - 不是任意地址,也不是普通
ByteArray - 当前 pointer size 也必须和布局创建时一致
结构体视图方法
| 方法 | 说明 |
|---|---|
get(index) | 读一条记录 |
set(index, values) | 写一条记录 |
getField(index, name) | 读单字段 |
setField(index, name, value) | 写单字段 |
toArray() | 读全部记录 |
setAll(values, startIndex?) | 批量写记录 |
完整例子
const Result = native.struct({
name: "Result",
fields: [
{ name: "status", type: "int32" },
{ name: "score", type: "double" }
]
});
const output = native.buffer({ size: Result.size });
const view = Result.view(output);
try {
view.set(0, {
status: 1,
score: 9.5
});
log(JSON.stringify(view.get(0)));
} finally {
output.close();
}
native.hook(options) / native.observe(options)
安装一个 native Hook。
这两个名字当前的关系
native.hook(...):主入口native.observe(...):兼容别名,当前直接走同一套注册逻辑
什么时候该用它
- 你要看调用参数
- 你要改参数
- 你要改返回值
- 你要跳过原函数
目标写法
和 native.invoke(...) 一样,三选一:
module + symbolmodule + offsetaddress
并且组合规则也完全一样:
address不能和symbol/offset混用module分支必须在symbol和offset里二选一- 绝对地址目标默认不会等模块加载;模块目标默认会等
推荐签名写法
signature: {
returnType: "int32",
argumentTypes: ["pointer", "int32"]
}
最常见例子:改参数 + 改结果
const loginHook = native.hook({
module: "libdemo.so",
symbol: "check_login",
signature: {
returnType: "int32",
argumentTypes: ["pointer", "int32"]
},
onEnter(call) {
const name = native.readCString(call.args[0], 256);
log("name=" + name);
call.setArgument(1, 0);
},
onLeave(call) {
call.setResult(1);
}
});
最常见例子:跳过原函数
native.hook({
module: "libdemo.so",
offset: "0x12a40",
signature: {
returnType: "bool",
argumentTypes: ["pointer"]
},
onEnter(call) {
call.skip(true);
}
});
Hook 选项里最值得先记住的字段
| 字段 | 说明 |
|---|---|
module / symbol | 模块 + 符号 |
module / offset | 模块 + ELF 偏移 |
address | 绝对地址 |
signature | 类型化签名 |
returnType / argumentCount | 旧 raw 模式写法 |
waitForModule | 模块暂未加载时是否等待 |
maxCalls | 最大回调次数,0 表示不限 |
onEnter | 进函数前回调 |
onLeave | 出函数后回调 |
onStatus | 安装 / 生命周期状态回调 |
snapshot | 开发期可选的快照联动 |
这些字段再说具体一点
| 字段 | 默认值 | 补充说明 |
|---|---|---|
waitForModule | 模块目标默认 true;绝对地址目标默认 false | 你传 module + symbol / module + offset 时,SO 暂时没加载也可以先挂起等待 |
maxCalls | 0 | 允许范围是 0..100000;0 就是不限制 |
returnType / argumentCount | raw 模式专用 | 不写 signature 时才主要依赖这套旧字段 |
snapshot | false / 不启用 | 当前既支持 true,也支持对象写法 |
snapshot 对象里能写什么
| 字段 | 可填值 | 默认值 | 说明 |
|---|---|---|---|
phase | enter / leave | enter | 在进函数还是出函数时触发快照联动 |
sessionId | MCP 启动快照后拿到的 UUID | 空字符串 | 只在你真的要挂 MCP 快照调试时才需要 |
tag | 不超过 96 个字符的字符串 | 空字符串 | 给这次快照打一个更好认的标签 |
raw 模式再补三条
- 不写
signature时,会退回returnType+argumentCount那套旧写法。 - 这时
returnType默认按pointer理解。 - 当前 raw / typed 最终都受“最多
16个参数 /16个栈槽”这层限制。
call 上能做什么
读数据
call.argscall.originalArgscall.resultcall.originalResultcall.argumentTypescall.returnTypecall.errnocall.originalErrno
改数据
call.setArgument(index, value)call.setArgumentBits(index, bits)call.setGpr(index, bits)call.setFprBits(index, bits)call.setStack(index, bits)call.setResult(value)call.setReturnBits(bits)call.skip(result?)call.setErrno(value)
Hook 管理接口
native.listHooks()native.hookStats()native.unhookAll()
native.replace(options)
直接用 JS 实现替换原函数。
例子
native.replace({
module: "libdemo.so",
symbol: "check_login",
signature: {
returnType: "int32",
argumentTypes: ["pointer", "int32"]
},
implementation(call) {
const name = native.readCString(call.args[0], 256);
log("forced login for " + name);
return 1;
}
});
和 hook(...) 的区别
| 方式 | 更适合什么 |
|---|---|
hook(...) | 保留原函数,前后插入、改参、改返回 |
replace(...) | 彻底自己接管函数逻辑 |
native.replace(...) 还要再记住这几条
implementation(call)是必填项,不写就会直接报错- 目标写法、
waitForModule、maxCalls、onStatus、snapshot这些规则都和native.hook(...)共用 onEnter/onLeave这种“拦截前后各做点事”的思路更适合hook(...)replace(...)更像“我自己就是新的函数体”
native.listHooks()
列出当前脚本运行时已经注册的 Hook 记录。
返回值
返回数组。每项当前最值得先看的字段有:
idownertargetaddressstateenabledrebindshitserror
例子
log(JSON.stringify(native.listHooks(), null, 2));
native.hookStats()
看当前 Hook 槽位占用和脚本拥有量。
返回值
当前最值得先看的字段有:
capacityusedavailableownedownerstates
例子
log(JSON.stringify(native.hookStats(), null, 2));
native.unhookAll()
卸掉当前脚本运行时拥有的全部 Hook。
返回值
number
表示这次请求关闭了多少个本运行时拥有的 Hook 句柄。
例子
const removed = native.unhookAll();
log(`removed hooks=${removed}`);
补充说明
这 3 个是最常用的 Hook 管理入口。
补充:listHooks
列出当前进程里已注册的 Hook 记录。
补充:hookStats
看当前 Hook 槽位和占用统计。
补充:unhookAll
卸掉当前脚本运行时拥有的所有 Hook。
例子
log(JSON.stringify(native.hookStats(), null, 2));
log(JSON.stringify(native.listHooks(), null, 2));
常见误区
1. 把 native.invoke(...) 当成“安全版反射”
不是。
它是在目标进程里真调机器码。签名错了,一样可能把进程带崩。
2. 把 managed buffer 当任意内存写
native.buffer(...) 只能写自己分配出来的受控缓冲区,不能拿来当“任意地址 patch 工具”。
3. 以为 native.read(...) / native.readCString(...) 就能无限读
当前都有硬上限:
read最多256 KiBreadCString最多64 KiB
4. 忽略 ABI
同一个函数签名,在 arm64-v8a 和 armeabi-v7a 上的寄存器 / 栈布局不是一回事。
如果你压根没确认 ABI,就不要急着硬调。
5. 把 WEEKLY 那种业务层调度思路带到这里
native 是底层进程能力。
想做脚本级定时调度,去看 work_manager 定时任务。
最后给你一个选路口诀
- 想查模块、符号、地址:先
listModules/findModule/addressInfo - 想扫模块内字节模式:用
scan - 想调一次函数验证:用
invoke - 想反复调同一个函数:用
function - 想传回调地址给 native:用
callback - 想准备指针参数或输出区:用
buffer - 想按结构体读写:用
struct - 想拦截调用:用
hook - 想完全接管函数:用
replace
