先看结论与判断条件

  • 文件名、DT_SONAME、DT_NEEDED 和显式 dlopen 名称是四类不同事实,不能用其中一个字段代替完整装载身份。
  • 同一 ABI 中多个不同文件声明相同 SONAME 会制造装载歧义,文件改名但 SONAME 与依赖引用不变也可能让替换结果偏离预期。
  • Android 动态链接器按调用者 namespace、可见库和搜索路径解析依赖,静态清单必须保留调用者与进程上下文。
  • CMake 导入名、输出名和预编译库文件需要与最终 ELF 动态段交叉核对,构建配置只说明意图,不代表最终产物。
  • readelf 可以取得 SONAME、NEEDED、header、program header 和符号信息,但静态字段通过不能证明目标设备已成功装载。
  • 发布门禁应按 ABI 绑定最终 APK 摘要、每个 SO 摘要、名称决策和设备加载回执,禁止用源码目录或另一渠道库替代。

先分清文件名、SONAME 和依赖名

APK 中 `lib/<abi>/libsample.so` 的 basename 是打包文件名,ELF dynamic section 的 `DT_SONAME` 是该共享对象声明的逻辑身份,另一个 ELF 的 `DT_NEEDED` 则保存它在链接时记录的依赖名称。三者经常相同,但这只是良好构建结果,不是概念上同一字段。简单重命名文件不会自动改写 SONAME,也不会修改所有调用方的 NEEDED。

运行时还有第四个名称来源:应用或第三方库传给加载 API 的字符串。显式路径、basename、逻辑库名和已经加载对象的身份可能走不同解析路径。诊断必须把加载调用者、进程、namespace、请求字符串、候选文件和最终 ELF 动态段放在一张表中。只看到目录里存在同名 so,不能证明调用方会选中它,也不能证明它的依赖会继续解析到同一组对象。

本文只回答 SO 文件名、SONAME 与 DT_NEEDED 不一致为何造成装载歧义,以及发布前怎样检查,不重复完整依赖闭包审计。静态一致性不能证明初始化函数、符号解析和异常传播成功;设备加载成功也不能证明业务算法或保护强度。没有最终 APK、目标 ABI 和真实回执时,只能报告名称事实与风险判断。

ELF 装载链中的四种名称
名称来源示例位置由谁产生主要用途
APK 文件名lib 目录 basename打包和构建输出定位随包文件
DT_SONAMEELF dynamic section链接器选项或默认输出声明运行身份
DT_NEEDED调用方 dynamic section链接时依赖解析请求依赖对象
显式加载名dlopen 或上层加载调用应用与运行库代码启动一次装载查找
已加载对象进程 linker 状态运行时解析结果后续符号绑定
构建目标名CMake 或其他构建配置工程配置描述构建意图

不一致会怎样制造错误命中

最常见错误是把 `libold.so` 复制并改名为 `libnew.so`,却保留内部 SONAME 为 `libold.so`。若调用方的 DT_NEEDED 仍请求 `libold.so`,改名文件不一定成为它期待的替代;若同时打包旧文件和新文件,两个不同字节又可能声明相同身份。加载顺序、namespace 可见性和已加载对象状态会共同影响结果,不能把“文件名不同”当作自然隔离。

第二类错误是升级供应商库时只替换一个 so,没有重新链接所有直接调用方。新文件的 SONAME 可能改变,而旧调用方仍把原名称写在 DT_NEEDED 中;或者 SONAME 不变但 ABI 和符号集合已经改变,静态名称看似一致,运行时却在符号解析或初始化阶段失败。名称门禁要与摘要和导出、导入符号差异配合,不能只用字符串相等放行。

