先看结论与判断条件

  • JavaVM 可作为进程级入口保存,JNIEnv 与当前线程绑定,不能从一个线程复制到另一个线程或长期当作全局句柄使用。
  • Java 创建并进入 Native 的线程通常已连接,Native 代码应先 GetEnv 并复用当前 JNIEnv,不能无条件 attach 后再 detach。
  • Native 创建的线程在首次 JNI 调用前需要连接;只有 attach 由当前线程责任域成功执行时,退出前才由同一责任域 detach。
  • 线程池要按 worker 生命周期管理连接,而不是每个任务盲目 attach/detach;取消、异常和早退必须汇聚到可证明的清理路径。
  • 待处理 JNI 异常不能被 detach 当作清理手段,Native 异常也不应越过 JNI 边界;错误转换与 detach 是两项独立责任。
  • 事件配对通过只能证明观测到的连接状态机完整,仍需逐 ABI 设备验证真实回调、并发、类加载和进程退出。

先确定谁创建线程,再确定谁负责连接和断开

JNI attach/detach 最常见的错误不是少写一行 API,而是没有定义所有权。Java 线程进入 Native 方法时,与 Native 代码自行创建的 pthread 或 std::thread 起点不同。前者已经处于虚拟机管理的线程上下文,后者必须先取得当前连接状态,才能安全调用 JNI。

契约应把线程分为 java-owned、native-owned 和 external-attached。java-owned 的生命周期由 Java 或运行时管理,Native 层不拥有 detach;native-owned 若由当前责任域 attach 成功,则线程退出前必须 detach;external-attached 表示线程早已由其他框架连接,当前模块只能借用,不能擅自断开。

验收记录至少包含 threadRunId、creator、processId、nativeThreadId、GetEnv 结果、attachAttempt、attachResult、ownedAttach、jniCallCount、pendingExceptionState、detachResult 和 exitReason。系统线程 ID 可能复用,不能单独作为长期身份,因此需要每次线程生命期唯一的 runId。

线程来源与 detach 责任
线程来源进入 Native 时状态当前模块动作退出责任
Java 调用线程通常已连接GetEnv 后借用 JNIEnv当前模块不 detach
Native 新建线程初始可能未连接必要时 attach若本域 attach 则 detach
Native 线程池 worker取决于 worker 生命周期worker 入口建立状态worker 退出统一清理
第三方回调线程状态不可假设GetEnv 并记录所有者只释放本域拥有连接
外部已连接线程已由其他模块 attach借用当前环境不得替其他模块 detach

JavaVM 可以共享,JNIEnv 必须留在线程内

Android JNI tips 说明 JNI 的线程、引用、异常与类加载边界直接影响桥接稳定性。JavaVM 是进入虚拟机线程接口的进程级对象,适合在初始化时保存;JNIEnv 则表示当前线程的 JNI 调用环境。把某线程的 JNIEnv 放进全局变量并在其他线程使用,会破坏线程归属。

正确入口不是“如果有全局 env 就调用”,而是每次在线程责任域中通过 JavaVM 获取当前状态。GetEnv 返回已连接时,使用本线程返回的 JNIEnv;返回未连接时,由明确的 native-owned 路径 attach;其他错误立即失败。是否 detach 由 attachedByThisScope 决定,而不是由 env 是否非空决定。

线程内缓存也要限制生命期。worker 可以在其整个生命期保持连接,并在退出清理;任务对象不应把 JNIEnv 传给下一个 worker。异步回调需要传递稳定业务句柄,再由执行回调的线程取得自己的 JNIEnv。本文不展开局部和全局引用管理,但引用生命期仍需单独契约。

JavaVM、JNIEnv 与业务句柄的边界
对象可见范围允许保存禁止用法
JavaVM进程级接口受控全局或服务对象跨进程复用
JNIEnv当前线程线程栈或 worker 状态跨线程缓存
threadRunId单次线程生命期事件和日志只用可复用系统 ID
业务句柄由契约定义跨任务传递夹带线程专属 env
ownedAttach当前责任域清理守卫由 env 非空推断

