Skip to content

Dynamic Code Loading

追踪动态 DEX 使用记录、SafetyNet 日志、持久化与清理,帮助定位加载、编译和删除之间的边界。

基于android-17.0.0_r1
AndroidPMSDynamicCodeLoadingDexUseDexOpt

Dynamic Code Loading ​

动态代码加载(Dynamic Code Loading,DCL)不是一个单独的“安全开关”,而是一组在不同时间点产生、消费和删除的数据。本文面向已经读过 DexOptHelper 入口 和 PMS 性能优化分析 的读者,解决一个具体问题:当应用加载 APK 之外的 DEX,或者系统记录到私有目录中的 Native 动态代码时,Android 17 到底由谁记录、记录什么、何时落盘,以及这些记录如何影响后续 dexopt 和清理。

本文不把动态加载等同于“必然恶意”,也不讨论 ART 类加载器的字节码执行细节。读完后,读者应能从 IPackageManager.notifyDexLoad() 定位到 DexUseManagerLocal,区分 primary/secondary dex 两种记录,解释 DynamicCodeLogger 的另一条安全日志链路,并根据包删除、用户删除或文件不可见等现象判断记录为何消失。

1. 两个记录者 ​

Android 17 同时存在两个职责不同的对象:

对象源码路径记录内容主要消费者
DexUseManagerLocalart/libartservice/service/java/com/android/server/art/DexUseManagerLocal.javaprimary/secondary dex、loader、用户、ABI、class loader context、最近使用时间ART Service、后台 dexopt、secondary dex reconcile
DynamicCodeLoggerframeworks/base/services/core/java/com/android/server/pm/dex/DynamicCodeLogger.javaDEX/Native 文件类型、owner、user、loading packageSafetyNet EventLog、动态代码诊断

两者都可能看到一个 secondary dex 路径,但数据模型和用途不同。前者需要知道“怎样加载”以便生成可用的编译产物;后者需要对文件名和内容做哈希,再按加载方 UID 写入安全事件。不能用一条记录替代另一条记录。

2. Binder 入口 ​

源码文件:frameworks/base/services/core/java/com/android/server/pm/PackageManagerService.java,符号:IPackageManagerImpl.notifyDexLoad。

java
public void notifyDexLoad(String loadingPackageName,
        Map<String, String> classLoaderContextMap, String loaderIsa) {
    int callingUid = Binder.getCallingUid();
    Computer snapshot = snapshot();

    if (!PackageManagerServiceUtils.isSystemOrRoot()
            && !snapshot.isCallerSameApp(
                    loadingPackageName, callingUid, true /* resolveIsolatedUid */)) {
        Slog.w(TAG, TextUtils.formatSimple(
                "Invalid dex load report. loadingPackageName=%s, uid=%d",
                loadingPackageName, callingUid));
        return;
    }

    DexUseManagerLocal dexUseManager = DexOptHelper.getDexUseManagerLocal();
    try (PackageManagerLocal.FilteredSnapshot filteredSnapshot =
            LocalManagerRegistry.getManager(PackageManagerLocal.class)
                    .withUnownedFilteredSnapshot(snapshot)) {
        dexUseManager.notifyDexContainersLoaded(
                filteredSnapshot, loadingPackageName, classLoaderContextMap);
    }
}

这里有三个容易被忽略的边界。

  1. loadingPackageName 来自调用方,但不能被调用方随意冒充。除 system/root 外,PMS 用 Binder.getCallingUid() 和 isCallerSameApp 校验;isolated UID 解析由第三个参数开启。校验失败只记录 warning 并返回,不会把异常输入送入 ART Service。
  2. loaderIsa 在 Android 17 的 Binder 实现中没有继续向下传递;ART Service 从 loadingPackageName 的包状态取得 primary ABI。阅读旧接口时不能假定 ISA 仍是记录字段。
  3. PMS 创建 FilteredSnapshot 后再交给 local manager。owner 查找需要包代码路径,但过滤快照仍限制调用方可见的包;对所有包的慢速扫描由 DexUseManagerLocal 在需要时取得 unfiltered snapshot。

notifyDexLoad 是同步进入 local manager 的调用,但写文件不是同步动作。DexUseManagerLocal 更新内存和 revision 后通过 debouncer 异步落盘,因此“调用成功”不等于磁盘文件已经改变。