第三类错误来自多个 AAR、jniLibs 和构建任务携带重复库。Gradle 最终可能按打包规则选择其中一份,源码树里的每个副本都不能代表交付结果。必须从最终 APK 或真实派生 APK 集提取每个 ABI 的 so,计算摘要并读取 dynamic section。若同一 SONAME 对应不同摘要,先查明所有者和选择规则;不能按目录顺序猜测哪个会被加载。

  • 文件改名同时审查 SONAME 和全部依赖引用
  • 供应商升级后重新链接并检查直接调用方
  • 相同 SONAME 的不同摘要被视为冲突
  • AAR、jniLibs 和构建产物重复来源可追溯
  • 最终 APK 而非源码目录作为静态事实
  • 名称一致后继续检查 ABI 和符号兼容

namespace 决定调用者能看到哪些库

Android 动态链接器按调用者所在 namespace 解析 DT_NEEDED、dlopen、搜索路径和允许库。相同请求字符串从不同调用者或进程发起,候选集合可能不同。AOSP VNDK namespace 资料展示了隔离、链接和允许列表等机制,但普通 App 的实际行为还受系统版本、应用打包、ClassLoader 和加载入口影响,不能把 VNDK 配置直接照搬成 App 结论。

静态清单因此要记录依赖边,而不仅是全局文件集合。每条 DT_NEEDED 应包含调用方路径、调用方 ABI、请求名称和在当前交付集中的候选提供者。显式 dlopen 还要记录调用模块、是否使用路径、请求发生的进程和加载时序。若扫描器只建立“项目里有这个名字”的全局索引,会把另一 ABI、另一 split 或不可见 namespace 中的文件误当成可解析候选。

已加载对象也会影响后续判断。某个库先由第三方 SDK 或系统组件加载,后续请求可能复用已存在对象,而不是按开发者预期再次选择文件。静态工具无法完整模拟每台设备的 linker 状态,只能标出同名、同 SONAME、重复摘要和路径歧义。最终放行还要在目标设备采集实际加载结果和失败日志,并与同一候选绑定。

名称冲突的常见装载场景
场景静态现象运行风险门禁决定
改名未改 SONAME文件名与声明不同身份复用或替换失效要求来源说明和设备验证
两个文件同 SONAME不同路径或摘要同身份加载顺序产生歧义默认阻断
NEEDED 无本地提供者依赖名未命中 APK依赖系统库或直接失败对照允许系统库清单
提供者改 SONAME旧调用方仍请求旧名依赖无法解析重新链接调用方
不同 ABI 名称漂移架构间 SONAME 不一致设备表现分裂按 ABI 独立阻断
namespace 不可见全局有文件但调用者不可见dlopen 或 NEEDED 失败设备验证调用上下文

从最终 APK 读取动态段而不是猜配置

GNU readelf 可以检查 ELF header、program header、dynamic section、符号、重定位和 unwind 信息。名称门禁至少对每个最终 so 执行 dynamic section 检查,提取 SONAME 与全部 NEEDED,同时读取 ELF class、machine 和 program header,用文件摘要绑定结果。工具输出、工具版本和原始文件路径都要保存,避免后续只剩手工抄写的名称。

SONAME 缺失不一定表示文件绝对无法加载,但会让身份和依赖匹配更多依赖文件名与调用方式。项目应定义哪些应用自有和第三方库允许没有 SONAME,并保存供应商或构建理由。扫描器不能自动给缺失项补一个文件名,也不能修改二进制后继续沿用原签名和摘要。任何修复都要回到构建链重新链接、重新打包并生成新候选。

DT_NEEDED 中的系统库不能要求在 APK 内出现,否则会产生大量误报。门禁需要按目标 minSdk、NDK 稳定 API 和平台允许范围维护受控系统库清单;应用自有依赖则必须在对应 ABI 和交付模块中有唯一提供者。第三方预编译库可能携带非公共或高版本依赖,静态名称发现只是第一步,仍需目标系统装载验证。

  • 每个最终 so 的 dynamic section 和文件摘要绑定
  • ELF machine、class、SONAME 与 NEEDED 同时保存
  • SONAME 缺失具有明确允许策略和来源
  • 系统库清单按 minSdk 与平台边界维护
  • 应用自有 NEEDED 在同 ABI 有唯一提供者
  • 修复通过重新链接生成新候选而非修改已签名包

