先看结论与判断条件

  • 任何可能抛出 Java 异常的 JNI 调用后都要立即检查 pending 状态,返回值在检查前不能当作有效业务数据。
  • 异常处理策略只能明确选择原样传播,或捕获、ExceptionClear 后转换;盲目清理会丢失 Java 类型、消息和堆栈。
  • pending exception 存在时不能继续任意 JNI 调用,清理局部引用与继续业务处理要遵循受限契约。
  • Java 异常、JNI 错误码、C++ 异常和进程崩溃是不同通道,跨边界前必须转换为单一、可审计的结果。
  • VMP 候选应位于异常已规范化之后的稳定 Native 决策核心,JNIEnv、jobject、局部引用和桥接查找逻辑通常排除。
  • 发布验证必须覆盖抛出、传播、清理、转换、重复调用、线程附着、进程恢复和符号化,单次成功调用不能放行。

pending exception 会改变后续 JNI 调用的前提

Native 代码通过 CallObjectMethod、CallVoidMethod、NewObject、FindClass、GetMethodID 等 JNI 接口进入 Java 运行时,这些操作可能因为 Java 方法主动抛出、类加载失败、内存不足或签名不匹配而留下 pending exception。此时 C++ 语句仍可能继续执行,JNI 返回值也可能是 null 或零值,但它们不能被解释为普通业务结果。第一责任是检查异常状态,而不是继续解引用或写入状态。

立即处理的原因不是代码风格,而是 JNI 环境已经处于异常路径。pending exception 存在时,Native 端能够安全执行的 JNI 操作受限制;继续查类、调方法、创建对象或读取字段,可能触发 CheckJNI 报告、产生二次故障,或让真正的 Java 异常被后续崩溃掩盖。错误表面最终可能出现在完全不同的地址,使堆栈看起来像随机 Native 问题。

每个边界调用应采用固定节奏:执行一个可能抛出的 JNI 操作,立刻调用 ExceptionCheck 或等价检查,若无异常再验证返回值,若有异常则进入传播或转换分支。不能先执行一批调用后统一检查,因为无法知道第一项失败后哪些返回值已经失效,也无法保证后续调用在 pending 状态下合法。

异常检查点与返回值使用顺序
JNI 操作潜在失败立即检查检查前禁止
FindClass类加载异常或类不存在ExceptionCheck 加 null继续 GetMethodID
GetMethodID签名或方法不存在ExceptionCheck 加 methodID继续 CallMethod
NewObject构造函数异常或分配失败ExceptionCheck 加 jobject保存或解引用对象
CallObjectMethodJava 方法抛出ExceptionCheck 加返回引用读取字段或转换字符串
CallVoidMethodJava 方法抛出ExceptionCheck推进业务成功状态
GetStringUTFChars转换或分配失败ExceptionCheck 加指针读取或释放无效指针

先决定传播还是转换,不能边走边猜

原样传播适合 Java 调用者已经定义异常契约的场景。Native 端检测到 pending exception 后停止普通 JNI 工作,只做必要的本地资源清理,然后直接返回,让 JVM 将原异常交给 Java 层。这样可以保留异常类型、消息、cause 和 Java 堆栈,也避免创建一个语义重复的新异常。调用者必须明确 native 方法可能抛出哪些异常,并在测试中覆盖。

清理并转换适合跨模块只允许结构化错误码的场景。Native 端先用 ExceptionOccurred 捕获 throwable 引用,在允许范围内保存必要分类,再调用 ExceptionClear,随后转换为应用定义的错误对象或状态。清理之后不能假装 Java 调用成功,原返回值必须丢弃。若要保留 cause,应在安全路径构造新异常或交给 Java 辅助方法,且任何再次抛出都要检查。

最危险的写法是发现异常后无条件 ExceptionClear,再继续原业务分支。这会把类加载失败、权限异常、参数错误和业务拒绝全部压成 null 或默认值,既丢失诊断证据,也可能把失败当作允许。另一种错误是同时保留 pending exception 又调用 ThrowNew,造成异常覆盖或未定义的错误路径。每个 wrapper 必须只有一种明确出口。

