先看结论与判断条件

  • 问题单必须以实际 APK 或 AAB 的 SHA-256 固定候选,并同时记录加固配置摘要;版本号、文件名、截图和口头描述都不能唯一识别诊断对象。
  • 最小复现要写清前置状态、逐步操作、首次异常点、期望行为、实际行为和复现频率,并提供未加固对照结果,不能只提交一句“加固后闪退”。
  • 设备信息应覆盖型号、系统版本、ABI、locale、方向、安装来源和账号或权限前置状态,矩阵中的每个结果都要绑定同一候选与同一场景。
  • 进程退出、ANR、Java 崩溃和 Native 崩溃需要不同诊断材料;ApplicationExitInfo、mapping、tombstone 与未剥离符号不能互相替代。
  • Java mapping 和 Native 符号必须来自同一构建并与候选摘要关联;符号化成功只是提高堆栈可读性,不证明根因或修复方案正确。
  • 提交前应使用白名单信息包清单,限制文件类型和大小,拒绝私钥、令牌、账号会话、客户数据与可重放材料,并由材料所有者做人工脱敏复核。

一份可处理的兼容问题单必须回答八个具体问题

高质量问题单不是堆满日志,而是让接收方能够确认诊断对象、复现异常并排除候选错配。提交前应回答:哪个候选出问题、对应哪份加固配置、未加固对照是否正常、怎样稳定复现、期望与实际差异是什么、在哪个环境发生、进程怎样退出,以及有哪些与同一构建对应的诊断材料。缺少其中一项,就应明确标为待补,而不是猜测。

问题标题应包含可搜索的症状、发生阶段和最小环境,例如“Android 14 arm64 冷启动后进入支付页触发 Native 退出”,比“加固失败”“兼容性不好”更能帮助分流。正文第一屏写候选摘要、首次异常步骤、复现频率和业务影响;完整环境、退出记录、堆栈、附件摘要和脱敏说明放在结构化字段中,避免关键信息埋在聊天记录。

材料齐全仍不等于根因已经确定。兼容异常可能来自应用原有缺陷、加固配置、构建漂移、第三方 SDK、系统行为、设备厂商差异、网络服务或账号状态。问题单的目标是建立可证伪的调查起点,让每个判断都能回到同一候选和同一复现路径,而不是在收到附件时承诺一定修复。

兼容问题首轮受理的八项核心信息
信息项最低内容证据形式缺失后的影响
候选身份文件 SHA-256 与构建标识本地重算记录无法确认诊断对象
加固配置工具版本与配置摘要配置导出或规范化摘要无法复现转换条件
对照基线未加固包同场景结果同设备测试回执无法判断是否由加固引入
复现步骤前置状态与逐步操作文字步骤或脱敏录屏无法稳定触发
环境型号、OS、ABI 与 locale设备信息记录无法建立覆盖范围
退出与堆栈原因、时间窗和相应材料退出记录、trace 或 tombstone无法选择诊断路径
期望和实际首个差异点可观察结果无法判断通过标准
脱敏声明已排除凭据和客户数据材料负责人复核附件不能安全流转

先固定候选身份,再比较加固前后的对照结果

每个问题候选都应在本地重新计算 SHA-256,并记录包类型、应用 ID、版本、构建号、ABI 范围、签名证书公开指纹、生成时间和加固配置摘要。SHA-256 是文件身份主键,其他字段用于解释它。若重新签名、重新打包、替换资源或重新运行加固,即使文件名和版本不变,也必须建立新候选记录。

对照实验至少包含同一设备、同一账号状态、同一数据准备和同一操作路径下的未加固包与问题包。若未加固包也失败,问题仍值得调查,但不能先归因于加固;若只有加固包失败,也只能说明差异与转换相关,不能直接断言某项保护规则就是根因。进一步定位需要逐项缩小配置、依赖和运行条件。

