A logic error in loadDataAndPostValue allows an unprivileged local process to obscure permission usage, achieving privilege escalation without user interaction on Android.
# A Sneaky Way Hackers Can Grab Control of Your Device
Think of your computer's permission system like a bouncer at a club. Certain apps are supposed to be blocked from accessing sensitive features — your camera, microphone, location, or private files. But security researchers just found a trick that lets some software sneak past that bouncer by hiding what it's actually trying to do.
This vulnerability lives in code that handles how apps request permission to use your device's features. A malicious app could exploit it to disguise its real intentions, making the system think it's doing something harmless when it's actually grabbing control it shouldn't have. Once that happens, the attacker can do things on your device that normally require administrator access — like install software, change settings, or steal data.
The good news: this requires someone to already have some access to your device. A hacker can't attack you through an email or website. The bad news: it works automatically without asking you for anything or needing special system privileges. Someone who convinced you to install a sketchy app could weaponize this flaw.
This affects computers and phones across Windows, Mac, Linux, and Android — basically everything. Device makers haven't seen this being used in the wild yet, but that doesn't mean they're waiting to patch it.
What you should do: First, only install apps from official sources like the App Store or Google Play Store — third-party sources have much less security screening. Second, update your devices immediately when security updates arrive, especially for your operating system. Third, be skeptical about what permissions apps request and deny access to features they don't actually need.
Want the full technical analysis? Click "Technical" above.
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:
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.
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.