GetEnv、attach、JNI 调用和 detach 组成状态机

一个可审计状态机从 thread-start 开始。先记录 get-env-attached 或 get-env-detached;只有 detached 状态可以进入 attach-ok;attached 状态才能执行 jni-call。若 attach 失败,线程必须走失败策略,不得继续使用未初始化的 JNIEnv,也不能伪造 detach 成功。

detach-ok 的前置条件更严格:线程当前已连接、连接由本责任域拥有、没有尚未处理的 JNI 异常,并且不会再执行 JNI。java-owned 或 external-attached 线程即使处于 attached,也不能由当前模块 detach。重复 attach 和重复 detach 都应被状态机阻断。

thread-exit 是最终门禁。native-owned 且 ownedAttach 为真时,必须先完成 detach;借用连接的线程退出当前 Native 调用时只结束本作用域,不改变虚拟机连接。工程判断是将所有 return、取消、错误和 C++ catch 汇聚到同一守卫,减少遗漏分支。

JNI 线程连接状态转换
当前状态事件允许结果阻断条件
startedGetEnvattached 或 detached未判断就 JNI 调用
detachedattachattachedOwnedattach 失败后继续
attachedBorrowedjni-call保持借用当前模块 detach
attachedOwnedjni-call保持拥有未处理异常继续调用
attachedOwneddetachdetached后续再次 JNI 调用

线程池、取消和早退要按 worker 生命周期清理

线程池中的 task 与 worker 不是同一生命周期。若每个任务进入时无条件 attach、结束时无条件 detach,后一个任务可能运行在被错误断开的 worker 上;若只 attach 不 detach,worker 销毁时又留下不完整清理。连接策略应绑定 worker 创建与退出,而非业务任务次数。

取消路径最容易漏配。任务可能在等待队列、JNI 调用前、Java 回调后或异常处理中被取消。每个路径都要保持状态机可终结:尚未 attach 就不 detach,已由本域 attach 则在 worker 真正退出时 detach,外部已连接则不改变连接。取消信号不能绕过 pending exception 处理。

工程上可使用 RAII、pthread TLS destructor 或线程池统一 teardown,但机制必须和实际线程模型匹配。若 worker 永久驻留,detach 发生在线程终止而不是任务结束;若每次创建短线程,守卫覆盖整个入口。只看源代码中出现 attach 与 detach 两个词,不能证明所有退出路径配对。

线程生命周期与连接策略
模型attach 时点detach 时点主要风险
短生命周期线程线程入口首次 JNI 前线程最终退出前早退遗漏
固定 worker 池worker 启动或首次需要时worker teardown按任务重复断开
Java executor 回调通常只 GetEnv当前模块不执行误判为 native-owned
第三方回调线程先检查当前状态按所有权决定未知框架已连接
取消中的 worker不改变既有策略真实退出时统一清理取消分支绕过守卫

JNI 异常、C++ 异常和 detach 是三个独立问题

JNI 调用可能留下待处理 Java 异常。在异常仍 pending 时继续执行多数 JNI 操作会产生新的错误或掩盖原始原因。线程契约应记录 exception-pending 与 exception-cleared 或 translated,确保进入下一次 jni-call、detach 或 thread-exit 前已经按项目策略处理。

Android C++ library support 说明 C++ 异常、RTTI 与 libc++ 链接方式由构建系统和运行库选择决定。Native 线程入口应捕获允许的 C++ 异常并转换为稳定错误,不让异常越过 C ABI 或 JNI 边界。catch 执行后仍要遵守 attachedByThisScope 的清理责任。

detach 不是异常清理 API,也不能修复错误的运行库或 unwind。若清理路径本身抛出、阻塞或访问已销毁状态,线程可能在退出前再次失败。工程判断是让 teardown 尽量无抛出、可重复检查,并把 Java 异常状态、Native 错误和 detach 结果分别记录。