3. Owner 查找 ​

源码文件:art/libartservice/service/java/com/android/server/art/DexUseManagerLocal.java,符号:findOwningPackage、checkForPackage。

java
private FindResult findOwningPackage(
        PackageManagerLocal.FilteredSnapshot snapshot,
        String loadingPackageName, String dexPath) {
    PackageState loadingPkgState =
            Utils.getPackageStateOrThrow(snapshot, loadingPackageName);
    FindResult result = checkForPackage(
            loadingPkgState, dexPath, true /* checkForSharedLibraries */);
    if (result != null) {
        return result;
    }

    result = checkForAllPackages(dexPath);
    if (result != null && result.type() != TYPE_DONT_RECORD
            && snapshot.getPackageState(result.owningPackageName()) != null) {
        return result;
    }
    return null;
}

查找顺序体现了性能和权限的折中:先检查加载方自己的 primary dex、共享库和 secondary dex;失败后才遍历所有包。全局扫描使用容量为 50 的 mRecentDexFilesToPackageNames 做最近路径缓存,命中缓存仍会重新验证包状态和路径,不会盲信缓存。

checkForPackage 将路径分为四类:

类型判定后续动作
TYPE_PRIMARY等于某个 split APK 路径,或非 builtin 共享库路径记录 primary dex use
TYPE_SECONDARY位于该用户 secondary dex location记录 secondary dex use
TYPE_DONT_RECORD位于包代码目录但不是当前支持的 secondary 位置,或 builtin 共享库明确忽略
null找不到 owner,或路径不可信忽略,不创建伪记录

因此,任意一个应用传入 /tmp/plugin.dex 并不会因为文件名带有 .dex 就产生记录。必须能从包状态、用户数据目录或共享库依赖中证明 owner。

4. Dex 类型分流 ​

源码文件:art/libartservice/service/java/com/android/server/art/DexUseManagerLocal.java,符号:notifyDexContainersLoaded、addPrimaryDexUse、addSecondaryDexUse。

java
for (var entry : classLoaderContextByDexContainerFile.entrySet()) {
    String dexPath = Utils.assertNonEmpty(entry.getKey());
    String classLoaderContext = Utils.assertNonEmpty(entry.getValue());
    FindResult findResult = findOwningPackage(snapshot,
            loadingPackageName, dexPath);
    if (findResult == null) {
        continue;
    }

    switch (findResult.type()) {
        case TYPE_PRIMARY:
            addPrimaryDexUse(findResult, dexPath, loadingPackageName,
                    isolatedProcess, lastUsedAtMs);
            break;
        case TYPE_SECONDARY:
            PackageState loadingPkgState =
                    Utils.getPackageStateOrThrow(snapshot, loadingPackageName);
            Utils.Abi abi = Utils.getPrimaryAbi(loadingPkgState);
            addSecondaryDexUse(findResult.owningPackageName(), dexPath,
                    loadingPackageName, isolatedProcess, classLoaderContext,
                    abi.name(), lastUsedAtMs);
            break;
        default:
            // Intentionally ignore.
    }
}

primary dex 的记录键是 owner 包名、APK/split 路径和 DexLoader。当 base APK 由 owner 自己加载时,代码把它视作一次冷启动并增加 package score;5 秒 cooldown 防止多进程同时加载把一次启动重复计数。

secondary dex 额外保存 classLoaderContext 和 ABI。这些字段不是展示用的注释:ART Service 需要它们决定是否能生成、复用或放弃 dexopt artifact。一个 owner 最多接受 mInjector.getMaxSecondaryDexFilesPerOwner() 个条目,默认常量为 500;超出后只写 warning,不会继续增长内存。

java
SecondaryDexUse secondaryDexUse =
        packageDexUse.mSecondaryDexUseByDexFile.computeIfAbsent(dexPath, k -> {
    if (packageDexUse.mSecondaryDexUseByDexFile.size()
            >= mInjector.getMaxSecondaryDexFilesPerOwner()) {
        AsLog.w("Not recording too many secondary dex use entries for "
                + owningPackageName);
        return null;
    }
    return new SecondaryDexUse();
});

记录成功后,mRevision++,随后 maybeSaveAsync() 调度写盘。若同一路径的 loader 已存在,更新的是最近使用时间、CLC 和 ABI,而不是重复创建文件节点。