构建系统名称必须与最终 ELF 交叉核对

Android NDK 的 CMake toolchain 用于固定 ABI、平台版本和第三方库导入方式。CMake target 名、OUTPUT_NAME、IMPORTED_LOCATION 和链接依赖会影响最终文件与链接命令,但它们只是构建意图。预编译库内部的 SONAME 可能与导入 target 或文件名不同,IMPORTED_NO_SONAME 等配置也会改变调用方记录什么名称。发布门禁必须查看最终链接输出和 ELF 动态段。

非标准构建系统同样需要显式固定 NDK toolchain、API level、ABI 和编译目标。手写 clang 命令、ndk-build、Rust 或供应商脚本可能使用不同 SONAME 选项和复制规则。团队应生成统一的最终 ELF 清单,而不是为每种构建系统编写一套不可比较的报告。配置正确也不证明第三方预编译库内部选项与目标运行时兼容。

构建审查还要保存链接命令和版本替换责任。若业务需要保持稳定 SONAME,升级时应确保所有直接调用方重新链接并验证符号契约;若计划改变 SONAME,则要同步更新文件名、加载调用和交付清单。不能通过复制两份同一字节、分别改文件名来模拟两个独立版本,因为内部身份和依赖边仍可能相同。

构建配置到最终 ELF 的核对表
构建字段最终证据可能漂移审查动作
CMake target最终文件和链接命令逻辑名与输出名不同保存目标映射
OUTPUT_NAMEAPK basename复制任务再次改名以最终 APK 为准
SONAME 选项DT_SONAME预编译库保留旧值逐文件读取动态段
target_link_libraries调用方 DT_NEEDED链接选择另一副本比较实际依赖名
IMPORTED_LOCATION提供者摘要与路径ABI 目录串用逐 ABI 校验 machine
平台与 toolchainELF ABI 和最低 API 假设自定义脚本未继承记录工具链版本

RELRO 与 NOW 是独立的链接硬化维度

GNU ld 的 `-z relro` 会生成 PT_GNU_RELRO,`-z now` 要求装载时完成符号解析。这些标志有助于降低部分重定位面风险,也可能让缺失符号更早暴露,但它们不解决 SONAME、文件名和 DT_NEEDED 不一致。一个同时启用 RELRO 与 NOW 的库仍可能声明错误 SONAME,调用方也可能请求另一个名称。

发布报告应将名称一致性、依赖可解析性、符号绑定策略和段权限分成独立门禁。把 `-z now` 当作名称修复,会掩盖真正的构建关系;把名称一致当作安全加固通过,也会遗漏段权限和延迟绑定风险。每项结论都要引用对应 ELF 字段和目标文件摘要,不能用一条链接器参数覆盖全部二进制属性。

VMP 或 SO 保护流程若重新链接、包装或复制库,需要重新读取 SONAME、NEEDED、program header 和摘要。保护前的 readelf 回执不能代表保护后候选,保护后名称相同也不能证明字节或符号契约未变。本文不宣称任何保护流程自动保持这些字段,实际候选需要独立静态和设备门禁。

  • SONAME 一致性与 RELRO、NOW 分开登记
  • 链接参数和最终 program header 交叉核对
  • NOW 使解析更早不等于依赖名称正确
  • 名称一致不替代段权限和符号审查
  • 保护后重新读取全部动态段与 program header
  • 每条结论绑定实际候选文件摘要

用清单脚本发现名称冲突和未解析依赖

下面的 Python 示例读取由 readelf 提取器生成的脱敏 JSON。每个条目包含 ABI、路径、文件名、SHA-256、SONAME 和 NEEDED,文档还提供允许的系统库集合。脚本按 ABI 建立提供者索引,报告文件名与 SONAME 不一致、SONAME 缺失、同一 SONAME 对应不同摘要,以及应用自有 NEEDED 没有唯一提供者。它不执行 dlopen、不修改 ELF,也不模拟 Android linker。

