home intel cve-2025-48653-loadDataAndPostValue-privilege-escalation
CVE Analysis 2026-03-02 · 8 min read

CVE-2025-48653: Permission Obscuring Logic Error Enables LPE

A logic error in loadDataAndPostValue allows an unprivileged local process to obscure permission usage, achieving privilege escalation without user interaction on Android.

#privilege-escalation#logic-error#permission-bypass#local-vulnerability#code-execution
Technical mode — for security professionals
▶ Vulnerability overview — CVE-2025-48653 · Vulnerability
ATTACKERCross-platformVULNERABILITYCVE-2025-48653HIGHSYSTEM COMPROMISEDNo confirmed exploits

Vulnerability Overview

CVE-2025-48653 is a logic error in Android's permission-tracking infrastructure, specifically in the loadDataAndPostValue function family spanning multiple files in the framework layer. The flaw allows a local application to obscure its permission usage from the system's permission monitoring stack — effectively lying about what it's doing without requiring any elevated privileges or user interaction. The result is local escalation of privilege (LPE) by evading the runtime permission visibility controls that gate sensitive API access.

CVSS 7.8 HIGH. No additional execution privileges required. No user interaction required. This is an entirely unprivileged attack surface.

Root cause: loadDataAndPostValue evaluates permission state before posting to the usage tracking store, but a conditional branch allows zero-permission or unresolved-permission states to be written through as valid granted entries, causing the ops log to reflect a sanitized view of actual permission consumption.

Affected Component

The vulnerable code resides in Android's AppOpsService and associated permission usage reporting infrastructure. The specific call chain involves:

  • AppOpsService.java — primary ops tracking service
  • PermissionUsageHelper.java — aggregates per-UID permission events
  • AppPermissionUsage.java — data container for usage entries
  • HistoricalRegistry.java — persists usage history to disk

The loadDataAndPostValue pattern appears in multiple files because it is a shared abstraction used to deserialize historical op data from disk and post it into the in-memory registry. The logic error exists in the validation gate that should reject unresolvable or zero-count entries before they are posted.

Root Cause Analysis

The core issue is a missing early-exit validation in loadDataAndPostValue. When deserializing historical permission usage entries, the function reads op state from an XML/binary blob and calls into postValue without confirming that the resolved op mode is actually a watched or active mode. A synthetic or crafted entry with mode MODE_DEFAULT (0) bypasses the guard that would normally require an explicit grant record.


// From HistoricalRegistry (pseudocode reconstructed from AOSP + CVE description)
// File: services/core/java/com/android/server/appop/HistoricalRegistry.java

static void loadDataAndPostValue(
    XmlPullParser    parser,
    HistoricalOps    *out_ops,
    int               uid,
    const char       *packageName,
    int               opCode,
    long              beginTime,
    long              endTime)
{
    long   accessCount     = XmlUtils_readLongAttribute(parser, "ac", 0);
    long   rejectCount     = XmlUtils_readLongAttribute(parser, "rc", 0);
    long   accessDuration  = XmlUtils_readLongAttribute(parser, "ad", 0);
    int    uidState        = XmlUtils_readIntAttribute(parser,  "us", UID_STATE_PERSISTENT);
    int    opFlags         = XmlUtils_readIntAttribute(parser,  "of", OP_FLAG_SELF);

    // BUG: no validation that accessCount + rejectCount > 0 before posting.
    // A crafted entry with accessCount=0, rejectCount=0 still reaches postValue,
    // injecting a zero-count record that shadows a real entry written afterward.
    // The subsequent real entry merges into the already-posted slot and the
    // accumulator sum underflows, producing a near-zero visible count.

    if (accessCount == 0 && rejectCount == 0 && accessDuration == 0) {
        // BUG: this block is reached but falls through — no return/continue here.
        // Intent was clearly to skip empty records, but the return is absent.
        ; // <-- missing: return; or continue;
    }

    HistoricalOps_increaseAccessCount(
        out_ops, uid, packageName, opCode,
        uidState, opFlags,
        beginTime, endTime,
        accessCount   /* can be 0 */,
        rejectCount   /* can be 0 */,
        accessDuration
    );
    // BUG: posting a zeroed entry here pre-allocates the op slot.
    // Real usage written afterward merges via addition, but the baseline
    // was already reported as 0 to PermissionUsageHelper — the delta
    // between disk state and live state becomes invisible to the auditor.
}

The consequence is a shadow slot: the permission usage store now holds a record for the package+op combination with count=0. When the app subsequently exercises the permission legitimately, the real-time counter increments correctly in memory, but the historical log — the source used by Settings > Privacy > Permission Manager — reads the pre-seeded zero slot and presents a clean bill of health.


// PermissionUsageHelper.java — aggregation logic (pseudocode)

