先看结论与判断条件
- 检查对象必须是发布变体实际解析出的直接与传递依赖,源码声明或单个 AAR 文件不能代表最终依赖图。
- 重复路径要区分内容完全相同、同名不同哈希、不同文件名但相同 SONAME,以及仅在部分 ABI 出现的缺口。
- 每个 Native 库都要记录 AAR 来源、坐标、ABI、归档路径、SHA-256、ELF machine、SONAME 和 NEEDED,才能解释最终装载关系。
- packagingOptions 的 pickFirst 或 exclude 只改变打包结果,不证明被保留版本满足所有上游 SDK 的 ABI 与符号契约。
- AAB 是发布输入,设备安装的是派生 APK 集;冲突修复后仍要核对目标设备 APK 中实际存在的 SO 和加载顺序。
- 静态清单用于提前阻塞可见冲突,最终兼容结论仍需要每个发布 ABI 上的加载、JNI、异常和核心业务回归。
先在加固前建立 Native 依赖基线
AAR 可以在 jni 目录内携带多个 ABI 的共享库,应用自身、直接 SDK 和传递依赖都可能贡献同名 SO。若冲突直到加固后才被发现,团队容易把安装失败、UnsatisfiedLinkError、缺失符号或 Native 崩溃直接归因保护工具。正确顺序是先对未加固发布变体建立可复核基线,再让加固候选继承同一依赖边界。
基线不是“项目里有多少个 .so”的文件列表。每一项还要记录解析变体、依赖坐标、AAR 文件摘要、ABI、归档路径、SO 摘要、SONAME 与 NEEDED。这样才能判断两个同名文件是否相同、谁把它带入构建、它期望哪些运行时依赖,以及冲突修复后实际保留了哪个版本。
工程判断上,提前发现冲突的目标是阻塞不可解释的打包选择,而不是用静态扫描宣布兼容。ELF 字段存在不证明动态加载、JNI 注册、异常传播或核心业务成功。本文没有具体 AAR、候选和设备回执,因此提供的是发现与门禁方法,不声称任何 SDK 或加固版本已经通过。
| 字段 | 回答的问题 | 来源 | 缺失风险 |
|---|---|---|---|
| dependencyCoordinate | 哪个依赖带入 | 解析后的变体图 | 无法定位所有者 |
| aarSha256 | 扫描的是哪个归档 | AAR 文件 | 同坐标内容漂移 |
| abi/path | 面向什么架构和打包位置 | ZIP jni 目录 | ABI 与路径冲突遗漏 |
| soSha256 | 内容是否完全一致 | SO 字节 | 同名不同版本被混同 |
| SONAME/NEEDED | 加载身份和依赖关系 | ELF dynamic section | 装载冲突无法解释 |
从发布变体的实际解析图追踪直接与传递来源
Android Gradle dependency resolution 说明直接和传递依赖会形成版本解析图,解析结果受声明、约束和冲突选择影响。SO 清单必须对应 release 或实际发布变体,不能用 debug 树代替。两个变体可能启用不同 SDK、产品风味、仓库内容或打包规则,最终 AAR 集自然不同。
Debug Android dependency resolution 建议从变体对应的依赖树和实际解析结果定位重复类与版本冲突。Native 冲突同样需要保存 resolved coordinate、selection reason 和依赖路径。看到某个 AAR 内含冲突文件时,不应立刻认定它是唯一根因,因为另一个传递依赖或本地 jniLibs 也可能贡献同名内容。
依赖清单要能区分声明版本和解析版本。动态版本、替换规则、平台约束或缓存变化都可能让相同构建脚本得到不同内容。发布门禁应保存锁定信息、仓库来源和 AAR 摘要;若解析结果发生变化,就重新生成 Native 清单,而不是复用上次扫描报告。
| 对象 | 需要保存 | 排查用途 | 不能证明 |
|---|---|---|---|
| 直接依赖 | 声明与解析坐标 | 确定业务所有者 | 只有它贡献 SO |
| 传递依赖 | 完整引入路径 | 定位间接 SDK | 上游版本一定兼容 |
| 选择原因 | 冲突选择和约束 | 解释解析版本 | Native ABI 契约正确 |
| 变体 | release、flavor 与属性 | 避免 debug 替代发布 | 最终 APK 已正确打包 |
| 归档摘要 | 每个 AAR 的 SHA-256 | 发现缓存或内容漂移 | 设备加载已经成功 |
按 ABI 和归档路径识别四类冲突
第一类是同一 ABI、同一归档路径出现多个提供者且内容不同,这是最直接的打包冲突;第二类路径相同且哈希相同,虽然内容一致,仍应合并所有来源并决定是否允许重复;第三类文件名不同却拥有相同 SONAME,装载身份可能发生碰撞;第四类某库只覆盖部分发布 ABI,导致特定设备缺失。
Android ABIs 说明每个 ABI 具有独立调用约定、寄存器、对齐和设备支持范围。arm64-v8a 与 armeabi-v7a 的同名库不能互换,x86_64 的虚拟测试通过也不能证明 arm64 真机可加载。清单要以 ABI 为一级分组,并把 ELF machine 与目录声明进行一致性核对。
缺口判断需要业务上下文。某个 AAR 可能有意只支持特定 ABI,应用也可能通过 abiFilters 明确缩小发布范围。扫描器应输出 missing-required-abi 和 extra-abi,最后由产品支持声明与发布配置决定是否阻塞。不能把“归档里没有”直接写成所有设备失败,也不能无视已声明 ABI 的缺失。
| 类型 | 识别条件 | 首要风险 | 处理方向 |
|---|---|---|---|
| 路径冲突 | 同 ABI 同文件名不同哈希 | 打包选择不可解释 | 确认唯一所有者与版本 |
| 重复相同内容 | 同路径同哈希多来源 | 来源冗余与升级漂移 | 显式允许或去重 |
| SONAME 冲突 | 不同路径共享 SONAME | 动态装载身份碰撞 | 复核上游与加载方式 |
| ABI 缺口 | 发布 ABI 缺少对应库 | 特定架构加载失败 | 补齐产物或缩小支持范围 |
| ELF 目录错配 | 目录 ABI 与 machine 不符 | 安装后无法正确执行 | 阻塞并重新构建库 |
用 ELF Header 和 Dynamic Section 解释文件身份
GNU readelf 可以检查 ELF header、program header、dynamic section、符号、重定位和 unwind 信息。冲突清单至少读取 Class、Machine、SONAME 与 NEEDED。Class 和 Machine 用于核对架构,SONAME 表示动态装载身份,NEEDED 列出链接依赖;文件名相同并不保证这些字段相同。
比较 NEEDED 可以发现版本漂移带来的依赖差异。例如同名库的一个版本依赖共享 C++ 运行时,另一个版本依赖额外厂商库,即使打包任务选中其中一个,另一个 SDK 的运行假设也可能被破坏。扫描结果需要保留原始 readelf 输出摘要,不能只给一个 conflict 布尔值。
静态信息也有边界。动态加载路径可能由 Java、JNI、dlopen 或系统命名空间决定,符号版本、加载顺序和异常类型只能在目标系统上验证。readelf 不会运行库,也不能证明构造函数、JNI_OnLoad 或业务入口成功。静态冲突消失后仍要执行加载与核心业务回归。
| 字段 | 回答的问题 | 异常信号 | 后续验证 |
|---|---|---|---|
| Class | 32 位或 64 位对象 | 与 ABI 目录不符 | 目标架构加载 |
| Machine | 目标指令架构 | 错误 machine | 设备安装与执行 |
| SONAME | 动态装载身份 | 不同文件共享身份 | 实际加载顺序 |
| NEEDED | 依赖哪些共享库 | 同名版本依赖集合不同 | 依赖闭包与启动 |
| Symbols/Relocations | 导入导出与重定位 | 缺失或版本不符 | JNI 和异常回归 |
packagingOptions 只能表达选择,不能替代兼容判断
构建系统面对重复 Native 文件时,团队可能使用 pickFirst、exclude 或其他 packagingOptions 让打包继续。pickFirst 回答“保留哪个路径的一个文件”,exclude 回答“不要打包哪个文件”,但都不回答被移除提供者是否需要不同符号、SONAME、异常运行时或初始化行为。
使用选择规则前必须确认所有上游 SDK 对该库的版本契约。若两个 AAR 打包了不同哈希的 libc++_shared.so、同名厂商库或自带公共运行时,应先查明发布建议和依赖关系,再决定统一版本、升级 SDK、排除重复或隔离加载。未经说明的 pickFirst 会把构建时冲突推迟为特定 ABI 的运行时故障。
规则要绑定变体和依赖摘要,并配置反向门禁。依赖升级后若冲突来源、哈希、SONAME 或 NEEDED 改变,原规则应进入人工复核,而不是继续无条件命中。理想报告同时显示 before providers、selected provider、excluded providers 和 decision record,使选择可审计。
| 动作 | 适用前提 | 必须证明 | 主要风险 |
|---|---|---|---|
| 统一上游版本 | SDK 可共同升级 | 解析图和 ABI 契约一致 | 业务升级回归不足 |
| 显式选中一个 | 内容兼容且所有者明确 | 符号、SONAME 与 NEEDED 满足 | 隐藏版本差异 |
| 排除重复文件 | 被排除者不需要独立内容 | 所有调用者继续工作 | 设备运行时缺失 |
| 重建 AAR | 有源代码和发布权限 | 每个 ABI 重新验证 | 供应链身份改变 |
| 缩小发布 ABI | 产品明确不支持该架构 | 商店与安装范围一致 | 误伤现有用户 |
核对 AAB 模块和设备派生 APK 中的最终 Native 内容
Android App Bundle format 说明 AAB 由 base、feature、配置与资产模块组成,设备实际安装的是派生 APK 集。AAR 清单只能说明依赖输入,不能证明最终设备拿到哪些 SO。冲突修复后要检查 base 与动态特性模块的 Native 内容,并按目标设备规格生成或获取派生 APK。
同名库可能出现在不同模块,按需特性也可能在后续安装时引入 Native 依赖。清单应记录 module、ABI、path、hash 和 SONAME,再与 AAR provider 对应。若最终 APK 中的库无法追到预期来源,说明打包规则、插件或构建步骤改变了结果,需要停止加固输入流转。
AAB 本身不能直接代表设备安装结果。测试需要覆盖至少每个发布 ABI 的派生 APK,核对安装、库加载、动态模块安装和核心 JNI 路径。静态 ZIP 对比通过只是门禁的一部分,不得写成商店交付、设备兼容或 Native 加固成功。
| 阶段 | 检查对象 | 关键身份 | 门禁问题 |
|---|---|---|---|
| 依赖解析 | 发布变体 AAR 集 | 坐标与 AAR 摘要 | 来源是否完整 |
| AAR 内容 | jni 下的 SO | ABI、路径与哈希 | 是否存在冲突和缺口 |
| AAB 模块 | base 与 feature Native 文件 | 模块与 SONAME | 规则是否改变结果 |
| 派生 APK | 目标设备 APK 集 | device spec 与 APK 摘要 | 设备会收到什么 |
| 设备运行 | 加载与 JNI 业务路径 | 候选、ABI 与回执 | 静态假设是否成立 |
用目标 ABI 的加载、JNI 和异常回归关闭静态门禁
Android NDK common problems 总结了 API level、缺失符号、STL、异常类型和错误依赖装载等常见兼容问题。冲突清单应将这些类别转成测试:每个发布 ABI 安装并启动,触发 System.loadLibrary 或等效入口,调用关键 JNI,覆盖异常和失败分支,并检查实际加载的库身份。
若多个 SDK 共用 Native 运行时,测试要分别触发其入口,避免只验证第一个加载者。崩溃与 UnsatisfiedLinkError 保存 Java 栈,Native signal 保存 tombstone 与符号,异常跨库边界要验证类型和恢复语义。单一 ABI 或单一设备通过不能代表完整架构与系统矩阵。
发布前门禁可分为 static-blocked、runtime-required 和 candidate-ready。重复不同哈希、SONAME 冲突、目录 machine 错配或必需 ABI 缺失时 static-blocked;静态清单无冲突但尚未运行时 runtime-required;目标矩阵和核心路径获得同候选回执后,才有资格进入 candidate-ready。
- 每个发布 ABI 至少有一个真实加载回执
- 共享 Native 运行时的所有上游入口分别触发
- JNI 正向、坏输入与异常分支均有断言
- Java 与 Native 诊断材料绑定同一候选
- 静态冲突修复后重新扫描最终派生 APK
- 未运行 ABI 继续标记 runtime-required
用只读脚本输出按 ABI 分组的重复 SO 清单
下面的 Python 示例读取一个只含待审 AAR 的目录和 requiredAbis JSON。脚本以只读方式打开 ZIP,仅接受 jni/<abi>/<name>.so 路径,限制成员数量与未压缩大小,计算 AAR 与 SO 的 SHA-256,并用 readelf 读取 Class、Machine、SONAME 和 NEEDED。
报告按 abi/name 聚合 providers,区分 identical-duplicate 与 conflicting-content;再按 abi/SONAME 检查多个内容身份,并根据 requiredAbis 输出每个库名的 ABI 缺口。ZIP 路径越界、ELF 读取失败、未知 ABI 或格式错误都会直接失败,避免用不完整清单继续决策。
准备 AAR Native 冲突评估时,可整理发布变体依赖图、AAR 文件、required ABI、packagingOptions、AAB、派生 APK 和设备回执,再通过御盾中央平台提交申请。本站关于 SO 保护范围和 Native 加载诊断的文章可用于后续运行门禁,但静态报告本身不代表兼容通过。
- 只扫描发布变体实际解析出的 AAR
- requiredAbis 来自产品支持与打包配置
- 重复路径区分同哈希与不同哈希
- SONAME 冲突与文件名冲突分别输出
- ABI 缺口仅作门禁输入并保留业务边界
- 静态通过后仍执行派生 APK 与设备验证
#!/usr/bin/env python3
import hashlib
import json
import re
import subprocess
import sys
import tempfile
import zipfile
from collections import defaultdict
from pathlib import Path
SO_PATH = re.compile(r"^jni/([^/]+)/([^/]+\.so)$")
SAFE_ABI = re.compile(r"^[A-Za-z0-9_-]+$")
MAX_MEMBERS = 20000
MAX_SO_BYTES = 256 * 1024 * 1024
def fail(message, code):
print(message, file=sys.stderr)
raise SystemExit(code)
def sha256_file(path):
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def sha256_bytes(value):
return hashlib.sha256(value).hexdigest()
def load_required_abis(path):
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
fail("cannot read required ABI file: " + str(exc), 3)
if not isinstance(value, dict) or not isinstance(value.get("requiredAbis"), list):
fail("requiredAbis must be a list", 4)
abis = value["requiredAbis"]
if not abis or not all(isinstance(x, str) and SAFE_ABI.fullmatch(x) for x in abis):
fail("requiredAbis contains invalid values", 5)
if len(abis) != len(set(abis)):
fail("requiredAbis contains duplicates", 6)
return sorted(abis)
def parse_readelf(path):
try:
header = subprocess.run(
["readelf", "--file-header", str(path)],
check=True, capture_output=True, text=True, timeout=20
).stdout
dynamic = subprocess.run(
["readelf", "--dynamic", str(path)],
check=True, capture_output=True, text=True, timeout=20
).stdout
except (OSError, subprocess.SubprocessError) as exc:
fail("readelf failed: " + str(exc), 7)
elf_class = None
machine = None
for line in header.splitlines():
stripped = line.strip()
if stripped.startswith("Class:"):
elf_class = stripped.split(":", 1)[1].strip()
if stripped.startswith("Machine:"):
machine = stripped.split(":", 1)[1].strip()
soname = None
needed = []
for line in dynamic.splitlines():
if "(SONAME)" in line and "[" in line and "]" in line:
soname = line.rsplit("[", 1)[1].split("]", 1)[0]
if "(NEEDED)" in line and "[" in line and "]" in line:
needed.append(line.rsplit("[", 1)[1].split("]", 1)[0])
if not elf_class or not machine:
fail("ELF header fields are missing", 8)
return {
"elfClass": elf_class,
"machine": machine,
"soname": soname,
"needed": sorted(set(needed)),
}
def scan_aar(aar_path, temporary_root):
records = []
try:
archive = zipfile.ZipFile(aar_path)
except (OSError, zipfile.BadZipFile) as exc:
fail("invalid AAR: " + str(exc), 9)
with archive:
members = archive.infolist()
if len(members) > MAX_MEMBERS:
fail("AAR has too many members: " + aar_path.name, 10)
for member in members:
match = SO_PATH.fullmatch(member.filename)
if not match:
continue
abi, name = match.groups()
if not SAFE_ABI.fullmatch(abi) or Path(name).name != name:
fail("unsafe SO path: " + member.filename, 11)
if member.file_size <= 0 or member.file_size > MAX_SO_BYTES:
fail("invalid SO size: " + member.filename, 12)
try:
data = archive.read(member)
except (OSError, RuntimeError, zipfile.BadZipFile) as exc:
fail("cannot read SO: " + str(exc), 13)
target = temporary_root / (sha256_bytes((aar_path.name + member.filename).encode()) + ".so")
target.write_bytes(data)
elf = parse_readelf(target)
records.append({
"aar": aar_path.name,
"aarSha256": sha256_file(aar_path),
"path": member.filename,
"abi": abi,
"name": name,
"soSha256": sha256_bytes(data),
**elf,
})
return records
def analyze(records, required_abis):
by_path = defaultdict(list)
by_soname = defaultdict(list)
by_name_abis = defaultdict(set)
for record in records:
by_path[(record["abi"], record["name"])].append(record)
if record["soname"]:
by_soname[(record["abi"], record["soname"])].append(record)
by_name_abis[record["name"]].add(record["abi"])
duplicates = []
for key, providers in sorted(by_path.items()):
if len(providers) < 2:
continue
hashes = {item["soSha256"] for item in providers}
duplicates.append({
"abi": key[0],
"name": key[1],
"kind": "identical-duplicate" if len(hashes) == 1 else "conflicting-content",
"providers": providers,
})
soname_conflicts = []
for key, providers in sorted(by_soname.items()):
hashes = {item["soSha256"] for item in providers}
names = {item["name"] for item in providers}
if len(hashes) > 1 or len(names) > 1:
soname_conflicts.append({
"abi": key[0],
"soname": key[1],
"providers": providers,
})
gaps = []
required = set(required_abis)
for name, present in sorted(by_name_abis.items()):
missing = sorted(required - present)
if missing:
gaps.append({"name": name, "present": sorted(present), "missing": missing})
return duplicates, soname_conflicts, gaps
def main():
if len(sys.argv) != 3:
print("Usage: aar_so_inventory.py AAR_DIRECTORY REQUIRED_ABIS_JSON", file=sys.stderr)
raise SystemExit(2)
aar_directory = Path(sys.argv[1])
abi_path = Path(sys.argv[2])
if not aar_directory.is_dir() or not abi_path.is_file():
print("required input is missing", file=sys.stderr)
raise SystemExit(2)
aar_files = sorted(path for path in aar_directory.iterdir() if path.is_file() and path.suffix == ".aar")
if not aar_files:
fail("AAR directory is empty", 14)
required_abis = load_required_abis(abi_path)
with tempfile.TemporaryDirectory(prefix="aar-so-inventory-") as temporary:
temporary_root = Path(temporary)
records = []
for aar_path in aar_files:
records.extend(scan_aar(aar_path, temporary_root))
if not records:
fail("no embedded SO files found", 15)
duplicates, soname_conflicts, gaps = analyze(records, required_abis)
blocked = any(item["kind"] == "conflicting-content" for item in duplicates) or bool(soname_conflicts) or bool(gaps)
result = {
"status": "blocked" if blocked else "runtime-validation-required",
"requiredAbis": required_abis,
"records": records,
"duplicates": duplicates,
"sonameConflicts": soname_conflicts,
"abiGaps": gaps,
}
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
if blocked:
raise SystemExit(20)
if __name__ == "__main__":
main()事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| 直接与传递依赖会形成版本解析图,解析结果变化可能影响运行兼容。 | Android Gradle dependency resolution 描述 Android 构建中的依赖解析。 | 依赖树只能缩小范围,不能证明某个 SDK 或 AAR 是唯一根因。 |
| 重复与版本冲突应从具体变体的依赖树和实际解析结果定位。 | Debug Android dependency resolution 说明依赖问题的变体化排查路径。 | 构建冲突修复不等于加固后 Native 运行时冲突已经消失。 |
| 每个 Android ABI 具有独立调用约定、寄存器、对齐与设备范围。 | Android ABIs 说明 NDK 支持的 ABI 及其技术差异。 | 声明支持某 ABI 不证明对应 SO 已正确构建、打包和运行。 |
| AAB 包含 base、feature、配置与资产模块,设备安装的是派生 APK 集。 | Android App Bundle format 描述 App Bundle 的结构与交付关系。 | AAB 本身不是设备直接安装的最终 APK,仍需核对派生结果。 |
| ELF header、dynamic section、符号、重定位与 unwind 信息可由工具静态检查。 | GNU readelf 描述 readelf 支持检查的 ELF 信息。 | 静态字段存在不证明动态加载、JNI、异常传播或业务路径成功。 |
| NDK 兼容问题常涉及 API、缺失符号、STL、异常类型与依赖装载。 | Android NDK common problems 汇总常见 Native 兼容故障。 | 故障目录不能替代目标 ABI 的真实启动、加载与异常回归。 |
| 同路径不同哈希、相同 SONAME 不同内容和必需 ABI 缺失应在加固前阻塞。 | 工程判断:这些差异使最终打包选择或动态加载身份无法得到唯一解释。 | 静态冲突成立不自动证明设备故障,仍需上游契约与运行证据。 |
| pickFirst 与 exclude 只能表达打包选择,不能证明所有 SDK 的 Native 契约兼容。 | 工程判断:被选择与被排除版本可能具有不同 SONAME、NEEDED、符号和异常运行时。 | 具体修复方式必须结合依赖所有者、发布 ABI 和设备回归决定。 |
工程常见问题
Gradle 构建已经成功,是否说明 AAR 内置 SO 没有冲突?
不能。打包规则可能选择或排除一个文件,使构建继续,但其他 SDK 可能依赖不同符号、SONAME 或运行时。还要核对 provider、哈希、ELF 字段、最终 APK 和设备加载。
两个 AAR 带有同名且同哈希的 SO 需要处理吗?
仍需记录所有来源并决定是否显式允许。内容相同降低版本冲突可能性,但重复来源会增加升级漂移风险;后续任一 AAR 更新都应重新扫描。
文件名不同为什么还要检查 SONAME?
动态装载身份不只由归档文件名决定。两个不同路径的库可能声明相同 SONAME,造成装载或依赖解析歧义,因此要按 ABI 同时检查路径、哈希和 SONAME。
使用 pickFirst 保留较新版本是否通常安全?
不能按版本新旧直接判断。必须确认所有上游 SDK 对符号、STL、异常、SONAME 和 NEEDED 的契约,并在每个发布 ABI 上触发其真实入口。
为什么扫描 AAR 后还要检查 AAB 派生 APK?
AAR 是依赖输入,AAB 还包含 base、feature 和配置模块,设备最终安装派生 APK。打包规则和模块化可能改变实际 SO 集,必须核对目标设备结果。
申请 AAR Native 冲突评估要准备什么?
准备发布变体依赖图、AAR 文件、required ABI、packagingOptions、AAB、派生 APK 和目标 ABI 设备回执,再从御盾中央平台提交申请。