先看结论与判断条件

  • 最终 APK 中的二进制 Manifest 才是当前候选实际携带的静态声明,仓库中的主 Manifest 只是合并输入之一。
  • 排查必须绑定完整 variant、Gradle task、applicationId、占位符与生成参数;同一仓库不同 source set 可以形成不同清单。
  • 直接和传递依赖都可能贡献权限、组件、provider 与元数据,应比较目标 variant 的实际解析图,而不是只搜索顶层依赖声明。
  • 漂移清单要区分新增、删除和值变化,并为每一项记录预期来源、实际来源线索、业务决定和验证状态。
  • AAB 的 base 与 feature 模块会影响最终派生 APK 集,单独查看 AAB 根目录或一个源码文件不能代表设备安装后的完整声明集合。
  • 门禁通过只表示当前候选的静态清单与批准基线一致,不证明运行时访问控制、业务权限使用或全部设备行为已经验证。

先确认比较对象,避免拿源码文件替代最终候选

Android app manifest 定义应用组件、权限、intent filter、SDK 约束和应用元数据,但构建输出中的清单并不是主模块源码文件的原样复制。变体 source set、库依赖、占位符、构建参数和模块合并都可能改变最终结果。审核如果只打开 app/src/main/AndroidManifest.xml,就可能漏掉候选真正声明的组件或权限,也可能误把已被覆盖的源码值当成发布事实。

第一步要冻结最终 APK 或派生 APK 的摘要,并用固定工具导出规范化 Manifest。基线也要说明来自哪里:产品批准的预期清单、上一个已发布候选,还是当前源码输入。三个对象不能混称为源码 Manifest。差分报告应同时记录 candidateSha256、variant、versionCode、applicationId、导出工具版本和基线版本,使每一条漂移都能回到确切候选。

差异分为新增、删除和值变化。新增权限与组件需要确认贡献源,删除项要判断是否被变体覆盖或依赖升级移除,值变化则要检查占位符、资源引用与构建参数。发现差异不等于发现恶意篡改,也不等于可以忽略;在来源、业务决定和测试影响被记录前,候选应保持待确认。

Manifest 比较中的三类对象
对象代表什么必须绑定不能替代
源码输入主模块和 source set 声明提交、路径与 variant最终二进制清单
批准基线产品允许的静态声明版本、审批与适用范围构建实际输入
最终 Manifest候选 APK 携带的声明APK 摘要与解析工具运行时行为证明
合并线索字段可能来自的输入报告、依赖与参数唯一根因结论
设备状态安装后平台可见结果设备与测试回执所有设备矩阵

完整 variant 是清单追踪的起点

Android build variants 由 build type、product flavor、source set、applicationId 和签名配置等条件组合形成。main、debug、release、渠道 flavor 与组合 source set 可能各自提供 Manifest 输入,同名属性会按构建规则形成最终值。排查必须从流水线实际执行的 Gradle task 确认完整 variant,不能从 APK 文件名或分支名称猜测。

variant 还会决定占位符和生成字段。applicationId 后缀、provider authority、重定向 scheme、渠道标识、服务开关和第三方 SDK key 常通过构建属性进入清单。报告应保存实际展开值及其来源参数,同时对敏感值进行安全脱敏;脱敏不能抹掉判断差异所需的摘要或版本。若占位符在构建日志中不存在来源,最终值就不能被标记为已解释。

同一源码提交产生不同 Manifest,先比较任务、flavor、build type、source set 命中和 Gradle 属性,再讨论工具不确定性。若两个构建声称相同 variant,却读取了不同环境变量或本地 properties,它们的输入仍不相同。工程门禁应让每个清单相关参数显式进入构建证明,避免事后依靠开发者记忆还原。

variant 清单输入的追踪字段
输入记录内容可能改变缺失时状态
Gradle task完整任务和 module目标 variant无法确认构建入口
build typerelease 等实际值调试、网络与组件声明变体不完整
product flavor维度和渠道值资源、权限与元数据渠道来源未知
source set命中的 Manifest 路径组件与属性覆盖合并输入不完整
占位符名称、脱敏值与来源authority、scheme 和 key最终值无法解释
applicationId最终值及后缀规则包名关联属性候选身份模糊

直接与传递依赖都要进入 Manifest 来源图

