先看结论与判断条件

  • 门禁必须枚举最终候选中的全部 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 与重复类的区别
现象正常含义危险误判处置
多个 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 同类迁移或 forkartifact 内容清单任意删一个
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、解析器、依赖图与设备回执的摘要,任何候选变化都重新扫描。

多 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 上手工删除一个类后继续沿用原门禁回执。

静态扫描通过能否证明运行时一定没有类加载问题?

不能。静态门禁只证明被扫描候选中没有违反类身份唯一性;反射字符串、动态功能安装、厂商运行时差异和调用语义仍需设备端测试。报告应把静态结果和运行证据分开陈述。

想用自己的 App 验证?

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

继续阅读: APK、DEX 与签名完整性如何一起验收