三种异常出口的契约
策略处理顺序适用条件禁止行为
原样传播检查、清理 Native 资源、返回Java 调用者理解原异常清理异常后伪装传播
清理并转换捕获、分类、清理、构造错误接口只接受规范错误继续使用原返回值
转换为新 Java 异常捕获、清理、ThrowNew、返回公开异常类型稳定抛出后继续 JNI 调用
返回错误码捕获、清理、记录分类、返回Java 层有强制检查契约错误码与成功值冲突
终止当前操作检查、释放本地资源、回退非关键可恢复路径推进部分业务状态
盲目 ExceptionClear无分类直接清理不应采用隐藏根因并继续执行

异常状态下的清理必须保持最小化

异常路径仍然要释放 Native RAII 资源,例如互斥锁、文件描述符和堆内存,但 JNI 清理要遵循 pending 状态限制。局部引用通常会在 native 方法返回时释放,显式删除引用是否必要取决于生命周期和数量。不要为了追求“清理完整”而在异常状态下调用新的 Java 方法、读取更多对象或格式化复杂消息,这些动作可能再次依赖失败的运行时路径。

wrapper 应把需要清理的 Native 资源放在 RAII 对象中,使传播异常时直接 return 也能释放。JNI 局部引用可使用小型作用域包装器,但包装器析构只能执行允许且可预测的引用删除,不应隐藏 FindClass、CallMethod 或日志回调。异常分支越短,越容易证明没有在 pending 状态下继续普通调用。

日志也要克制。ExceptionDescribe 适合受控调试,不应成为生产错误处理的默认动作,因为它可能输出内部类名、消息或业务数据。生产回执保存边界名称、操作类别、错误分类、候选摘要和设备信息即可,不复制完整 throwable 内容到普通日志。若需要详细堆栈,应在受控诊断通道关联同一事件 ID。

Java 异常与 C++ 异常是两条不同通道

Java pending exception 保存在 JNI 环境的运行时状态中,C++ exception 则通过编译器、unwind 信息和运行库传播。Native wrapper 不能用 catch(...) 假定捕获 Java 异常,也不能让未处理的 C++ 异常穿过 JNI 导出函数进入 Java。边界前必须分别处理:JNI 调用后检查 pending,Native 调用用明确的 C++ 异常或错误码契约,最终只向 Java 暴露已声明结果。

Android C++ library support 说明 C++ 异常、RTTI 与 libc++ 链接方式依赖构建系统和运行库选择。即使编译打开异常选项,跨 SO 的 type_info、unwind 和运行库组合仍需核对。本文不展开 C++ 运行库一致性,只强调 Java pending exception 不会因为启用了 C++ exceptions 自动安全传播,两者要在 wrapper 中明确汇合。

推荐的内部结果可以是 Result<Value, NativeError> 或固定错误枚举,包含 category、operation 与 retryable,但不携带 jobject、JNIEnv 指针和局部引用。JNI 外层把 Java 异常转换为该结果,或选择原样传播;Native 核心只处理纯数据。这样单元测试可以脱离 JVM 覆盖决策,设备测试则专门验证 wrapper、类加载、线程和异常映射。

四类失败通道的责任边界
通道表示形式边界动作诊断证据
Java 异常pending throwable传播或清理后转换Java 类型与边界分类
JNI 查找失败null 加 pending 状态立即检查并停止类、方法和签名标识
C++ 异常throw 与 unwind在 JNI 导出边界捕获Native 类型与构建身份
Native 错误码Result 或枚举显式映射给 Java操作和错误类别
进程信号tombstone 与地址崩溃诊断流程ABI、符号与候选
业务拒绝服务端或状态机结果保持拒绝语义主体、对象和原因码

SO/VMP 边界应放在异常规范化之后

JNI 导出函数、类与方法查找、jobject 解包、局部引用管理和 ExceptionCheck 分支属于桥接层。它们与 Android 运行时、类加载器、方法签名和线程附着高度耦合,发生问题时需要可读堆栈和精确诊断,通常不宜粗放进入 SO/VMP。保护这些胶水不会让异常契约更可信,反而可能改变控制流布局并增加排查难度。

更适合保护的是异常已经转换为纯数据之后的 Native 决策核心,例如许可证状态组合、风险评分、离线计数和商业规则。核心函数不接收 JNIEnv、jobject、jclass 或局部引用,不直接调用 Java,不负责线程附着,也不自行清理 pending exception。它的输入输出可以在主机单元测试和设备测试中复用,保护前后容易比较。