输入必须来自同一个最终 APK 或明确的设备交付集,不能混入源码输出目录。示例对缺失文件、空清单、字段不完整、路径 basename 与 file_name 不符、重复 SONAME 冲突和无法解析依赖设置可达失败路径。文件名与 SONAME 不同会进入 review 列表而不是一律判死,因为某些受控构建可能有明确理由;同身份不同字节则直接阻断。

生产实现还应加入模块、split、调用进程、显式加载调用、ELF machine、Native 符号和 namespace 设备证据。系统库 allowlist 要按目标平台维护,不能为了让门禁通过不断追加未知名称。脚本输出是静态决策表,真正加载对象、搜索顺序和初始化结果仍需目标设备确认。

检查文件名、SONAME 和 DT_NEEDED 的一致性
from pathlib import Path
import json
import sys

if len(sys.argv) != 2:
    raise SystemExit("usage: soname_gate.py elf-inventory.json")

path = Path(sys.argv[1]).resolve()
if not path.is_file():
    raise SystemExit(f"input missing: {path.name}")
document = json.loads(path.read_text(encoding="utf-8"))
objects = document.get("objects")
if not isinstance(objects, list) or not objects:
    raise SystemExit("objects must be a non-empty list")
system_libraries = set(document.get("system_libraries", []))

providers = {}
reviews = []
for item in objects:
    required = ("abi", "path", "file_name", "sha256", "needed")
    if any(item.get(key) in (None, "") for key in required):
        raise SystemExit("ELF inventory entry is incomplete")
    if Path(item["path"]).name != item["file_name"]:
        raise SystemExit(f"path and file_name differ: {item['path']}")
    soname = str(item.get("soname") or "").strip()
    if not soname:
        reviews.append({"abi": item["abi"], "file": item["file_name"], "issue": "missing-soname"})
        continue
    key = (item["abi"], soname)
    providers.setdefault(key, []).append(item)
    if soname != item["file_name"]:
        reviews.append({"abi": item["abi"], "file": item["file_name"], "issue": "file-soname-diff", "soname": soname})

collisions = []
for (abi, soname), matches in providers.items():
    digests = {item["sha256"] for item in matches}
    if len(digests) > 1:
        collisions.append({"abi": abi, "soname": soname, "files": [item["path"] for item in matches]})
if collisions:
    raise SystemExit(json.dumps({"soname_collisions": collisions}, ensure_ascii=False))

unresolved = []
for caller in objects:
    for needed in caller["needed"]:
        if needed in system_libraries:
            continue
        matches = providers.get((caller["abi"], needed), [])
        if len(matches) != 1:
            unresolved.append({"caller": caller["path"], "needed": needed, "providers": len(matches)})
if unresolved:
    raise SystemExit(json.dumps({"unresolved_needed": unresolved}, ensure_ascii=False))

print(json.dumps({"status": "static-consistent", "reviews": reviews}, ensure_ascii=False, indent=2))

在目标 ABI 和真实调用路径完成装载回归

NDK 常见问题包括缺失符号、STL、API level、异常类型和错误依赖装载,名称门禁通过后仍要在目标设备测试。矩阵至少覆盖每个交付 ABI、最低支持系统、主要系统版本、冷启动、延迟功能入口、显式 dlopen 路径和所有使用目标库的进程。只验证 System.loadLibrary 返回不够,还要调用代表性符号并触发初始化、错误和卸载相关路径。

设备回执应记录最终 APK 摘要、so 摘要、ABI、系统、进程、调用者、请求名称、实际加载路径、linker 错误和业务入口。若系统不提供完整加载路径,应保存可合法取得的日志和模块状态,并明确证据限制。单一设备成功不能代表全部 namespace 与厂商实现,另一个 ABI 的成功也不能覆盖当前 ABI。

