/ ANDROID, CONTENT PROVIDERS

Content Providers and the potential weak spots they can have

Android security checklist: Content Providers

Today, let’s dive deeper into Content Providers and the potential weak spots they can have. ​ Before we jump into the details, we want to stress how important it is to keep your users’ data safe. Nowadays, security is a big deal, and you definitely want to protect your users against problems like those that can come from Content Providers. ​ If you’re curious about how Oversecured can help your team watch over every bit of your code, sending you automatic reports about any vulnerabilities and telling you how to fix them, just get in touch with us. We’ve got more information about securing your apps and users’ data that we’d love to share.

Content Providers are a fundamental component of the Android platform, extensively used for sharing data among different applications. Often, they provide access to sensitive content. However, due to common errors in their implementation and protection mechanisms, they are susceptible to various vulnerabilities. This article will explore the most prevalent issues in their operation and potential methods to exploit them.

Before delving into demonstrating the errors, it is essential to comprehend the concept of providers and their intended tasks. Broadly, providers can be categorized into three primary groups:

  1. Sharing and Modifying Content: Applications frequently employ an SQLite database to store and manipulate content. This approach is effective until the need arises to share data with other applications. For instance, in Android, built-in default providers allow access to user contacts and SMS messages. In such cases, queries from the ContentProvider implementation are generally proxied to the database using methods like query(), update(), delete(), insert(), and bulkInsert(). The application that accesses the content invokes corresponding methods in ContentResolver.

  2. Providing read/write Access to Files: Providers are also used to create ParcelFileDescriptor objects (file descriptors for the requested files) with read and/or write access. In such scenarios, developers implement methods like openFile(), openAssetFile(), openTypedAssetFile(), and/or openFileHelper(). The recipient of content files must call methods such as ContentResolver.openFile(), ContentResolver.openAssetFile(), ContentResolver.openInputStream(), ContentResolver.openOutputStream(), ContentResolver.openFileDescriptor(), ContentResolver.openAssetFileDescriptor(), ContentResolver.openTypedAssetFile(), and/or ContentResolver.openTypedAssetFileDescriptor(). While the diverse range of return values might seem overwhelming, it’s important to note that AssetFileDescriptor wraps ParcelFileDescriptor, and ParcelFileDescriptor wraps FileDescriptor. Methods like ContentResolver.openInputStream() and ContentResolver.openOutputStream() simply employ the file descriptor to open streams for reading or writing.

  3. Executing Commands: The call() method serves this purpose. It takes the name of an action and its arguments as input, returning the result encapsulated in a Bundle object. Executed actions can encompass a wide range of functionalities. For instance, in Android, a provider for device settings exists, enabling reading and editing of settings via the call() method.

All the common errors described can impact both directly exported providers and those declared with the android:grantUriPermissions="true" flag. Attackers can exploit various pathways to gain access, as elaborated in the article Gaining access to arbitrary* Content Providers.

Table of content

  1. Insecure FileProvider
  2. Path-traversal when using data from Uri
  3. Errors in android:readPermission and android:writePermission declarations
  4. Proxying requests to more secure providers
  5. Mixing sensitive and non-sensitive data in one database
  6. Executing sensitive actions in Content Provider

Insecure FileProvider

In recent Android versions, obtaining content files directly from other apps is restricted due to SELinux limitations, even if file access mode is set to 777. To address the issue of file exchange between different applications while enhancing security, apps can grant access to specific files or folders. This task is so common that Android developers introduced the FileProvider class, which greatly simplifies this process. In the AndroidManifest.xml, such providers cannot be exported, but they are always declared with the android:grantUriPermissions="true" flag. They also always refer to an XML resource file that describes paths to shareable files. The following possible entries with paths exist:

  • root-path: arbitrary files (/)
  • files-path: files in the private files directory (/data/user/0/com.victim/files/)
  • cache-path: files in the private cache directory (/data/user/0/com.victim/cache/)
  • external-path: arbitrary files on the SD card (/sdcard/)
  • external-files-path: files in the files folder on the SD card within the app’s directory (/sdcard/Android/data/com.victim/files/)
  • external-cache-path: files in the cache folder on the SD card within the app’s directory (/sdcard/Android/data/com.victim/cache/)
  • external-media-path: media files on the SD card within the app’s directory (/sdcard/Android/media/com.victim/)

The issue arises when such a provider grants access to too many files. This impacts security and could lead to unauthorized access to unintended files when combined with other vulnerabilities.

For instance, on Samsung devices, this allowed gaining read/write access to arbitrary files with system privileges: vulnerability

A common attack chain involves intent redirection followed by gaining access to arbitrary providers with the android:grantUriPermissions="true" flag. The third link in this attack is an insecure FileProvider that, for example, grants access either to all files or entire directories like files or cache.