不要在问题单中上传签名私钥、证书密码、生产令牌或可重放账号会话。需要确认签名差异时,可提供公开证书信息、签名检查输出和候选摘要;需要确认配置差异时,可提供去除秘密后的规范化配置或其摘要。调查人员若无法用这些材料区分候选,应先补身份信息,而不是索取整套生产凭据。

加固兼容问题的候选身份元组
字段问题包对照包比较规则边界
SHA-256实际文件重算实际文件重算必须不同且各自固定只证明字节身份
版本与构建号清单实际值清单实际值预期应一致不能替代摘要
签名公开信息证书指纹与方案证书指纹与方案记录预期差异不提交私钥
配置摘要加固配置规范化摘要未加固标记绑定到问题候选不证明配置正确
测试前置状态设备、账号与数据同一条件逐项对齐外部服务仍可能漂移

把“闪退”改写成可以重复执行的最小复现

最小复现从前置状态开始:应用是全新安装还是覆盖安装,是否首次启动,是否已授予权限,是否登录,是否有历史缓存,网络是否可用,服务端开关是什么。随后按编号记录每一次点击、输入、页面跳转和等待条件,并指出首次偏离期望的步骤。视频可以辅助理解,但不能替代文字步骤和候选摘要。

期望与实际必须是可观察行为。期望可以是“支付页完成初始化并显示订单确认按钮”,实际可以是“进入页面后 800 毫秒内进程退出,系统记录 REASON_CRASH_NATIVE”;不要写“应该正常”“感觉卡顿”。若问题是性能或无响应,应给出测量窗口、采样方法和阈值来源,不能凭主观感受下结论。

复现频率要写分子和分母,并说明是否在清理数据、重启设备或切换网络后变化。例如同一候选连续十次冷启动中三次失败,比“偶现”更可复核。小样本不能推导总体故障率,但能帮助选择日志窗口和缩小前置条件。若没有稳定复现,应保留失败发生时的精确时间和环境,而不是补写不存在的步骤。

最小复现步骤的记录模板
阶段应记录内容合格示例常见无效写法
前置状态安装、权限、账号、缓存与网络全新安装且未登录环境正常
操作步骤按时间顺序编号启动后进入订单并点击确认随便操作就闪退
首次差异首个可观察异常按钮点击后进程退出加固不兼容
期望结果业务可验收状态确认页显示订单号应该没问题
复现频率失败次数与总次数冷启动 3/10偶尔发生

设备矩阵要服务真实覆盖范围,不是随意罗列机型

设备环境至少包含制造商和型号、系统版本与安全补丁、ABI、屏幕方向、locale、安装来源以及是否存在厂商定制行为。对于仅在覆盖安装、低存储、特定权限或后台恢复时出现的问题,还要记录这些状态。只写“Android 14”不足以解释不同 ABI、设备实现和应用状态带来的差异。

Firebase Test Lab Android matrices 将一次矩阵执行拆成设备型号、系统版本、方向和 locale 等维度,任一执行失败会影响对矩阵的判断。设计问题复现矩阵时应先覆盖真实用户集中环境、最低支持版本、已知高风险 ABI 和问题发生设备,再加入少量对照点;扩大数量不能弥补候选错配或场景不一致。

矩阵报告必须给每个单元格绑定候选摘要、场景版本、开始时间、结果类别和可用附件。云真机通过不表示所有实体设备兼容,单台真机失败也不能代表整个系统版本失败。遇到差异时,应优先比较 ABI、厂商系统、权限状态、安装方式和退出原因,形成下一轮最小矩阵,而不是无边界地增加机型。

兼容问题设备矩阵的选择和解释
矩阵维度首轮选择依据结果记录不能推出的结论
设备型号真实发生设备与代表性对照制造商、型号和硬件信息一个型号代表全部厂商
系统版本最低支持、主流和问题版本版本与安全补丁同大版本行为完全一致
ABI包内实际支持和故障 ABI运行 ABI 与库清单Java 正常即可排除 Native
locale 与方向真实场景和资源风险具体值与切换步骤默认语言通过即可全覆盖
安装与状态新装、覆盖、恢复或后台路径前置状态和数据策略单一路径代表全部生命周期

根据退出类型选择 ApplicationExitInfo、ANR trace 或崩溃材料

Android ApplicationExitInfo 可以提供历史进程退出原因,并在相应平台版本中关联 ANR trace,新版本还可返回 Native tombstone。反馈时应记录异常发生的设备时间、应用进程、候选版本和用户操作,再导出与该时间窗对应的退出记录。只提交一段无时间信息的 logcat,容易把旧进程、其他包或后续重启混入调查。

退出原因用于选择调查分支,不是根因结论。Java 未捕获异常应配合同构建 mapping 处理混淆堆栈;Native 崩溃需要 tombstone、ABI、加载库和未剥离符号;ANR 需要 trace、主线程状态、锁等待和用户路径;低内存、权限或系统终止则要结合系统状态。不要把所有退出统一写成“闪退”。

采集命令和系统接口受 Android 版本、权限、设备策略与日志保留窗口影响。拿不到某项材料时应明确写“未获得”、说明尝试方式和替代证据,不能生成占位堆栈。若日志包含账号、请求参数、设备标识或业务数据,先由材料所有者脱敏并保留原始材料的受控位置,普通问题包只放必要片段。

不同退出类型对应的诊断材料
症状类型首要材料关联字段常见误判
Java 崩溃异常堆栈与同构建 mapping候选摘要、版本和线程把混淆名称当根因
Native 崩溃tombstone 与未剥离符号ABI、Build ID 和加载库符号化后即宣称修复
ANRANR trace 与用户路径发生时间、进程和主线程状态只截取最后一行日志
系统终止ApplicationExitInfo 与系统状态退出原因、重要性和时间窗统一归为应用崩溃
功能异常未退出业务状态与受控日志期望、实际和前置数据用崩溃工具替代业务复现

Java mapping 与 Native 符号必须来自同一构建并单独归档

R8 retrace 要使用产生问题构建的 mapping 文件还原混淆后的 Java 或 Kotlin 堆栈。mapping 来自另一个构建时,即使版本号相同,也可能得到错误名称或无法还原。问题包应记录 mapping 文件摘要、对应候选摘要、构建变体和工具版本;文件本身若受访问控制,可先提交元数据并通过授权渠道提供。

ndk-stack 处理 Native 崩溃时需要未剥离符号目录与同一构建地址信息。ABI、共享库名称、Build ID 和 tombstone 地址必须能够对应。Include native symbols 说明发布构建可生成独立 Native 调试符号文件并用于平台诊断,但归档责任仍在项目:每个符号包要绑定版本、ABI、候选摘要和保留周期。

符号化只是把地址转换成更可读的函数和源码位置,不证明该行就是根因,也不排除内存破坏在更早位置发生。加固可能改变代码布局,因此尤其不能拿未加固构建符号处理加固后地址。调查结论应区分“堆栈已还原”“可疑调用路径”“根因已由实验验证”和“修复已在同一范围回归”四个证据等级。

Java 与 Native 诊断材料的配对规则
材料必须绑定适用问题不适用范围验收动作
R8 mapping同构建候选摘要与变体Java/Kotlin 混淆堆栈Native 地址保存摘要并执行 retrace
Native symbolsABI、SO、Build ID 与候选摘要Native 地址符号化Java 混淆名称校验身份后执行 ndk-stack
tombstone设备、时间、进程和 ABINative 崩溃现场一般业务逻辑异常与符号和候选配对
符号化输出工具版本与输入摘要提高堆栈可读性自动证明根因保留命令和非零错误

用白名单、大小限制和人工复核保护客户秘密

