CVE-2026-0075 AI-generated analysis
platform/packages/providers/ContactsProvider, ContactsProvider2 (CP2) query/insert/update/delete entry points
When the caller lacks the provider's READ_PERMISSION (query; READ_CONTACTS according to the commit message) or WRITE_PERMISSION (insert/update/delete), ContactsProvider2 now replaces any SQLiteException whose message contains "json" with one carrying the fixed message "Stripped exception message". It also calls cursor.getCount() inside the try block of query so that deferred SQLite errors are caught and filtered too.
Root cause
ContactsProvider2.query/insert/update/delete caught any Exception, logged it and rethrew it unchanged to the caller. According to the commit message, CP2 applied no strict SQL checks to callers. A caller without READ_CONTACTS could therefore send malformed queries and run a side channel attack by parsing the strings returned in the resulting SQLite exception. The code comment ties this to certain "json" tokens. In query(), cursor.getCount() was only called in the finally block, so an exception raised when the cursor was materialized never reached the catch block.
How the fix works
- src/com/android/providers/contacts/ContactsProvider2.java: adds a new private helper, maybeStripAndThrowSQLiteExceptionWithJson(Exception e, String permission, LogFields.Builder logBuilder). It acts only when all three hold: e is a SQLiteException, getContext().checkCallingPermission(permission) is not PERMISSION_GRANTED, and e.getMessage() lower-cased with Locale.ROOT contains "json". It then builds new SQLiteException("Stripped exception message"), records it in logBuilder and throws it in place of the original. The code comment says checkCallingPermission is used on purpose instead of checkCallingOrSelfPermission, so that non-binder or self calls made after clearing identity do not get the raw message.
- ContactsProvider2.insert, ContactsProvider2.update, ContactsProvider2.delete: each catch (Exception e) block now calls maybeStripAndThrowSQLiteExceptionWithJson(e, WRITE_PERMISSION, logBuilder) before the existing logBuilder.setException(e); throw e;.
- ContactsProvider2.query: after queryInternal(...) returns a non-null cursor, the try block now calls logBuilder.setResultCount(cursor.getCount()). An SQLite error deferred until the cursor is materialized is therefore thrown inside the try and reaches the catch. The catch calls maybeStripAndThrowSQLiteExceptionWithJson(e, READ_PERMISSION, logBuilder). The finally block now only logs logBuilder.build() and no longer computes the result count.
- Adds the import android.database.sqlite.SQLiteException.
Completeness
- Covers the four entry points patched in ContactsProvider2 (query, insert, update, delete). For query it covers both the immediate exception path and the path where the error appears when the cursor is materialized.
- The filter is keyword based. Only SQLiteException messages containing "json" (case-insensitive) are stripped, and any other SQLite error text still reaches unprivileged callers unchanged. The fix adds no SQL validation and does not limit which SQLite functions callers may use, even though the commit message describes the problem more broadly.
- Only SQLiteException instances are handled. Other exception types pass through unchanged.
- Callers that hold the checked permission (READ_PERMISSION for query, WRITE_PERMISSION for insert/update/delete) still get full error messages. The commit message justifies this because callers with READ_CONTACTS can read the whole database anyway.
- This diff does not touch other provider entry points (e.g. bulkInsert, applyBatch, call, openAssetFile, or paths in AbstractContactsProvider). The material does not show whether they can return SQLite error text.
Behavior changes
- Unprivileged callers that trigger a SQLiteException whose message contains "json" now get a SQLiteException with the generic message "Stripped exception message". The original text is also no longer written to the CP2 LogFields log, which records the stripped exception instead.
- query() now calls cursor.getCount() on a non-null cursor inside the try block before returning. Errors raised at that point are now caught, logged and subject to stripping, instead of being thrown from the finally block.
- query() no longer sets the result count in finally. The count is set only on success, so failed queries are logged without a result count.
Per major
- Android 14: Same fix: commit 7d344b678bde in android-security-14.0.0_r28 (cherry-pick of 6044ca5db93f). 8/8 patched lines present at android-security-14.0.0_r29.
- Android 15: Same fix: commit 930817423647 in android-security-15.0.0_r16, identical hunks at slightly different line offsets. 8/8 lines present at android-security-15.0.0_r17.
- Android 16: Same fix: commit bb9f36b3ff6d in android-security-16.0.0_r7, identical hunks (query hunk around line 6020). 8/8 lines present at android-security-16.0.0_r8.
- Android 17: Same fix: commit 819ebe4a7c68 is in base android-17.0.0_r1. 8/8 lines present at android-security-17.0.0_r1.
Unknowns
- Which CP2 URIs or paths callers can reach without the read/write permissions (the material does not show path permissions or URI grants).
- Which permission WRITE_PERMISSION resolves to (the material names only READ_CONTACTS).
- Whether other entry points (bulkInsert, applyBatch, call, file open paths) can return SQLite error text, and whether a separate change addressed them.
- Which exact SQLite json functions or messages were used, and what information the error text exposed. The material only indicates the keyword "json".