若业务要求保护整个 native 方法,应先用 wrapper 把 JNI 操作集中到薄边界,再让 protectedCore 接受复制后的值。任何 wrapper 变化都要重新验证异常检查顺序、返回值失效、局部引用和 Java 观察到的异常类型。没有真实候选证据时,只能给出方法候选和测试计划,不能声称变换保持 ABI 或异常语义。

JNI 方法内部的 VMP 选择矩阵
对象耦合VMP 建议验证重点
JNI 导出入口签名、注册和运行时通常排除符号与调用约定
FindClass 与 GetMethodID类加载器和签名排除查找失败与 pending
CallMethod 包装器Java 异常和引用通常排除立即检查与传播
线程附着JavaVM 与线程生命周期排除attach、detach 和退出
纯数据校验器低运行时耦合敏感时可保护边界值与错误枚举
Native 决策核心业务状态与规则稳定敏感逻辑可保护保护前后语义一致

常见错误会把第一现场推迟成崩溃

Android NDK common problems 汇总的故障包括 API level、缺失符号、STL、异常类型和依赖装载等问题。它们可能在 JNI 边界表现为类或方法查找失败、Native 装载失败或异常类型错配。故障清单只能帮助分类,不能代替目标 ABI 的真实启动与异常回归;尤其不能看到一个 null 就立即归因于 SO/VMP。

错误代码常把 CallObjectMethod 返回 null 当作合法“没有数据”,随后继续 GetObjectClass 或 GetStringUTFChars。若 null 实际伴随 pending exception,后续崩溃会遮住最初 Java 原因。另一个模式是先 ExceptionCheck,再记录日志时调用 Java logger,导致异常状态下继续普通 JNI 调用。日志应使用不依赖 Java 的最小 Native 通道,或在清理并分类后交给上层。

静态审查应搜索所有可能抛出异常的 JNI 调用,要求相邻检查和明确出口;动态回归则让 Java helper 主动抛出不同异常,确认 Native 不使用返回值、不推进状态并保持预期类型。两者都通过才能说明当前候选覆盖了已知契约,但仍不代表所有系统异常、内存压力和设备实现都已验证。

用事件序列检查器审计 wrapper 异常契约

下面的 Python 脚本读取 JNI wrapper 在测试模式输出的结构化事件序列。每个可能抛出异常的 jni-call 后必须紧跟 exception-check;pending=false 才能继续普通调用,pending=true 时只能 capture、describe、clear 或 propagate。若选择 clear,下一步必须 translate-error,随后只能清理 Native 资源并 return-error。事件只记录操作类别和错误分类,不保存 Java 对象、消息或用户数据。

状态机拒绝未检查的连续调用、异常存在时的普通 JNI 操作、清理后继续成功路径、转换后再次调用 Java 和缺少终止出口。这样可以把 wrapper 的控制流约束变成 CI 可读取的回执。实际 Native 包装器仍需在每次 JNI 调用后生成对应事件,并保证埋点本身不调用 Java 或改变异常状态。生产构建可以关闭详细事件,只保留同候选测试回执。

脚本通过不证明 C++ 源码所有路径都被执行,也不证明事件未被漏报。项目应结合代码审查、分支覆盖和主动抛出测试验证埋点完整性。它也不会检查 JNI 允许操作的完整规范列表,而是采用更严格的项目契约:异常出现后只允许最短传播或清理转换路径。严格子集更易审计,但需要在设计时确认不会遗漏必要清理。

审计 JNI pending exception 的事件序列
from pathlib import Path
import json
import sys

if len(sys.argv) != 2:
    raise SystemExit(2)
trace_path = Path(sys.argv[1])
if not trace_path.is_file():
    raise SystemExit(2)
