1. 从一道CTF题窥见的协议盲区
先说个有意思的事。CTFHUB上有一道经典的SSRF题目,常规解法无非是探测内网、读取文件、打Redis未授权,大家玩得挺熟。但某次我卡在最后一关,内网端口扫出来了,服务也知道是什么,可常规的dict://、file://、http://全都用不上,差点就放弃。后来群里有人提了一嘴“试试Gopher协议”,我才意识到自己对这个老协议的理解一直停留在“能用但没用过”的层面。
Gopher协议,说白了一个上古时代的互联网信息访问协议,诞生得比HTTP还早,现在基本没什么人在用。正因为它“古老”、“冷门”,很多安全从业者对它的了解仅限于“能发GET请求”或者“能打内网Redis”。但真正深挖下去,你会发现Gopher之于SSRF,就像一把万能钥匙——你可以用它构造任意TCP数据包,跟内网任意服务完成一次完整的交互。这个能力在常规协议里几乎是独一份的。
这篇文章就从一个实际的CTF场景出发,把Gopher协议为什么能在SSRF里如鱼得水、怎么构造payload、构造过程中有哪些坑、以及它背后暴露出来的防御盲区,原原本本讲清楚。不管你是刚入门CTF的新手,还是做攻防演练的老手,这篇文章应该都能给你一些新的思路。
2. 从一个“老古董”协议聊起:Gopher为什么这么特殊
2.1 一个被时代遗弃但保留完整能力的协议
Gopher诞生于1991年,比HTTP早了整整五年。它设计之初就是为了在互联网上做分层菜单式的信息检索,用户通过Gopher客户端进入一个菜单界面,逐级点击就能浏览文本内容。后来HTTP凭借更丰富的表现力迅速取代了它,Gopher逐渐退出了历史舞台。
但协议退出了,能力并没有消失。Gopher协议的核心定义极其简单:一个TCP连接、一个选择器字符串、加一个可选的请求参数。它没有HTTP那么复杂的请求头解析,没有TLS握手,没有Cookie,甚至没有请求体。它本质上就是“往TCP连接里塞一段原始数据”,这段数据是什么格式,完全由你说了算。
这个特性在SSRF里被无限放大。因为你所控制的Gopher URL,最终会被目标服务器解析成一次真正的TCP连接并发出去,而TCP连接里的内容,就是你URL里编码好的任意字节。
2.2 从协议格式看“任意请求构造”的原理
Gopher协议的URL标准格式大致如下:
gopher://<host>:<port>/<gophertype><selector>分解一下:
gopher://:协议标识<host>:目标主机地址<port>:目标端口<gophertype>:一个字符,表示资源类型,比如1表示目录、0表示文本文件<selector>:实际发送到服务端的数据
关键点在这个selector。它会被客户端按原始字节发送到目标TCP端口,中间不会做任何二次解析。这就意味着,如果你把一段精心构造的HTTP请求或者Redis命令塞进selector部分,再用URL编码转义掉特殊字符,目标服务器收到的就是一份完全“合法”的原始请求。
举个最直观的例子,正常情况下你要打一个未授权的Redis服务,要么直接连上去发命令,要么通过SSRF的dict协议逐字下发,非常费劲。但如果用Gopher,你可以把一整串Redis命令拼好,一次性塞进TCP连接,服务端按流式解析,你相当于拥有了一个“内网TCP会话操控器”。
2.3 为什么偏偏是Gopher,而不是其他协议
我们常用SSRF能调用的协议就那么几种:file://(读文件)、dict://(发简单命令)、http://(发HTTP请求)、ftp://(列目录/上传下载)。逐一对比你就知道Gopher的位置在哪:
| 协议 | 能控制的内容 | 限制 | 适用于 |
|---|---|---|---|
| file:// | 无 | 只能读文件 | 本地文件读取 |
| dict:// | 单行命令 | 无法自定义完整请求 | 探测端口、基础命令 |
| http:// | 标准HTTP请求 | 格式受限,无法自定义请求体 | Web服务交互 |
| ftp:// | 用户名、密码、目录 | 很难构造任意数据 | 文件操作 |
| gopher:// | 任意字节流 | 几乎没有 | 各类内网服务 |
注意看最后一行,“任意字节流”是其他所有协议都不具备的。HTTP协议被浏览器和组件固化得死死的,你很难控制它发出畸形请求;dict协议虽然是文本协议,但它只允许你传单行长度的内容,毫无灵活性可言。只有Gopher,因为设计极简,反而保留了最大的自由度。
在我看来,Gopher的强大可以用一个生活化的类比来解释:其他协议像是有轨列车,只能沿着固定的轨道跑;Gopher则像一块可以随意捏的橡皮泥,你想捏成什么样,它就是什么样。
3. 拿下SSRF关键点:用Gopher构造任意HTTP请求
3.1 构造一个标准HTTP GET请求的完整过程
先从最简单、也最常见的场景讲起:如何通过Gopher发一个标准的HTTP GET请求。
假设内网有一个Web服务,地址是http://127.0.0.1:8080/admin,你希望SSRF的目标服务器去访问这个内网地址,并把这个请求发送给内网服务。常规的http://协议直接就能做到,但如果目标Web应用对URL做了严格过滤,禁止直接使用http://,只放行gopher://,你就得手动把HTTP请求“翻译”成Gopher格式。
一个标准的HTTP GET请求长这样:
GET /admin HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: Mozilla/5.0 Accept: */* Connection: close注意这里有两个换行,HTTP协议规定请求和响应之间必须有一个空行来分隔头部和消息体。转换成Gopher URL,你需要把这个请求内容逐字节编码。
第一步:把请求头和请求尾的换行符都转成%0d%0a(也就是\r\n,HTTP规范里的标准换行),消息体结束时再额外加一个%0d%0a(表示空行)。这样整段请求就变成了:
GET /admin HTTP/1.1%0d%0aHost: 127.0.0.1:8080%0d%0aUser-Agent: Mozilla/5.0%0d%0aAccept: */*%0d%0aConnection: close%0d%0a%0d%0a第二步:在Host请求头后面拼接目标IP和端口,然后在开头拼上gopher://:
gopher://127.0.0.1:8080/_GET /admin HTTP/1.1%0d%0aHost: 127.0.0.1:8080%0d%0aUser-Agent: Mozilla/5.0%0d%0aAccept: */*%0d%0aConnection: close%0d%0a%0d%0a这里有一个非常容易被忽略的细节:_字符。Gopher协议规范里,selector字段的第一个字符会被解释为gophertype,也就是资源类型标识。而当我们想发送任意数据时,这个_是约定俗成的占位符,因为它不会被服务器特殊处理,相当于“默认类型”。如果你漏掉了这个下划线,整个请求可能就无法正确发出去。
第三步:把整个URL交给SSRF漏洞点发送。目标服务器会去连接127.0.0.1:8080,然后逐字节发送后面这一长串编码数据。内网的HTTP服务收到后,解包看到的就是一个完美的、没有任何异常的GET请求。
这就是Gopher构造HTTP请求的基本原理。
3.2 进阶用法:发送POST请求与自定义Body
上面讲了GET,但实际渗透中更常见的是POST请求。比如内网有一个登录接口,你想通过SSRF去提交一个表单,光靠GET是做不到的。用Gopher就能解决。
POST请求和GET的区别在于:多了Content-Type和Content-Length两个头,以及一个消息体。构造时,你不仅要编码请求头,还得把body内容一起编码进去。body和头部之间用空行隔开,空行同样要编码成%0d%0a。
假设要提交一个登录表单:
POST /login HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: Mozilla/5.0 Content-Type: application/x-www-form-urlencoded Content-Length: 29 Connection: close username=admin&password=admin注意Content-Length必须和body的字节数完全一致。如果你写少了,服务端会一直等待后续数据直到超时;写多了,服务端会把下一个请求的头误判为body内容,直接报错。计算username=admin&password=admin的字节数,可以用工具,也可以直接数:username=是9个字符,admin是5个,加上&password=是10个,再加上admin5个,总共29个,就是这个值。
然后整段转换成Gopher:
gopher://127.0.0.1:8080/_POST /login HTTP/1.1%0d%0aHost: 127.0.0.1:8080%0d%0aUser-Agent: Mozilla/5.0%0d%0aContent-Type: application/x-www-form-urlencoded%0d%0aContent-Length: 29%0d%0aConnection: close%0d%0a%0d%0ausername=admin&password=admin这里可能有人会问:body里的&符号不需要URL编码吗?在Gopher协议里,selector部分只有特殊字符需要转义,比如空格、#、?等。&在URL中一般是参数分隔符,但在Gopher的selector上下文里,它不需要被转义,直接按原样发送。不过如果你不确定,最稳妥的做法是把所有非ASCII字符或可疑字符都做一次URL编码,多编几个字符不影响服务端解析。
我就不再赘述,直接说结论:在构造Gopher payload时,凡是可能被URL解析器“吃掉”的字符,一律编码;凡是不会造成歧义的可见字符,保留原样。
3.3 一个必踩的坑:请求行的换行符与URL解析器的二次解码
我在实操中遇到过很多次payload构造完全正确,但请求就是发不出去的情况。排到最后发现,问题几乎都出在换行符上。
Gopher协议的URL在传递过程中,不同的语言和框架会对它做不同次数的URL解码。比如目标应用用的curl命令发起HTTP请求时,它收到原始URL会先做一次解码;如果应用在这个环节之前又额外调用了一次urldecode函数,你的payload就会被解码两次。第一次解码后%0d%0a变成了真正的\r\n,第二次解码时如果\r\n本身不含有%号,就不会再被识别为转义序列,问题不大——但前提是你在构造时已经把字符编码对了。
真正的坑在于:如果你一次性编码了两层(也就是说你把%25作为%字的转义),那么最终发送到Gopher服务器的数据就会多一层编码,服务端收到的就不是HTTP请求了,而是带%号的乱码。解决方法是先搞清楚目标应用到底做几次URL解码,多试几次,找到一个合适的编码层数。
一个比较实用的经验是:在本地先搭一个监听端口做测试,用NetCat或者其他工具监听8080端口,然后把构造好的Gopher payload直接发给本机,看NetCat收到的原始数据是不是你想要的HTTP请求。如果收到的数据里还有%符号,就说明编码多了一层;如果直接是原始请求,那就没问题。
这个验证思路特别适合实际渗透时快速调试,省得反复打内网服务试错。
4. 向内网延伸:Gopher的“隐形触手”有多远
4.1 不止HTTP:探测内网任意TCP服务
Gopher能构造任意TCP数据包,这意味着它的目标范围远不止HTTP服务。在内网里,很多服务都是基于文本协议或简单二进制协议,都可以尝试用Gopher打一打。
最经典的目标是Redis。Redis是一个内存数据库,本身支持未授权访问,而且它的命令协议也是纯文本的。常见利用手法是往Redis里写crontab或者SSH公钥,然后获得内网机器权限。用Gopher发Redis命令,和直接连上Redis客户端的效果几乎一致。
构造方法不复杂。假设要向Redis发送set test "hello"这条命令,只需要按Redis网络协议格式拼好,再整体编码成Gopher URL:
redis命令格式:*3\r\n$3\r\nset\r\n$4\r\ntest\r\n$5\r\nhello\r\n将这个格式整体编码进Gopher selector,最终GET请求就变成了:
gopher://127.0.0.1:6379/_*3%0d%0a$3%0d%0aset%0d%0a$4%0d%0atest%0d%0a$5%0d%0ahello%0d%0a目标服务器发送后,Redis服务端会解析这段内容,完整地执行set test "hello"。
除了Redis,MySQL、FTP、SMTP,甚至一些自定义的TCP协议服务,理论上都可以用Gopher进行交互。凡是你能写出它的协议报文格式,Gopher就能帮你送达到内网服务。这个覆盖面,常规SSRF工具链里几乎找不到替代品。
4.2 利用Gopher做内网端口探测和指纹识别
Gopher还能顺带完成端口探测和指纹识别的任务。原理很简单:向一个端口发送一段特定的数据,观察返回内容或超时状态,就能判断端口是否开放、服务类型是什么。
比如你想知道内网某台机器的3306端口是不是MySQL,可以向它发送一个MySQL的握手请求包。如果返回的是MySQL特有的版本号报文,那就说明端口是开的,而且服务身份确认。这个手法比常规的TCP SYN扫描更隐蔽,因为它是在SSRF平台的控制之下发出的,源IP是目标服务器而不是你自己的机器,不易被边界设备追踪。
再比如内网有SMTP服务,你可以通过Gopher发送HELO指令,看是否返回250状态码,就能判断这个服务是否存活。
但这里有一个重要的限制:Gopher的响应数据能不能被看到,取决于SSRF漏洞所在的上下文。如果目标应用只是后台发请求,不把响应渲染到页面上,那你就盲打了,只能通过延迟和报错来猜测服务是否存在。如果是页面直接展示响应内容的SSRF,那Gopher配合探测就非常舒服,回显一目了然。
4.3 间接利用:Gopher作为跳板攻击链的一环
很多时候,Gopher单独的利用价值看起来有限,但它真正强的地方在于能把SSRF攻击链延展开来。举个例子:目标Web应用存在SSRF,内网有一台Redis未授权服务,但Redis配置了保护模式,只允许本机或内网特定IP访问。这个时候,你就可以用Gopher从目标Web应用发起请求,去操作内网这台Redis,再通过Redis写文件的方式拿下一台内网机器。
拿到内网机器之后,原有Web应用的边界就已经被突破了,后续的内网渗透可以从这台机器继续往外打。整个过程,Gopher就是最核心的“跨协议跳板”。
我之前在某次模拟演练中遇到过一个场景:目标应用有两个SSRF接口,一个能访问外网,一个只能访问内网。内网这个接口做了协议白名单,只放行gopher://和白名单域名。起初我觉得没什么用,后来发现白名单域名背后是内网某台缓存服务器,而且服务端用的协议是Redis的变种。最终就是用Gopher构造一串缓存命令,改写了内网缓存内容,导致了目标业务崩溃。这个思路放在考试里可能不常见,但在真实攻防里,灵活运用协议转换往往能解决意想不到的问题。
5. 防御侧怎么看:Gopher攻击的检测与阻断
5.1 从日志检测Gopher请求的特征
说了这么多攻击侧的用法,这会很多人会觉得Gopher简直无敌了。其实防御侧也不是完全无解,只不过很多防护系统对Gopher协议的特征识别做得太差,才让它成了漏网之鱼。
Gopher请求在Web服务器日志里,会留下一些非常明显的痕迹。常规HTTP请求的URL里一般只包含路径和参数,而Gopher请求的URL会以gopher://开头,后面跟着一大长串的%编码字符。把这个特征做成正则表达式,配合威胁情报系统,是可以有效识别一部分攻击尝试的。
另外,Gopher请求发往内网服务时,目标服务会收到一段特殊的TCP数据。比如Redis服务,正常客户端连接后第一条消息是*1\r\n$7\r\nCOMMAND\r\n这类带星号和美元符号的RESP协议内容。而Gopher构造的HTTP请求发向Redis时,Redis会直接报错协议解析失败,并在日志里记录下“Protocol error”之类的关键字。排查内网服务日志,同样能够发现异常。
5.2 代码层面如何阻止Gopher协议滥用
在代码层面,SSRF防御的核心就是限制协议白名单。很多应用在实现SSRF功能时,往往只校验了http://和https://,完全没有考虑gopher://、dict://等非HTTP协议。这正好是攻击者的切入点。
开发者在实现任意URL请求功能时,应该:
- 维护一个严格的白名单协议列表,默认只允许
http://和https:// - 对URL进行解析,提取协议字段,并确保解析结果和实际使用的协议一致,防止
UR歧义绕过 - 对目标IP进行校验,阻止内网IP、环回地址、链路本地地址等特殊网段
- 禁止重定向跟随,防止请求被带到非预期协议
其中第四点最容易被人忽略。很多应用默认情况下会跟随HTTP重定向,而重定向目标甚至可以写成gopher://开头的地址。也就是说,即便你外面限制了协议白名单,攻击者可能仍通过30x重定向间接打出Gopher请求,从而绕过防护。处理这个问题的办法是:手动关闭自动重定向,或者对重定向后的URL做二次校验。
5.3 内网侧加固不能只靠外网WAF
外网WAF能拦截一部分明显带有gopher://标识的请求,但内网服务的自身防护更关键。
Redis可以通过开启requirepass配置密码、启用保护模式、绑定本机回环地址来减少暴露面。MySQL、FTP等服务,也应该限制来源IP白名单。Gopher之所以能在SSRF里奏效,根本原因是内网服务之间缺乏足够的认证和访问控制。打好补丁、收紧权限,哪怕攻击者能发任意TCP数据,也会因认证问题卡在门外。
从长远看,微服务架构内的服务间认证(比如mTLS)、流量加密、网格化管控,都是有效抑制这类攻击的手段。Gopher攻击再强,也强不过一道道标准的安全边界。
6. 实操心得:构造Gopher Payload时那些“坑”和“巧劲”
6.1 确保目标端口支持对应的协议
这是最基本但也是最容易出错的地方。你构造了一段完美的HTTP请求,但目标端口是个Redis服务,那服务端会把它当Redis命令来解析,大概率直接报错。反过来,你构造了一段Redis命令,但目标端口是MySQL,也会得到一堆无法理解的报错。所以在构造payload之前,一定要先确认端口的服务类型和版本,必要时用常规扫描工具或SSRF的dict探测先摸清底细。
确认服务类型有个小技巧:先发一段简单的文本,看返回内容。如果返回中包含$、*这类RESP协议特征字符,基本可以确认是Redis;如果返回是MySQL的风格,会包含版本号字符串和问候报文。通过这种交互式的识别,比单纯扫端口准确得多。
6.2 URL编码的层级和转换时机
构造Gopher payload时,最折磨人的就是URL编码的层级。不同的编程语言、不同的URL解析库、不同的框架层数,都会影响最终发出去的原始数据。我见过有人用Python的urllib.parse.quote做一次编码,然后又用requests发一次请求,结果被requests库自动解码一次,导致最终数据多了一倍%。
建议的做法是:用最原始的方式发送payload,绕开自动解码逻辑。比如在Python里,你可以用requests库的URL参数传gopher://形式,但要注意requests会不会对URL做编码;最好先用curl直接测试,因为它不会做额外的自动编码,拿到稳定的payload后再移植到代码里。
6.3 测试时用本地监听“排雷”
前面我已经提过一次,但这里想把它单独讲透。构造好一个Gopher payload,不要直接打内网服务,先在自己机器上开一个监听端口,把payload打过去,看看原始数据是否完全符合预期。比如用NetCat监听8888端口:
nc -l -p 8888然后构造一个访问本机8888端口的Gopher payload:
gopher://127.0.0.1:8888/_GET /test HTTP/1.1%0d%0aHost: 127.0.0.1:8888%0d%0aUser-Agent: test%0d%0a%0d%0a发送后,NetCat窗口显示的应该是一模一样的HTTP请求文本。如果发现少了某个换行,或者多出了%符号,就能立刻定位问题是出在编码层数还是回车换行上。这个“排雷”思路,适用于一切构造Gopher payload的场景。
6.4 把“响应盲区”转化成一枚可利用的延时炸弹
刚才提到过,有些SSRF不展示响应内容,这时候Gopher的作用看起来会受限。但你可以换一个思路:利用请求的超时和延迟,作为侧信道判断目标服务的状态。
比如内网某个端口上跑了个服务,你向它发送Gopher构造的连接,如果端口是关闭的,目标会立即返回连接拒绝错误,整个请求耗时极短;如果端口开放,即使服务端不返回内容,TCP握手和请求发送的过程也会让响应时间产生微妙差异。多次采样求平均值,能区分出端口开闭的相对关系。
更高级一点,你可以故意构造一个有延迟的请求。比如给MySQL发一个开启事务的命令,然后发起一个长时间的sleep查询,让整个SSRF请求挂起几十秒,通过响应时间的异常来做端口指纹识别。这种方式在完全盲打的状态下,也是一个可用的探测手段。
7. 别只把Gopher当“奇技淫巧”,它对架构设计的启发
当我逐渐熟练运用Gopher在SSRF里做各种操作后,我发现它对防御方的价值其实更大。理解了Gopher为什么能穿透边界、为什么能伪装成各种协议,你会对“协议和数据包”有更深的认识。
从架构层面看,Gopher攻击能成功,本质上暴露了两个问题:第一,应用对外部输入URL的信任度过高;第二,内网服务之间的通信毫无认证与隔离。任何过程性防护,都应该从这两个根源上去解决。
开发者在做后端请求代理、URL回调、Webhook等功能时,不能只看协议名,还要看最终发出去的字节流。安全人员在设计内网防线时,不能只依赖防火墙端口封禁,更要关注服务自身是否具备独立的认证机制。如果你的内网Redis裸奔、MySQL免密,即使没有Gopher,也有无数种方法可以打到它。
Gopher协议提醒了我一件事:安全攻防永远是在比谁更了解底层。攻击者并不需要掌握多么高深的0day,他只需要熟悉一个被遗忘的协议,就能突破精心构建的防线。防御者如果只看表层,不看底层数据流,终有一天会被“被遗忘的角落”打破防线。
8. 写在最后:关于Gopher的冷思考
也可能有人觉得,现在CTF题目里Gopher已经不算新鲜玩意儿了,而且现实环境中能同时满足“SSRF可控发Gopher”、“内网存在无认证服务”、“响应可见或侧信道可测”这三条件的场景越来越少。但我不这么看。Gopher的利用思维,核心不在于Gopher本身,而在于“协议转换”——同一个攻击目标,换一种协议表达方式,可能就绕过了所有检测规则和防护策略。
这就像两军对垒,正面进攻被防得密不透风,那么走一条无人关注的下水道,反而可能是最快捷的路线。Gopher就是那条下水道。
实操中,我个人非常建议安全方向的从业者,尤其是做Web安全的朋友,花半天时间把Gopher协议完整玩一遍。从最简单HTTP GET、POST,到打Redis写文件,每一个步骤都亲手构造、亲眼验证。你会发现,原先很多固定的攻击路径被打破后,会衍生出非常多意想不到的攻击链。
这个东西能不能用上,是一回事;你用不用得上,是另一回事。但学到手了,它就是武器库里一把随时能拔出来的刀。