问题包只应包含诊断所需材料。优先使用白名单标签,例如已脱敏日志、退出信息、Java 堆栈、Native tombstone、复现步骤和映射符号元数据;明确拒绝 `.env`、私钥、证书容器、keystore、数据库导出、生产配置、完整网络抓包和账号会话。文件名检查只能挡住明显错误,内容仍需负责人逐项复核。

脱敏不能破坏诊断关联。可以将用户 ID、订单号、设备标识和 URL 参数替换成稳定占位符,使同一实体在一份材料中保持一致;不能删掉时间戳、线程、异常类型、库名、ABI 或关键调用顺序。原始材料若因审计需要保留,应放在授权存储中并限制访问,普通问题单只引用受控记录号,不复制原文。

NIST SP 800-218 SSDF 支持在安全开发流程中保留来源、构建、验证与变更证据,并处理供应链风险。对应到问题反馈,应指定材料所有者、脱敏复核者、访问范围、保留期限和删除责任。组织级框架不替某个项目决定哪些字段属于秘密,最终仍要遵守合同、隐私政策和内部数据分级。

兼容问题信息包的材料白名单和禁入项
材料类别默认处理允许内容必须排除复核责任
日志与 trace脱敏后纳入时间、线程、异常和必要上下文令牌、账号和业务载荷日志所有者
候选元数据纳入摘要、版本、ABI 和公开签名信息私钥与密码发布负责人
mapping 与 symbols授权提供摘要、Build ID 和必要文件无关源码与秘密配置构建负责人
复现材料纳入步骤、期望、实际和脱敏录屏真实客户数据问题提交人
原始现场受控留存内部记录号直接复制到普通工单数据负责人

用只读校验器生成安全的兼容问题信息包清单

下面的 Python 示例读取归档根目录和 `compatibility-report.json`。清单声明候选、对照候选、环境、复现步骤和附件。脚本限制附件标签、扩展名、单文件大小与总大小,拒绝绝对路径、目录逃逸、符号链接和常见敏感文件名,重新计算所有附件与候选 SHA-256,然后输出可提交文件清单。

脚本不会自动上传材料,也不会读取签名私钥或调用调试接口。文件名和扩展名过滤不能证明内容已经脱敏,因此清单还要求 `humanRedactionReviewed` 为真,并把其视为人工责任声明。真实提交前仍应打开每个文件复核账号、令牌、客户数据、内网地址和可重放请求,必要时仅提交受控记录号。

向御盾反馈兼容问题时,可准备候选与对照包摘要、加固配置摘要、最小复现、期望与实际结果、设备矩阵、ApplicationExitInfo、同构建 mapping 或 Native 符号元数据和脱敏附件,再通过御盾中央平台提交申请。完整信息包能缩短确认往返,但不构成定位、修复或兼容通过承诺。

  • 问题候选与未加固对照候选分别重算 SHA-256
  • 设备型号、系统版本、ABI 和 locale 均已填写
  • 至少三步可执行复现且包含前置状态
  • 附件只使用明确白名单标签
  • 敏感文件名、危险扩展名和符号链接被拒绝
  • 单文件与信息包总大小受到限制
  • 人工脱敏复核声明为真且仍需打开文件复查
  • 清单通过不冒充根因、修复或兼容结论
校验候选身份并生成脱敏兼容问题附件清单
from pathlib import Path
import hashlib
import json
import os
import re
import sys

if len(sys.argv) != 3:
    raise SystemExit(2)
root = Path(sys.argv[1]).resolve()
manifest_path = Path(sys.argv[2]).resolve()
if not root.is_dir() or not manifest_path.is_file() or root not in manifest_path.parents:
    raise SystemExit(2)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if manifest.get("schema") != "hardening-compatibility-report-v1":
    raise SystemExit(2)

