先看结论与判断条件

  • 检查对象必须是发布变体实际解析出的直接与传递依赖,源码声明或单个 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 或加固版本已经通过。

AAR Native 基线的最小字段
字段回答的问题来源缺失风险
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 的缺失。

AAR 内置 SO 的四类冲突
类型识别条件首要风险处理方向
路径冲突同 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 或业务入口成功。静态冲突消失后仍要执行加载与核心业务回归。

ELF 字段在冲突排查中的作用
字段回答的问题异常信号后续验证
Class32 位或 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,使选择可审计。

重复 SO 的处理决策
动作适用前提必须证明主要风险
统一上游版本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 到设备 APK 的核对链
阶段检查对象关键身份门禁问题
依赖解析发布变体 AAR 集坐标与 AAR 摘要来源是否完整
AAR 内容jni 下的 SOABI、路径与哈希是否存在冲突和缺口
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 与设备验证
扫描 AAR 内嵌 SO 并报告路径、SONAME 与 ABI 冲突
#!/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 设备回执,再从御盾中央平台提交申请。

想用自己的 App 验证?

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

继续阅读: SO 加固与 Native 兼容性检查清单