2025 L3HCTF, 赛中只解出了 2 web + 1 misc
WEB
best_profile
给了源码,大概结构如图
是个python写的神秘网站,可以发现ip_detail路由这里有个很明显的SSTI漏洞点
@app.route("/ip_detail/<string:username>", methods=["GET"])
def route_ip_detail(username):
"""IP详情查看路由"""
# 通过HTTP请求获取用户最后访问的IP
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地址
ip = re.findall(r"\d+\.\d+\.\d+\.\d+", last_ip)
# 查询IP地址对应的国家信息
country = geoip2_reader.country(ip)
except (ValueError, TypeError):
country = "Unknown"
# 动态生成HTML模板
template = f"""
<h1>IP Detail</h1>
<div>{last_ip}</div>
<p>Country:{country}</p>
"""
return render_template_string(template)
这里用到了render_template_string(template),且template中的这个last_ip是前面的requests.get请求对应的res.text内容
如果可以控制res.text,那就相当于可以直接SSTI RCE了。所以下一步先看看他请求的这个/get_last_ip/{username}路由
(这里我本来想传个带../的username打穿越,但是经过一番尝试发现好像穿不了)
@app.route("/get_last_ip/<string:username>", methods=["GET", "POST"])
def route_check_ip(username):
"""获取用户最后访问IP路由"""
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)
检查一下/get_last_ip/{username}这个路由对应的代码
首先,需要满足current_user.is_authenticated才能使用getip功能,这意味着,如果要利用这个路由打前面提到的SSTI,需要想办法绕过这个登陆验证。因为前面/ip_detail处是直接通过requests.get(f"http://127.0.0.1/get_last_ip/{username}")发送的GET请求,不会携带cookie,也就无法满足这个current_user.is_authenticated条件
然后,他返回的页面中的变量只有username对应的user.last_ip,我们需要检查这个user.last_ip是否可控
先找找这个user.last_ip是从哪来的,观察代码,可以发现
...
from werkzeug.middleware.proxy_fix import ProxyFix
...
app.wsgi_app = ProxyFix(app.wsgi_app)
...
@app.after_request
def set_last_ip(response):
"""请求后处理函数:记录用户最后访问IP"""
if current_user.is_authenticated:
current_user.last_ip = request.remote_addr
db.session.commit()
return response
这玩意使用了默认配置的ProxyFix,user.last_ip来自于每次请求后记录的request.remote_addr。
server {
listen 80 default_server;
location / {
proxy_pass http://127.0.0.1:5000;
}
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;
}
}
观察给出的nginx.conf可以发现前面的反代没有配置XFF头。由于默认情况下ProxyFix会处理一个XFF头,把remote_addr设置成XFF头的值。我们可以通过添加XFF头,控制request.remote_addr,进而控制user.last_ip,然后控制/get_last_ip/{username}页面的内容
这里有个点要注意一下,引号会被转义,所以这个地方实际上是一个经典的无引号SSTI问题,可以直接抄个payload
到现在为止,我们已经可以控制在登陆状态下向/get_last_ip/{username}请求返回的内容了,但还需要解决current_user.is_authenticated验证才能打通这个SSTI
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;
}
回过头来看看nginx.conf中的缓存配置,可以发现他会缓存所有对以 .gif、.jpg、.jpeg、.png、.bmp、.swf 结尾的URL的请求的响应内容
我们可以创建一个用户名为xx.jpg的帐户,然后带着对应的cookie和配置好的XFF随便请求一个接口,让系统保存lastip,之后再带着cookie请求/get_last_ip/xx.jpg。因为这个URL以.jpg结尾,这个请求的响应内容会被缓存。在缓存过期前,即使不携带cookie直接请求/get_last_ip/xx.jpg,也可以直接通过缓存获取到包含SSTI payload的内容。这样就能绕过current_user.is_authenticated的判断。
最后直接打/ip_detail这里的SSTI就能get flag了。
gateway_advance
这道题也是给出了源码,非常地简单粗暴
一个 OpenResty + nginx.conf 配置文件,没别的东西了
OpenResty 集成了 Nginx 和 LuaJIT,可以在 Nginx 配置中直接编写 Lua 脚本对请求进行处理,我们看看 nginx.conf 里写了什么东西
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")
}
首先,初始化的时候读取了 /flag 和 /password 文件的内容,分别存入变量 flag 和 password 中,然后删除掉了这两个文件。这里可以注意到f2是没有被close掉的,所以如果我们后面可以做到任意文件读取,就能通过爆破 /proc/self/fd/x 找回password文件的内容,但是f被close掉了,基本上就是没啥招了,只能看看能不能通过读取/proc/self/maps -> /proc/self/mem,直接读内存里flag变量的值,来拿到flag。
接着往下看,可以发现定义了几个接口:
server {
listen 80 default_server;
location / {
content_by_lua_block {
ngx.say("hello, world!")
}
}
显示helloworld,这玩意没什么用
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;
}
一个任意文件读取接口,但是只能从本地访问,注意这个接口提供了对Accept-Ranges头的支持
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
}
}
一眼顶针,这里通过ngx.req.get_uri_args()拿参数的时候没有做检查(ngx.req.get_uri_args()默认情况下最多解析 100 个请求参数,当超出限制时,它将返回第二个值,该值是字符串 “truncated”)这就导致只要传大于100个参数我们就能绕过他的第一个黑名单WAF,然后由于/static接口是支持通过Accept-Ranges请求头限制文件读取字节范围的,所以我们可以通过切片的方式把黑名单关键词拆开读取,绕过第二个WAF。基本上,通过这个接口我们就能读取任意文件,然后配合爆破fd就能读到password的值:passwordismemeispasswordsoneverwannagiveyouup
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"
}
}
}
拿到password之后配好请求头就可以利用最后这个接口愉快地读内存了:
curl -s -H "X-Gateway-Password: passwordismemeispasswordsoneverwannagiveyouup" -H "X-Gateway-Filename: /proc/self/mem" -H "X-Gateway-Start: 128408370556928" -H "X-Gateway-Length: 1179648" http://43.138.2.216:17794/read_anywhere | strings | grep -E "l3h|ctf|flag" -i
即可找到L3HCTF{g4t3way_st1ll_n0t_s3cur3}
MISC
Please Sign In
TODO
