A confused deputy in MediaProvider.java allows any local app to bypass external storage write permissions, achieving local privilege escalation with zero user interaction required.
Your phone has a bouncer system that controls which apps can access what. Some apps need permission to read your photos or documents, and you grant that access when you install them. This vulnerability is like a bouncer who gets confused about which permissions an app actually has—and lets it slip through to areas it shouldn't reach.
Specifically, there's a flaw in how Android's MediaProvider (the system that manages your photos, videos, and media files) checks permissions. An app that's supposed to have limited access could trick the system into thinking it has broader rights, like writing to storage areas it shouldn't touch.
Think of it like a bank employee who's authorized to access one account, but figures out how to access others by confusing the teller system. The hacker isn't breaking in—they're just gaming the rules that are supposed to stop them.
Right now, this hasn't been used in active attacks, but researchers have confirmed it works. Any app on your phone—whether malicious or compromised—could potentially exploit this to secretly modify your files or plant harmful content.
This matters most if you've installed apps from unofficial sources or side-loaded software. Phones with the latest security patches are safer, but this vulnerability affects multiple Android versions.
What you should do: First, keep your phone updated to the latest Android security patch. Second, only install apps from Google Play Store or other official app stores—they screen for malicious behavior. Third, regularly check which apps have storage permissions and revoke access for apps that don't need it. Your phone's settings usually let you manage this under Apps and Permissions.
Want the full technical analysis? Click "Technical" above.
CVE-2025-48579 is a local privilege escalation vulnerability residing in MediaProvider.java, the content provider that mediates all scoped storage access on Android. The flaw is classified as a confused deputy: MediaProvider runs with elevated system permissions and performs write operations on behalf of calling applications without adequately verifying whether the caller actually holds the necessary external storage write grant. An unprivileged application — one that has never been granted WRITE_EXTERNAL_STORAGE or scoped media permissions — can coerce MediaProvider into writing attacker-controlled data to arbitrary locations on external storage. CVSS 8.4 (HIGH). No user interaction. No additional execution privileges beyond a normal installed application.
The issue was addressed in the Android Security Bulletin for March 2026.
Affected Component
Package:com.android.providers.media (MediaProvider) File:packages/providers/MediaProvider/src/com/android/providers/media/MediaProvider.java Functions: Multiple — including insert(), update(), and internal helpers that delegate to enforceCallingOrSelfPermission-gated write paths Privilege context:MediaProvider runs as u0_a[media] with android:sharedUserId="android.media" and holds READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE, and direct filesystem access to /sdcard via FUSE Attack surface: Any installed application; no special permissions required
Root Cause Analysis
The confused deputy arises because several write-path functions in MediaProvider check permissions using getCallingUid() in one context but then execute the actual filesystem write under the provider's own identity. The permission check and the privileged action are not atomic — and critically, in at least one code path the permission check is skipped entirely when the call arrives via a ContentResolver relayed operation where the calling UID has been substituted with the provider's own UID by a prior binder transition.
The simplified pseudocode of the vulnerable flow in insert() and the internal insertFile() helper:
// MediaProvider.java — vulnerable insert() path (pre-patch)
@Override
public Uri insert(Uri uri, ContentValues values) {
// ... URI matching and table resolution ...
final int callingUid = Binder.getCallingUid();
final LocalCallingIdentity ident = getCachedCallingIdentityForFuse(callingUid);
// BUG: isCallingPackageAllowedHidden() returns true for system-relayed
// calls because callingUid has already been overwritten to the provider's
// own UID by a prior clearCallingIdentity() in the FUSE dispatch path.
if (isCallingPackageAllowedHidden(ident)) {
// Fast-path: skip permission enforcement entirely
return insertFileUnchecked(uri, values); // <-- writes with provider's own creds
}
enforceCallingOrSelfPermission(WRITE_EXTERNAL_STORAGE, TAG);
return insertFileChecked(uri, values);
}
private Uri insertFileUnchecked(Uri uri, ContentValues values) {
// Resolves relative path from ContentValues, creates file on FUSE mount
final String relativePath = values.getAsString(MediaColumns.RELATIVE_PATH);
final String displayName = values.getAsString(MediaColumns.DISPLAY_NAME);
// BUG: no secondary caller identity check here; path is fully attacker-controlled
File destFile = new File(
Environment.getExternalStorageDirectory(), relativePath + displayName
);
destFile.getParentFile().mkdirs(); // creates arbitrary directories
destFile.createNewFile(); // creates arbitrary file as MediaProvider
// ... inserts DB row, returns content URI ...
}
Root cause:MediaProvider.insert() gates its permission check on isCallingPackageAllowedHidden(), which returns true for calls arriving after a FUSE-layer clearCallingIdentity(), allowing any app that can trigger this code path to write arbitrary files to external storage under the provider's elevated identity.
The isCallingPackageAllowedHidden helper evaluates the cached LocalCallingIdentity struct, which is populated at FUSE dispatch time — before the actual content provider call. When a second app re-enters via ContentResolver inside the same FUSE transaction, the cached identity reflects the FUSE initiator (the provider itself), not the actual remote caller.
// LocalCallingIdentity population — the confused deputy's trust anchor
static LocalCallingIdentity getCachedCallingIdentityForFuse(int uid) {
// uid here is Binder.getCallingUid() — but inside a FUSE callback this
// is the provider's own UID, not the original app's UID.
LocalCallingIdentity cached = sCallingIdentityCache.get(uid);
if (cached == null) {
cached = LocalCallingIdentity.fromExternal(getContext(), uid);
sCallingIdentityCache.put(uid, cached);
}
return cached; // returns PROVIDER identity, not attacker identity
}
Exploitation Mechanics
EXPLOIT CHAIN:
1. Attacker app (zero permissions beyond INTERNET) is installed.
2. App opens a direct FUSE file descriptor to a path on /sdcard it does NOT
own (e.g., /sdcard/Android/data/com.victim.app/files/config.json) via
the MediaStore content URI for that path, triggering a FUSE lookup.
3. Inside the FUSE kernel callback, MediaProvider dispatches to its own
insert() / update() handler. At this point Binder.getCallingUid() ==
MediaProvider's own UID (the FUSE thread's identity).
4. getCachedCallingIdentityForFuse() returns a cached LocalCallingIdentity
marked as "hidden allowed" because it belongs to the provider itself.
5. isCallingPackageAllowedHidden() returns TRUE. Permission enforcement
is skipped. insertFileUnchecked() is called.
6. Attacker supplies ContentValues with:
RELATIVE_PATH = "Android/data/com.victim.app/files/"
DISPLAY_NAME = "config.json"
(plus arbitrary MIME type / data URI)
7. insertFileUnchecked() creates/overwrites the target file as MediaProvider
(which has write access to all of external storage via FUSE mount).
8. Attacker writes a malicious config.json. On next launch of com.victim.app,
victim app loads attacker-controlled config -> further code execution
within victim's sandbox (separate chain).
IMPACT WITHOUT CHAINING:
- Write/overwrite any file under /sdcard accessible to MediaProvider FUSE
- Plant files in other apps' external data directories
- Overwrite media files, documents, downloads without READ or WRITE grants
Memory Layout
This is a logic/permission bypass rather than a memory corruption bug; there is no heap spray or buffer overflow. However, the LocalCallingIdentity cache state is the critical in-process data structure that enables the bypass. Its relevant fields:
LocalCallingIdentity (Java object, approximate field layout)
+0x00 int uid // e.g. 1013 (media) — attacker wants this
+0x04 String packageName // "com.android.providers.media"
+0x08 int packageFlags // ApplicationInfo.FLAG_SYSTEM set
+0x0C int permission_WRITE_EXT // PERMISSION_GRANTED (1)
+0x10 boolean hasPackageInterop // true for system packages
+0x14 boolean isCallingPackageSelf // TRUE when UID == provider UID <-- key flag
CACHE STATE (pre-exploit, normal operation):
sCallingIdentityCache[uid=10234 (attacker)] -> { isCallingPackageSelf=FALSE }
sCallingIdentityCache[uid=1013 (provider)] -> { isCallingPackageSelf=TRUE }
CACHE STATE (during FUSE dispatch — confused deputy window):
Binder.getCallingUid() returns 1013 (provider/FUSE thread)
getCachedCallingIdentityForFuse(1013) returns provider's own identity
isCallingPackageAllowedHidden() evaluates provider identity -> TRUE
Permission check bypassed. Attacker's ContentValues processed as provider.
Patch Analysis
The fix, landed in the March 2026 bulletin, separates the FUSE-layer identity from the content provider caller identity. A snapshot of the calling UID is taken before any clearCallingIdentity() call and threaded through to the permission check site:
// BEFORE (vulnerable): permission check uses FUSE-thread UID
@Override
public Uri insert(Uri uri, ContentValues values) {
final int callingUid = Binder.getCallingUid(); // may be provider's own UID
final LocalCallingIdentity ident = getCachedCallingIdentityForFuse(callingUid);
if (isCallingPackageAllowedHidden(ident)) {
return insertFileUnchecked(uri, values); // no permission check
}
enforceCallingOrSelfPermission(WRITE_EXTERNAL_STORAGE, TAG);
return insertFileChecked(uri, values);
}
// AFTER (patched, Android Security Bulletin 2026-03-01):
@Override
public Uri insert(Uri uri, ContentValues values) {
// Capture the ORIGINAL caller UID before any identity transitions
final int originalCallingUid = mCallingUidTracker.getOriginalCallingUid();
// Build identity from the *original* caller, not the FUSE-thread UID
final LocalCallingIdentity ident =
LocalCallingIdentity.fromExternal(getContext(), originalCallingUid);
// isCallingPackageAllowedHidden now evaluates the ACTUAL caller
if (isCallingPackageAllowedHidden(ident)) {
return insertFileUnchecked(uri, values);
}
// For all non-hidden callers, enforce write permission against original UID
enforceWritePermissionForCallerLocked(uri, originalCallingUid);
return insertFileChecked(uri, values);
}
// Additional fix: insertFileUnchecked now validates relative path is within
// the calling package's own external data directory when caller is non-system
private Uri insertFileUnchecked(Uri uri, ContentValues values) {
final String relativePath = values.getAsString(MediaColumns.RELATIVE_PATH);
// NEW: path must not escape the package-owned directory
if (!isRelativePathSafe(relativePath, mCallingUidTracker.getOriginalCallingUid())) {
throw new SecurityException("Relative path traversal denied: " + relativePath);
}
// ... rest of insert logic ...
}
The patch also introduces isRelativePathSafe() as a secondary defence, rejecting any RELATIVE_PATH in ContentValues that would resolve outside the caller's package-scoped directory when the caller is not a privileged system package. This defence-in-depth catches confused deputy re-entry attempts even if the UID tracking is bypassed by a future variant.
Detection and Indicators
Exploitation leaves traces in several places:
LOGCAT INDICATORS (pre-patch — absence of expected SecurityException):
// Normal rejection when app lacks permission:
W MediaProvider: java.lang.SecurityException: caller does not hold
android.permission.WRITE_EXTERNAL_STORAGE
// During exploitation — NO SecurityException logged; instead:
D MediaProvider: Inserting file: /sdcard/Android/data/com.victim.app/files/config.json
D MediaProvider: Created new file at content://media/external/files/1337
AUDIT LOG (SELinux / auditd):
type=AVC msg=audit(...): avc: granted { write } for
pid= comm="AsyncTask" path="/sdcard/Android/data/..."
scontext=u:r:mediaprovider:s0
tcontext=u:object_r:media_rw_data_file:s0
-- Note: scontext is mediaprovider, NOT the attacker app's context
BEHAVIORAL INDICATORS:
- App with no WRITE_EXTERNAL_STORAGE holding MediaStore insert() URIs
- Files modified in /sdcard/Android/data// without that
package running
- Unexpected entries in MediaStore DB (MediaColumns.OWNER_PACKAGE_NAME
shows attacker package for files in victim package directory)
Query the MediaStore database for ownership anomalies:
import subprocess, json
# Pull MediaStore DB and check for OWNER_PACKAGE_NAME mismatches
result = subprocess.run([
"adb", "shell",
"content", "query",
"--uri", "content://media/external/files",
"--projection", "_id:relative_path:owner_package_name"
], capture_output=True, text=True)
for line in result.stdout.splitlines():
if "Android/data/" in line:
# Extract owner vs path package
# Flag entries where owner_package_name != path component
path_pkg = line.split("Android/data/")[1].split("/")[0] if "Android/data/" in line else ""
owner = line.split("owner_package_name=")[1].split(",")[0] if "owner_package_name=" in line else ""
if path_pkg and owner and path_pkg != owner:
print(f"[SUSPICIOUS] {line.strip()}")
Remediation
Apply the March 2026 Android Security Bulletin patch immediately. The patch is present in Android security patch level 2026-03-01 and later. Verify on-device with:
adb shell getprop ro.build.version.security_patch
# Must return 2026-03-01 or later
For application developers: Do not rely solely on MediaStore permission checks as a security boundary. Use Context.checkCallingOrSelfPermission() with explicit UID verification for any content provider that performs privileged filesystem operations. Never pass caller-supplied RELATIVE_PATH or DISPLAY_NAME values directly to filesystem APIs without canonicalization and containment checks.
For device manufacturers shipping custom MediaProvider forks: audit all insertFileUnchecked-equivalent fast paths for identity confusion at FUSE/Binder transitions. The pattern of clearCallingIdentity() followed by a permission check using the post-clear UID is a structural confused deputy waiting to happen.