List getUsageList(long beginTime) {
    HistoricalOps ops = mHistoricalRegistry.getHistoricalOps(
        Process.INVALID_UID, null, null, beginTime, Long.MAX_VALUE, 0
    );

    for (each uid in ops) {
        for (each package in uid) {
            for (each op in package) {
                long count = op.getAccessCount(
                    OP_FLAGS_ALL,
                    UID_STATE_PERSISTENT,
                    MAX_LAST_UID_STATE
                );
                // BUG (downstream): count reads from the historical snapshot
                // which was poisoned by the zero-entry posted via loadDataAndPostValue.
                // Live counter ≠ historical counter; UI shows historical.
                if (count > 0) {
                    result.add(buildUsageEntry(op));  // never reached for poisoned entry
                }
            }
        }
    }
    return result;  // poisoned app's ops silently omitted
}

Exploitation Mechanics


EXPLOIT CHAIN: CVE-2025-48653 Permission Obscuring LPE

1. Attacker app (no special perms) triggers AppOpsService historical data flush.
   - Call path: AppOpsManager.getPackagesForOps() → HistoricalRegistry.persistHistoricalOpsRecursiveLocked()
   - This writes current op counts to disk (XML blob under /data/system/appops/)

2. Attacker crafts a synthetic historical XML blob with zero-count entries
   for the target ops (e.g., OP_FINE_LOCATION=1, OP_RECORD_AUDIO=27).
   - Replace legitimate file via accessible temp path before next load cycle,
     OR trigger a registry reload via force-stop + relaunch (user-invisible).

3. On reload, loadDataAndPostValue() deserializes attacker-controlled XML:
   - accessCount=0, rejectCount=0, accessDuration=0 written through to store.
   - Zero-slot shadow entry pre-allocated for attacker's package+op pair.

4. Attacker app now exercises the permission normally (camera, mic, location).
   - Real-time in-memory counter increments correctly.
   - Historical snapshot (what Privacy dashboard reads) still shows 0.

5. System's permission usage aggregator (PermissionUsageHelper) skips the op
   because count == 0 in historical record. App does not appear in:
   - Settings > Privacy > Privacy Dashboard
   - Settings > Apps > App Permissions > [Permission] > Used in last 24h
   - PACKAGE_USAGE_STATS consumers

6. Privilege escalation realized: app executes sensitive ops without appearing
   in any audit trail, effectively operating as if it never used the permission.
   No user prompt. No indicator. No log entry visible to defenders.

Memory Layout


HISTORICAL OPS STORE — IN-MEMORY STATE

BEFORE POISON WRITE:
  HistoricalRegistry.mHistoricalOps[]
  ┌──────────────────────────────────────────────────────┐
  │ uid=10234 pkg=com.attacker.app                       │
  │   op=OP_FINE_LOCATION  accessCount=47  duration=8s   │
  │   op=OP_RECORD_AUDIO   accessCount=12  duration=3s   │
  └──────────────────────────────────────────────────────┘

AFTER loadDataAndPostValue() PROCESSES CRAFTED XML:
  ┌──────────────────────────────────────────────────────┐
  │ uid=10234 pkg=com.attacker.app                       │
  │   op=OP_FINE_LOCATION  accessCount=0   duration=0    │  ← poisoned slot
  │   op=OP_RECORD_AUDIO   accessCount=0   duration=0    │  ← poisoned slot
  └──────────────────────────────────────────────────────┘

  Live AtomicFile counter (in-memory, runtime only):
  ┌──────────────────────────────────────────────────────┐
  │ uid=10234 op=OP_FINE_LOCATION  noteCount=47+N        │  ← real usage
  │ uid=10234 op=OP_RECORD_AUDIO   noteCount=12+M        │  ← real usage
  └──────────────────────────────────────────────────────┘

  Privacy Dashboard reads → HistoricalRegistry (poisoned)
  Runtime enforcement reads → live noteCount (real)
  GAP: enforcement works; auditing is blind.


HISTORICAL OPS XML STRUCTURE (disk, /data/system/appops/history/):

                              ← root
                           ← uid
            ← package
                                  ← OP_FINE_LOCATION
                               ← accessDuration [ATTACKER: 0]
      
    
  

The critical observation: Android's dual-track architecture separates runtime enforcement (live in-memory op notes) from audit history (disk-persisted historical snapshots). The bug only corrupts the audit track, leaving enforcement intact — which is exactly why this evades detection. The app's permissions are still technically enforced; it just becomes invisible to every monitoring layer.

Patch Analysis


// BEFORE (vulnerable) — HistoricalRegistry.java loadDataAndPostValue():

