先看结论与判断条件
- Java long 只保存不透明标识,不保存可解引用地址,也不承担 Native 对象所有权。
- 句柄由 slot 与 generation 共同标识,槽位复用前递增代际,让旧值无法命中新对象。
- create、borrow、close 和异步回调必须进入同一状态机,不能由各 JNI 方法自行解释生命周期。
- borrow 需要在锁内取得受生命周期保护的访问权,不能校验后再用裸指针跨越 close。
- 进程重建、库卸载和候选切换都使旧 Java handle 失效,持久化层不得恢复这些数值。
- 发布结论要绑定同一候选的复用、竞态、异常、退出与设备端回执,静态审查不能代替运行验证。
把问题定义为身份复用,而不是空指针判断
最危险的旧句柄并不一定是零,也不一定指向已经取消映射的内存。常见失败过程是 Native 对象关闭后,分配器或自建对象池把同一地址交给另一个对象;Java 仍持有旧 long,再次调用时地址看起来有效,却访问了身份完全不同的新对象。单纯判断 handle != 0 只能排除未初始化,无法排除释放后复用。
裸指针方案还把内存布局暴露给跨层协议。Java 字段、Bundle、异步任务或日志一旦把地址当稳定 ID 使用,Native 侧就很难区分本次进程中的对象、上一次页面实例以及关闭后重新分配的对象。地址宽度、对齐和转换细节也不应该成为业务协议;跨 JNI 的值应表达身份,而不是表达存储位置。
工程上应把 stale handle 设计成一等终态。调用方得到的不是随机崩溃、错误对象结果或模糊的 IllegalState,而是可分类的 invalid_slot、generation_mismatch、closing、closed 等结果。只有当槽位存在、generation 相等且状态允许时,访问才进入对象逻辑;这条判断必须发生在任何解引用之前。
| 场景 | 裸指针 long | slot 加 generation | 期望终态 |
|---|---|---|---|
| 从未创建 | 常以零约定 | 保留无效值 | invalid_handle |
| 关闭后再次调用 | 可能访问释放内存 | 代际或状态不匹配 | stale_handle |
| 槽位被新对象复用 | 可能误命中新对象 | 新 generation 不同 | generation_mismatch |
| 并发 close | 校验与使用间竞态 | 借用协议保护生命周期 | busy 或 closed |
| 进程重建 | 旧数值仍像地址 | 表和进程 epoch 已重置 | process_mismatch |
句柄编码只表达槽位与代际,不泄露对象地址
一个实用结构是把固定宽度的 slot 和 generation 编入无符号语义的长整数。slot 选择句柄表中的条目,generation 表示该槽位第几次分配。编码和解码只处理整数位段,绝不从 long 转回对象地址。Java 可以传递这个值,但真正的对象引用只存在于 Native 或受控桥接层的表项中。
generation 要在槽位重新开放前递增,并避开保留的无效值。发生回绕时不能悄悄恢复旧代际,可以退休该槽位、扩大代际位数,或者让进程级 epoch 参与验证。选择取决于预期创建次数和进程寿命,但原则一致:系统必须能够证明一个被接受的 handle 对应当前分配,而不是仅凭概率认为旧值不会回来。
编码不是安全令牌,也不能充当调用授权。攻击者猜不到数值并非设计目标,Java 层拿到合法 handle 也不代表它有权执行任意操作。业务权限、对象能力和线程限制仍需单独检查。句柄表解决的是生命周期身份,不能把它宣传为防篡改、访问控制或加密边界。
| 字段 | 作用 | 创建规则 | 拒绝条件 |
|---|---|---|---|
| slot | 定位表项 | 从空闲表项分配 | 越界或未占用 |
| generation | 区分复用代次 | 每次复用递增 | 与当前表项不同 |
| state | 限制当前动作 | 由状态机推进 | closing 或 closed |
| type tag | 区分对象种类 | 创建时固定 | JNI 方法类型不符 |
| process epoch | 隔离进程实例 | 进程启动时生成 | 恢复值来自旧进程 |
create、borrow 与 close 必须共享一套状态机
create 的提交点不是内存分配完成,而是对象已经满足可见不变量并被原子安装到句柄表。若构造、依赖加载或 JNI 返回前发生异常,表项不能处于半初始化状态。常见做法是在表外完成可能失败的准备,锁内分配 slot、确认 generation、安装对象并切换到 active,然后才把编码后的 handle 返回 Java。
borrow 不能只在锁内查到指针,再解锁后无保护地使用。close 可能在这两步之间销毁对象。表项需要引用计数、读写租约或共享所有权来覆盖实际操作区间:借用成功时增加活动借用,操作结束时释放;close 先切到 closing 阻止新借用,再等待或协调既有借用,最后销毁并递增 generation。
close 应定义幂等性,但幂等不等于吞掉所有错误。相同 Java 对象的重复 close 可以返回 already_closed,旧 generation 的 close 必须返回 stale_handle,类型不符则返回 type_mismatch。不同结果帮助定位页面重复释放、迟到回调和跨对象串线;如果统一当成功,资源计数看似平稳,生命周期错误却会继续污染后续请求。
| 状态 | 允许 borrow | 允许 close | 后续动作 |
|---|---|---|---|
| empty | 否 | 否 | 可分配并递增 generation |
| initializing | 否 | 回滚创建 | 安装或清空 |
| active | 是 | 切换 closing | 执行受保护访问 |
| closing | 否 | 返回处理中 | 等待活动借用归零 |
| closed | 否 | 返回已关闭 | 清空并允许复用 |
| retired | 否 | 否 | generation 回绕后不再分配 |
异步回调要携带 generation 和任务代次
JNI 调用返回后,Native 工作线程可能继续计算,再通过 listener、Future 或事件队列回到 Java。只在任务启动时校验 handle 不够,因为页面可能已关闭对象并创建新对象。每个异步任务应捕获 handle、对象内部 taskGeneration 和取消状态;回调投递前重新确认任务仍属于当前对象,Java 接收端再核对页面或请求 generation。
回调不能通过缓存的 JNIEnv 跨线程使用,也不能假定 Java 引用永久有效。Android JNI 指南对线程附着与引用生命周期给出边界,工程实现应由受控桥接层在线程进入时获取环境,并把弱引用消失、异常待处理和回调目标关闭映射为明确终态。这里的规则并不证明某个变换后的 SO 已保持线程语义,候选仍需设备验证。
close 与回调的顺序要预先选择。可以规定 close 等待所有任务退出,也可以允许任务完成但丢弃迟到结果;两种方案都要让状态机可观察。最差的做法是 Java 字段先清零,Native 任务仍拿裸指针回调,因为日志只看到 handle 为零,却无法证明旧任务是否写入了新页面或调用了已释放 listener。
| 事件 | 对象 generation | 任务 generation | 失败处理 |
|---|---|---|---|
| 任务创建 | 必须匹配 active 表项 | 分配新代次 | 拒绝启动 |
| Native 完成 | 再次匹配 | 必须仍为 current | 丢弃结果并记回执 |
| Java 页面接收 | handle 未被替换 | 请求代次一致 | 忽略迟到回调 |
| 用户取消 | 对象可继续存在 | 标记 cancelled | 禁止业务提交 |
| 对象 close | 切换 closing | 全部失效 | 等待或受控丢弃 |
进程重建和持久化边界要明确拒绝旧数值
Native handle 只在创建它的进程实例内有效。Activity 重建不一定意味着进程重建,但 savedInstanceState、数据库、磁盘缓存和跨进程消息都不应保存并恢复 handle。恢复时应持久化可重建的业务参数,例如资源逻辑 ID、配置摘要和用户选择,再通过新的 create 流程生成新句柄。旧 long 即使非零也必须视为无效。
如果应用确实有多个进程,句柄表不能天然跨进程共享。Binder 请求应传递稳定业务标识或受系统管理的资源描述,并由拥有对象的进程解析;直接传 long 只会把另一个地址空间的槽位号带过去。进程名称、PID 或 epoch 可用于诊断和拒绝,但 PID 也会复用,不能单独承担永久身份。
库重新加载、热更新候选切换或测试环境重置同样会清空表。Java 包装对象可以记录创建它的 bridgeEpoch,并在每次 nativeCall 前与当前桥接实例比较;不匹配时要求重新创建。这个检查是工程防线,不代表可以跳过 Native 表内 generation 校验,因为并发 close 与槽位复用仍会发生在同一个 epoch 中。
用安全句柄表把陈旧引用变成确定性失败
下面的 Kotlin 示例只演示句柄分配、借用和关闭,不保存地址,也不包含真实业务对象。handle 的高位保存 generation,低位保存 slot;每个外部输入先解码并检查范围,再在同一把锁内核对表项。borrow 在锁内执行传入操作,示例因此牺牲并发度来突出生命周期正确性,生产实现可以替换为受验证的租约或共享所有权。
代码把 generation 不匹配视为输入派生的异常路径。close 先验证当前代际,再清除 value、递增 generation 并把槽位放回空闲状态。代际到达保留边界时退休槽位,避免回绕后重新接受非常久以前的值。具体位宽和容量必须依据应用生命周期评估,不能照抄示例数字后宣称风险已经消除。
这段校验器适合放进单元测试和设备测试的夹具,用固定序列证明旧句柄被拒绝:创建 A、关闭 A、创建 B 复用槽位,再用 A 的 handle 借用或关闭。测试还应覆盖并发 close、异步取消、JNI 异常和进程重建。示例不含 JNI 指针转换,因此也不会成为地址操作或绕过脚本。
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
class GenerationHandleTable<T>(private val capacity: Int) {
private data class Entry<T>(
var generation: Int = 1,
var value: T? = null,
var retired: Boolean = false
)
private val lock = ReentrantLock()
private val entries = List(capacity) { Entry<T>() }
fun create(value: T): Long = lock.withLock {
val slot = entries.indexOfFirst { it.value == null && !it.retired }
require(slot >= 0) { "handle table is full" }
val entry = entries[slot]
entry.value = value
encode(slot, entry.generation)
}
fun <R> borrow(handle: Long, operation: (T) -> R): R = lock.withLock {
val slot = decodeSlot(handle)
val generation = decodeGeneration(handle)
require(slot in entries.indices) { "invalid handle slot" }
val entry = entries[slot]
check(!entry.retired) { "handle slot is retired" }
check(entry.generation == generation) { "stale handle generation" }
val value = checkNotNull(entry.value) { "handle is closed" }
operation(value)
}
fun close(handle: Long) = lock.withLock {
val slot = decodeSlot(handle)
val generation = decodeGeneration(handle)
require(slot in entries.indices) { "invalid handle slot" }
val entry = entries[slot]
check(entry.generation == generation) { "stale handle generation" }
checkNotNull(entry.value) { "handle is already closed" }
entry.value = null
if (entry.generation == Int.MAX_VALUE) {
entry.retired = true
} else {
entry.generation += 1
}
}
private fun encode(slot: Int, generation: Int): Long =
(generation.toLong() shl 32) or (slot.toLong() and 0xffffffffL)
private fun decodeSlot(handle: Long): Int = (handle and 0xffffffffL).toInt()
private fun decodeGeneration(handle: Long): Int = (handle ushr 32).toInt()
}回归矩阵要覆盖复用、竞态、异常和诊断链
最小测试不是 create 后调用一次,而是强制发生槽位复用。测试记录 handleA 的 slot 与 generation,关闭后创建 B,确认可能复用相同 slot 但 generation 已变化;随后用 handleA 执行 borrow、close 和异步完成事件,全部必须在解引用前失败,同时 handleB 的对象状态保持不变。若只观察没有崩溃,无法排除旧操作误写新对象。
设备端 instrumented test 用于覆盖真实 Android 运行时、页面生命周期、线程附着和 JNI 异常传播。矩阵至少包括旋转重建、后台恢复、用户快速返回、工作线程与 close 竞争、弱引用消失、Native 异常映射以及进程被系统终止后的恢复。单台设备的结果只证明该候选在该环境和路径中的表现,不能外推完整 API、ABI 与厂商组合。
若出现 Native 崩溃或异常退出,ApplicationExitInfo、tombstone 和符号化产物可以帮助把退出记录关联到候选哈希、架构、时间窗和操作序列。调试符号必须与构建产物对应,公共文章不展示可运行攻击链。诊断证据说明发生了什么,不自动证明修复完成;修复后的新候选要重跑同一复用序列。
| 用例 | 关键操作 | 必须观察 | 不能替代 |
|---|---|---|---|
| 槽位复用 | A 关闭后创建 B | 旧 A 被拒绝且 B 不变 | 只查是否崩溃 |
| 并发关闭 | borrow 与 close 交错 | 无校验后悬空窗口 | 静态锁检查 |
| 迟到回调 | 取消后 Native 完成 | 旧任务不提交结果 | 页面字段清零 |
| 进程重建 | 恢复持久化状态 | 重新 create 新 handle | 复用保存的 long |
| 异常路径 | 构造和回调抛错 | 表项可回收且异常清晰 | 仅正常路径 |
| 候选变化 | 重新构建或处理 SO | 绑定新哈希重跑 | 沿用旧回执 |
发布门禁绑定候选哈希、状态回执和适用限制
发布前先列出所有 Java long 到 Native 对象的入口,标明创建者、关闭者、线程、允许状态和错误终态。每个入口都必须通过同一个句柄表解析,不允许某些 JNI 方法绕过 generation 直接转换地址。代码审查要搜索 reinterpret、指针到整数转换和持久化 handle 的路径,但搜索结果只是风险清单,仍需运行回执。
候选发生编译参数、JNI 签名、异常映射、线程模型、库装载或加固边界变化后,应生成新哈希并重跑关键矩阵。Android NDK 常见问题清单有助于排查符号、API level、运行库和依赖装载,不能替代目标 ABI 上的启动、创建、借用、关闭和异常测试。没有项目证据时,应明确写成待验证,而不是填入性能或兼容数字。
站内关于 JNI 文件描述符所有权的文章可继续核对 handle 内部资源的关闭责任;准备评估商业 App 的 SO 加固范围和跨层生命周期时,可通过页面行动按钮进入御盾中央平台提交候选包、JNI 接口表与复用测试序列。提交只启动评估,是否拒绝陈旧句柄仍以同一候选的设备回执为准。
- Java long 只承载不透明 slot 与 generation。
- 所有 JNI 入口在解引用前校验范围、代际、类型和状态。
- borrow 的保护期覆盖实际对象操作,不留下校验后悬空窗口。
- close 阻止新借用,处理活动任务,再递增 generation。
- 异步回调同时核对对象代际、任务代际和页面代际。
- 进程重建不恢复 handle 数值,只用业务参数重新创建。
- 测试强制槽位复用,并证明旧操作不影响新对象。
- 候选变化后以新哈希重跑设备、异常和退出矩阵。
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| JNI 线程、引用、异常和类加载边界需要显式设计,不能把缓存的环境或引用当作永久资源。 | Android JNI tips 说明 JNI 中线程、引用、异常检查与类加载相关的实践边界。 | 该指南不定义本文的 generation 表实现,也不证明某个候选 SO 已保持 JNI 语义。 |
| 地址转 long 不能提供释放后身份保证,槽位复用需要额外代际判断。 | 工程判断:对象地址和自建槽位都可能在关闭后复用,身份协议必须包含当前分配代次。 | 具体回绕策略、位宽和容量要依据项目创建频率与进程寿命验证。 |
| NDK 兼容排查必须覆盖 API level、符号、运行库、异常和依赖装载。 | Android NDK common problems 列出这些 Native 兼容故障类别及其诊断方向。 | 故障类别不能替代目标候选在各 ABI 和设备上的句柄生命周期回归。 |
| Native 崩溃定位需要与候选、架构和符号产物匹配。 | Debug Android native code 说明 Native 调试与符号化所需的构建和架构条件。 | 能还原堆栈不等于陈旧句柄已修复,也不能据此声称生产兼容通过。 |
| 进程退出原因和 Native 诊断数据可以成为句柄竞态调查的辅助回执。 | Android ApplicationExitInfo 提供退出原因、时间信息和受版本限制的诊断数据入口。 | 退出记录要与同一候选、时间窗和用户路径关联,单条记录不能证明根因。 |
| 依赖 Android 生命周期、线程和系统 API 的语义应在设备端执行验证。 | Android instrumented tests 说明 instrumented test 在 Android 设备环境中运行并可使用框架 API。 | 单一设备或单一路径通过不能代表完整 API、ABI、芯片与厂商矩阵。 |
| 安全发布过程应保留来源、变更、构建与验证证据。 | NIST SP 800-218 SSDF 将安全开发、验证与供应链风险管理纳入组织实践。 | SSDF 不规定某个 App 加固产品的功能,也不替具体 handle 回归矩阵给出结论。 |
| 御盾项目中的具体 stale handle 阻断结果当前不能从通用资料推出。 | 项目证据尚未接入:需要同一候选哈希、复用序列、设备环境和实际状态回执。 | 在这些回执齐备前,只能给出工程门禁,不能写客户、性能、攻击阻断或兼容通过结论。 |
工程常见问题
把指针转成 Java long,再在 close 后置零,为什么还不够?
置零只更新某个 Java 持有者,副本、异步任务或旧页面仍可能保留原值;地址复用后旧值甚至会命中新对象。句柄表要在中心位置校验 slot、generation 和状态。
generation 越大是否就绝对不会回绕?
不能这样保证。必须评估位宽和创建频率,并在达到保留边界时退休槽位、引入进程 epoch 或采用更宽代际,不能让回绕静默恢复旧身份。
borrow 校验通过后能否立即释放锁再使用对象?
只有另有引用计数、租约或共享所有权覆盖整个使用期时才可以。否则 close 可能在解锁后销毁对象,形成典型的校验后悬空窗口。
重复 close 应该直接返回成功吗?
可以为同一当前对象定义幂等终态,但旧 generation、类型错误和跨进程值应明确拒绝。全部吞成成功会掩盖迟到回调和对象串线。
Activity 重建时可以从 savedInstanceState 恢复 handle 吗?
不应恢复。保存可重建的业务参数,在当前进程和桥接实例中重新 create;若进程已重建,旧表根本不存在,原 long 没有可验证身份。
SO 加固后只跑 Java 单元测试能证明句柄安全么?
不能。静态与单元测试可检查编码和状态机,但 JNI 线程、异常、库装载、页面生命周期和退出诊断需要绑定新候选的设备端测试回执。