Remediation:
Each FileProvider should be responsible for a specific action, such as sharing images. Providers for general tasks should not be created. Additionally, each provider should only grant access to a specific subfolder (e.g., /data/user/0/com.victim/cache/my_image_cache/ instead of the entire /data/user/0/com.victim/cache/).

Path-traversal when using data from Uri

The most common mistake when working with files in providers is using data obtained from methods like Uri.getLastPathSegment(), Uri.getPathSegments(), and others that return specific parts of a URI. Developers need to remember that these methods also decode values. Hence, an attacker can perform URL-encoding on expected values, successfully executing a path-traversal attack.

Oversecured has already described such errors. For instance, in the case of Google, this led to arbitrary code execution, where using a decoded value allowed gaining read+write access to arbitrary files. Another documented example allowed read-only access to arbitrary files with system privileges on Samsung devices: vulnerability

Example of vulnerable code:

File AndroidManifest.xml:

<provider
    android:name="com.victim.PathTraversalProvider"
    android:authorities="com.victim.path_traversal"
    android:exported="true" />

File PathTraversalProvider.java:

public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
    File file = new File(getContext().getFilesDir(), uri.getLastPathSegment());
    return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode));
}

As per the developer’s intention, the app should return file descriptors for files in the files directory. However, the following attack can be used to retrieve content from arbitrary files:

Uri uri = Uri.parse("content://com.victim.path_traversal/..%2Fshared_prefs%2Fsecrets.xml");
try (InputStream inputStream = getContentResolver().openInputStream(uri)) {
    Log.d("evil", new String(inputStream.readAllBytes()));
} catch (IOException e) {
    //...
}

Remediation:
It’s necessary to either validate the resulting path by calculating the canonical path or re-encode the value: Uri.encode(Uri.getLastPathSegment()).

Errors in android:readPermission and android:writePermission declarations

In addition to the android:permission attribute, providers have two more attributes: android:readPermission and android:writePermission. The former sets the permission for read methods like query(). The latter applies to write methods including insert(), update(), delete(), and bulkInsert(). The call() method requires the caller to have either android:readPermission or android:writePermission. Methods for reading files check the access mode (read-only, write-only, or read+write).

Example 1:

<provider
    android:name="com.victim.ProtectedProvider"
    android:authorities="com.victim.protected"
    android:readPermission="com.victim.SIGNATURE_LEVEL"
    android:exported="true" />

In this case, read operations will require the com.victim.SIGNATURE_LEVEL permission, but no permission will be needed for write operations.

Example 2:

<provider
    android:name="com.victim.ProtectedProvider"
    android:authorities="com.victim.protected"
    android:permission="com.victim.NORMAL_LEVEL"
    android:readPermission="com.victim.SIGNATURE_LEVEL"
    android:exported="true" />

Here, read operations will require the com.victim.SIGNATURE_LEVEL permission, and write operations will need the com.victim.NORMAL_LEVEL permission.

Now, let’s consider a vulnerable application example. Code similar to this has been encountered by the Oversecured team multiple times:

File AndroidManifest.xml:

<provider
    android:name="com.victim.ProtectedProvider"
    android:authorities="com.victim.protected"
    android:readPermission="com.victim.SIGNATURE_LEVEL"
    android:exported="true" />

File ProtectedProvider.java:

public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
    return ParcelFileDescriptor.open(new File(uri.getPath()), ParcelFileDescriptor.MODE_READ_ONLY);
}

In this case, the ContentResolver will only check the mode value during access rights verification, which is ignored in the openFile() method’s code. Exploiting the read access to arbitrary files would look like this:

try {
    Uri uri = Uri.parse("content://com.victim.protected/data/user/0/com.victim/shared_prefs/secrets.xml");
    ParcelFileDescriptor pfd = getContentResolver().openFile(uri, "w", null);
    try (InputStream inputStream = new FileInputStream(pfd.getFileDescriptor())) {
        Log.d("evil", new String(inputStream.readAllBytes()));
    }
} catch (IOException e) {
    //...
}

Remediation:
In addition to the obvious advice of verifying access mode, it’s crucial not to leave read/write access without the required permissions. If a developer is unsure whether to demand read or write permission, it’s recommended to set the permission to the android:permission attribute.

Proxying requests to more secure providers

One typical mistake developers make is combining different providers’ functionality into one with a decreased level of security. Let’s consider the following example:

File AndroidManifest.xml:

<permission android:name="com.victim.NORMAL_PERMISSION" android:protectionLevel="normal" />
<uses-permission android:name="com.victim.NORMAL_PERMISSION" />
<provider
    android:name="oversecured.test.CommonContentProvider"
    android:authorities="com.victim.common"
    android:permission="com.victim.NORMAL_PERMISSION"
    android:exported="true" />

