先看结论与判断条件
- 应用包含多个 C++ 共享库并跨库交换 STL 对象或异常时,应统一 libc++ 策略;混用静态副本、共享运行库或旧 STL 会扩大未定义行为风险。
- 最终 APK 中每个 ABI 的 `libc++_shared.so` 应来源明确且只有一个交付副本,构建目录中的多个候选必须在打包前解决,而不是依赖覆盖顺序。
- DT_NEEDED 能证明某个 SO 声明依赖共享运行库,却不能证明没有依赖的 SO 一定是纯 C、静态 libc++ 或与其他库完全隔离。
- 异常和 RTTI 跨 SO 需要一致的编译与运行时边界;启用选项只表示构建意图,仍要验证实际 throw、catch、dynamic_cast 与析构路径。
- NDK 版本、API level、ABI、toolchain、CMake 导入和预编译库来源都要绑定候选,单一架构通过不能外推其他 ABI。
- 软件加固可能重写加载或调用路径,但运行库一致性结论必须来自最终 SO 和真实设备,不能用配置文件或构建成功代替运行证据。
一致性的核心不是文件名,而是跨 SO 的 C++ 语义边界
一个 Android 应用可以同时包含自研 Native 库、加固载荷和多个第三方预编译 SO。只要它们都局限在 C ABI、各自分配并释放内存、不跨边界传播异常或 RTTI,内部实现可以不同;一旦 `std::string`、容器、异常对象、type_info 或析构责任跨库移动,运行库选择就成为共同协议。
所谓一致不能简化为所有文件都出现 `libc++_shared.so` 字样。需要确认每个 SO 的 STL 链接方式、编译器与 NDK 来源、异常与 RTTI 选项、导出接口、分配释放责任和最终加载对象。静态扫描解决依赖存在性,构建元数据解释来源,设备测试验证运行语义。
加固前后还要绑定同一源码、ABI、依赖和签名阶段。若候选同时升级 NDK 或替换第三方库,故障不能只归因于加固。工程判断是建立 perAbiRuntimeContract,记录共享运行库摘要、SO 清单、边界规则和回归用例,再让最终 APK 证据覆盖配置意图。
| 边界对象 | 一致性要求 | 常见故障 | 验证 |
|---|---|---|---|
| 纯 C 数据 | 布局与所有权明确 | 结构体版本错配 | ABI 契约测试 |
| STL 对象 | 同一兼容运行库语义 | 析构或布局异常 | 跨库构造销毁 |
| 异常对象 | unwind、类型与捕获一致 | 未捕获或终止 | 跨库 throw/catch |
| RTTI | type_info 可比较 | dynamic_cast 失败 | 跨库类型测试 |
| 堆内存 | 分配者与释放者协议 | 重复释放或堆损坏 | 所有权与压力回归 |
libc++ 静态链接与共享链接要按应用拓扑选择
Android C++ library support 说明 NDK 提供 libc++,并区分静态与共享链接方式;C++ 异常和 RTTI 也受构建系统选项影响。应用只有一个 Native 共享库且边界封闭时,静态运行库可能较易管理;应用包含多个共享库并交换 C++ 对象时,共享运行库通常更容易维持单一运行时。
多个 SO 各自静态链接 libc++ 会把运行时状态复制到不同库中。即使链接成功,跨库对象、异常或内存所有权也可能触发难以稳定复现的问题。共享链接则要求最终包为每个 ABI 提供匹配的 `libc++_shared.so`,所有声明该依赖的 SO 在加载时解析到交付的那一份。
选择共享运行库也不是自动安全。第三方 AAR 可能携带自己的同名文件,Gradle 打包规则可能通过覆盖或 pickFirst 隐藏来源冲突。最终只剩一个文件不代表它就是正确版本。需要在依赖合并前记录候选来源和摘要,并在最终 APK 再扫描一次实际交付副本。
| 策略 | 适用前提 | 主要风险 | 必需证据 |
|---|---|---|---|
| 单 SO 静态 | 边界封闭且无多库交换 | 后续拆库后假设失效 | 导出接口与回归 |
| 多 SO 各自静态 | 严格 C 边界和独立所有权 | 运行时副本与跨库语义 | 边界审计和设备测试 |
| 多 SO 共享 | 统一交付 libc++_shared | 同名运行库来源冲突 | DT_NEEDED 与摘要 |
| 第三方预编译 | 供应方提供完整元数据 | 内部选项不可见 | 版本说明和实际扫描 |
| 混合策略 | 边界经过专门证明 | 异常、RTTI 和释放错配 | 逐接口契约 |
异常、RTTI 和 unwind 必须用真实跨库路径验证
Android C++ library support 指出 C++ 异常与 RTTI 由相关编译选项控制。一个库抛出 C++ 异常、另一个库捕获时,异常类型、unwind 信息、清理动作和运行库状态共同参与。某个 SO 单独启用异常不代表跨库传播一定完整,尤其是预编译库内部选项未知时。
RTTI 也不只是 `dynamic_cast` 能否编译。跨 SO 进行类型识别时,类定义、可见性、type_info 和链接边界都可能影响结果。加固或符号可见性调整后,原本偶然可用的路径可能暴露问题。工程判断是为每个跨库多态接口建立成功和失败用例,并确认析构由正确模块完成。
设备回归应覆盖正常返回、抛出并捕获、未匹配类型、析构清理和重复调用。若项目规定异常不得跨边界,也要测试库内异常被转换成稳定错误码,防止遗漏路径直接越界传播。配置审查、readelf 与符号表都只能发现部分线索,不能代替执行结果。
| 路径 | 前置契约 | 失败信号 | 证据 |
|---|---|---|---|
| 库内捕获 | 异常不越过边界 | 进程终止或错误泄漏 | 错误转换用例 |
| 跨库捕获 | 类型和 unwind 一致 | catch 未命中 | 真实 throw/catch |
| dynamic_cast 成功 | 共享类型定义和 RTTI | 返回空或异常 | 多态对象用例 |
| dynamic_cast 失败 | 失败语义明确 | 错误对象被误用 | 负向类型用例 |
| 析构清理 | 所有权和模块明确 | 泄漏、重复释放 | 生命周期压力测试 |
分配与释放必须落在同一可证明的所有权协议中
跨 SO 返回 `std::string`、容器或由一方 `new` 的对象,会把分配器、对象布局和析构实现一起带过边界。若另一方使用不同运行库副本或不同契约释放,问题可能在特定容量、异常路径或并发时才出现。函数调用成功一次不能证明所有权安全。
更稳定的公共边界通常使用固定宽度基础类型、字节缓冲区、句柄和成对创建销毁函数。分配对象的一方负责提供释放入口,调用方不直接推断内部类型。若必须传递 C++ 对象,应记录编译器、NDK、libc++、可见性和版本策略,并将双方作为同一发布单元升级。
软件加固评估要特别关注包装层是否改变释放责任。桥接代码可能复制参数、捕获异常或延迟回调,若原接口对所有权描述含糊,处理后更容易暴露双重释放、悬空引用或生命周期错配。没有项目代码和设备证据时,不能宣称某条边界已经安全。
| 接口形态 | 所有权要求 | 兼容风险 | 改进 |
|---|---|---|---|
| STL 值返回 | 调用方析构 | 运行库和布局耦合 | 改用缓冲区或句柄 |
| 裸指针返回 | 释放者必须明确 | 释放器错配 | 提供配对 destroy |
| 回调对象 | 生命周期跨线程 | 悬空引用 | 引用计数或取消契约 |
| 异常越界 | 捕获方运行时一致 | 未捕获终止 | 边界内转换错误码 |
| Opaque handle | 创建者负责销毁 | 句柄版本误用 | 版本和状态校验 |
NDK、API level 与 ABI 是三条独立的一致性轴
Android NDK common problems 将常见故障归因于 API level、缺失符号、STL、异常类型和依赖装载等问题。运行库文件存在并不保证目标设备可加载所有依赖;某个 SO 引用了高于设备可用级别的符号,或依赖未打包的库,启动时仍会失败。
Android ABIs 说明各 ABI 拥有独立调用约定、寄存器、对齐和设备支持范围。`arm64-v8a` 的运行库和 SO 不能拿来证明 `armeabi-v7a`、`x86_64` 等目标正常。每个 ABI 都要有独立的最终 SO 集、`libc++_shared.so` 副本、摘要和设备执行结果。
不同 ABI 的 `libc++_shared.so` 二进制摘要本来就可能不同,不能要求跨 ABI 哈希相同。一致性是每个 ABI 内来源、工具链和依赖策略明确,并且所有需要共享运行库的 SO 都解析到该 ABI 的交付文件。报告必须按 ABI 分组,禁止把文件总数混成一个结论。
| 轴 | 需要固定 | 典型故障 | 验证 |
|---|---|---|---|
| NDK/toolchain | 版本与编译器来源 | 运行库或符号差异 | 构建证明和摘要 |
| API level | 最低平台与符号使用 | 旧设备缺失符号 | 目标 API 设备启动 |
| ABI | 调用约定与文件集合 | 错架构或对齐问题 | 逐 ABI 扫描和运行 |
| STL mode | 静态或共享策略 | 多运行时混用 | DT_NEEDED 与边界审计 |
| 加载顺序 | 依赖和命名空间 | 装载错误副本 | 最终包和运行日志 |
CMake 与非标准构建都要输出可审计的工具链身份
Android NDK CMake 文档说明 CMake toolchain 用于固定 ABI、平台版本和第三方库导入方式。Imported target 可以把预编译 SO 接入构建,却不会重新编译其内部代码。项目自己的 CMake 参数正确,不能推出供应商库使用相同 NDK、STL、异常或 RTTI 选项。
NDK with other build systems 说明非标准构建需要显式固定 NDK toolchain、API level、ABI 和编译目标。自定义脚本若依赖宿主环境的 clang、sysroot 或链接器默认值,同一源码可能生成不同运行库依赖。工程判断是把编译命令、toolchain 文件、NDK 摘要和产物摘要保存为候选证据。
第三方预编译库应收集供应方支持的 ABI、minSdk、NDK 或构建器、STL 模式、异常和 RTTI、导出接口及更新策略。缺失字段不能靠 readelf 全部还原,应标为未知并用更严格边界和设备测试处理。把未知写成“兼容”会让后续故障失去责任入口。
| 材料 | 回答的问题 | 缺失风险 | 补救 |
|---|---|---|---|
| ABI 与 minSdk | 在哪些设备加载 | 错架构或缺失符号 | 逐 ABI 设备验证 |
| NDK/toolchain | 由何种运行时生成 | 来源不可追溯 | 供应方回执 |
| STL mode | 静态还是共享 | 运行库副本混用 | 依赖扫描加边界限制 |
| 异常与 RTTI | 跨库语义是否可用 | catch 或 cast 异常 | 专项契约测试 |
| 导出与所有权 | 谁创建和销毁对象 | 堆损坏或泄漏 | C ABI 包装层 |
最终 APK 扫描与设备测试分别回答存在性和运行语义
依赖扫描应针对最终 APK 或 AAB 派生 APK 中提取的 `lib/<abi>`,而不是某个中间构建目录。对每个 SO 读取 DT_NEEDED、SONAME、构建标识和文件摘要,记录是否依赖 `libc++_shared.so`、是否出现旧运行库名称,以及该 ABI 实际打包了哪份共享运行库。
DT_NEEDED 没有 `libc++_shared.so` 不能自动写成纯 C 或安全静态链接,因为静态运行库不会以动态依赖出现,库也可能不使用 C++。这类对象应标记 `not-declared`,再通过供应方元数据和接口审计判断。扫描器只能阻止明显混用,不能恢复所有内部编译选项。
Android instrumented tests 可在真实 Android 环境验证组件和系统 API 语义。Native 回归应逐 ABI 覆盖库加载、JNI 注册、跨库分配释放、异常、RTTI、多线程回调和故障恢复。单一设备通过不能代表完整 API、ABI 和厂商矩阵,也不能证明加固保护强度。
- 扫描对象来自最终 APK 的 lib 目录
- 每个 ABI 独立列出 SO 与运行库
- 共享依赖存在时恰好有一个交付副本
- 旧 STL 名称出现即阻断审查
- not-declared 不自动判定为纯 C 或静态安全
- 跨库异常、RTTI 和所有权有设备用例
- 扫描与运行结果绑定同一候选摘要
用 readelf 扫描最终 SO 的共享运行库一致性
下面的 Python 示例接收 NDK `llvm-readelf` 路径和从最终 APK 提取的 `lib` 根目录。它逐 ABI 扫描 `.so` 的动态依赖,计算文件 SHA-256,并检查需要 `libc++_shared.so` 的 ABI 是否恰好打包一份同名运行库,同时阻断已知旧 STL 依赖。
脚本将没有共享 libc++ 依赖的普通 SO 标为 `not-declared`,不会擅自判断它是纯 C 还是静态链接。readelf 失败、目录结构异常、共享依赖缺少运行库或旧 STL 出现时返回非零状态。跨 ABI 的运行库摘要不会互相比较,因为它们是不同目标二进制。
准备多个 SO 的 C++ 运行库评估时,可整理最终 APK、逐 ABI SO 清单、NDK 与 toolchain、STL 模式、异常与 RTTI、预编译库材料和设备用例,再通过御盾中央平台提交申请。扫描通过只证明显式动态依赖满足规则,不证明内部编译选项或运行兼容完整。
- llvm-readelf 来自受控 NDK toolchain
- 输入目录来自最终候选提取结果
- 每个 ABI 至少包含一个 SO
- 显式共享用户要求唯一 libc++_shared 副本
- 旧 STL 依赖进入阻断结果
- 所有文件计算 SHA-256
- not-declared 保持未知边界
from pathlib import Path
import hashlib
import json
import re
import subprocess
import sys
if len(sys.argv) != 3:
raise SystemExit(2)
readelf = Path(sys.argv[1])
lib_root = Path(sys.argv[2])
if not readelf.is_file() or not lib_root.is_dir():
raise SystemExit(2)
needed_pattern = re.compile(r"Shared library: \[(.+?)\]")
legacy_runtimes = {"libgnustl_shared.so", "libstlport_shared.so"}
reports = {}
violations = []
abi_dirs = sorted(path for path in lib_root.iterdir() if path.is_dir())
if not abi_dirs:
raise SystemExit(2)
def digest(path):
value = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
value.update(chunk)
return value.hexdigest()
for abi_dir in abi_dirs:
libraries = sorted(abi_dir.glob("*.so"))
if not libraries:
raise SystemExit(2)
runtime_files = [path for path in libraries if path.name == "libc++_shared.so"]
entries = []
shared_users = []
for library in libraries:
completed = subprocess.run([str(readelf), "-d", str(library)], check=False, capture_output=True, text=True)
if completed.returncode != 0:
raise SystemExit(3)
needed = sorted(set(needed_pattern.findall(completed.stdout)))
mode = "shared" if "libc++_shared.so" in needed else "not-declared"
if mode == "shared" and library.name != "libc++_shared.so":
shared_users.append(library.name)
bad = sorted(legacy_runtimes.intersection(needed))
if bad:
violations.append({"abi": abi_dir.name, "library": library.name, "reason": "legacy-cxx-runtime", "needed": bad})
entries.append({"library": library.name, "sha256": digest(library), "cxxRuntimeMode": mode, "needed": needed})
if shared_users and len(runtime_files) != 1:
violations.append({"abi": abi_dir.name, "reason": "shared-runtime-count", "expected": 1, "actual": len(runtime_files)})
reports[abi_dir.name] = {"packagedSharedRuntime": [{"file": path.name, "sha256": digest(path)} for path in runtime_files], "sharedRuntimeUsers": sorted(shared_users), "libraries": entries}
result = {"status": "pass" if not violations else "blocked", "abis": reports, "violations": violations, "boundary": "not-declared does not prove pure C or safe static libc++"}
print(json.dumps(result, ensure_ascii=False, indent=2))
if violations:
raise SystemExit(3)事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| Android NDK 的 C++ 支持包含 libc++ 静态与共享链接,并由构建选项控制异常和 RTTI。 | Android C++ library support 描述 libc++、链接方式以及 C++ 异常和 RTTI 配置。 | 启用选项不证明跨 SO 的 exception、type_info、unwind 和析构路径已经正确。 |
| Native 兼容故障常见于 API level、缺失符号、STL、异常类型和依赖装载。 | Android NDK common problems 汇总 NDK 应用常见构建与运行问题。 | 问题清单不能替代目标候选在各 ABI 设备上的实际启动和异常回归。 |
| 不同 Android ABI 具有独立调用约定、寄存器、对齐和设备支持范围。 | Android ABIs 描述 NDK 支持 ABI 的平台约定和特征。 | 声明支持某 ABI 不代表对应 SO、运行库已经构建、打包并在设备验证。 |
| CMake toolchain 能固定 ABI、平台版本并定义预编译第三方库的导入方式。 | Android NDK CMake 描述 NDK CMake toolchain 和 imported library 配置。 | CMake 配置不能揭示或改变预编译 SO 内部的 STL、异常和 RTTI 选项。 |
| 非标准构建系统需要显式固定 NDK toolchain、API level、ABI 和编译目标。 | NDK with other build systems 描述非 CMake 构建接入 NDK 的必要参数。 | 构建参数正确不证明第三方预编译库与目标运行时或跨库接口兼容。 |
| 依赖真实 Android 运行时、组件和系统 API 的 Native 语义可通过设备端测试验证。 | Android instrumented tests 说明 instrumented test 在真实 Android 环境执行。 | 单一设备通过不能代表完整 API、ABI、系统版本和厂商矩阵。 |
| 多个 SO 跨库交换 C++ 对象、异常或 RTTI 时应采用可证明一致的运行库策略。 | 工程判断:这些对象携带布局、type_info、unwind、分配器和析构语义,不能视为普通字节。 | 一致策略降低边界风险,不自动证明所有接口实现、线程和异常路径正确。 |
| 最终包内没有 libc++_shared 动态依赖的 SO 应标为未知,而非自动判定安全静态链接。 | 工程判断:DT_NEEDED 看不到静态运行库,也不能说明某个二进制是否使用 C++。 | 确认内部模式仍需供应方材料、构建记录、接口审计和设备证据。 |
工程常见问题
最终 APK 每个 ABI 只有一个 libc++_shared.so 就一定兼容吗?
不一定。还要确认它的来源、NDK、API level、依赖用户、异常、RTTI、所有权边界和设备执行;唯一文件只解决明显副本冲突。
一个 SO 没有依赖 libc++_shared.so,是否说明它是纯 C?
不能。它可能是纯 C、静态链接 libc++,或采用其他内部方式。DT_NEEDED 只能说明动态依赖,需要供应方和构建材料补充。
多个 SO 可以各自静态链接 libc++ 吗?
只有在边界严格隔离且不交换 C++ 对象、异常、RTTI 和释放责任时才可能可控,仍需逐接口审计和设备回归。
为什么不同 ABI 的 libc++_shared.so 哈希不需要相同?
不同 ABI 是不同目标二进制,调用约定和机器代码不同。应验证每个 ABI 内来源与依赖一致,而不是跨 ABI 比较相同哈希。
CMake 配置统一能证明第三方预编译 SO 一致吗?
不能。Imported library 不会重新编译第三方二进制,仍需供应方元数据、依赖扫描、接口边界和真实设备测试。
申请多个 SO 运行库一致性评估前需要什么?
准备最终 APK、逐 ABI SO、NDK 与 toolchain、STL 模式、异常与 RTTI、预编译库材料、接口所有权和设备用例,再从御盾中央平台提交申请。