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 同时存在两个职责不同的对象:
| 对象 | 源码路径 | 记录内容 | 主要消费者 |
|---|---|---|---|
DexUseManagerLocal | art/libartservice/service/java/com/android/server/art/DexUseManagerLocal.java | primary/secondary dex、loader、用户、ABI、class loader context、最近使用时间 | ART Service、后台 dexopt、secondary dex reconcile |
DynamicCodeLogger | frameworks/base/services/core/java/com/android/server/pm/dex/DynamicCodeLogger.java | DEX/Native 文件类型、owner、user、loading package | SafetyNet EventLog、动态代码诊断 |
两者都可能看到一个 secondary dex 路径,但数据模型和用途不同。前者需要知道“怎样加载”以便生成可用的编译产物;后者需要对文件名和内容做哈希,再按加载方 UID 写入安全事件。不能用一条记录替代另一条记录。
2. Binder 入口
源码文件:frameworks/base/services/core/java/com/android/server/pm/PackageManagerService.java,符号:IPackageManagerImpl.notifyDexLoad。
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);
}
}这里有三个容易被忽略的边界。
loadingPackageName来自调用方,但不能被调用方随意冒充。除 system/root 外,PMS 用Binder.getCallingUid()和isCallerSameApp校验;isolated UID 解析由第三个参数开启。校验失败只记录 warning 并返回,不会把异常输入送入 ART Service。loaderIsa在 Android 17 的 Binder 实现中没有继续向下传递;ART Service 从loadingPackageName的包状态取得 primary ABI。阅读旧接口时不能假定 ISA 仍是记录字段。- 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。
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。
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,不会继续增长内存。
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 标记组成:
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。
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 分段:
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。
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 hash | record 只入库;路径不在 app data 或哈希失败会移除并不写事件 |
registerDexModule 回调 false | PMS registerDexModule | Android 17 的兼容行为,不能靠该 API 触发编译 |
12. 阅读练习
- 从
IPackageManagerImpl.notifyDexLoad开始,指出 UID 校验、快照创建、owner 查找、mRevision增加和最终消费者分别在哪个对象中完成。 - 给定一个位于
/data/user/0/owner/files/plugin.dex的路径,比较它在DexUseManagerLocal和PackageDynamicCodeLoading中的字段集合,并说明为什么前者需要 CLC/ABI 而后者只需要文件类型和加载包集合。 - 设想 owner 被卸载但文件尚未立即删除:启动时哪条同步逻辑会先移除记录?若文件仍存在但对其他 app 不可读,
cleanupRecordsLocked会保留哪类 loader? - 运行设备诊断时,把
dumpsys package中的 secondary dex 信息、ART Service 的 dexopt 日志和 SafetyNet EventLog 分开采集;只有三者的时间、路径和 user 都能对应时,才能把“已加载”“已记录”和“已编译”判定为同一事实。