File CommonContentProvider.java:

public class CommonContentProvider extends ContentProvider {
    private static final UriMatcher MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
    private static final int CONTACTS_CODE = 1;

    static {
        MATCHER.addURI("com.victim.common", "contacts", CONTACTS_CODE);
        //...
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
                        String sortOrder) {
        switch (MATCHER.match(uri)) {
            case CONTACTS_CODE: {
                Uri newUri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI;
                return getContext().getContentResolver().query(newUri, projection, selection,
                        selectionArgs, sortOrder);
            }
            //...
        }
    }

In this case, a vulnerable application accesses the system provider that stores user contacts. However, the provider is protected by the com.victim.NORMAL_PERMISSION permission with the normal protection level, while the system provider is declared with the dangerous protection level. This allows an attacker to obtain the com.victim.NORMAL_PERMISSION permission and use the application with legitimate but vulnerable functionality to read user contacts and potentially abuse the entire system.

Oversecured also considers a vulnerability when requests are proxied from a non-exported provider with the android:grantUriPermissions="true" flag to a non-exported provider without this flag. With other vulnerabilities in place, the attacker can gain access, resulting in a reduction of the application’s security level and user protection.

Furthermore, one of the most severe security mistakes is using dynamic URIs, where an attacker can control the provider to which the request is directed, like in the following example:

private static final UriMatcher MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
private static final int PROXY_CODE = 1;

static {
    MATCHER.addURI("com.victim.proxy", "proxy", PROXY_CODE);
    //...
}

@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
                    String sortOrder) {
    switch (MATCHER.match(uri)) {
        case PROXY_CODE: {
            Uri newUri = Uri.parse(uri.getQueryParameter("uri"));
            return getContext().getContentResolver().query(newUri, projection, selection,
                    selectionArgs, sortOrder);
        }
        //...
    }
}

Such an error can allow an attacker to access all the providers that the vulnerable application has access to (including ecosystem apps and system providers accessible through requested permissions).

Remediation:
Developers should avoid proxying requests to other providers within their implementations.

Mixing sensitive and non-sensitive data in one database

Let’s examine a vulnerable application fragment with two content providers that use different access permissions but share the same database.

File AndroidManifest.xml:

<permission
    android:name="com.victim.SECURE_PERMISSION"
    android:protectionLevel="signature" />
<provider
    android:name="com.victim.SensitiveContentProvider"
    android:authorities="com.victim.sensitive"
    android:exported="true"
    android:permission="com.victim.SECURE_PERMISSION" />

<provider
    android:name="com.victim.InsensitiveContentProvider"
    android:authorities="com.victim.insensitive"
    android:exported="true" />

File SensitiveContentProvider.java:

public class SensitiveContentProvider extends ContentProvider {
    private DatabaseHelper helper;

    @Override
    public boolean onCreate() {
        helper = new DatabaseHelper(getContext());
        return true;
    }

    @Override
    public String getType(Uri uri) {
        return null;
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
                        String sortOrder) {
        return helper.getReadableDatabase().query(DatabaseHelper.SENSITIVE_TABLE_NAME, projection,
                selection, selectionArgs, null, null, sortOrder);
    }

    @Override
    public Uri insert(Uri uri, ContentValues values) {
        long id = helper.getWritableDatabase().insert(DatabaseHelper.SENSITIVE_TABLE_NAME, null, values);
        return ContentUris.withAppendedId(uri, id);
    }

    @Override
    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
        return helper.getWritableDatabase().update(DatabaseHelper.SENSITIVE_TABLE_NAME, values,
                selection, selectionArgs);
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {
        return helper.getWritableDatabase().delete(DatabaseHelper.SENSITIVE_TABLE_NAME, selection,
                selectionArgs);
    }
}

File InsensitiveContentProvider.java:

public class InsensitiveContentProvider extends ContentProvider {
    private DatabaseHelper helper;

    @Override
    public boolean onCreate() {
        helper = new DatabaseHelper(getContext());
        return true;
    }

    @Override
    public String getType(Uri uri) {
        return null;
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
                        String sortOrder) {
        return helper.getReadableDatabase().query(DatabaseHelper.INSENSITIVE_TABLE_NAME, projection,
                selection, selectionArgs, null, null, sortOrder);
    }

    @Override
    public Uri insert(Uri uri, ContentValues values) {
        long id = helper.getWritableDatabase().insert(DatabaseHelper.INSENSITIVE_TABLE_NAME, null, values);
        return ContentUris.withAppendedId(uri, id);
    }

    @Override
    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
        return helper.getWritableDatabase().update(DatabaseHelper.INSENSITIVE_TABLE_NAME, values,
                selection, selectionArgs);
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {
        return helper.getWritableDatabase().delete(DatabaseHelper.INSENSITIVE_TABLE_NAME, selection,
                selectionArgs);
    }
}