def safe_file(relative_path):
    if not isinstance(relative_path, str) or not relative_path or os.path.isabs(relative_path):
        raise SystemExit(2)
    unresolved = root / relative_path
    resolved = unresolved.resolve()
    if root not in resolved.parents or unresolved.is_symlink() or not resolved.is_file():
        raise SystemExit(2)
    if re.search(r"(^|[._-])(secret|token|password|private|keystore|credential)([._-]|$)", unresolved.name, re.I):
        raise SystemExit(4)
    if unresolved.suffix.lower() in {".env", ".key", ".pem", ".p12", ".jks", ".keystore", ".db", ".sqlite"}:
        raise SystemExit(4)
    return resolved

def digest(path):
    value = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
            value.update(chunk)
    return value.hexdigest()

def verify_record(record, max_bytes):
    if not isinstance(record, dict) or not re.fullmatch(r"[0-9a-f]{64}", str(record.get("sha256", ""))):
        raise SystemExit(2)
    path = safe_file(record.get("path"))
    if path.stat().st_size > max_bytes or digest(path) != record["sha256"]:
        raise SystemExit(3)
    return {"path": record["path"], "sha256": record["sha256"], "bytes": path.stat().st_size}

candidate = verify_record(manifest.get("candidate"), 2 * 1024 * 1024 * 1024)
baseline = verify_record(manifest.get("baselineCandidate"), 2 * 1024 * 1024 * 1024)
if candidate["sha256"] == baseline["sha256"]:
    raise SystemExit(2)
environment = manifest.get("environment")
if not isinstance(environment, dict) or not all(str(environment.get(k, "")).strip() for k in ("model", "osVersion", "abi", "locale")):
    raise SystemExit(2)
steps = manifest.get("reproductionSteps")
if not isinstance(steps, list) or len(steps) < 3 or any(not isinstance(s, str) or len(s.strip()) < 8 for s in steps):
    raise SystemExit(2)
if manifest.get("humanRedactionReviewed") is not True:
    raise SystemExit(4)
allowed_labels = {"sanitized_log", "exit_info", "java_stack", "native_tombstone", "reproduction_steps", "symbol_manifest"}
attachments = manifest.get("attachments")
if not isinstance(attachments, list) or not attachments:
    raise SystemExit(2)
package = []
total_bytes = 0
for item in attachments:
    if not isinstance(item, dict) or item.get("label") not in allowed_labels:
        raise SystemExit(2)
    checked = verify_record(item, 25 * 1024 * 1024)
    checked["label"] = item["label"]
    total_bytes += checked["bytes"]
    package.append(checked)
if total_bytes > 100 * 1024 * 1024:
    raise SystemExit(3)
report = {"status": "compatibility-package-list-valid", "candidateSha256": candidate["sha256"], "baselineSha256": baseline["sha256"], "environment": environment, "attachments": package, "boundary": "name and manifest checks do not prove content redaction, root cause, fix, or compatibility"}
print(json.dumps(report, ensure_ascii=False, indent=2))

事实依据与适用边界

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