Android Gradle dependency resolution 说明直接和传递依赖会形成实际版本解析图。一个顶层 SDK 可能继续引入其他 AAR,而每个 AAR 都可能携带自己的 Manifest。于是,仓库全文搜索没有找到某个权限或 provider,并不代表它不是构建输入;需要查看目标 variant 的已解析组件、版本和来源仓库,再定位贡献该字段的具体制品。

依赖树必须与候选同批生成。动态版本、版本约束、平台依赖、仓库顺序和缓存状态可能让同一顶层声明解析到不同制品。比较两个候选时,先对解析图规范化并计算摘要,找出新增、移除和版本变化,再把 Manifest 漂移映射到相关 AAR。依赖图只能缩小范围,不能因为某个版本同时变化就认定它是唯一根因。

Debug Android dependency resolution 建议从变体对应的依赖树和实际解析结果处理重复类与版本冲突。Manifest 漂移也应采用同样的变体边界:不要用 debugRuntimeClasspath 解释 release 候选,也不要只查 compile classpath 而忽略打包所用配置。若依赖替换、exclude 或 resolutionStrategy 改变了最终图,应把规则本身作为来源证据保存。

依赖贡献 Manifest 的调查顺序
步骤输入证据输出错误做法
确定配置目标 variant 与 classpath正确依赖范围使用其他变体依赖树
冻结解析图坐标、版本、仓库和校验候选依赖摘要只看声明文件
定位 AAR制品与内部 Manifest可能贡献源仅做仓库全文搜索
核对规则约束、exclude 与替换版本选择原因把缓存当成来源
关联漂移字段与依赖变化待验证根因时间相关即因果
形成回执候选、图与字段可复核记录复用旧构建日志

按 SDK、权限、组件和元数据分层归因

uses-sdk 漂移会改变安装范围与平台行为边界,但最终数值可能由构建配置、Manifest 输入或工具处理共同决定。报告应分别记录 minSdk、targetSdk 和其他明确导出的 SDK 字段,并回到 variant 配置核对来源。本文只定位静态字段为何变化,不把 SDK 变化扩写为完整兼容性结论;真实行为仍需针对对应平台版本回归。

权限差分要保存权限名、是否新增或删除、来源模块和批准决定。某个 SDK 引入权限不等于应用一定在运行时使用,也不等于权限无风险。组件差分则应按 activity、service、receiver 和 provider 记录规范化类名、enabled、permission、process 与 intent action。exported 等暴露面字段可以进入漂移清单,但其专项安全判断应由独立审查处理,避免在本篇形成另一套答案。

application 元数据和组件元数据常承载 SDK 配置、authority、启动器或功能开关。值可能是字面量、资源引用或占位符结果,直接比较字符串容易把等价解析误判为变化,也可能漏掉资源背后的实际值变化。建议同时保存二进制 Manifest 的规范化值和关联资源标识;需要解释运行值时,再绑定同一 APK 的资源表解析结果。

Manifest 漂移字段与责任边界
字段组比较内容来源线索仍需验证
SDKminSdk、targetSdkvariant 与构建配置平台行为和设备回归
权限名称及新增删除主模块或 AAR运行时使用和业务必要性
组件类型、类名和关键属性source set 或依赖运行入口与安全影响
intent filteraction、category、data组件合并输入实际解析行为
application 元数据键与规范化值占位符、资源或 SDK运行时读取结果
provider authority最终 authorityapplicationId 与占位符冲突和安装行为

来源归因需要合并报告与最终二进制交叉验证

构建过程生成的 Manifest 合并报告适合回答某个节点或属性由哪个输入加入、覆盖或移除。它是重要来源线索,但必须与当前候选绑定。若报告来自另一次 task、另一工作区或不同依赖解析图,即使文件名相同也不能解释当前 APK。门禁可将报告摘要、目标 variant、依赖图摘要和最终 APK 摘要写入同一索引,防止报告漂移。

最终二进制清单负责证实候选实际携带什么。建议先把二进制 Manifest 导出为稳定 JSON:包名和版本单列,uses-sdk 按固定键排序,权限去重排序,组件使用类型加规范化类名作为主键,intent filter 和 metadata 再分别排序。这样能够区分语义字段差异与 XML 属性顺序差异,也便于把结果交给自动门禁。