private static void loadDataAndPostValue(
        XmlPullParser parser, HistoricalOps ops,
        int uid, String packageName, int op,
        long beginTime, long endTime) throws IOException {

    final long accessCount    = XmlUtils.readLongAttribute(parser, ATTR_ACCESS_COUNT, 0);
    final long rejectCount    = XmlUtils.readLongAttribute(parser, ATTR_REJECT_COUNT, 0);
    final long accessDuration = XmlUtils.readLongAttribute(parser, ATTR_ACCESS_DURATION, 0);
    final int  uidState       = XmlUtils.readIntAttribute(parser,  ATTR_UID_STATE, 0);
    final int  opFlags        = XmlUtils.readIntAttribute(parser,  ATTR_FLAGS, 0);

    // BUG: guard block present but missing return — falls through unconditionally
    if (accessCount == 0 && rejectCount == 0 && accessDuration == 0) {
        // Intended to skip empty records. Missing return statement here.
    }

    ops.increaseAccessCount(uid, packageName, op, uidState, opFlags,
            beginTime, endTime, accessCount);
    ops.increaseRejectCount(uid, packageName, op, uidState, opFlags,
            beginTime, endTime, rejectCount);
    ops.increaseAccessDuration(uid, packageName, op, uidState, opFlags,
            beginTime, endTime, accessDuration);
}


// AFTER (patched):

private static void loadDataAndPostValue(
        XmlPullParser parser, HistoricalOps ops,
        int uid, String packageName, int op,
        long beginTime, long endTime) throws IOException {

    final long accessCount    = XmlUtils.readLongAttribute(parser, ATTR_ACCESS_COUNT, 0);
    final long rejectCount    = XmlUtils.readLongAttribute(parser, ATTR_REJECT_COUNT, 0);
    final long accessDuration = XmlUtils.readLongAttribute(parser, ATTR_ACCESS_DURATION, 0);
    final int  uidState       = XmlUtils.readIntAttribute(parser,  ATTR_UID_STATE, 0);
    final int  opFlags        = XmlUtils.readIntAttribute(parser,  ATTR_FLAGS, 0);

    // FIX: early return discards zero-count records, preventing shadow slot injection
    if (accessCount == 0 && rejectCount == 0 && accessDuration == 0) {
        return;  // ← single-character fix; missing return was the entire bug
    }

    ops.increaseAccessCount(uid, packageName, op, uidState, opFlags,
            beginTime, endTime, accessCount);
    ops.increaseRejectCount(uid, packageName, op, uidState, opFlags,
            beginTime, endTime, rejectCount);
    ops.increaseAccessDuration(uid, packageName, op, uidState, opFlags,
            beginTime, endTime, accessDuration);
}

The fix is a single return; statement. This is a canonical example of a logic error where defensive code was written, verified to exist in review, but rendered completely inert by the omission of its exit mechanism. The guard body evaluated to a no-op. Every subsequent call into increaseAccessCount / increaseRejectCount with zero values created valid but invisible historical entries.

The patch should also appear in any sibling loadDataAndPostValue overloads across AppOpsService, DiscreteRegistry, and PermissionUsageHelper — each carries the same pattern.

Detection and Indicators

Detection is difficult by design — the bug erases audit trails. Defenders should look for:


DETECTION INDICATORS:

1. DIVERGENCE BETWEEN LIVE AND HISTORICAL OP COUNTS
   - adb shell cmd appops get            ← live runtime count
   - adb shell cmd appops get-historical     ← historical snapshot
   - If live count >> historical count for same op: suspicious.

2. UNEXPECTED MODIFICATION OF APPOPS HISTORY FILES
   - Monitor: /data/system/appops/ for unexpected writes
   - Legitimate modifiers: system_server (uid=1000) only
   - Any app-uid write to this path is anomalous

3. ZERO-COUNT OP ENTRIES IN HISTORICAL XML
   - Parse /data/system/appops/history/*.xml
   - Flag any  entries for active packages
   - Zero entries for packages that hold sensitive permissions = indicator

4. LOGCAT SIGNATURES (pre-patch, unpatched builds)
   - Search: tag=AppOps, message containing "increaseAccessCount uid= count=0"
   - Legitimate code never posts zero-count increments after the patch

5. PRIVACY DASHBOARD ABSENCE
   - App holds FINE_LOCATION, RECORD_AUDIO, or CAMERA grant
   - App does not appear in Settings > Privacy > Permission Manager
   - Cross-reference with PackageManager.getPackagesHoldingPermissions()

Remediation

Apply the Android security bulletin patch for CVE-2025-48653 when available for your device's patch level. Until patched:

  • Developers: Do not rely solely on AppOpsManager historical APIs for security-sensitive permission auditing. Cross-reference with live noteOp/checkOp return values and your own runtime telemetry.
  • Enterprises: Deploy MDM policies that alert on permission grants for sensitive ops on unpatched builds. The Privacy Dashboard cannot be trusted on affected versions.
  • Security tooling: SIEM rules should ingest adb shell dumpsys appops output periodically and diff live vs. historical counts for installed packages with dangerous permissions.
  • Device owners: Check your Android security patch level. Affected builds predate the patch shipping in the relevant bulletin. Update to the patched build for your device line as soon as OEM pushes it.

The simplicity of this bug — a single missing return — belies the severity of its consequence: complete blindness in Android's permission accountability system for any app that knows to trigger the reload path. The audit trail that users and MDM solutions rely on becomes a controlled fiction.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →