[PWNHUB 公开赛 2018]傻fufu的工作日(文件上传数组键名差异漏洞)
一、题目相关
题目介绍和初始步骤

页面提供一个文件上传表单,提示 “Allow One Image File”,只允许上传 GIF/JPG/JPEG/PNG 图片。
先扫目录,发现index.php.bak文件,但是文件代码被PHPJiaMi加密了,解密后发现包含一个核心上传类文件 UploadFile.class.php,也被加密了。
PHPJiaMi 解密方法
解密脚本:phpjiami.zip
本题源码使用 PHPJiaMi 加密,index.php.bak 和 UploadFile.class.php.bak 均为密文。解密方法如下:
- 专用解密脚本:下载 phpjiami 解密脚本,将加密文件放入
encode/目录,运行php phpjiami.php,解密结果输出到decode/ - Xdebug 动态调试:在
eval()处下断点单步跟踪,运行时变量即为解密后的代码 - Hook eval 输出:修改加密文件,将最后的
eval($decoded)替换为echo $decoded或file_put_contents('decoded.php', $decoded),直接输出解密后的明文
解密后得到的就是下面分析的 UploadFile.class.php 源码。
完整解密后的源码
index.php(入口文件)
1 |
|
注意 HTML 中有两个
name="upfile"的元素:一个文本输入框(用于自定义文件名),一个文件选择框(用于选择上传文件)。提交时文本内容进入$_POST['upfile'],文件进入$_FILES['upfile']。
UploadFile.class.php(核心上传类)
1 |
|
漏洞点定位
漏洞出在以下两行之间的不一致:
| 操作 | 代码 | 含义 |
|---|---|---|
| 白名单检查 | $filename[count($filename)-1] |
按下标取最后一个位置的值 |
| 写入文件 | end($filename) |
按数组顺序取最后一个元素 |
在索引连续的数组中,两者结果一致。但键名不连续时,结果分叉了。
利用原理
发送请求时将 POST 字段名构造为数组形式:
1 | upfile[1] → "jpg" |
PHP 解析结果:
1 | $_POST['upfile'] = [ |
执行检查:
1 | count($filename) // = 2 |
执行写入:
1 | end($filename) // = 'php' 🚩 数组末尾是 php! |
最终文件保存为 uniqid.php,成功上传 PHP webshell。
用bp发送请求
1 | ------WebKitFormBoundary |
知识拓展:HTTP/PHP 协议细节
$_POST 与 $_FILES 的分离存储
一开始有个疑惑:发请求时同时有 upfile[1]=jpg、upfile[0]=php 和文件上传 upfile=webshell.jpg,它们不会互相覆盖吗?
答案是不会。 PHP 把 multipart 请求中的数据根据类型分别存到了两个不同的超全局变量中:
| 数据类型 | 存储位置 | 示例 |
|---|---|---|
| 文本字段(无文件内容) | $_POST |
$_POST['upfile'] = [1=>'jpg', 0=>'php'] |
| 文件字段(含文件内容) | $_FILES |
$_FILES['upfile']['tmp_name']、['name']、['size'] 等 |
两者是独立的超全局变量,互不干扰。而本题源码特意优先从 $_POST 读取扩展名:
1 | $filename = !empty($_POST[$this->field]) // $_POST['upfile'] 非空? |
当 $_POST['upfile'] = [1=>'jpg', 0=>'php'] 时,条件成立,$filename 直接等于这个数组,根本不看 $_FILES['upfile']['name'] 里的 webshell.jpg。文件内容(shell 代码)则通过move_uploaded_file($_FILES[$this->field]['tmp_name'], ...) 正常写入磁盘。
关键理解: HTTP 协议本身没有”数组”概念,upfile[1] 在协议层面只是一个普通字符串字段名。是 PHP 在解析 $_POST 时识别出 [] 语法,自动将其构造为数组。其他语言(Java、Python、Node.js)不会做这种展开。
?: 运算符详解与文件名拼接
源码第 57 行:
1 | $new_name = ($this->new_name ?: $origin_name) . '.' . $ext; |
拆解:
| 部分 | 含义 | 示例值 |
|---|---|---|
?: |
Elvis 运算符——$a ?: $b 等价于 $a ? $a : $b |
如果 $this->new_name 为真(非空)就用它,否则用 $origin_name |
$origin_name |
current($filename)——数组第一个元素的值 |
'jpg' |
$ext |
end($filename)——数组最后一个元素的值 |
'php' |
| 结果 | $new_name = 'jpg.php' |
保存为 upload/jpg.php |
正常流程 vs 利用流程对比:
| 流程 | $filename 来源 |
$_POST['upfile'] |
$filename 结果 |
current() |
end() |
最终文件名 |
|---|---|---|---|---|---|---|
| 正常 | $_FILES['name'] → explode('.', 'webshell.jpg') |
空 | ['webshell', 'jpg'] |
'webshell' |
'jpg' |
webshell.jpg |
| 利用 | $_POST['upfile'] |
[1=>'jpg', 0=>'php'] |
[1=>'jpg', 0=>'php'] |
'jpg' |
'php' |
jpg.php |
利用流程中,webshell.jpg 里的 webshell 根本没有被用到——current() 取到的是 POST 数组的第一个值 'jpg',文件名主体就变成了 jpg。但文件内容仍然是 shell,写入 jpg.php 后照样能执行。
multipart/form-data 协议格式详解
在 Burp Suite 中看到的请求体格式是 HTTP 的 multipart/form-data 协议,用于在请求体中携带多个独立的数据块(包括文件)。完整的格式如下:
1 | POST /upload HTTP/1.1 |
各部分的作用:
| 协议部分 | 含义 |
|---|---|
boundary=----xxx |
随机生成的分隔字符串,用于在请求体中区分不同字段 |
------xxx |
分隔线——告诉 HTTP 服务器”一个新字段开始了” |
Content-Disposition: form-data; name="字段名" |
声明该字段在表单中的名称 |
; filename="xxx" |
仅文件字段有——告诉服务器文件原始名称 |
Content-Type: xxx |
仅文件字段有——告诉服务器文件类型 |
| 空行 | 头体分隔——上面是字段元信息,下面是字段值 |
| 字段值 | 文本或文件的二进制内容 |
------xxx-- |
结束标记——末尾多两个 --,表示整个 multipart 结束 |
为什么 Burp Suite 可以任意添加字段?
因为 HTTP 协议对 Content-Disposition: name="..." 中的字段名没有做任何限制。name="upfile[1]" 在协议层面只是一个普通字符串。浏览器受 HTML 表单规范限制,但 Burp Suite 让你完全控制原始请求,想写什么字段名都行。
| 工具/环境 | 能否修改字段名 | 原因 |
|---|---|---|
| 浏览器 HTML 表单 | ❌ 不能 | 只能按 <input name="upfile"> 发送 |
| Burp Suite 拦截后修改 | ✅ 随意改 | 完全控制原始 HTTP 请求 |
| curl | ✅ 可以 | -F "upfile[1]=jpg" 显式指定 |
| 编程发送(Python requests 等) | ✅ 可以 | 直接构造 multipart 数据 |
1.7.4 Content-Type 对比:不只是 POST
multipart/form-data 只是 HTTP 请求的 Content-Type 之一。常见的三种 POST 数据格式:
| Content-Type | 数据格式 | 能否上传文件 | 典型场景 |
|---|---|---|---|
application/x-www-form-urlencoded |
username=admin&password=123 |
❌ 不能 | 普通表单登录、搜索等 |
multipart/form-data; boundary=xxx |
每字段用 boundary 分隔 | ✅ 能 | 文件上传、含文件的大表单 |
application/json |
{"username":"admin"} |
❌ 不能 | REST API、Ajax 请求 |
不过实际场景中,文件上传 99% 走 POST + multipart/form-data,因为 HTML <form> 只支持 POST,且浏览器原生支持此组合。本漏洞的核心不在于 HTTP 方法或 Content-Type,而在于 PHP 对 [] 语法的数组化解析,配合源码中不严谨的数组取值逻辑。
1.7.5 PHP 数组索引模型:与 Python 列表的关键区别
很多有 Python 背景的学习者会在这里产生困惑,认为 $arr[n] 是按位置/下标索引——就像 Python 的 list[n] 取第 n 个元素。这是理解漏洞的关键障碍。
PHP 数组本质上是「有序哈希映射」(ordered hash map),$arr[n] 存取的是键名为 n 的元素,而非第 n 个位置。插入顺序决定遍历顺序,键名决定访问路径。
以上传的数组为例:
1 | $_POST['upfile'] = [ |
| 操作 | PHP 语义 | 结果 | Python 对比(如果是 list) |
|---|---|---|---|
$arr[0] |
键名为 0 的元素 | 'php' |
list[0] = 第 0 个元素 → 'jpg' ❌ |
$arr[1] |
键名为 1 的元素 | 'jpg' |
list[1] = 第 1 个元素 → 'php' ❌ |
count($arr) |
元素总数 | 2 |
len(list) → 2 ✅ |
$arr[count($arr)-1] |
等价于 $arr[1] → 键名 1 |
'jpg' |
list[len-1] = 最后一个元素 → 'php' ❌ |
end($arr) |
数组末尾的元素(值而非键) | 'php' |
list[-1] → 'php' ✅ (巧合一致) |
current($arr) |
数组开头的元素 | 'jpg' |
list[0] → 'jpg' ✅ (巧合一致) |
关键差异对比表:
| 特性 | PHP $arr[n] |
Python list[n] |
|---|---|---|
| 索引方式 | 键名访问(key lookup) | 位置访问(index) |
| 键必须连续? | ❌ 可以不连续 | ✅ 始终 0,1,2,… |
| 键顺序 = 插入顺序? | ✅ 是(有序哈希) | ✅ 是 |
arr[0] 含义 |
键名为 0 的元素 | 第一个元素(位置 0) |
| 取最后一个元素 | end($arr) |
list[-1] |
| 取第一个元素 | current($arr) |
list[0] |
正是这种差异制造了本题的漏洞:
1 | // PHP 语义下,以下两种操作完全不等价 |
如果 PHP 数组像 Python 列表那样严格按位置索引,$arr[count($arr)-1] 和 end($arr) 永远一致,这个漏洞就不存在了。
记忆口诀: PHP 里 $arr[n] 是在数组中找钥匙挂牌 n 的那个,不是数到第 n 个。
其他 PHP 数组特性相关的绕过方法
以下方法与本题思路一致,均利用 PHP 数组操作的特性绕过安全校验。
$_FILES 中 name 字段的数组化绕过
本题利用的是 $_POST 字段的数组化。同样的技巧也可以作用于 $_FILES——当 name="upfile[]" 或 name="upfile[name][]" 时,$_FILES['upfile']['name'] 变为数组而非字符串:
1 | Content-Disposition: form-data; upfile="upfile[]"; filename="webshell.php" |
这时某些校验函数会因传入数组而失效:
| 函数 | 传入数组时的行为 |
|---|---|
strrchr($name, '.') |
参数类型错误,返回 false |
pathinfo($name) |
参数类型错误,返回 false |
strtolower($name) |
返回空或警告,视 PHP 版本 |
preg_match() |
返回 false 而非匹配结果 |
如果不做 is_array() 检查就走这些函数,校验层可能因为返回 false 而认为”不合法 → 拒绝”,但某些逻辑中 false 反而被 ! 取反后通过。
真正危险的是能让数组绕过检查同时被正常写入的组合——就像本题的 is_array() → 跳过分割 的逻辑,使数组直接进入后续处理。
in_array() 松散比较的类型混淆绕过
PHP 的 in_array() 如果不传第三个参数 true,使用**松散比较 ==**而非严格比较 ===:
1 | $allow_ext = ['jpg', 'jpeg', 'gif', 'png']; |
松散比较下,PHP 存在大量类型转换陷阱:
1 | var_dump(in_array('php', ['jpg', 0])); // true! 'php' == 0 → true |
这意味着如果 $allow_ext 中的某个值(如通过配置读取时)变成了整数 0 或 null,攻击者传入任何非数字开头的字符串都能通过检查。
1 | // 假设配置解析异常,$allow_ext = ['jpg', 'gif', 0]; |
数组与字符串连接导致校验绕过
PHP 中,数组与字符串用 . 连接会得到 "Array":
1 | $arr = ['jpg', 'php']; |
如果校验代码用 . 拼接路径后检查扩展名,传入数组就会使扩展名固定变为 "Array":
1 | $ext = $_POST['ext']; // 攻击者传 ext[]=jpg |
这不会绕过,反而会被拒绝。但反过来,如果白名单中恰好允许了 "Array",那就会绕过。更典型的利用场景是日志记录或错误信息中的字符串拼接,当拼接结果传入了 file_put_contents() 或 include() 时,路径变为 xxxArrayxxx,可能触发文件包含。
总结
| 数组绕过手法 | 关键差异 / 利用点 | 防御要点 |
|---|---|---|
count-1 vs end() 键名差异 |
不连续键名时下标取值与顺序取值的分叉 | 统一使用同一条路径取值 |
$_FILES['name'] 数组化 |
传入数组使字符串函数失效 | 先 is_array() 检查再处理 |
in_array() 松散比较 |
== 导致的类型转换('php' == 0) |
始终开启第三个参数 true |
数组 + . 字符串连接 |
数组拼接得到固定字符串 "Array" |
类型检查后再拼接 |
