flawopen.com/Teardowns/cve-2024-23712-android-appops-attribution-tag-dos

● CVE-2024-23712 · CVSS 5.5 · 中危
安全研究 · FlawOpen

CVE-2024-23712:Android AppOps 归因标签导致的资源耗尽

CVE-2024-23712(CVSS 5.5;Android 评为高危):AppOpsService 会记录调用方应用提供的任意代理归因标签,因此普通应用无需额外权限就能让 /data/system/appops_accesses.xml 无限增长,造成本地拒绝服务。

💡 通俗易懂的原理解析 (ELI5)

一栋写字楼的前台有一本访客登记簿,每个部门占一页,每位快递员都要填写“陪同部门”一栏。没人拿这一栏去核对大楼的正式部门名单。有个快递员每送一次就写一个新编的部门名,一天几千次。登记簿为每个名字新开一页,每晚还要整本誊抄进档案,最后前台整天都在抄页。修复办法是:名字不在正式名单上,这一栏就留空;并且限制每家公司最多能登记多少个部门。

核心概念与专有名词

AppOpsService
system_server 中的服务,记录哪个应用在何时、代表谁使用了哪项受保护操作(相机、位置、麦克风等)。
归因标签
应用在清单中用 <attribution android:tag=...> 声明的标签,用于把数据访问归到应用的某项功能。平台限制每个软件包可声明的数量(MAX_NUM_ATTRIBUTIONS)。
代理操作
代表另一个应用记录的 app-op,例如在 AttributionSource 链中。代理方的 uid、包名和归因标签会和访问记录一起保存。
appops_accesses.xml
/data/system 下 AppOpsService 持久化访问记录的文件,由 scheduleWriteLocked() 重写,开机时重新解析。

根本原因剖析 (Root Cause)

AppOpsService.java 中的 noteOperationUnchecked() 和 startOperationUnchecked() 把调用方可控的 proxyAttributionTag 直接传给 AttributedOp.accessed(),没有检查代理应用是否声明过该标签。每个不同的字符串都会产生一条新的代理记录,驻留内存并写入 /data/system/appops_accesses.xml,因此单个应用就能把文件撑大,直到 system_server 耗尽内存和 I/O。修复会把 isAttributionTagDefined() 在两个软件包清单中都找不到的标签置为 null,并把 MAX_NUM_ATTRIBUTIONS 从 10,000 降到 1,000。

攻击执行流程分解

步骤 1

普通应用充当代理

一个没有特殊权限的已安装应用代表另一个软件包记录 app-op,并通过 AppOpsManager 传入自己的代理归因标签。

步骤 2

每次调用都用新标签

应用每次调用都生成一个新的、未声明的标签字符串,noteOperationUnchecked() 原样全部接受。

步骤 3

状态无限增长

AttributedOp.accessed() 为每个不同的代理标签保存一条记录,scheduleWriteLocked() 不断把越来越大的集合写入 appops_accesses.xml。

步骤 4

本地拒绝服务

system_server 花费越来越多的内存和磁盘 I/O 来序列化和重新解析该文件,设备变慢甚至卡死,重启后依然如此。

源代码对比:漏洞与安全实现

存在漏洞的实现
// services/core/java/com/android/server/appop/AppOpsService.java (before 2024-04-01)
private SyncNotedAppOp noteOperationUnchecked(int code, int uid, @NonNull String packageName,
        @Nullable String attributionTag, int proxyUid, String proxyPackageName,
        @Nullable String proxyAttributionTag, @OpFlags int flags,
        boolean shouldCollectAsyncNotedOp, @Nullable String message,
        boolean shouldCollectMessage) {
    // (uid and package verification elided)

    // BUG: proxyAttributionTag comes from the calling app and is never checked
    // against the <attribution> tags that app declared. Each new string becomes
    // another attributed entry, held in memory and persisted to
    // /data/system/appops_accesses.xml.
    synchronized (this) {
        final Ops ops = getOpsLocked(uid, packageName, attributionTag,
                pvr.isAttributionTagValid, pvr.bypass, /* edit */ true);
        final Op op = getOpLocked(ops, code, uid, true);
        final AttributedOp attributedOp = op.getOrCreateAttribution(op, attributionTag);
        attributedOp.accessed(proxyUid, proxyPackageName, proxyAttributionTag,
                uidState.getState(), flags);
        scheduleWriteLocked();
    }
    return new SyncNotedAppOp(AppOpsManager.MODE_ALLOWED, code, attributionTag, packageName);
}

// services/core/java/com/android/server/pm/pkg/component/ParsedAttributionImpl.java
/** Maximum amount of attributions per package */
static final int MAX_NUM_ATTRIBUTIONS = 10000;
加固后的安全修复
// services/core/java/com/android/server/appop/AppOpsService.java (2024-04-01, AOSP 6beb68ca)
private SyncNotedAppOp noteOperationUnchecked(int code, int uid, @NonNull String packageName,
        @Nullable String attributionTag, int proxyUid, String proxyPackageName,
        @Nullable String proxyAttributionTag, @OpFlags int flags,
        boolean shouldCollectAsyncNotedOp, @Nullable String message,
        boolean shouldCollectMessage) {
    // (uid and package verification elided)

    // FIX: drop a proxy attribution tag that neither package declared, so a
    // caller can no longer mint unlimited entries. startOperationUnchecked()
    // gets the same check.
    if (proxyAttributionTag != null
            && !isAttributionTagDefined(packageName, proxyPackageName, proxyAttributionTag)) {
        proxyAttributionTag = null;
    }
    synchronized (this) {
        final Ops ops = getOpsLocked(uid, packageName, attributionTag,
                pvr.isAttributionTagValid, pvr.bypass, /* edit */ true);
        final Op op = getOpLocked(ops, code, uid, true);
        final AttributedOp attributedOp = op.getOrCreateAttribution(op, attributionTag);
        attributedOp.accessed(proxyUid, proxyPackageName, proxyAttributionTag,
                uidState.getState(), flags);
        scheduleWriteLocked();
    }
    return new SyncNotedAppOp(AppOpsManager.MODE_ALLOWED, code, attributionTag, packageName);
}

private boolean isAttributionTagDefined(@Nullable String packageName,
        @Nullable String proxyPackageName, @Nullable String attributionTag) {
    if (packageName == null) {
        return false;
    } else if (attributionTag == null) {
        return true;
    }
    PackageManagerInternal pmInt = LocalServices.getService(PackageManagerInternal.class);
    if (proxyPackageName != null) {
        AndroidPackage proxyPkg = pmInt.getPackage(proxyPackageName);
        if (proxyPkg != null && isAttributionInPackage(proxyPkg, attributionTag)) {
            return true;
        }
    }
    AndroidPackage pkg = pmInt.getPackage(packageName);
    return isAttributionInPackage(pkg, attributionTag);
}

// services/core/java/com/android/server/pm/pkg/component/ParsedAttributionImpl.java
/** Maximum amount of attributions per package */
static final int MAX_NUM_ATTRIBUTIONS = 1000;

工程与系统安全加固清单

参考来源