先看结论与判断条件
- 信号处理器安装是进程级共享状态,后安装组件可能覆盖业务库、诊断 SDK 或运行时先前登记的 action。
- 链式转交不能只保存函数地址,还要保留 sigaction 的 handler 形态、mask、flags 与 SIG_DFL、SIG_IGN 语义。
- 处理器只执行预分配、无锁、异步信号安全的最小操作,格式化日志、堆分配、JNI 和普通锁都移到信号上下文外。
- 重入保护必须区分首次进入和同线程或跨线程再次触发,递归时直接进入预先定义的最小终态。
- 诊断采集、业务恢复和进程终止是不同目标;致命同步信号通常不能被当作普通异常后安全继续。
- 加固、SDK 升级、装载顺序或链接变化后,要重新核对实际安装链、同候选符号和受控故障回执。
一个信号只有一个当前 action,所有组件共享
Native 库调用 sigaction 安装处理器时,修改的是进程对该信号的当前处置,而不是为自己增加一个独立监听器。后安装的组件如果没有保存旧 action,就会让之前的诊断、运行时或业务处理器失去入口。问题常在装载顺序变化后出现:调试包正常,发布包因加固、懒加载或 SDK 初始化时机不同而只剩最后一个处理器。
治理第一步是建立 signal registry。每个组件声明 ownerId、信号集合、installPhase、expectedPreviousOwner、chainPolicy 和 uninstallPolicy。registry 是发布期契约,不依赖运行日志猜顺序;实际安装后再写入 receipt,记录观察到的前任 action 摘要和当前 action 摘要。声明与观察不一致时阻止完成。
系统默认、忽略和自定义 handler 是不同前任类型。SIG_DFL 不能当作空指针跳过,SIG_IGN 也不能被无条件改成默认终止。自定义 action 还可能使用 sa_handler 或 sa_sigaction 两种入口。链式代码必须根据 flags 选择正确签名,不可把三参数函数按一参数调用。
| 角色 | 安装目的 | 前任处理 | 退出责任 |
|---|---|---|---|
| 运行时或系统 | 平台运行与默认处置 | 由后续组件识别 | 按平台语义 |
| 业务 Native 库 | 受控状态或诊断 | 保存并按策略转交 | 不得假设可恢复 |
| 诊断 SDK | 最小崩溃记录 | 链到前任或默认 | 采集后交还终态 |
| 加固运行层 | 完整性或诊断 | 不能静默覆盖 | 声明所有权边界 |
| 测试探针 | 验证安装链 | 仅测试候选启用 | 测试结束恢复 |
保存完整 sigaction,而不是一个旧函数指针
完整前任信息包括入口联合体、sa_mask 和 sa_flags。mask 决定处理期间额外阻塞哪些信号,flags 影响三参数入口、自动重置、重启系统调用和替代信号栈等行为。只保存 oldHandler 地址,再用当前组件自己的 mask 与 flags 调用,会改变前任原有语义,产生重复进入或参数不匹配。
安装过程应先构造不可变新 action,再用 sigaction 原子取得旧 action。旧 action 保存到预分配表,表项在处理器可见前已经完整发布。不能在信号处理器第一次执行时才分配链节点或查全局容器;故障可能发生在初始化中途,半构造链比没有诊断更难排查。
卸载同样需要所有权校验。只有当前 action 仍然属于本组件时,才可恢复安装时保存的前任;若另一个组件已经接管,盲目恢复会覆盖新处理器。移动 App 中 Native 库通常随进程常驻,减少运行时卸载能缩小竞态,但测试和动态模块仍需显式卸载契约。
| 字段 | 作用 | 链式要求 | 遗漏后果 |
|---|---|---|---|
| sa_handler | 单参数入口 | 按 handler 形态调用 | 签名不匹配 |
| sa_sigaction | 三参数入口 | 仅对应 flag 启用时调用 | 上下文丢失 |
| sa_mask | 处理期间阻塞集 | 保留前任语义 | 递归或顺序漂移 |
| sa_flags | 行为选择 | 逐位审计与保存 | 重置或重启变化 |
| previousKind | 默认、忽略、自定义 | 分别决策 | 错误终态 |
| ownerGeneration | 安装代际 | 卸载前核对 | 覆盖后来处理器 |
处理器内部只做最小异步信号安全工作
信号可能打断任意线程在任意函数中的执行。被打断代码也许正持有 malloc、日志、JNI、C++ 运行库或业务锁;处理器再次调用这些设施会死锁、破坏内部状态或触发另一个信号。普通应用代码中安全的操作,在异步信号上下文中不自动安全,必须依据目标平台和函数契约建立 allowlist。
常见安全设计是预分配固定大小记录区,处理器只写入信号号、有限上下文标识和原子重入状态,再通过受控文件描述符发出最小通知。符号化、JSON 序列化、网络上传、类查找和 UI 提示都在进程外或恢复到普通执行上下文后完成。记录区满时丢弃附加信息,也不在处理器中扩容。
公开登记应列出 declaredOperations,而不是贴完整实现或地址。门禁只允许如 write_fixed_record、set_atomic_flag、chain_previous 和 restore_default 等抽象操作;出现 allocate、format_log、jni_call、lock 或 network_send 就拒绝。抽象清单不能证明机器码合规,仍需代码审查和同候选测试。
| 操作类别 | 处理器内策略 | 替代位置 | 失败处理 |
|---|---|---|---|
| 固定记录写入 | 按平台 allowlist | 处理器内最小执行 | 记录丢失不递归 |
| 原子重入标记 | 预先验证实现 | 处理器内 | 进入最小终态 |
| 格式化日志 | 禁止 | 普通线程或进程外 | 只保留原始字段 |
| 内存分配 | 禁止 | 初始化阶段预分配 | 容量不足降级 |
| JNI 调用 | 禁止 | 恢复后的普通路径 | 不触达 Java |
| 锁与网络 | 禁止 | 后台采集器 | 不等待 |
重入保护要覆盖递归、并发与链式回环
处理器自身发生故障、前任处理器再次触发同一信号,或两个组件错误地互相链回,都可能形成递归。单纯依赖默认 mask 不够,因为 flags、不同信号和跨线程并发会改变行为。每个信号表项需要预分配 reentry state,首次进入记录 owner 和 generation,再次进入不执行复杂采集,直接采用最小终态。
链式回环来自登记错误:A 保存 B 为前任,而 B 的当前配置又把 A 当作前任。registry 门禁应构建每个信号的有向链,验证没有重复 owner、没有循环、最终落在 default、ignore 或显式 terminal owner。运行时 receipt 再核对实际 previousOwner,不能只相信静态声明。
跨线程同时触发时,一个全局布尔值可能让第二个线程误走恢复路径,也可能让首次线程退出后标记永不清除。对于致命故障,目标通常是确保采集有限且终态确定,而不是继续业务;状态机要定义 first_entered、reentered 和 terminal 三种状态,具体原子实现需按平台审查。
| 状态 | 允许动作 | 链式行为 | 终态 |
|---|---|---|---|
| idle | 登记首次故障 | 按策略转交 | 由链决定 |
| first_entered | 写最小记录 | 至多一次转交 | 等待明确终态 |
| reentered | 跳过复杂采集 | 不回到已访问 owner | 立即最小终态 |
| terminal | 不再业务处理 | 不继续链 | 默认或受控退出 |
| unknown_generation | 拒绝使用旧表 | 恢复已知默认 | 记录配置失配 |
诊断采集不能伪装成可恢复业务异常
同步致命信号通常意味着当前指令、内存访问或执行状态已经不可安全继续。处理器可以保存最小证据并把处置交给前任或系统默认,但不应通过简单修改返回地址、清除标志后继续业务。恢复策略必须由明确的平台和业务契约证明,不能把一次未立即崩溃当作恢复成功。
诊断 SDK 的职责是采集有限上下文,不拥有最终业务语义。若前任是默认处置,SDK 采集后需要恢复或重新触发明确定义的终态;若前任是业务处理器,则按保存的完整 action 转交。链中每个 owner 只执行一次,不能因为都想上传日志而相互调用。
信号事件与 ANR 也要分开。ANR 多由主线程阻塞、锁、Binder、I/O 或组件超时形成,不等于 Native 致命信号。崩溃处理器里做阻塞 I/O 反而可能制造卡顿或丢失系统记录。退出证据、ANR trace 和业务时间线分别采集,再在普通分析环境关联。
同候选符号和退出记录决定证据能否复核
Native 崩溃地址只有匹配正确架构、同一构建符号和目标候选时才可可靠还原。处理器链变化常伴随库装载与链接变化,版本名相同也可能包含不同 SO。每份受控故障回执要记录 App 候选、SO 哈希、ABI、signal registry 摘要和符号包身份。
ApplicationExitInfo 可在相应平台版本提供退出原因和诊断数据入口,但仍需按候选、时间窗与测试路径关联。若自定义处理器改变默认终态或进程快速重启,系统记录可能与预期不同。缺少退出记录时标记证据缺口,不用自定义日志中的“caught signal”替代系统事实。
ndk-stack 一类符号化工具可把地址映射到同构建符号,但符号化成功不自动证明处理器覆盖、递归或某个 SDK 是根因。需要结合安装 receipt、重入状态、链式访问顺序和退出信息判断。公开报告保留摘要和判定,不披露私有符号、内存地址或故障复现链。
| 证据 | 回答问题 | 绑定身份 | 不能证明 |
|---|---|---|---|
| install receipt | 实际安装顺序与前任 | SO 与 registry | 处理器代码安全 |
| signal record | 哪个 owner 首次进入 | 候选与 generation | 故障根因 |
| reentry state | 是否发生再次进入 | signal 与 owner | 为何再次触发 |
| system exit | 系统观察的终态 | 版本与时间窗 | 具体责任函数 |
| symbolization | 地址对应符号 | 同构建符号包 | 链式策略正确 |
| device test | 目标环境行为 | 设备与候选 | 未覆盖矩阵通过 |
用登记门禁检查重复占用、回环和不安全声明
signal registry 可以使用公开安全 JSON,每个 signal 包含 handlers 数组,按安装顺序列出 owner、previousOwner、chainPolicy、declaredOperations 和 terminal。门禁不安装真实处理器,也不触发故障,只验证静态契约:owner 唯一、前任连接连续、操作都在 allowlist、链无回环且最终终止。
下面的 Python 校验器从命令行读取 registry。输入缺失时直接非零退出;随后拒绝空信号表、重复 owner、前任不等于上一节点、不安全操作、递归 owner 和没有 terminal 的链。它使用抽象 owner 和操作名,不包含内部地址、客户数据或可运行攻击代码。
静态门禁通过后还要在目标候选读取实际 action 摘要,与 registry 对比。某些 SDK 可能在运行后重新安装处理器,生命周期测试需覆盖初始化、登录、模型加载、后台再前台和动态模块启用。实际链与声明不同即阻止完成,不通过修改报告让它看起来一致。
import json
import sys
from pathlib import Path
SAFE_OPERATIONS = {"write_fixed_record", "set_atomic_flag", "chain_previous", "restore_default"}
def stop(message):
print(f"signal registry rejected: {message}", file=sys.stderr)
raise SystemExit(2)
def load_registry(path_text):
path = Path(path_text)
if not path.is_file():
print("signal registry rejected: registry file is missing", file=sys.stderr)
raise SystemExit(2)
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
stop(f"cannot parse registry: {exc}")
def validate(data):
signals = data.get("signals")
if not isinstance(signals, list) or not signals:
stop("signals must be a non-empty list")
signal_ids = set()
for entry in signals:
signal_id = entry.get("signal")
if not signal_id or signal_id in signal_ids:
stop("signal ids must be present and unique")
signal_ids.add(signal_id)
handlers = entry.get("handlers")
if not isinstance(handlers, list) or not handlers:
stop(f"{signal_id} has no handler chain")
owners = set()
previous = "system"
for index, handler in enumerate(handlers):
owner = handler.get("owner")
if not owner or owner in owners:
stop(f"{signal_id} handler {index} repeats an owner")
owners.add(owner)
if handler.get("previousOwner") != previous:
stop(f"{signal_id} handler {index} breaks the previous-owner chain")
operations = set(handler.get("declaredOperations", []))
if not operations or not operations.issubset(SAFE_OPERATIONS):
stop(f"{signal_id} handler {index} declares unsafe operations")
previous = owner
if handlers[-1].get("terminal") not in {"default", "ignore", "exit"}:
stop(f"{signal_id} has no explicit terminal policy")
print(f"signal registry accepted: {len(signals)} signals")
if __name__ == "__main__":
if len(sys.argv) != 2:
stop("usage: validate_signal_registry.py registry.json")
validate(load_registry(sys.argv[1]))发布门禁按安装链、故障回执和恢复行为验收
发布前固定 App、SO、registry 与符号包身份,先验证静态链,再在隔离设备用公开安全的受控测试接口触发预定义故障类别。测试不暴露生产攻击入口,只在测试候选启用。回执核对安装顺序、每个 owner 至多进入一次、重入走最小终态、系统退出与符号化能够关联。
加固、SDK、装载顺序、链接参数或运行时版本变化后,要重新读取实际 action 并重跑关键矩阵。测试包括冷启动、懒加载前后、后台恢复、动态模块启用和组件卸载尝试。旧候选的链式结果不能替新 SO 签字,单台设备通过也不能代表全部 ABI 和厂商环境。
站内的 Native 崩溃符号化文章可继续检查地址与同构建符号绑定;准备评估商业 App 的 SO 加固和信号处理器兼容时,可通过页面行动按钮进入御盾中央平台提交候选包、SDK 清单和 registry。提交只启动评估,链式安全与退出结论仍以同一候选回执为准。
- 每个信号记录所有者、安装阶段和完整前任 action。
- 异步信号上下文操作使用经过平台审查的最小 allowlist。
- 链条没有重复 owner、回环或缺失 terminal。
- 重入时跳过复杂采集并进入明确最小终态。
- 系统退出、符号包和安装 receipt 绑定同一候选。
- SDK、加固或装载顺序变化后重新验证实际 action。
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| Native 调试和崩溃还原需要正确架构、候选与符号。 | Debug Android native code 说明 Native 调试所需构建和符号条件。 | 可调试不证明处理器链正确,也不能公开形成故障攻击链。 |
| Native 地址符号化必须使用同一构建的未剥离符号。 | ndk-stack 说明崩溃地址与符号目录的还原要求。 | 符号化成功不证明覆盖、递归或某个 owner 是根因。 |
| 退出记录需要与候选版本、时间窗和测试路径关联。 | Android ApplicationExitInfo 提供退出原因及相应平台诊断数据入口。 | 系统记录不自动说明链中哪个处理器首先破坏状态。 |
| ANR 与 Native 致命信号需要分别归因。 | Diagnose Android ANRs 按主线程、锁、Binder、I/O 和组件超时分层诊断。 | trace 表象帧不一定是初始阻塞源,也不证明信号处理器参与。 |
| 实际安装链和生命周期行为应在 Android 设备回归。 | Android instrumented tests 说明设备端测试可访问应用上下文和系统 API。 | 单一设备通过不能覆盖全部 API、ABI、厂商和 SDK 组合。 |
| 处理器变更需要保留来源、构建、验证和变更证据。 | NIST SP 800-218 SSDF 提供安全开发与供应链治理实践。 | 组织级框架不定义具体信号链,也不证明某候选通过。 |
| 重复 owner、链式回环或不安全操作声明必须阻止发布。 | 工程判断:这些配置会破坏单一当前 action 与异步信号安全边界。 | 静态登记仍需与目标候选实际 action 和机器码核对。 |
| 当前不能声称目标 App 的信号处理器链已经安全。 | 项目证据尚未接入;需要候选 SO、registry、设备故障和系统退出回执。 | 文章只提供方法和门禁代码,不替代实际验收。 |
工程常见问题
多个 SDK 都调用 sigaction,系统会自动依次通知吗?
不会。进程对一个信号只有当前 action,后安装者必须保存并按契约转交前任,否则之前处理器会被覆盖。
只保存旧 handler 函数地址为什么不够?
还需要区分 handler 签名,并保存 sa_mask、sa_flags、默认与忽略语义;遗漏这些字段会改变前任执行条件。
信号处理器里能不能直接写完整 JSON 日志?
不应。格式化、分配、锁和普通日志设施可能不具备异步信号安全性,应只写预分配固定记录,复杂工作移到信号上下文外。
捕获 SIGSEGV 后跳过故障指令继续运行可行吗?
不能作为通用恢复策略。执行状态和内存可能已经不可靠,处理器应最小采集并进入前任或默认终态,除非有严格平台契约证明可恢复。
有 tombstone 是否就能证明处理器递归?
不能。还需安装顺序、重入状态、链式访问记录和同构建符号,tombstone 只提供崩溃上下文的一部分。
SO 加固后为什么要重新检查信号链?
加固可能改变装载顺序、初始化入口、符号和运行层处理器。候选哈希变化后,旧 action 与退出回执不能证明新包行为。