5. 用户与 loader ​

secondary dex 属于具体 human user。addSecondaryDexUse 使用 mInjector.getCallingUserHandle() 写入 mUserHandle,而不是从 owner 包的默认用户猜测。loader 则由包名和 isolated-process 标记组成:

java
DexLoader loader = DexLoader.create(loadingPackageName, isolatedProcess);
secondaryDexUse.mUserHandle = mInjector.getCallingUserHandle();
SecondaryDexUseRecord record = secondaryDexUse.mRecordByLoader
        .computeIfAbsent(loader, k -> new SecondaryDexUseRecord());
record.mClassLoaderContext = classLoaderContext;
record.mAbiName = abiName;
record.mLastUsedAtMs = lastUsedAtMs;

这组字段能回答三个调试问题:谁加载了文件、在哪个用户空间加载、ART 看到的 class-loader 链是什么。isPrimaryDexUsedByOtherApps 正是基于 loader 集合判断“是否由其他应用使用”,而不是仅看 owner 是否有一次启动记录。

动态代码日志的模型略有不同。源码文件:frameworks/base/services/core/java/com/android/server/pm/dex/PackageDynamicCodeLoading.java。

java
Map<String, PackageDynamicCode> mPackageMap;
// owning package -> file path -> DynamicCodeFile

static class DynamicCodeFile {
    final char mFileType;       // 'D' or 'N'
    final int mUserId;
    final Set<String> mLoadingPackages;
}

同一路径只能绑定一个 user;如果后来以另一个 user 上报,add 会忽略该条目。这是对“私有 app 文件按用户隔离”不变量的保护,而不是把跨用户访问当作正常共享。

6. 两种持久化 ​

DexUseManagerLocal 使用 protobuf 文件 /data/system/package-dex-usage.pb。它的写入由 15 秒最小间隔和 debouncer 共同限制;写入时先序列化到临时文件,再以 ATOMIC_MOVE 替换目标文件。保存失败只记录错误并保留内存状态,下一次 revision 变化仍可重试。初始化阶段 load() 必须恰好调用一次,否则可能覆盖尚未读取的记录。

DynamicCodeLogger 包装的 PackageDynamicCodeLoading 仍使用文本文件 package-dcl.list。格式版本头是 DCL1,随后按 owner 分段:

text
DCL1
P:owner.package
D:0:loader.package:/data/user/0/owner.package/files/plugin.dex
N:0:loader.package:/data/user/0/owner.package/lib/libx.so

路径中的反斜杠、换行和回车会在写入时转义;版本头、字段正则或转义序列错误都会令读取失败。读取失败不会把半解析结果合并进当前 map,而是保留原有内存状态。单个 owner 的文件上限是 100,超过后 PackageDynamicCode.add 返回 false。

7. 安全日志链 ​

源码文件:frameworks/base/services/core/java/com/android/server/pm/dex/DynamicCodeLogger.java。

recordDex(loaderUserId, dexPath, owningPackageName, loadingPackageName) 直接把 DEX 记录交给 PackageDynamicCodeLoading。recordNative(loadingUid, path) 则先通过 IPackageManager.getPackagesForUid 找到 loading package,再从 UID 得到 userId;没有可解析包名或 Binder 查询失败时直接返回。

真正写 SafetyNet 事件发生在 logDynamicCodeLoading,而不是 record 方法。它按 user 查询 owner 的 ApplicationInfo,判断路径位于 credential-protected(CE)还是 device-protected(DE)目录,然后调用 Installer.hashSecondaryDexFile 计算内容哈希。EventLog 子标签分别是 dcl(DEX)和 dcln(Native),消息至少包含文件名哈希;能拿到 32 字节内容哈希时再追加内容哈希。

路径不在 CE/DE 目录、包已被该用户卸载、Installer 无法哈希或文件已删除时,logger 会移除对应记录并异步写回。这条链不负责 dexopt,也不读取 class-loader context。

8. 启动与清理 ​

PMS 初始化完成所有包数据目录 reconcile 后,构造 userToPackagesMap 并调用 mDynamicCodeLogger.load(userPackages)。load 先读取文本文件,再用已安装包到用户的反向映射执行 syncData:删除不存在的 owner,删除 owner 不再安装的 user,并从 loading package 集合中移除已卸载的包。