异常与清理责任分层
问题责任动作失败信号不能替代
JNI pending exception检查、描述或转换并清理后续 JNI 异常detach
C++ exception在线程边界捕获转换跨边界终止Java 异常处理
attach 失败停止 JNI 路径env 未建立仍调用伪造空环境
detach 失败记录并阻断验收退出责任不完整忽略返回值
teardown 异常无抛出清理与诊断二次故障掩盖原错只保留最终堆栈

调试材料必须绑定架构、符号和精确候选

Android NDK common problems 将 API level、缺失符号、STL、异常类型和错误依赖装载列为常见故障。attach/detach 表象也可能来自更早的 SO 加载、运行库或符号问题。例如线程尚未进入 JNI 守卫就因依赖失败退出,不能写成 detach 漏配。

Debug Android native code 说明 Native 调试和 tombstone 还原依赖正确符号、架构与构建产物。每条崩溃证据要绑定 APK SHA-256、SO 摘要、ABI、Build ID、符号包和线程 runId。使用另一构建的符号化结果会把调用点映射到错误位置。

公开报告应保留错误分类、候选身份和修复边界,不提供攻击复现链、内部地址或敏感 tombstone。调试器能停在线程退出处,不代表线上所有时序已覆盖;动态观察必须和自动化事件台账、重复运行和设备矩阵结合。

Native 线程故障所需身份
材料绑定字段用途错配后果
APK文件 SHA-256锁定交付候选旧回执误用
SO摘要、ABI、Build ID定位实际二进制错误符号化
符号包同 Build ID还原 Native 堆栈位置和函数错误
线程事件threadRunId 与 sequence重建状态机线程 ID 复用混淆
设备环境系统、ABI 和进程限定复现范围结论外推

逐 ABI 设备测试覆盖回调、并发和进程退出

Android instrumented tests 在真实 Android 环境执行,可访问组件和系统 API。JNI 线程回归应由设备测试触发 Java 线程回调、Native 短线程、固定 worker、第三方线程入口、取消、异常和进程重建,并通过受控事件接口读取状态机结果。

Android ABIs 说明各 ABI 具有独立调用约定、寄存器、对齐和设备支持范围。一个 arm64 设备上的 attach/detach 配对不能证明其他 ABI 的函数边界、运行库和 unwind 相同。每个承诺支持的 ABI 都应绑定实际打包 SO 与代表设备结果。

单一设备通过不能代表完整 API、ABI 和厂商矩阵。先用稳定设备复现状态机,再扩展到最低支持系统、不同 ABI、厂商和负载条件。通过门禁要求事件完整、无未处理异常、所有权正确和业务结果一致,但不把它写成加固保护强度结论。

  • Java-owned 和 native-owned 线程分别测试
  • GetEnv 的 attached 与 detached 分支都有覆盖
  • 短线程与固定 worker 生命周期分别验证
  • 早退、取消和异常均进入清理守卫
  • pending JNI 异常在后续调用前处理
  • 逐 ABI 绑定实际 SO 和设备回执
  • 单设备通过不外推完整矩阵

用事件状态机检查 attach 与 detach 所有权配对

下面的 Python 示例读取 Native 线程事件台账和期望候选 SHA-256。每条事件包含 threadRunId、creator、sequence、event 和 candidateSha256。校验器按线程重放 get-env、attach、jni-call、异常、detach 与 thread-exit,检查连接状态和 ownedAttach。

java creator 必须先出现 get-env-attached,native creator 可从 get-env-detached 后 attach,也可借用外部已连接状态。只有 attach-ok 建立 ownedAttach 后才允许 detach-ok;在未连接或存在 pending exception 时执行 jni-call、detach 或退出都会被阻断。脚本不调用设备命令,也不解析敏感内存。

