Android ContentProvider — source audit

Android ContentProvider — source audit

TL;DR: ContentProviders are the most-leaked IPC surface in Android. Exported providers without permission gates → arbitrary apps read/write your data. SQL injection in query() selection strings → table escape. openFile() returning ParcelFileDescriptor for attacker-controlled paths → path traversal into private files. grantUriPermissions=true opens transient-grant surface. Companion to android-source-review-methodology and android-ipc-and-intent-source-audit.

Manifest read

1
2
3
4
5
6
7
8
9
10
<provider
  android:name=".MyProvider"
  android:authorities="com.example.provider"
  android:exported="true"                   is it exposed?
  android:permission="..."                   is it gated?
  android:readPermission="..."
  android:writePermission="..."
  android:grantUriPermissions="true">       ← can it issue transient grants?
  <grant-uri-permission android:pathPattern=".*"/>
</provider>

Build the table:

| Provider | Exported | Read perm | Write perm | grantUriPermissions | path-patterns | |—|—|—|—|—|—|

For each provider, open the source.

The four methods

1
2
3
4
5
6
7
class MyProvider extends ContentProvider {
    public Cursor query(Uri uri, String[] projection, String sel, String[] selArgs, String sort) { ... }
    public Uri    insert(Uri uri, ContentValues values) { ... }
    public int    update(Uri uri, ContentValues values, String sel, String[] selArgs) { ... }
    public int    delete(Uri uri, String sel, String[] selArgs) { ... }
    public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { ... }
}

SQL injection — selection and sortOrder

The classic provider bug: the provider passes user-controlled selection (or sortOrder) directly into a SQL string.

1
2
3
4
5
public Cursor query(Uri uri, ..., String sel, String[] args, String sort) {
    SQLiteDatabase db = mHelper.getReadableDatabase();
    // BAD: sel is attacker-controlled (caller of contentResolver.query)
    return db.query("users", null, sel, args, null, null, sort);
}

A caller does:

1
2
3
4
5
6
val c = contentResolver.query(
    Uri.parse("content://com.example.provider/items"),
    null,
    "1) UNION SELECT password, NULL FROM users--",   // injection
    null, null
)

Fix patterns:

  • Use parameterised statements only (? placeholders, args array).
  • Restrict selection to an allowlist of columns.
  • SQLiteQueryBuilder.setStrict(true) and setProjectionMap() to block surprising columns.
  • For modern projects, Room — its compiled queries don’t accept arbitrary SQL.

Path traversal in openFile

1
2
3
4
5
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
    String name = uri.getLastPathSegment();
    File f = new File(getContext().getFilesDir(), name);
    return ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY);
}

Attacker calls with content://com.example.provider/../../../databases/secret.db. getLastPathSegment() returns secret.db, but if the implementation uses the whole path or doesn’t canonicalise, it leaks files.

Canonicalise + bound:

1
2
3
File root = getContext().getFilesDir().getCanonicalFile();
File f = new File(root, name).getCanonicalFile();
if (!f.toPath().startsWith(root.toPath())) throw new FileNotFoundException("escape");

grantUriPermissions=true

Sets the provider as “able to grant transient access to specific URIs”. A clean use: the app shares a single file URI with the user’s selected editor.

The risks:

  • <grant-uri-permission android:pathPattern=".*"/> — grants over the entire authority, removing the point.
  • Code that builds an Intent with FLAG_GRANT_READ_URI_PERMISSION for an attacker-controlled URI → the recipient can read whatever the provider serves at that URI.

URI matchers — a quiet source of confusion

1
2
3
4
5
private static final UriMatcher MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
static {
    MATCHER.addURI(AUTHORITY, "items", ITEMS);
    MATCHER.addURI(AUTHORITY, "items/#", ITEM_ID);
}

The numeric # matches only digits. * matches any single segment. A provider that uses * for an ID-like segment may produce SQL with unexpected types.

Look for path patterns wider than necessary:

1
grep -rn 'addURI\|UriMatcher\.\*' src/

FileProvider and external storage

FileProvider (androidx) issues content:// URIs that map to internal file paths. It’s safer than raw file:// sharing, but:

  • res/xml/file_paths.xml defines the mapping. A wide <external-path name="x" path="."/> shares the whole storage root.
  • A poorly-built FileProvider chain (your app gives a URI to a partner, partner gives it back to a malicious app) can let untrusted code read app-private files.
1
find . -name 'file_paths.xml'

Concurrent grants and lifecycle

grantUriPermission() calls without matching revokeUriPermission() accumulate. On older Android they survived process death; modern Android cleans up but engineering-bug provider lifecycle still keeps grants longer than intended.

Source-audit checklist

  • Every exported provider either has a permission=/readPermission/writePermission or has a documented “anyone can read these public records” intent.
  • query() does not interpolate selection / sortOrder into raw SQL.
  • openFile() canonicalises paths and bounds to a known directory.
  • grantUriPermissions is on only when needed and <grant-uri-permission> paths are tight.
  • file_paths.xml does not share whole storage roots.
  • Binder#getCallingUid() checks happen before sensitive returns when the provider is internal-only.

References