本文判断事实或工程依据适用限制
ApplicationExitInfo 可提供进程退出原因,并在相应平台版本中关联 ANR trace 或 Native tombstone。Android ApplicationExitInfo 官方参考定义了历史进程退出信息和可用 trace 接口,可支持按退出类型选择材料。退出记录仍需与同一候选、进程、设备时间窗和用户路径关联,退出原因枚举本身不是根因结论。
混淆后的 Java 或 Kotlin 堆栈需要与同一构建产生的 mapping 配对还原。R8 retrace 说明了使用 mapping 文件对混淆堆栈执行 retrace 的方法,可支持问题包记录 mapping 摘要和构建身份。retrace 不处理 Native 地址,不能纠正错误候选身份,也不能因名称还原成功就宣布根因已经定位。
Native 崩溃符号化需要未剥离符号目录和对应构建的地址信息。ndk-stack 说明了使用符号目录处理 Native 崩溃输出的方式,可支持 ABI、共享库和符号归档配对要求。符号化成功只提高堆栈可读性,不证明首个可见帧就是根因,也不替代同一候选上的实验验证。
Android 发布构建可以生成独立 Native 调试符号文件并用于平台诊断。Include native symbols 说明了为发布构建包含或生成 Native 调试符号的官方能力,可支持预先归档诊断材料。符号文件仍要另外绑定候选摘要、版本、ABI 和 Build ID;生成或上传成功不证明后续问题一定可定位。
Android 云测试矩阵由设备型号、系统版本、方向和 locale 等维度组成,单次执行结果会影响矩阵判断。Firebase Test Lab Android matrices 的入门资料说明了 Android 测试矩阵选择与执行,可支持结构化记录环境。云设备矩阵不能替代真实用户分布和最低支持范围,也不能从有限通过样本推导所有设备兼容。
安全发布与问题处置应保留来源、构建、验证、变更和供应链风险相关证据。NIST SP 800-218 SSDF 提供组织级安全软件开发实践,可支持为问题材料指定所有者、复核和保留责任。SSDF 不定义御盾产品功能、兼容通过标准或具体数据分级规则,项目结论仍依赖真实候选和授权测试。
加固兼容问题应同时提供问题候选和未加固对照候选的独立摘要。这是基于控制变量和文件身份的工程判断:没有对照就难以判断异常是否已存在,没有摘要就无法确认比较对象。对照差异只能建立调查方向,不能单独证明加固是根因;服务端、账号、设备状态和依赖仍可能同时变化。
文件名与扩展名过滤不能保证附件内容已经完成脱敏。这是基于数据泄露面的工程判断:敏感值可以出现在普通文本、堆栈、URL、截图或合法扩展名文件中,因此需要人工复核。示例校验器只拒绝明显危险材料并验证清单,不能识别所有个人信息、商业秘密、内网信息或可重放凭据。

工程常见问题

只提供崩溃截图可以反馈加固兼容问题吗?

截图可以说明表面症状,但不能唯一识别候选、退出类型和复现环境。至少还要提供问题包 SHA-256、未加固对照结果、复现步骤、设备与系统信息、发生时间和相应 trace 或堆栈。截图含账号或业务数据时也必须先脱敏。

未加固包也会失败,还需要提交给御盾吗?

可以提交,但要明确对照包在同一设备、状态和步骤下的结果。未加固包也失败意味着不能先归因于加固,却可能帮助识别原有缺陷、依赖问题或环境条件。问题单应提供两个候选摘要和差异,不应隐藏对照失败。

Java 崩溃和 Native 崩溃需要提交同一套材料吗?

不完全相同。Java 或 Kotlin 混淆堆栈需要同一构建的 R8 mapping;Native 崩溃需要 tombstone、ABI、共享库、Build ID 和未剥离符号。两类问题都需要候选摘要、环境、复现步骤和时间窗,但 mapping 与 Native symbols 不能互换。

符号化成功是否表示兼容问题根因已经找到?

不是。符号化把地址或混淆名称转换为更可读位置,帮助形成假设,但内存破坏、竞态或状态错误可能发生得更早。根因需要由控制变量、代码检查或实验验证,修复还要针对同一候选范围完成回归。

问题附件中能否包含生产 token、证书或完整数据库?

不能默认提交。私钥、证书密码、生产 token、账号会话、完整数据库和无关客户数据应排除。需要说明身份或请求时,优先使用公开指纹、稳定占位符、受控记录号和最小日志片段,并由材料所有者完成脱敏与授权复核。

向御盾提交兼容问题前最少应整理什么?

整理问题候选和未加固对照候选摘要、加固配置摘要、三步以上复现、期望与实际、设备环境、发生时间、退出类型、同构建 mapping 或 Native 符号元数据,以及经过人工脱敏的必要附件,再通过御盾中央平台提交申请。

想用自己的 App 验证?

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

继续阅读: 御盾 VMP 接入前准备清单