准备 JNI 线程连接评估时,可整理最终 APK、逐 ABI SO、JavaVM 保存点、线程创建者、事件台账、异常策略、符号材料和设备矩阵,再通过御盾中央平台提交申请。事件配对通过只证明观测状态机满足契约,不证明所有隐藏路径或加固兼容完整。

  • 线程事件绑定精确候选摘要
  • threadRunId 在单次生命期唯一
  • creator 在同一线程事件中一致
  • 只有本域 attach 才拥有 detach
  • pending exception 阻止后续调用和退出
  • thread-exit 前 ownedAttach 已清除
  • 状态机失败返回非零状态
重放线程事件并校验 attach/detach 配对和所有权
from pathlib import Path
import json
import re
import sys

if len(sys.argv) != 3:
    raise SystemExit(2)
events_path = Path(sys.argv[1])
expected_candidate = sys.argv[2].lower()
if not events_path.is_file() or not re.fullmatch(r"[0-9a-f]{64}", expected_candidate):
    raise SystemExit(2)
events = json.loads(events_path.read_text(encoding="utf-8"))
if not isinstance(events, list) or not events:
    raise SystemExit(2)
allowed_creators = {"java", "native", "external"}
allowed_events = {"thread-start", "get-env-attached", "get-env-detached", "attach-ok", "jni-call", "jni-exception-pending", "jni-exception-cleared", "detach-ok", "thread-exit"}
groups = {}
for row in events:
    run_id = str(row.get("threadRunId", ""))
    creator = row.get("creator")
    event = row.get("event")
    sequence = row.get("sequence")
    if not run_id or creator not in allowed_creators or event not in allowed_events or not isinstance(sequence, int):
        raise SystemExit(2)
    if str(row.get("candidateSha256", "")).lower() != expected_candidate:
        raise SystemExit(2)
    groups.setdefault(run_id, []).append(row)
violations = []
reports = []
for run_id, rows in sorted(groups.items()):
    rows.sort(key=lambda row: row["sequence"])
    if len({row["sequence"] for row in rows}) != len(rows):
        raise SystemExit(2)
    creator = rows[0]["creator"]
    if any(row["creator"] != creator for row in rows) or rows[0]["event"] != "thread-start":
        raise SystemExit(2)
    attached = False
    owned_attach = False
    pending_exception = False
    attach_count = 0
    detach_count = 0
    jni_calls = 0
    exited = False
    for row in rows[1:]:
        event = row["event"]
        if exited:
            violations.append({"threadRunId": run_id, "reason": "event-after-exit"})
            continue
        if event == "get-env-attached":
            if attached:
                violations.append({"threadRunId": run_id, "reason": "duplicate-attached-state"})
            attached = True
        elif event == "get-env-detached":
            if attached or creator == "java":
                violations.append({"threadRunId": run_id, "reason": "invalid-detached-state"})
        elif event == "attach-ok":
            if attached:
                violations.append({"threadRunId": run_id, "reason": "attach-while-attached"})
            else:
                attached = True
                owned_attach = True
                attach_count += 1
        elif event == "jni-call":
            if not attached or pending_exception:
                violations.append({"threadRunId": run_id, "reason": "jni-call-invalid-state"})
            jni_calls += 1
        elif event == "jni-exception-pending":
            if not attached or pending_exception:
                violations.append({"threadRunId": run_id, "reason": "invalid-exception-pending"})
            pending_exception = True
        elif event == "jni-exception-cleared":
            if not pending_exception:
                violations.append({"threadRunId": run_id, "reason": "clear-without-pending"})
            pending_exception = False
        elif event == "detach-ok":
            if not attached or not owned_attach or pending_exception:
                violations.append({"threadRunId": run_id, "reason": "detach-without-ownership"})
            else:
                attached = False
                owned_attach = False
                detach_count += 1
        elif event == "thread-exit":
            if pending_exception or owned_attach:
                violations.append({"threadRunId": run_id, "reason": "unclean-thread-exit"})
            exited = True
    if not exited:
        violations.append({"threadRunId": run_id, "reason": "missing-thread-exit"})
    reports.append({"threadRunId": run_id, "creator": creator, "attachCount": attach_count, "detachCount": detach_count, "jniCalls": jni_calls})