交叉验证时,从最终差异反向追踪,而不是把所有源码 Manifest 两两比较。每条漂移建立 status:已批准、待业务确认、待来源确认或拒绝。已批准需要来源文件、依赖制品或构建参数和审批记录;待来源确认不能通过一句第三方 SDK 引入关闭。若最终字段不存在于报告,也要检查是否使用了错误报告或后续打包阶段发生变化。

单条漂移记录的可复核结构
字段示例含义门禁要求禁止值
path权限或组件规范化路径唯一定位字段模糊页面描述
changeadded、removed、changed明确变化类型不一致
expected/actual批准值与候选值可机器比较口头结论
source文件、AAR 或参数绑定版本和摘要某个 SDK
decision批准、待确认或拒绝责任人与理由默认通过
evidence报告、依赖图和候选同批摘要绑定历史截图

AAB 与功能模块要求在派生 APK 层再次核对

Android App Bundle format 将应用组织为 base、feature、配置和资产模块,设备最终安装的是派生 APK 集。某个功能模块可以携带自己的 Manifest 声明,因此只解析 base 源码或 AAB 顶层材料,不能代表设备在安装该功能后可见的完整组件集合。盘点必须说明目标时点是首次安装、安装时功能齐备,还是按需模块加载之后。

对 AAB 发布,应保存 AAB 摘要、模块清单和针对目标设备派生的 APK 集。base APK 的 Manifest 与功能 split 的 Manifest 分别解析,再按该设备实际安装集合汇总。未请求的按需模块不存在不一定是缺陷,安装时模块缺失则需要调查。结论必须绑定设备规格和交付时点,不能把所有模块 Manifest 的并集写成每台设备的实际状态。

本地派生结果仍不是商店真实交付回执。它可以证明当前 AAB、工具和参数产生了某个清单集合;测试轨道或设备实际安装记录才能说明线上链路在指定环境交付了什么。若本文只拿到 APK 候选,就只报告该 APK 的静态清单和来源线索,不宣称 AAB 全模块或 Play 交付已经验证。

AAB 场景下 Manifest 核对范围
时点应核对对象允许的结论不可外推
AAB 构建base 与 feature 模块输入模块声明来源设备已取得
本地派生设备 scope 的 APK set工具选择结果商店最终交付
首次安装base 与安装时模块当前安装集合按需模块状态
功能安装后新增 feature split该时点组件集合其他功能组合
测试轨道真实下发与设备安装指定轨道回执全部生产设备
单 APK 审计当前二进制 Manifest该文件静态声明AAB 全模块完整

把 Manifest 漂移门禁放在签名和发布审批之前

NIST SP 800-218 SSDF 强调在安全开发流程中保留来源、构建、验证和变更证据。Manifest 漂移门禁应把源码提交、variant、依赖图、合并报告、最终候选、差分决定和测试回执连成一条链。它不需要阻止所有变化,而是阻止没有来源、没有批准或没有对应验证计划的变化进入发布。

推荐的停线条件包括:最终候选摘要未绑定;variant 或 source set 不明;新增权限或组件找不到贡献源;依赖版本变化未进入批准记录;关键占位符来源缺失;AAB 模块范围与设备时点不明;差分工具输入不是当前候选。通过条件则要求每条漂移都有明确决定,并让批准基线更新到新的不可变版本。

静态一致仍不是全部完成。Manifest 无漂移只能说明候选声明符合基线,不能证明业务代码没有动态放宽访问控制,也不能证明组件在所有设备和系统版本上的运行语义。发布流程还应安排对应的设备回归、权限路径测试和专项暴露面审查。申请商业加固时,清单门禁帮助确定保护边界,但不能冒充攻击阻断或兼容通过证据。

  • 最终 APK 摘要、variant 与解析工具已绑定
  • 主模块和所有命中 source set 已列入输入图
  • 目标 variant 的直接与传递依赖图已冻结
  • 权限、组件、SDK 与元数据逐项记录漂移
  • 每条差异拥有来源、决定、责任人与证据
  • 静态门禁之后仍安排设备和专项安全验证

用只读差分脚本把最终 Manifest 漂移变成发布阻断项