完整发布材料包括最终 ELF 清单、readelf 原始输出、CMake 或其他 toolchain 配置、链接命令、重复库来源、名称决策、系统库清单和设备回归。需要评估实际 SO 时,可通过御盾中央平台提交脱敏的 ELF 动态段、文件摘要、ABI、加载调用和最终候选摘要,先固定名称所有权,再处理 namespace 与设备装载。

  • 每个交付 ABI 都在真实设备或等价环境验证
  • 冷启动、延迟加载和目标进程入口均被触发
  • 加载成功后继续调用代表性符号和初始化路径
  • 设备回执绑定最终 APK 和每个 so 摘要
  • linker 错误与文件名、SONAME、NEEDED 结果关联
  • 单一设备结果不会外推到所有 namespace 和厂商

事实依据与适用边界

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

本文判断事实或工程依据适用限制
readelf 可以检查 ELF header、program header、dynamic section、符号、重定位和 unwind 信息。GNU readelf 说明 ELF 文件检查选项和输出范围。静态字段存在不能证明动态加载、符号调用或异常传播成功。
-z relro 生成 PT_GNU_RELRO,-z now 要求装载时完成符号解析。GNU ld options 说明 RELRO 与 NOW 等链接器选项。链接标志不能修复 SONAME 不一致,也不保护业务算法或运行时明文。
Android 动态链接器按调用者 namespace 解析 DT_NEEDED、dlopen、搜索路径和允许库。AOSP linker namespace 说明动态链接器 namespace 的隔离与链接机制。VNDK namespace 文档不能替代普通 App 进程在目标设备上的实测。
NDK 兼容故障常见于 API level、缺失符号、STL、异常类型和错误依赖装载。Android NDK common problems 汇总常见 Native 构建与运行问题。故障清单不能替代目标 ABI 的实际启动、符号调用和异常回归。
CMake toolchain 用于固定 ABI、平台版本和第三方库导入方式。Android NDK CMake 说明 Android CMake toolchain 和库导入配置。CMake 配置不覆盖已预编译二进制的内部编译与 SONAME 选项。
非标准构建必须显式固定 NDK toolchain、API level、ABI 与编译目标。NDK with other build systems 说明非 CMake 构建的工具链和目标配置。构建参数正确不能证明第三方预编译库与目标运行时兼容。
文件名、SONAME、DT_NEEDED 和显式加载名应作为四类独立事实记录。工程判断:分层记录可以发现改名未重链接、重复身份和加载调用漂移。静态名称关系不能完整模拟 namespace 搜索和已加载对象状态。
同一 ABI 中一个 SONAME 对应不同摘要应在发布前阻断。工程判断:多个不同字节共享运行身份会让实际命中依赖加载顺序与可见范围。具体装载结果仍需目标进程和设备证据确认,阻断规则是保守工程门禁。

工程常见问题

把 libold.so 改名为 libnew.so 会自动改变 SONAME 吗?

不会。文件复制和改名不会自动改写 ELF dynamic section,也不会更新其他库中已有的 DT_NEEDED。

文件名与 SONAME 不同是否一定无法加载?

不一定,但会增加依赖解析和替换的解释成本。需要检查调用方式、DT_NEEDED、namespace、重复库和目标设备结果。

两个不同文件可以声明相同 SONAME 吗?

技术上可能出现,但同一 ABI 与交付范围内会制造身份歧义,应默认阻断并查清构建与所有者。

readelf 检查通过是否代表 SO 一定能运行?

不代表。还要在目标 ABI 和系统验证 namespace 可见性、符号解析、初始化、错误路径和代表性业务调用。

启用 RELRO 和 NOW 能修复名称不一致吗?

不能。它们属于链接硬化和符号解析策略,SONAME、文件名与 DT_NEEDED 关系仍需独立修复。

准备 SO 名称一致性审查需要哪些材料?

准备最终 APK、逐 ABI so 摘要、readelf 动态段、链接命令、CMake 或工具链配置、重复库来源、加载调用和设备回执。

想用自己的 App 验证?

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

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