应用数据销毁是另一条明确的删除入口。源码文件:frameworks/base/services/core/java/com/android/server/pm/AppDataHelper.java。

java
mInstaller.destroyAppData(volumeUuid, packageName, realUserId,
        flags, ceDataInode, pccCeDataInode);
mPm.getDynamicCodeLogger().notifyPackageDataDestroyed(packageName, userId);

notifyPackageDataDestroyed 对 USER_ALL 删除 owner 的全部 DCL 记录,否则只删除指定 user。它不会删除 DexUseManagerLocal 的 protobuf;ART 数据由 DexUseManagerLocal.cleanup() 依据包存在性、文件可见性、loader 是否仍安装以及 CLC 中引用的 dex 文件是否存在来清理。

清理过程先在锁外做 I/O,收集每个 dex 路径的 visibility,再回到 mLock 删除记录。若 secondary dex 变成 NOT_OTHER_READABLE,其他应用的 loader 会被移除;若 CLC 引用的 dex 缺失,CLC 被改为 =UnsupportedClassLoaderContext=,避免 ART 继续使用不可验证的上下文。

9. DexOpt 消费 ​

DexUseManagerLocal 的注释直接说明两个消费者:判断一个应用是否被其他应用使用,以及决定哪些 secondary dex 要 dexopt、如何 dexopt。SecondaryDexopter 读取 getCheckedSecondaryDexInfo,据此取得路径、user、loader、ABI、CLC 和 visibility;后台 dexopt 再结合 package score 和最近使用时间安排工作。

这解释了一个常见现象:notifyDexLoad 已返回,但 oat/vdex 文件没有立刻出现。记录先进入内存,落盘有 debounce,编译又由后台 dexopt 的调度时机决定。反过来,只有 SafetyNet 的 package-dcl.list 记录并不能证明 ART 已为该 DEX 生成 artifact。

10. API 边界 ​

Android 17 的 registerDexModule 已被明确禁用:PMS 记录 info 日志,并通过 handler 异步回调 onDexModuleRegistered(..., false, "registerDexModule call not supported since Android U")。注释给出的原因是 ART Service 不支持显式 dexopt,且该 API 不提供正确的 class-loader context,无法可靠地产生运行时可加载的 artifact。

因此,调试 secondary dex 时应以运行时 notifyDexLoad 上报和后台 dexopt 为主线;不要把 registerDexModule 当作手工触发编译的恢复手段。

11. 失败定位 ​

现象首先检查代码含义
没有任何 dex-use 记录PMS warning、isCallerSameApp、findOwningPackage调用方冒充、路径无 owner 或路径被过滤,都会直接返回
有记录但无编译产物protobuf revision、后台 dexopt、CLC/ABI记录与编译是异步两阶段;CLC 缺失或文件不可见会跳过/降级
重启后记录消失protobuf 是否原子替换成功、包/user 是否仍存在启动 load 后会按已安装包同步清理
其他应用 loader 消失getDexFileVisibility、cleanupRecordsLocked文件权限变为不可被其他应用读取,记录被有意删除
SafetyNet 没有事件logDynamicCodeLoading、CE/DE 路径判断、Installer hashrecord 只入库;路径不在 app data 或哈希失败会移除并不写事件
registerDexModule 回调 falsePMS registerDexModuleAndroid 17 的兼容行为,不能靠该 API 触发编译

12. 阅读练习 ​

  1. 从 IPackageManagerImpl.notifyDexLoad 开始,指出 UID 校验、快照创建、owner 查找、mRevision 增加和最终消费者分别在哪个对象中完成。
  2. 给定一个位于 /data/user/0/owner/files/plugin.dex 的路径,比较它在 DexUseManagerLocal 和 PackageDynamicCodeLoading 中的字段集合,并说明为什么前者需要 CLC/ABI 而后者只需要文件类型和加载包集合。
  3. 设想 owner 被卸载但文件尚未立即删除:启动时哪条同步逻辑会先移除记录?若文件仍存在但对其他 app 不可读,cleanupRecordsLocked 会保留哪类 loader?
  4. 运行设备诊断时,把 dumpsys package 中的 secondary dex 信息、ART Service 的 dexopt 日志和 SafetyNet EventLog 分开采集;只有三者的时间、路径和 user 都能对应时,才能把“已加载”“已记录”和“已编译”判定为同一事实。