flawopen.com/Teardowns/cve-2024-23712-android-appops-attribution-tag-dos
CVE-2024-23712:Android AppOps のアトリビューションタグによるリソース枯渇
CVE-2024-23712(CVSS 5.5、Android の評価は高):AppOpsService は呼び出し元アプリが渡したプロキシのアトリビューションタグを無条件に記録していたため、追加権限のない普通のアプリでも /data/system/appops_accesses.xml を際限なく肥大化させ、ローカルのサービス拒否を引き起こせました。
オフィスビルの受付には、部署ごとに1ページの来訪者名簿があり、配達員は毎回「付き添い部署」欄を記入します。この欄をビルの正式な部署一覧と照合する人はいません。ある配達員が配達のたびに新しい架空の部署名を書き、それを1日に何千回も繰り返します。名簿は名前ごとに新しいページを増やし、毎晩まるごと保管庫へ書き写されるため、やがて受付は書き写すだけで手一杯になります。対策は、正式な一覧にない名前なら欄を空欄にすること、そして1社が登録できる部署の数に上限を設けることです。
主要な概念と専門用語
AppOpsServicesystem_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 に下げました。
ステップ・バイ・ステップの攻撃フロー
普通のアプリがプロキシになる
特別な権限を持たないインストール済みアプリが、別パッケージの代理として app-op を記録し、AppOpsManager 経由で自分のプロキシ用アトリビューションタグを渡します。
呼び出しごとに新しいタグ
アプリは呼び出しのたびに未宣言の新しいタグ文字列を生成します。noteOperationUnchecked() はそれをすべてそのまま受け入れます。
状態が際限なく増える
AttributedOp は異なるプロキシタグごとに記録を保存し、.accessed()scheduleWriteLocked() が増え続ける集合を appops_accesses.xml に書き込み続けます。
ローカルのサービス拒否
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;
エンジニアリング&システム堅牢化チェックリスト
- ✓セキュリティパッチレベル 2024-04-01 以降(AOSP コミット 6beb68ca を含む)で端末を出荷する。
- ✓システムサービスでは、クライアントが渡した識別子を保存状態のキーとして使う前に、サーバー側の登録情報(ここではパッケージのマニフェスト)と照合する。
- ✓永続化するすべての構造に、クライアントごとのエントリ数の上限を設ける。このパッチでは
MAX_NUM_ATTRIBUTIONSを 10,000 から 1,000 に減らした。 - ✓クライアントが選んだ一意のキーを数千個送り、保存状態と永続化ファイルが一定サイズ内に収まることを確認するテストを追加する。
- ✓アプリではすべてのアトリビューションタグをマニフェストで宣言し、宣言済みのタグに対してだけ
Contextでコンテキストを作る。未宣言のプロキシタグは今後破棄される。.createAttributionContext()