下面的 Python 示例读取两份规范化 JSON:批准基线和从最终 APK 导出的候选清单。它校验候选摘要、包名、usesSdk、权限、组件与 application 元数据结构,再输出新增、删除和值变化。组件以类型和规范化类名作为键,权限集合去重排序;输入缺失、重复组件、无效摘要或包名变化会返回非零状态,发现未批准漂移也以非零状态结束。

脚本不直接解析二进制 AndroidManifest.xml,也不猜测合并来源。生产流水线应使用固定版本的受信解析器从当前 APK 生成 candidate JSON,并由构建过程生成包含来源的基线;脚本输出随后与合并报告、source set 和依赖图关联。若两份 JSON 都由人工照抄,静态一致只能证明两份记录相同,不能证明记录与 APK 一致。

申请 APK 商业加固评估前,可准备最终候选、源码提交、variant、source set、依赖解析图、合并报告、清单差分、组件与权限批准记录及设备回归范围,再从御盾中央平台提交申请。没有当前候选与真实回执时,不宣称权限风险已消除、组件攻击被阻断、兼容矩阵通过或御盾已经处理该项目。

  • 基线版本和 candidateSha256 都由流水线生成
  • 组件名称在导出阶段完成一致的规范化
  • SDK、权限、组件和元数据分别比较
  • 重复组件、无效摘要和包名变化产生非零失败
  • 差分结果继续关联 source set、AAR 与合并报告
  • 静态一致后仍保留运行时与设备验证
比较批准基线与最终 APK 的规范化 Manifest
from pathlib import Path
import hashlib
import json
import re
import sys

SHA256 = re.compile(r"^[0-9a-f]{64}$")
COMPONENT_TYPES = {"activity", "service", "receiver", "provider"}

def load_json(path_text):
    path = Path(path_text)
    if not path.is_file():
        raise SystemExit(2)
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        raise SystemExit(2)
    if not isinstance(value, dict):
        raise SystemExit(2)
    return value

def normalize_permissions(value):
    if not isinstance(value, list) or any(not isinstance(item, str) or not item for item in value):
        raise SystemExit(2)
    if len(value) != len(set(value)):
        raise SystemExit(2)
    return sorted(value)

def normalize_components(value):
    if not isinstance(value, list):
        raise SystemExit(2)
    result = {}
    for item in value:
        if not isinstance(item, dict) or item.get("type") not in COMPONENT_TYPES or not isinstance(item.get("name"), str):
            raise SystemExit(2)
        key = f"{item['type']}:{item['name']}"
        if key in result:
            raise SystemExit(2)
        result[key] = {field: item.get(field) for field in ("enabled", "permission", "process", "intentActions")}
    return result

def normalize(document, require_digest):
    required = ("packageName", "usesSdk", "permissions", "components", "metadata")
    if any(field not in document for field in required):
        raise SystemExit(2)
    if not isinstance(document["packageName"], str) or not document["packageName"]:
        raise SystemExit(2)
    if not isinstance(document["usesSdk"], dict) or not isinstance(document["metadata"], dict):
        raise SystemExit(2)
    if require_digest and (not isinstance(document.get("candidateSha256"), str) or SHA256.fullmatch(document["candidateSha256"].lower()) is None):
        raise SystemExit(2)
    return {
        "packageName": document["packageName"],
        "usesSdk": document["usesSdk"],
        "permissions": normalize_permissions(document["permissions"]),
        "components": normalize_components(document["components"]),
        "metadata": document["metadata"],
    }

def compare_mapping(prefix, expected, actual, changes):
    for key in sorted(set(expected) | set(actual)):
        path = f"{prefix}.{key}"
        if key not in expected:
            changes.append({"path": path, "change": "added", "actual": actual[key]})
        elif key not in actual:
            changes.append({"path": path, "change": "removed", "expected": expected[key]})
        elif expected[key] != actual[key]:
            changes.append({"path": path, "change": "changed", "expected": expected[key], "actual": actual[key]})

if len(sys.argv) != 3:
    raise SystemExit(2)
baseline_doc = load_json(sys.argv[1])
candidate_doc = load_json(sys.argv[2])
baseline = normalize(baseline_doc, False)
candidate = normalize(candidate_doc, True)
if baseline["packageName"] != candidate["packageName"]:
    raise SystemExit(3)
