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로 제한됩니다.
프록시 작업
AttributionSource 체인처럼 다른 앱을 대신해 기록되는 app-op입니다. 프록시의 uid, 패키지, 속성 태그가 접근 기록과 함께 저장됩니다.
appops_accesses.xml
AppOpsService가 접근 기록을 저장하는 /data/system의 파일로, 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를 쓰고, 기기는 재부팅 후에도 느려지거나 멈춥니다.

소스 코드 비교: 취약한 구현 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;

엔지니어링 및 시스템 보안 강화 체크리스트

출처