result = {"status": "pass" if not violations else "blocked", "candidateSha256": expected_candidate, "threads": reports, "violations": violations}
print(json.dumps(result, ensure_ascii=False, indent=2))
if violations:
    raise SystemExit(3)

事实依据与适用边界

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

本文判断事实或工程依据适用限制
JNI 的线程、引用、异常与类加载边界会直接影响 Android Native 桥接稳定性。Android JNI tips 描述 JavaVM、JNIEnv、线程连接、异常和相关 JNI 实践。JNI 建议不能证明某个加固 SO 保持 ABI、线程时序或全部桥接语义。
C++ 异常、RTTI 与 libc++ 链接方式取决于构建系统和运行库选择。Android C++ library support 描述 libc++、异常和 RTTI 配置。启用编译选项不证明跨 SO 的 exception type_info、unwind 和线程清理完整。
NDK 兼容故障常涉及 API level、缺失符号、STL、异常类型和依赖装载。Android NDK common problems 汇总常见 Native 构建与运行故障。故障列表不能替代目标 ABI 的线程、启动、异常和退出回归。
Native 调试与 tombstone 还原需要匹配的符号、架构和构建产物。Debug Android native code 描述 Native 调试与符号化所需材料。调试能力不能作为生产兼容通过,也不应扩展为公开攻击复现链。
依赖真实 Android 运行时、组件和系统 API 的 JNI 语义可通过设备端测试验证。Android instrumented tests 说明 instrumented test 在真实 Android 环境执行。单一设备通过不能代表完整 API、ABI、系统版本和厂商矩阵。
每个 Android ABI 具有独立调用约定、寄存器、对齐和设备支持范围。Android ABIs 描述 NDK 支持 ABI 的平台约定和特征。声明支持某 ABI 不代表对应 SO 已构建、打包并完成线程回归。
只有当前线程责任域成功 attach 时,才拥有对应 detach 责任。工程判断:所有权标记可以区分本域建立的连接与 Java 或第三方已建立的连接。所有权契约需要真实事件证明,不能由 JNIEnv 非空或代码关键词出现推断。
线程池应按 worker 生命周期管理 JNI 连接,而不是按任务无条件连接和断开。工程判断:task 与 worker 生命周期不同,按任务 detach 可能破坏后续任务的线程状态。具体策略取决于线程池实现、回调来源和退出模型,仍需项目设备证据。

工程常见问题

可以把一个线程获得的 JNIEnv 保存给其他线程使用吗?

不可以。JNIEnv 与当前线程绑定,应保存 JavaVM,并让每个执行 JNI 的线程通过 GetEnv 获取自己的环境。

每次调用 JNI 前都执行 AttachCurrentThread 更安全吗?

不安全。应先 GetEnv;线程已连接时直接借用,只有未连接且由当前责任域 attach 成功时才记录 detach 责任。

Java 创建的线程返回 Native 方法前需要手工 detach 吗?

当前 Native 模块不应断开它没有建立的连接。Java 或运行时管理的线程只借用当前 JNIEnv,detach 所有权不属于该调用。

线程池应该每个任务 attach 和 detach 吗?

通常应按 worker 生命周期设计。任务和线程不是同一对象,无条件按任务断开可能让同一 worker 的后续任务进入错误状态。

DetachCurrentThread 能清除待处理 JNI 异常吗?

不能把 detach 当作异常处理。应先按项目策略检查、转换或清理 pending exception,再完成后续调用和线程清理。

申请 JNI 线程连接评估前需要哪些材料?

准备最终 APK、逐 ABI SO、JavaVM 保存点、线程创建者、状态事件、异常策略、符号材料和设备矩阵,再从御盾中央平台提交申请。

想用自己的 App 验证?

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

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