try:
    events = json.loads(trace_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
    raise SystemExit(2)
if not isinstance(events, list) or not events:
    raise SystemExit(2)
allowed_pending = {"exception-capture", "exception-describe", "exception-clear", "propagate", "native-cleanup"}
allowed_translated = {"native-cleanup", "return-error"}
state = "ready"
issues = []
terminal = False
for index, event in enumerate(events):
    if not isinstance(event, dict) or not isinstance(event.get("op"), str):
        raise SystemExit(2)
    op = event["op"]
    if terminal:
        issues.append({"index": index, "issue": "event-after-terminal"})
        continue
    if state == "need-check":
        if op != "exception-check" or not isinstance(event.get("pending"), bool):
            issues.append({"index": index, "issue": "jni-call-not-immediately-checked"})
            state = "invalid"
        elif event["pending"]:
            state = "pending"
        else:
            state = "ready"
        continue
    if state == "pending":
        if op not in allowed_pending:
            issues.append({"index": index, "issue": "ordinary-operation-while-exception-pending"})
            state = "invalid"
        elif op == "exception-clear":
            state = "cleared"
        elif op == "propagate":
            terminal = True
        continue
    if state == "cleared":
        if op != "translate-error" or not isinstance(event.get("category"), str) or not event["category"]:
            issues.append({"index": index, "issue": "cleared-exception-not-translated"})
            state = "invalid"
        else:
            state = "translated"
        continue
    if state == "translated":
        if op not in allowed_translated:
            issues.append({"index": index, "issue": "jni-operation-after-error-translation"})
            state = "invalid"
        elif op == "return-error":
            terminal = True
        continue
    if state == "ready" and op == "jni-call":
        state = "need-check"
    elif state == "ready" and op not in {"native-work", "native-cleanup", "return-success"}:
        issues.append({"index": index, "issue": "unexpected-ready-operation"})
    elif state == "ready" and op == "return-success":
        terminal = True
if state in {"need-check", "pending", "cleared", "translated"} and not terminal:
    issues.append({"index": len(events), "issue": "unterminated-exception-contract"})
print(json.dumps({"eventCount": len(events), "terminal": terminal, "issues": issues}, ensure_ascii=False, indent=2))
if issues or not terminal:
    raise SystemExit(3)

设备回归要主动制造每条异常路径

Android instrumented tests 适合验证依赖真实 Android 运行时、组件和系统 API 的语义。测试应用可以提供 Java helper,分别在构造函数、实例方法、静态方法和字符串转换前后主动抛出异常,Native wrapper 调用后检查 Java 层观察到的类型、错误码和业务状态。单元测试只能覆盖纯 Native 结果,不能替代 JVM pending 状态。

矩阵还应覆盖方法不存在、类加载器不同、null 参数、线程未附着、重复调用、局部引用压力和进程恢复。不同 API、ABI 与厂商系统选择代表设备,记录候选摘要、SO build ID、异常入口和结果。一个设备通过只能支持该组合,不能直接推广到完整矩阵;blocked 用例也不应停止其他独立路径继续取证。

保护前后比较必须使用同一输入和异常触发器。确认 pending 检查点没有移动,原样传播仍保留异常类型,转换路径仍返回同一 category,业务状态没有部分提交,Native 清理没有泄漏或二次调用。若 SO/VMP 改变控制流或符号布局,回归报告要绑定变换后的最终 SO,不得用未保护库的结果代替。

JNI 异常契约的设备测试矩阵
用例触发位置核心断言失败后定位
Java 方法主动抛出CallMethod 内部立即检查并保持出口wrapper 检查顺序
类或方法不存在FindClass 或 GetMethodIDnull 不进入后续调用签名与类加载器
清理并转换pending=true 分支clear 后只返回规范错误异常分类映射
原样传播Java 契约异常原类型到达调用者清理与返回路径
线程附着异常工作线程调用JNIEnv 不跨线程复用attach 与 detach
保护后回归最终 SO 候选结果、异常和状态一致候选与符号身份

崩溃诊断要绑定同一构建的符号

若异常契约失效最终导致 Native 崩溃,ndk-stack 可使用未剥离符号目录与同一构建的地址信息还原堆栈。地址、ABI、build ID、最终 SO 摘要和符号包摘要必须对应,重新构建的近似符号不能替代。符号化成功只把地址转换为函数与行号,不证明第一处 pending exception 已被定位。

Debug Android native code 说明 Native 调试和 tombstone 还原依赖正确符号、架构与构建产物。调试回执应关联 Java 异常日志、wrapper 事件 ID、tombstone、设备和候选,按时间寻找第一项异常检查缺口。公开文章和普通日志不应包含可运行攻击链、私有地址、完整用户数据或敏感符号包。

准备接入评估时,可在御盾中央平台提交最终 APK、SO 摘要与 build ID、未剥离符号回执、JNI 接口清单、异常策略、VMP 方法清单和设备矩阵。登录、注册、申请、价格与控制台动作统一由中央平台承接。没有同候选的真实异常回归和符号证据时,应保持待验证,不写兼容通过、性能收益或攻击阻断结论。

事实依据与适用边界

以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。

本文判断事实或工程依据适用限制
JNI 注册、线程、引用、异常和类加载边界会直接影响 Native 桥接稳定性。Android JNI tips 描述 JNI 线程、引用、异常检查与类加载等实践。JNI 建议不证明某个 SO/VMP 变换保持 ABI、异常语义或设备兼容。
NDK 兼容故障常涉及 API level、缺失符号、STL、异常类型和依赖装载。Android NDK common problems 汇总常见 NDK 构建与运行故障类别。故障清单只能帮助分类,不能替代目标 ABI 上的真实启动和异常回归。
C++ 异常、RTTI 与 libc++ 链接方式取决于构建系统和运行库选择。Android C++ library support 描述异常、RTTI 与 C++ 运行库配置。启用编译选项不证明跨 SO 的 type_info、unwind 和异常传播完整。
依赖真实 Android 运行时与系统 API 的 JNI 异常语义适合在设备端验证。Android instrumented tests 描述在 Android 设备环境执行测试的用途。单一设备通过不能代表完整 API、ABI、厂商和线程矩阵。
Native 地址还原需要未剥离符号目录与同一构建的地址信息。ndk-stack 描述使用符号目录和崩溃输出还原 Native 堆栈。符号化成功不证明第一现场或根因已经定位。
Native 调试与 tombstone 分析依赖正确符号、架构和构建产物。Debug Android native code 描述 Native 调试、符号与设备诊断入口。调试能力不构成生产兼容通过,也不应扩展为公开攻击复现链。
pending exception 出现后必须停止普通 JNI 工作并选择传播或清理转换。工程判断:继续使用失效返回值和推进状态会掩盖原始 Java 异常并制造二次故障。允许的最小清理操作与异常类型仍需依据实际 wrapper 和 JNI 规范逐项核对。
SO/VMP 应优先保护异常规范化后的纯 Native 决策核心。工程判断:JNIEnv、局部引用、查找、线程和异常检查胶水耦合运行时且需要高可诊断性。是否适合保护以及保护后语义是否一致,需要真实候选与设备证据确认。

工程常见问题

JNI 调用返回 null 时为什么不能直接当作没有数据?

null 可能同时伴随 pending exception。必须先 ExceptionCheck,再根据无异常的 null 契约处理;否则后续 JNI 调用或解引用会遮住真正的 Java 异常。

发现 pending exception 后可以直接 ExceptionClear 吗?

不能无条件清理。应先决定原样传播还是捕获后转换,保存必要分类并丢弃原返回值。盲目清理会丢失异常类型、消息和堆栈,还可能把失败当作成功。

C++ catch 能否捕获 Java pending exception?

不能把两者视为同一通道。Java 异常保存在 JNI 环境状态,C++ exception 依赖 unwind 和运行库。wrapper 必须分别处理并在 JNI 导出边界汇合。

JNI wrapper 是否适合整体做 SO/VMP?

通常不宜粗放保护。类与方法查找、引用、线程和 ExceptionCheck 需要高可诊断性。先提取纯数据决策核心,再对稳定敏感函数评估保护。

代码审查确认每处都调用 ExceptionCheck 后还要真机测试吗?

需要。静态审查无法证明所有分支被执行,也不能覆盖类加载器、线程附着、运行时异常类型和保护后控制流。设备测试应主动抛出并核对每条出口。

申请 JNI 异常契约评估需要准备什么?

准备最终 APK、SO 摘要与 build ID、JNI 接口和调用清单、异常传播与转换规则、VMP 方法清单、符号回执和设备测试矩阵,再通过御盾中央平台提交。

想用自己的 App 验证?

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

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