File DatabaseHelper.java:

public class DatabaseHelper extends SQLiteOpenHelper {
    private static final String DATABASE_NAME = "data.db";
    private static final int DATABASE_VERSION = 1;

    public static final String SENSITIVE_TABLE_NAME = "Sensitive";
    private static final String CREATE_SENSITIVE_TABLE =
            "CREATE TABLE " + SENSITIVE_TABLE_NAME + " (" +
                    "_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
                    "sensitiveData TEXT NOT NULL)";

    public static final String INSENSITIVE_TABLE_NAME = "Insensitive";
    private static final String CREATE_INSENSITIVE_TABLE =
            "CREATE TABLE " + INSENSITIVE_TABLE_NAME + " (" +
                    "_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
                    "staticData TEXT NOT NULL)";

    public DatabaseHelper(Context context) {
        super(context, DATABASE_NAME, null, DATABASE_VERSION);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(CREATE_SENSITIVE_TABLE);
        db.execSQL(CREATE_INSENSITIVE_TABLE);
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int i, int i1) {
        db.execSQL("DROP TABLE IF EXISTS " + SENSITIVE_TABLE_NAME);
        db.execSQL("DROP TABLE IF EXISTS " + INSENSITIVE_TABLE_NAME);
        onCreate(db);
    }
}

In this example, the application has adequately separated access at the Android level; only trusted apps can access the SensitiveContentProvider. However, at the database level, an attacker can exploit SQL injection to access sensitive data. For example, like this:

Uri insensitiveUri = Uri.parse("content://com.victim.insensitive/");
String whereSqli = "1=2 UNION SELECT * FROM Sensitive -- ";
Cursor cursor = getContentResolver().query(insensitiveUri, null, whereSqli, null, null);
if (cursor != null && cursor.moveToFirst()) {
    do {
        Log.d("evil", "Leakged entry: " + cursor.getString(1));
    } while (cursor.moveToNext());
}

Remediation:
Developers should use separate database files for each content provider. Additionally, ensure that content providers of different security levels do not communicate with each other based on the passed URI.

Executing sensitive actions in Content Provider

Sometimes developers add complex logic to a Content Provider in addition to content sharing. For example, debug actions, automatic data encryption or decryption, creation/modification of files outside defined directories, and so on. Such overloading of functionality in one place often leads to vulnerabilities. Let’s consider an example that security experts at Oversecured found in a very popular application.

File AndroidManifest.xml:

<provider
    android:name="com.victim.InternalContentProvider"
    android:authorities="com.victim.internal"
    android:exported="false" />

File InternalContentProvider.java:

private static final UriMatcher MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
private static final int DEBUG_CODE = 1;

static {
    MATCHER.addURI("com.victim.internal", "debug", DEBUG_CODE);
    //...
}

@Override
public String getType(Uri uri) {
    return null;
}

@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
                    String sortOrder) {
    switch (MATCHER.match(uri)) {
        case DEBUG_CODE: {
            debug();
            return null;
        }
        //...
    }
    //...
}

private void debug() {
    File dbFile = getContext().getDatabasePath("internal.db");
    File outputFile = new File(Environment.DIRECTORY_DOWNLOADS, dbFile.getName());
    try (InputStream inputStream = new FileInputStream(dbFile)) {
        try (OutputStream outputStream = new FileOutputStream(outputFile)) {
            inputStream.transferTo(outputStream);
        }
    } catch (IOException e) {
        //...
    }
}

The logic of this code was such that for development purposes, a way to view the database content was added. This was not considered a vulnerability because the content provider was not exported, meaning it was protected. The code review passed.

However, the application contained dozens of places where it accepted URIs from outside and called ContentResolver.query(). Thus, an attacker could create a URI like content://com.victim.internal/debug and make the application execute dangerous functionality.

Remediation:
Developers should avoid complex logic in Content Providers and use them only as proxies for internal files or database data. They should not modify, create, or move internal data or files except for specifically defined functions like insert(), delete(), and others.

Make your mobile apps stronger against all the vulnerabilities that Content Providers might have.

Schedule a demonstration of Oversecured Automated Vulnerability Scanning Solution to see how it operates and how it can assist your company in stopping vulnerabilities in your code at any stage of development.

  • Get an in-depth look into how our integration can optimize your security processes.
  • Learn how our solution fits effortlessly into your CI/CD pipeline.
  • Benefit from insights shared by our security specialists during the demo.

Ready to enhance your app’s security? Fill out the form below to take the first step!

Protect your apps today!

It can be challenging to keep track of security issues that appear daily during the app development process. Drop us a line and we'll help you automate this process internally, saving tons of resources with Oversecured.