先看结论与判断条件
- 门禁必须枚举最终候选中的全部 classes*.dex,并按类描述符建立反向索引;只检查主 DEX 会遗漏直接风险。
- multidex 是合法分包机制,不是同一类可重复定义的豁免;重复判定应基于描述符而非文件名或源码路径。
- 重复类必须关联同一 release 变体的 resolved dependency graph、artifact checksum 与 transform 轨迹,不能拿 debug 依赖树代替。
- 排查要区分传递依赖冲突、shade 或 relocation 失配、加固残留旧实现和生成类碰撞,例外必须可复核且有失效条件。
- AAB 发布还需检查目标设备对应的 APK set;仅扫描通用 APK 或本地安装路径不足以代表所有交付组合。
- 静态索引无重复只证明候选的类身份约束通过,仍需验签、安装、冷启动、反射与设备端回归验证运行语义。
直接答案:扫描最终所有 DEX,并回连变体依赖图
最终 APK 出现多个 classes*.dex 时,发布门禁应枚举每个 DEX 的全部类描述符,建立 descriptor 到 dex 文件的反向索引。任何描述符出现在两个物理 DEX 中都先标记 blocked,再与当前 release 变体的 dependencyInsight、R8 mapping、重定位记录和加固输出对应,不能只看 classes.dex。
构建日志没有报 duplicate class 也不足以放行。依赖冲突可能已在早期被解析、打包工具可能重定位类,加固或二次处理还可能把旧实现残留到新 DEX。权威对象是最终签名候选,所有扫描结果绑定 APK SHA-256、工具版本和每个 DEX 摘要。
重复结构不等于已发生可利用漏洞,也不能立即认定某个 SDK 是根因。门禁先保存 descriptor、DEX 归属和来源线索,再从变体依赖图与处理流水线定位。修复后必须重新构建、扫描、签名、验签和设备回归,不能直接删除签名包中的条目。
| 输入 | 回答的问题 | 不能单独证明 | 保存方式 |
|---|---|---|---|
| 最终 APK | 真实发布字节 | 类来源 | SHA-256 |
| 所有 classes*.dex | 类最终归属 | 依赖根因 | DEX 摘要 |
| 依赖解析图 | 直接与传递版本 | 最终一定打包 | 变体报告 |
| R8 mapping | 重命名与合并 | 运行语义正确 | mapping 摘要 |
| 处理流水线 | 加固与重定位步骤 | 没有残留 | 阶段摘要 |
| 设备测试 | 实际加载行为 | 全设备兼容 | API 与回执 |
多 DEX 是容量与加载布局,不是重复类的豁免
Android multidex 允许代码分散到多个 DEX,并在旧系统上采用相应加载路径。正常拆分的含义是不同类被分配到不同 DEX,不是同一 descriptor 可以在多个 DEX 中各保留一份。门禁需要区分多 DEX 布局和 duplicate descriptor,不能把后者解释为预期拆分。
旧系统的主 DEX 约束会影响哪些类必须放在 classes.dex,但主 DEX 列表不应复制类定义。若保护工具生成代理、桥接或兼容层,应使用新的唯一 descriptor,并验证引用目标。使用相同 descriptor 覆盖旧实现会让加载选择依赖工具和平台行为,不能作为发布设计。
扫描顺序不能决定有效类。即使某次设备总是加载第一份定义,另一个解析器、安装模式或后处理工具也可能采取不同路径。发布门禁拒绝歧义,从源依赖或变换阶段消除重复,而不是记录哪一份碰巧生效。
| 现象 | 正常含义 | 危险误判 | 处置 |
|---|---|---|---|
| 多个 DEX | 容量与布局拆分 | 允许重复 descriptor | 全量索引 |
| 主 DEX 列表 | 旧系统启动所需类 | 复制定义到主 DEX | 核对唯一归属 |
| 生成代理 | 新 descriptor 委托 | 覆盖原类名 | 重命名并验引用 |
| 重定位 | 包名改变避免冲突 | 保留原副本 | 扫描新旧命名 |
| R8 合并 | 优化多个实现 | 运行类必然唯一 | 核对最终 DEX |
| 加固输出 | 新增运行支持类 | 重复业务类合理 | 阶段差分 |
依赖解析必须锁定实际 release 变体和选择结果
Gradle 的直接与传递依赖形成解析图,同一模块可能因为约束、平台、替换或冲突策略选择特定版本。排查必须运行当前 release 变体的 dependencies 与 dependencyInsight,不读取另一个 flavor、debug 或缓存中的旧报告。报告记录 component、selected reason、requested versions 和引入路径。
重复类常来自两个不同坐标携带同一源码、旧版 fat AAR 嵌入依赖、迁移期间新旧 SDK 并存或本地 jar 与 Maven 模块重复。依赖树能缩小来源,但最终 class 还可能经过 R8、desugar 和加固变换,因此不能只删除第一条看起来重复的依赖。
修复优先在依赖声明和版本约束上完成:排除错误传递项、统一坐标、升级兼容版本或由供应方修复重复打包。不得使用随意 pickFirst、宽泛 exclude 或删除类文件绕过,因为这些动作可能使运行时找到错误版本,构建通过不代表语义正确。
| 线索 | 可能原因 | 验证 | 错误修复 |
|---|---|---|---|
| 同 group 不同 version | 解析漂移 | dependencyInsight | 同时强塞两版 |
| 不同 group 同类 | 迁移或 fork | artifact 内容清单 | 任意删一个 |
| fat AAR 与模块 | 嵌入重复 | 解包来源摘要 | 全局 exclude |
| 本地 jar 与 Maven | 旧文件残留 | 仓库与构建脚本 | 按文件名猜 |
| 重定位前后 | shade 配置错误 | mapping 与类索引 | 只忽略报告 |
| 加固前后新增 | 处理阶段残留 | 阶段 DEX 差分 | 修改已签名包 |
最终类索引要保留 descriptor、DEX 摘要和物理归属
扫描器从 APK ZIP 中识别 classes.dex、classes2.dex 等实际条目,不能假设编号连续,也不能只读取第一个匹配。每个 DEX 先计算 SHA-256,再用可靠解析器输出完整 class descriptor。反向索引保留 descriptor、dexName、dexDigest 和出现次数,集合去重会掩盖物理重复。
名称规范化必须遵循 DEX descriptor 语义,例如 Lcom/example/Foo; 与数组、内部类和大小写严格区分。工具解析失败、DEX 条目重复、空索引或数量异常时保持 blocked。扫描器不执行类代码,也不把 DEX 解压到不受控目录,输出中避免携带客户私有类的完整业务说明。
若最终 APK 没有重复 descriptor,仍需保存类数量与 DEX 摘要作为基线,用于下一次依赖或加固升级的 drift 比较。新增 DEX、类总数大幅变化或业务包迁移都要求解释,但不能凭静态数量写成安全或性能结论。
重定位、残留旧实现和生成类要分别归因
正确重定位会改变包名与 descriptor,使两个实现能够在明确边界共存;如果原包名副本仍留在另一个 DEX,则说明 shade、缓存或处理阶段可能残留。审计同时搜索原前缀与新前缀,并检查调用方引用,不能只因新名称存在就认定迁移完成。
残留旧实现常来自增量构建目录、重复输入归档或加固流水线合并错误。门禁应对 clean 构建与增量构建分别生成最终类索引,结果必须一致;若清理缓存后差异消失,仍要修复可重复构建问题并保存根因,不能把 clean 作为永久人工步骤。
编译器生成类、资源访问类和保护运行时类也需要唯一 descriptor。其名称可能随编译器变化,但同一候选内不能物理重复。允许例外必须基于解析器误报或明确非类元数据,并有最小匹配、负责人、到期和测试证据,禁止使用整个包前缀白名单。
| 类别 | 观察特征 | 需要证据 | 放行条件 |
|---|---|---|---|
| 传递依赖冲突 | 坐标路径不同 | 解析图与 artifact 摘要 | 统一来源后无重复 |
| 重定位残留 | 新旧包同时存在 | shade mapping 与调用引用 | 原副本移除 |
| 旧构建产物 | clean 与增量不同 | 构建输入与缓存日志 | 可重复一致 |
| 加固阶段残留 | 处理前后新增重复 | 阶段 DEX 差分 | 工具修复并重建 |
| 生成类碰撞 | 相同 synthetic descriptor | 编译器与输入 | 唯一命名 |
| 解析器误报 | 交叉工具结果不同 | 原始 DEX 与工具版本 | 确认非物理重复 |
AAB 和设备派生 APK 集也要扫描,不能只扫 universal
如果发布输入是 AAB,设备最终获得的是按配置派生的 APK 集。bundletool 可以为 device spec 生成和检查 APKS,门禁至少覆盖 base、feature 与实际安装集合中的所有 DEX。只扫描 universal APK 可能包含不同模块组合,不能代表设备定向交付。
每个代表设备配置保存 AAB SHA-256、bundletool 版本、device spec、APKS 摘要、selected APK 与 DEX 索引。跨 split 的同一 class descriptor 也需检查,因为同一安装会话中的重复定义可能造成加载或安装歧义。未交付模块不参与该配置,但要在其他配置覆盖。
本地 bundletool 工件可能使用调试签名,不能冒充生产候选;Play 线上派生还需测试轨道抽样。报告区分 local-derived、store-delivered 和 device-installed。任何层级差异都保持未知或 blocked,不用本地扫描自动填充线上结论。
修复后要做加载、反射和关键业务回归
消除重复可能改变实际被调用的实现版本、方法签名、资源依赖和序列化行为。设备回归至少覆盖应用启动、受影响 SDK 初始化、反射入口、数据库或网络序列化、关键业务动作和异常路径。Android instrumented tests 用于依赖真实运行时与系统 API 的部分。
反射、JNI、ServiceLoader 或字符串类名可能绕过静态调用图。修复依赖后要核对 R8 missing rules、运行日志和准确入口,避免删除曾被错误重复但实际上被间接使用的实现。静态索引通过不能证明间接加载无误,设备回执必须绑定同一候选。
性能和包体变化只能以同一设备、同一候选配置的测量报告陈述。重复类消失通常会改变大小,但不能提前虚构节省比例。若关键流程崩溃、加载失败或行为变化,状态仍为 blocked,不能因为类索引干净就登记发布通过。
用所有 DEX 的公开类清单建立反向索引
下面的 Python 示例读取由 DEX 解析器生成的公开 JSON 清单,每项包含 dexName、dexSha256 和 classes。它验证摘要与 descriptor 格式,拒绝重复 DEX 名称、单个 DEX 内重复和跨 DEX 重复,并输出反向索引摘要。代码不执行类、不修改 APK。
真实流水线应先从冻结候选枚举全部 DEX,再用固定版本解析器生成清单。依赖线索可以作为另一个字段关联,但不能让依赖报告覆盖最终观察。输入派生的格式、摘要或重复差异会非零退出,便于发布门禁保存证据。
脚本通过只说明提供的类清单没有重复,不证明清单来自完整 APK、依赖根因已修复或设备运行兼容。下游必须绑定 APK、每个 DEX、解析器、依赖图与设备回执的摘要,任何候选变化都重新扫描。
import json
import re
import sys
from collections import defaultdict
from pathlib import Path
if len(sys.argv) != 2:
raise SystemExit("usage: audit_dex_classes.py dex-class-inventory.json")
inventory_path = Path(sys.argv[1]).resolve()
if not inventory_path.is_file():
raise SystemExit(f"inventory missing: {inventory_path.name}")
with inventory_path.open("r", encoding="utf-8") as stream:
inventory = json.load(stream)
if not isinstance(inventory, list) or not inventory:
raise SystemExit("inventory must be a non-empty array")
digest_pattern = re.compile(r"^[0-9a-f]{64}$")
descriptor_pattern = re.compile(r"^L[^;]+;$")
seen_dex = set()
reverse_index = defaultdict(list)
errors = []
for item in inventory:
if not isinstance(item, dict):
errors.append("DEX entry is not an object")
continue
dex_name = item.get("dexName")
dex_digest = str(item.get("dexSha256", "")).lower()
classes = item.get("classes")
if not dex_name or dex_name in seen_dex:
errors.append(f"missing or duplicate DEX name: {dex_name!r}")
continue
seen_dex.add(dex_name)
if not digest_pattern.fullmatch(dex_digest):
errors.append(f"invalid digest for {dex_name}")
if not isinstance(classes, list) or not classes:
errors.append(f"empty class list for {dex_name}")
continue
local_seen = set()
for descriptor in classes:
if not isinstance(descriptor, str) or not descriptor_pattern.fullmatch(descriptor):
errors.append(f"invalid descriptor in {dex_name}: {descriptor!r}")
continue
if descriptor in local_seen:
errors.append(f"duplicate descriptor inside {dex_name}: {descriptor}")
local_seen.add(descriptor)
reverse_index[descriptor].append({"dex": dex_name, "sha256": dex_digest})
duplicates = {key: value for key, value in reverse_index.items() if len(value) > 1}
if duplicates:
errors.append(f"cross-DEX duplicate count: {len(duplicates)}")
if errors:
print(json.dumps({"status": "failed", "errors": errors, "duplicates": duplicates}, indent=2))
raise SystemExit("DEX class inventory contains review-blocking differences")
print(json.dumps({
"status": "passed",
"dex_count": len(seen_dex),
"unique_class_count": len(reverse_index),
"boundary": "class uniqueness passed; dependency cause and runtime remain separate gates",
}, indent=2))发布报告要保留来源、修复和设备证据
证据包包含最终 APK 或 AAB 摘要、所有 DEX 摘要、解析器版本、类反向索引、release 变体依赖图、dependencyInsight、artifact 内容摘要、R8 mapping、处理阶段差分、修复变更和设备测试。所有对象绑定同一 release。
NIST SP 800-218 SSDF 强调来源、构建、验证和变更证据。可以报告某候选的实际重复 descriptor 与修复后结果,不能写成所有运行冲突已消失、某 SDK 是唯一根因或攻击被阻断。没有项目数据时不提供客户、性能、排名或收录数字。
权限差异属于另一条发布门禁,可查看[APK 权限差异发布门禁](/zh-cn/articles/apk-permission-diff-release-gate/),本篇只回答重复类。需要提交候选和依赖图,可从[御盾中央平台申请加固服务](https://www.leonadev.com/console/)。登录、注册、价格、购买和控制台统一由中央平台承接。
- 冻结最终 APK 或 AAB 并记录 SHA-256
- 枚举所有 classes*.dex 且不假设编号连续
- 建立 descriptor 到物理 DEX 与摘要的反向索引
- 依赖分析锁定准确 release 变体与解析原因
- 重定位、残留、生成类和误报分别归因
- AAB 按代表 device spec 扫描实际安装集合
- 修复后重新构建、签名、验签并做设备回归
- 所有例外最小、可过期且有交叉工具证据
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| Android 构建可把应用代码分散到多个 DEX,门禁不能只检查 classes.dex。 | Android multidex 说明多 DEX 的构建与加载背景;最终候选中的全部 classes*.dex 才是本文扫描对象。 | multidex 文档不证明某个具体候选没有重复类,也不替代对成品字节的枚举。 |
| 重复类根因需要回到实际解析的直接和传递依赖图。 | Android Gradle dependency resolution 描述依赖解析与版本选择;本文据此要求保存 release 变体的解析快照。 | 依赖图只提供来源线索,不能证明最终 APK 中的物理类归属。 |
| 重复类和版本冲突应使用目标变体的解析结果定位,而不是依赖泛化推断。 | Debug Android dependency resolution 提供依赖冲突与重复类的诊断路径,支持以实际构建变体为准。 | 构建期问题被修复不等于加固、重打包或后处理阶段没有重新引入重复实现。 |
| AAB 交付需要按设备配置检查实际生成的 APK 组合。 | bundletool 支持从 AAB 生成和检查 APK set,本文据此要求扫描交付视角的 split 组合。 | 未使用正式签名与明确设备规格生成的 APK set 不能当作生产候选验收证据。 |
| 发布门禁需要保留候选、来源、验证和变更证据。 | NIST SP 800-218 SSDF 将软件供应链风险与安全开发、验证和发布证据纳入组织实践。 | SSDF 不规定 Android 重复类阈值,也不能证明御盾或任何具体产品已完成项目验证。 |
| 涉及反射、组件与系统 API 的语义需要真实 Android 运行时回归。 | Android instrumented tests 说明设备或模拟器上的 instrumentation 可验证依赖 Android 框架的行为。 | 单一设备或单一路径通过不能代表全部 API、ABI、厂商系统和交付 split。 |
| 同一类描述符出现在多个物理 DEX 时应默认阻断发布。 | 工程判断:这是本文的保守工程门禁:类加载结果会受容器、顺序和工具链影响,无法仅凭构建成功证明语义唯一。 | 若工具产生的是重复索引记录而非真实定义,必须回到 dex 字节与哈希复核;未经复核不得放行。 |
| 重定位、生成类与旧实现残留需要分别归因。 | 工程判断以描述符、方法集合哈希、依赖来源、transform 轨迹和阶段差分共同确认,避免只凭文件名猜根因。 | 没有 transform 日志或阶段候选时只能标记来源未定,不能虚构具体 SDK 或加固步骤为根因。 |
| 静态无重复不能替代签名和运行时验证。 | 工程判断:类索引、签名验证与设备测试回答不同问题,发布证据应分别记录其命令、候选哈希和结果。 | 本文不提供客户数据、兼容率、性能数字或攻击阻断结论。 |
| 修复后的候选必须从可信输入重建,不能在原包上删除冲突类后直接发布。 | 工程判断:工程门禁要求保持构建可复现性和签名连续性,使依赖排除、重定位或规则变更可被审计。 | 具体签名策略、密钥托管和发布审批由项目流程决定,正文不包含任何凭据或私有地址。 |
工程常见问题
只要 Gradle 构建成功,最终 APK 就不会有重复类吗?
不能这样判断。Gradle 会拦截部分依赖阶段的重复定义,但加固、重打包、字节码注入、shade 或拆分处理仍可能改变最终 DEX。发布门禁必须扫描将要签名和交付的候选,并把结果与同一 release 变体依赖图关联。
同一个类出现在 classes.dex 和 classes2.dex 一定要阻断吗?
真实类定义的描述符跨物理 DEX 重复时应默认阻断,因为加载选择和工具链行为难以作为稳定发布契约。若怀疑扫描器重复枚举,应直接复核两个 DEX 的字节、类数据偏移与哈希,而不是凭经验加入长期白名单。
启用 multidex 后为什么还要检查全部 DEX?
multidex 只是让方法和类被合法分配到多个 DEX,不允许同一类身份出现两份实现。只看主 DEX 会漏掉次级 DEX 之间或主次 DEX 之间的碰撞,因此类描述符索引必须覆盖全部 classes*.dex。
依赖树能直接告诉我哪个 SDK 导致重复类吗?
依赖树只能提供候选来源。最终产物还可能经历 R8、重定位、生成代码和加固处理,物理类未必与单个依赖节点一一对应。应结合 artifact checksum、mapping、transform 轨迹和方法集合哈希归因。
AAB 是否只需扫描 base 模块生成的 APK?
不够。设备会按 ABI、密度、语言和动态功能配置获得不同 split 组合,应使用 bundletool 生成并检查代表性 APK set,确认每个可安装组合内的类身份唯一,且使用正式候选签名流程复核。
是否可以给已知重复类设置永久白名单?
不建议。例外至少要绑定描述符、两个来源哈希、候选版本、负责人、原因和失效日期,并证明其中一份不是可加载的真实定义。任何来源或字节变化都应让例外失效并重新阻断。
修复重复类后需要重新做哪些验证?
从锁定依赖和可信输入重建候选,重新扫描全部 DEX、核对签名和哈希,再覆盖安装、冷启动、反射入口、关键组件和设备矩阵。不能在旧 APK 上手工删除一个类后继续沿用原门禁回执。
静态扫描通过能否证明运行时一定没有类加载问题?
不能。静态门禁只证明被扫描候选中没有违反类身份唯一性;反射字符串、动态功能安装、厂商运行时差异和调用语义仍需设备端测试。报告应把静态结果和运行证据分开陈述。