~/posts/mobile/android-runtime-restrictions-bypass.md

Android 运行时限制:命名空间与 Hidden API

沿 soinfo 与 Runtime 两条数据链,区分 Android 7–9 的原生库装载限制和 Hidden API 策略,解释内联访问的依赖差异,并给出固定输入、前后对照的验证方法。

date[31:24]
read[23:16]
8 分钟
cat[15:8]
移动端
目录
  1. 0x00先辨认拒绝访问的层次
  2. 0x01从 soinfo 找到命名空间
  3. 0x02命名空间状态影响后续装载
  4. 0x03Hidden API 最终读取 Runtime 策略
  5. 0x04两种内联访问为何产生不同依赖
  6. 0x05让策略写入与观测闭合
  7. 0x06分析结论
  8. 0x07参考资料

同一个 Android 应用中,dlopen() 可能拒绝装载已经存在的系统库,JNI 也可能找不到类中实际存在的方法。前者经过动态链接器的命名空间检查,后者经过 Android Runtime(ART)的非 SDK 接口策略;排查时应先区分拒绝发生在哪一层。

Android 7–9 的相关实现可以沿 soinfo 和 art::Runtime 两条数据链展开。Romain Thomas 对这两条路径的分析揭示了进程内策略状态与接口依赖之间的关系;内部对象布局仍需与目标构建逐一匹配,不是跨版本 ABI。

先辨认拒绝访问的层次

Android 7 开始限制应用依赖私有原生库。下面是该版本环境中的 linker 报错节选,省略号表示省去的上下文:

linker · 历史报错节选log
library "/system/lib64/libart.so" ... is not accessible for the namespace:
[name="classloader-namespace", ... permitted_paths="/data:/mnt/expand:..."]

文件存在、路径正确、进程位数匹配,都不足以保证装载成功。报错中的 classloader-namespace 指向下一步要检查的对象:发起装载的模块属于哪个命名空间,该命名空间允许访问哪些库。

原生库访问可按库类型和目标 API 级别分为以下四种情况。“后续平台”指 Android 7 文档当时描述的兼容性方向,并非所有当前版本的实测结论。规则背景见 Android 7.0 行为变更。

Android 7 原生库访问规则(历史文档)4 行
库类型 目标 API 级别 动态链接器访问 Android 7.0 行为 当时文档描述的后续行为
NDK 公开库 任意 允许 正常运行 正常运行
临时开放的私有库 ≤ 23 暂时允许 正常运行,并输出 logcat 警告 运行时错误
临时开放的私有库 ≥ 24 受限 运行时错误 运行时错误
其他私有库 任意 受限 运行时错误 运行时错误

另一类失败出现在 JNI 方法查询:

JNI method lookupcpp
jclass cls = env->FindClass("android/os/Debug");
jmethodID method = env->GetStaticMethodID(
    cls, "getVmFeatureList", "()[Ljava/lang/String;");

这只是查询片段,假定 FindClass 已成功且没有待处理异常。历史案例中,后一次查询因 Hidden API 策略返回空值并产生 NoSuchMethodError。应同时检查 JNI 异常;仅凭空值判定“这个类没有该方法”,会把策略拒绝误认成符号不存在。

请求 决策位置 主要分析对象
装载原生库 linker 调用模块的 soinfo 与 namespace
查询 Java 成员 ART 成员分类、调用上下文与 Runtime 策略

从 soinfo 找到命名空间

链接器为已装载的 ELF 模块维护 soinfo,其中记录模块名、路径、装载基址及命名空间关联。相关的两个成员如下;这是字段摘录,不表示它们在结构中的完整顺序或偏移:

linker_soinfo.h · field excerptcpp
android_namespace_t* primary_namespace_;
android_namespace_list_t secondary_namespaces_;

主命名空间和次命名空间共同构成模块的装载上下文。单独检查目标文件路径,会漏掉调用方所处的上下文。

历史实现用 g_soinfo_handles_map 保存句柄到 soinfo* 的映射。定位时需要结合该链接器的符号信息与运行时映像信息计算对象地址。这里必须区分三个量:句柄值、soinfo* 和库的装载基址,它们不是同一个地址。

定位自有 JNI 模块的遍历逻辑可写成下面的结构性示意。handles、get_soname 和内部类型需要按目标版本提供,代码不是独立可编译的实现:

find_soinfo.cpp · illustrativecpp
for (const auto& entry : handles) {
    soinfo* info = entry.second;
    const char* name = get_soname(info);
    if (name != nullptr && wanted_name == std::string(name)) {
        return info;
    }
}
return nullptr;
  1. 映射的值指向 soinfo;不要把键当作模块基址使用。
  2. 按 SONAME 内容匹配。wanted_name 应是字符串对象;std::string 与 C 字符串的重载比较本身就是内容比较,区别在于避免两个裸指针之间的地址比较。

命名空间状态影响后续装载

下面的接口作用于当前模块关联的命名空间:

namespace policy · historical interfacecpp
ns->set_ld_library_paths({"/system/lib64", "/system/lib"});
ns->set_isolated(false);

搜索路径和隔离标志承担不同职责:前者影响库文件搜索,后者影响命名空间访问检查。对绝对路径 dlopen 而言,也应区分“找到文件”和“允许访问文件”,而不是把两者统称为路径问题。

修改这些对象会影响使用该命名空间的后续请求;若多个模块共享它,影响范围也可能覆盖这些模块。它没有因此改变其他进程的链接器配置,更不等于取得另一应用的数据权限。

验证时固定同一个库路径、调用模块和装载标志,同时检查 dlopen 返回值、dlerror() 和模块列表。应先排除库已装载造成的干扰;若同时改了路径和隔离标志,所得结果只证明组合有效,分别测试才有助于确定是哪一项改变了分支。

Hidden API 最终读取 Runtime 策略

Android 9 的相关调用路径如下。这是逻辑调用关系示意,不是包含寄存器和运行地址的调试器快照;GetActionFromAccessFlags 也是策略判断的一环:

flowchart TD
  accTitle: Android 9 Hidden API 策略读取链
  accDescr: JNI 方法查询经过成员访问判定,最终读取当前 Runtime 的 Hidden API enforcement policy。
  A["GetStaticMethodID"] --> B["FindMethodID"]
  B --> C["ShouldBlockAccessToMember"]
  C --> D["hiddenapi::GetMemberAction"]
  D --> E["GetActionFromAccessFlags"]
  E --> F["Runtime::Current()"]
  F --> G["GetHiddenApiEnforcementPolicy()"]

在这份历史实现中,策略包括不检查、只警告、阻止 dark-grey 与 black-list 成员,以及只阻止 black-list 成员。图描述的是相关路径的概括,不意味着每次查询都会无条件执行所有节点。成员标记与调用上下文仍参与最终决定。

dark-grey、black-list 是当时的术语。后续 Android 调整过分类与规则,排查新系统时应使用对应版本的 非 SDK 接口限制文档。

两种内联访问为何产生不同依赖

第一种取得实例的方式是 art::Runtime::Current()。它在该实现中读取静态变量 art::Runtime::instance_,对应符号为 _ZN3art7Runtime9instance_E。函数被内联,仍可能留下对外部变量的导入,从而产生 libart.so 链接依赖。

另一条路径来自 JNI_OnLoad 接收的 JavaVM*。在该 ART 实现里,它对应内部 JavaVMExt 对象,后者保存 Runtime* 并提供 GetRuntime()。两条路径可并排比较:

读取静态实例
Runtime::Currentcpp
art::Runtime* runtime = art::Runtime::Current();
读取已有对象字段
JavaVMExt::GetRuntimecpp
art::Runtime* runtime =
    reinterpret_cast<art::JavaVMExt*>(vm)->GetRuntime();

左侧内联的是“访问静态变量”,右侧在匹配布局下可退化为“从已知对象读取字段”。因此,内联消除函数调用,不等于消除所有符号依赖。这也解释了为什么只搜索导出的 getter 或 setter,可能错过实际字段访问。

让策略写入与观测闭合

历史实现中的 setter 用于调整当前 Runtime 的成员访问策略:

Hidden API policy · historical interfacecpp
runtime->SetHiddenApiEnforcementPolicy(
    hiddenapi::EnforcementPolicy::kNoChecks);

这段接口示意的前提是已取得匹配版本的有效对象。它本身不是一次成功运行的证据;还需要对同一个成员做调整前后的检查。

最小验证记录4 步
  1. 1

    记录基线

    固定设备系统、ART 构建、架构、应用目标 API 级别与查询成员。记录返回值和待处理 JNI 异常,在隔离的测试流程中处理异常后再继续。

  2. 2

    核对状态位置

    记录实际对象、字段与当前策略,确认读写来自同一进程、同一构建,避免把符号不存在当成逻辑不存在。

  3. 3

    只改变相关策略

    再次执行同一查询,比较返回值与异常。原生库路径则同时比较装载结果、错误信息和模块列表。

  4. 4

    恢复基线再检查

    恢复策略或重启测试进程,确认原行为重新出现;将验证步骤与观察结果分别保存。

上述步骤定义验证方法,不代表全部 Android 7–9 构建都已通过验证。成功与否应以目标设备上相同输入的前后对照为准。

分析结论

这两条链都指向应用进程内的运行时状态:原生库装载沿 soinfo 进入 namespace,Java 成员查询沿 ART 进入 Runtime。它们主要管理应用对内部实现的依赖,与系统服务权限、SELinux、内核检查等边界分属不同层次。

排查时先定位拒绝点,再确定判定所读取的对象,最后用固定输入验证状态变化。生产应用应优先迁移到公开 NDK 与 SDK 接口,而不是依赖内部字段长期保持稳定。

参考资料

NORMAL~/posts/mobile/android-runtime-restrictions-bypass.md§--
0%zh-CN