先看结论与判断条件

  • 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 相等且状态允许时,访问才进入对象逻辑;这条判断必须发生在任何解引用之前。

裸指针与 generation handle 的故障差异
场景裸指针 longslot 加 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返回已关闭清空并允许复用
retiredgeneration 回绕后不再分配

异步回调要携带 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 指针转换,因此也不会成为地址操作或绕过脚本。

generation handle 表的生命周期校验
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 线程、异常、库装载、页面生命周期和退出诊断需要绑定新候选的设备端测试回执。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: SO 加固与 Native 兼容性检查清单