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을 끝없이 키워 로컬 서비스 거부를 일으킬 수 있었습니다.
사무실 건물 안내 데스크에는 부서마다 한 페이지씩 있는 방문자 명부가 있고, 배달원은 매번 "동행 부서" 칸을 채웁니다. 이 칸을 건물의 공식 부서 목록과 대조하는 사람은 없습니다. 한 배달원이 배달할 때마다 새로 지어낸 부서 이름을 적고, 이를 하루에 수천 번 반복합니다. 명부는 이름마다 새 페이지를 추가하고 매일 밤 통째로 보관소에 옮겨 적히니, 결국 안내 데스크는 옮겨 적는 일만 하게 됩니다. 해결책은 공식 목록에 없는 이름이면 칸을 비워 두고, 회사 하나가 등록할 수 있는 부서 수에 상한을 두는 것입니다.
핵심 개념 및 용어
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으로 낮췄습니다.
단계별 공격 실행 흐름
일반 앱이 프록시가 됨
특별한 권한이 없는 설치된 앱이 다른 패키지를 대신해 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;
엔지니어링 및 시스템 보안 강화 체크리스트
- ✓AOSP 커밋 6beb68ca가 포함된 보안 패치 수준 2024-04-01 이상으로 기기를 출시하세요.
- ✓시스템 서비스에서는 클라이언트가 보낸 식별자를 저장 상태의 키로 쓰기 전에 서버 측 등록 정보(여기서는 패키지 매니페스트)와 대조하세요.
- ✓저장되는 모든 구조에 클라이언트별 항목 수의 엄격한 상한을 두세요. 이 패치는
MAX_NUM_ATTRIBUTIONS를 10,000에서 1,000으로 줄였습니다. - ✓클라이언트가 고른 고유 키 수천 개를 보내고 저장 상태와 저장 파일이 정해진 크기 안에 머무는지 확인하는 테스트를 추가하세요.
- ✓앱에서는 모든 속성 태그를 매니페스트에 선언하고, 선언된 태그에 대해서만
Context로 컨텍스트를 만드세요. 선언되지 않은 프록시 태그는 이제 버려집니다..createAttributionContext()