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)

オフィスビルの受付には、部署ごとに1ページの来訪者名簿があり、配達員は毎回「付き添い部署」欄を記入します。この欄をビルの正式な部署一覧と照合する人はいません。ある配達員が配達のたびに新しい架空の部署名を書き、それを1日に何千回も繰り返します。名簿は名前ごとに新しいページを増やし、毎晩まるごと保管庫へ書き写されるため、やがて受付は書き写すだけで手一杯になります。対策は、正式な一覧にない名前なら欄を空欄にすること、そして1社が登録できる部署の数に上限を設けることです。

主要な概念と専門用語

AppOpsService
system_server 内のサービス。どのアプリがどの保護された操作(カメラ、位置情報、マイクなど)を、いつ、誰の代理で使ったかを記録します。
アトリビューションタグ
アプリがマニフェストで <attribution android:tag=...> として宣言するラベル。データアクセスをアプリ内の機能に紐づけます。パッケージごとの宣言数には上限(MAX_NUM_ATTRIBUTIONS)があります。
プロキシ操作
AttributionSource チェーンのように、別のアプリの代理として記録される app-op。プロキシ側の uid、パッケージ名、アトリビューションタグがアクセス記録と一緒に保存されます。
appops_accesses.xml
/data/system にある、AppOpsService がアクセス記録を永続化するファイル。scheduleWriteLocked() で書き直され、起動時に再度解析されます。

根本原因の分析 (Root Cause)

AppOpsService.java の noteOperationUnchecked() と startOperationUnchecked() は、呼び出し元が制御できる proxyAttributionTag を、プロキシアプリがそのタグを宣言しているか確かめずに AttributedOp.accessed() へ渡していました。異なる文字列ごとに新しいプロキシ記録が作られ、メモリに残り、/data/system/appops_accesses.xml に書き込まれるため、1つのアプリだけで 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 を費やし、端末は再起動後も含めて遅くなるか固まります。

ソースコード比較:脆弱 vs 堅牢化

脆弱な実装
// 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;

エンジニアリング&システム堅牢化チェックリスト

参考資料