1606 字
8 分钟
L3HCTF_2025_WEB
2025-07-14

2025 L3HCTF, 赛中只解出了 2 web + 1 misc

WEB#

best_profile#

给了源码,大概结构如图 alt text 是个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

这玩意使用了默认配置的ProxyFixuser.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}页面的内容
alt text 这里有个点要注意一下,引号会被转义,所以这个地方实际上是一个经典的无引号SSTI问题,可以直接抄个payload alt text 到现在为止,我们已经可以控制在登陆状态下向/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 配置文件,没别的东西了 alt text 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 文件的内容,分别存入变量 flagpassword 中,然后删除掉了这两个文件。这里可以注意到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

L3HCTF_2025_WEB
https://blog.asteriax.site/posts/l3hctf_2025_web/
作者
ASTERIAX
发布于
2025-07-14
许可协议
CC BY-NC-SA 4.0