changes = []
compare_mapping("usesSdk", baseline["usesSdk"], candidate["usesSdk"], changes)
compare_mapping("components", baseline["components"], candidate["components"], changes)
compare_mapping("metadata", baseline["metadata"], candidate["metadata"], changes)
for permission in sorted(set(candidate["permissions"]) - set(baseline["permissions"])):
    changes.append({"path": f"permissions.{permission}", "change": "added"})
for permission in sorted(set(baseline["permissions"]) - set(candidate["permissions"])):
    changes.append({"path": f"permissions.{permission}", "change": "removed"})
print(json.dumps({"candidateSha256": candidate_doc["candidateSha256"], "changes": changes}, ensure_ascii=False, indent=2))
raise SystemExit(1 if changes else 0)

事实依据与适用边界

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

本文判断事实或工程依据适用限制
Manifest 定义组件、权限、intent filter、SDK 约束和应用元数据。Android app manifest 描述应用清单的核心声明对象及平台作用。静态清单不能证明运行时访问控制没有被业务代码放宽,也不提供设备回归结论。
build type、product flavor、source set、applicationId 和签名配置能够组合出不同发布变体。Android build variants 描述 variant 的组成和构建配置关系。同一代码仓库不代表所有 variant 具有相同 Manifest、SDK、资源、证书或运行行为。
直接与传递依赖形成实际版本解析图,解析结果变化可能改变进入构建的制品。Android Gradle dependency resolution 描述依赖声明、传递关系和版本解析。依赖图只能缩小 Manifest 贡献源范围,不能单独证明某个 SDK 是唯一根因。
依赖问题应从目标变体的依赖树和实际解析结果定位。Debug Android dependency resolution 说明重复类和版本冲突的变体级调查方法。构建冲突被解决不等于最终 Manifest 漂移、加固后运行语义或设备兼容同时通过。
AAB 包含 base、feature、配置与资产相关模块,设备安装的是派生 APK 集。Android App Bundle format 描述模块结构与派生 APK 的交付模型。AAB 文件本身不是设备直接安装的最终 APK,本地模块清单也不证明商店真实交付。
安全发布流程应保存来源、构建、验证和变更证据。NIST SP 800-218 SSDF 描述安全软件开发中的来源、构建、验证和变更实践。SSDF 是组织级框架,不定义具体 Manifest 根因,也不证明某个 App 加固产品能力。
最终二进制 Manifest 与合并来源报告必须绑定同一候选和 variant。工程判断:来自其他任务或依赖图的报告不能解释当前 APK 中的静态字段。摘要绑定能避免证据错配,但不单独证明报告来源可信或运行时行为正确。
新增、删除和值变化需要独立记录来源、业务决定和验证状态。工程判断:三类变化影响不同输入和测试路径,统一写成不一致无法形成可执行门禁。差分分类只支持静态归因,不能替代组件暴露面专项审查、权限使用验证或真机测试。

工程常见问题

为什么仓库主 Manifest 没有某个权限,最终 APK 却出现了?

它可能来自目标 variant 的其他 source set、直接或传递 AAR、占位符或模块 Manifest。应从最终字段反向核对当前 variant 的合并报告和实际依赖解析图。

源码 Manifest 与最终 APK 不同,是否说明 APK 被篡改?

不能直接说明。构建合并本来就可能产生差异。需要绑定候选摘要、variant、source set、依赖和参数,判断每个最终字段是否有可复核来源和批准决定。

只查看 Gradle 顶层依赖能否找到 Manifest 贡献源?

通常不够。传递依赖也可能携带 AAR Manifest,且版本约束和解析规则会改变最终制品。应使用目标 variant 的实际解析图定位。

合并报告显示字段来源后,是否可以直接发布?

不能。还要确认报告与当前 APK 和 variant 绑定,字段变化经过业务批准,并安排对应设备、权限路径和专项安全验证。来源可解释不等于风险可接受。

AAB 的 base Manifest 是否代表设备安装后的全部组件?

不一定。功能模块可以携带独立 Manifest,实际集合与设备选择和功能安装时点有关。应按派生 APK 集和设备实际安装状态核对。

申请 APK 加固评估前需要准备哪些 Manifest 材料?

准备最终候选与摘要、源码提交、variant、source set、依赖解析图、合并报告、规范化清单差分、批准记录和设备回归范围,再通过御盾中央平台提交申请。

想用自己的 App 验证?

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

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