PHP 代码审计:(一)文件上传
0x00 概述
在网站的运营过程中,不可避免地要对网站的某些页面或内容进行更新,这时便需要使用到网站上的文件上传功能。如果不对被上传文件进行限制,或限制被绕过,该功能便有可能会被利用于上传可执行文件、脚本到服务器上,今儿进一步导致服务器沦陷。
由此可见,了解上传漏洞的前提是了解文件上传这个功能,以及其中的原理。如果只知道有文件上传这个东西,以及可能会出现漏洞,那么跟不知道一个样。
具体来说,一些用户上传的文件还是PHP脚本,这些PHP脚本上传到服务器上能够被用户通过服务器直接访问,其中包含的一些命令就会被执行。文件上传的功能就是如此强大,如果你的网站在文件上传方面控制得不够好,就会沦陷。
导致文件上传漏洞的原因较多,主要包含:
服务器配置不当
开源编辑器上传漏洞
本地文件上传限制被绕过
过滤不严或被绕过
文件解析漏洞导致文件执行
文件路径截断
...
其中,开源编辑器漏洞和文件上传漏洞原理一样,只不过多了一个编辑器。上传的时候还是会把我们的脚本上传上去。
过滤不严这个非常常见,后面的例子中我们会看到。比如大小写问题,网站只验证是否是小写,我们就可以把后缀名改成大写。
然后是文件解析漏洞。比如 Windows 系统会涉及到这种情况:文件名为1.php;.jpg
,IIS 6.0 可能会认为它是jpg
文件,但是执行的时候会以php
文件来执行。我们就可以利用这个解析漏洞来上传。再比如 Linux 中有一些未知的后缀,比如a.php.xxx
。由于 Linux 不认识这个后缀名,它就可能放行了,攻击者再执行这个文件,网站就有可能被控制。
最后是路径截断,就是在上传的文件中使用一些特殊的符号,使文件在上传时被截断。比如a.php%00.jpg
,这样在网站中验证的时候,会认为后缀是jpg
,但是保存到硬盘的时候会被截断为a.php
,这样就是直接的php
文件了。
常用来截断路径的字符是:
\0
?
%00
也可以使用超长的文件路径造成截断。
这些都是可能导致截断的字符。需要注意的是,在实战中由于网站的编/解码规则不同,需要灵活应用。比如\0
不行了就换成%00
,也可以尝试各种编码,比如 base64 或者 unicode,多试几次。
0x01 代码
文件上传首先需要一个表单,如下,我们把它叫做a.html
:
这里有几个要素:
action
属性是提交的目标。method
属性是提交所用的HTTP方法,常用的就是 POST 和 GET,文件上传一般用 POST。enctype
属性必须要写成这样,因为文件上传和普通的提交具有不同的编码方式。如果不写的话,可能会被当做urlencoded
,就是k1=v1&k2=v2
的键值对形式,导致解析不出东西。最后是文件输入框,它的
name
属性非常重要,它是PHP脚本中寻找文件的关键字。
接下来是PHP脚本中的东西,PHP中通过$_FILES
对象来读取文件,通过下列几个属性:
$_FILES[file]['name']
- 被上传文件的名称。$_FILES[file]['type']
- 被上传文件的类型。$_FILES[file]['size']
- 被上传文件的大小(字节)。$_FILES[file]['tmp_name']
- 被上传文件在服务器保存的路径,通常位于临时目录中。$_FILES[file]['error']
- 错误代码,0为无错误,其它都是有错误。
t.php
中的代码写成这样:
可以看到那个aaa
就是文件输入框中的name
属性。
我们把这两个文件放到服务器的目录中,或者直接在目录下启动PHP自带的服务器。之后打开a.html
随便传上去一个文件,会得到这样的结果,这里我直接上传了a.html
:
array(5) {
["name"]=> string(6) "a.html"
["type"]=> string(9) "text/html"
["tmp_name"]=> string(44) "C:\Users\asus\AppData\Local\Temp\php43A1.tmp"
["error"]=> int(0)
["size"]=> int(133)
}
需要说的是,在处理文件上传的时候,不应信任文件类型type
,因为类型在浏览器生成之后,是可以改的。甚至可以手动构造出于类型与实际内容不匹配的数据包。
同时也不应该信任文件名称name
。而是应该分离文件名与扩展名,对扩展名进行白名单过滤。文件名按需舍弃重新生成,或者过滤后再使用。
过度信任这些东西会产生一些隐患,下面我们就会看到。
0x02 实战
实战部分我会使用DVWA中的例子来演示。DVWA是用PHP+Mysql编写的一套用于常规WEB漏洞教学和检测的WEB脆弱性测试程序。包含了SQL注入、XSS、盲注等常见的一些安全漏洞。项目主页在这里,源码在Github上。
下载下来部署完成之后,我们打开vulnerabilities/upload/source/
,这里是上传漏洞部分的源码,可以看到有三个难度:低中高。
先看低级难度low.php
:
if( isset( $_POST[ 'Upload' ] ) ) {
// Where are we going to be writing to?
$target_path = DVWA_WEB_PAGE_TO_ROOT . "hackable/uploads/";
$target_path .= basename( $_FILES[ 'uploaded' ][ 'name' ] );
// Can we move the file to the upload folder?
if( !move_uploaded_file( $_FILES[ 'uploaded' ][ 'tmp_name' ], $target_path ) ) {
// No
$html .= 'Your image was not uploaded.
';
}
else {
// Yes!
$html .= "{$target_path} succesfully uploaded!
";
}
}
可以看到什么过滤都没有,可以直接上传任意文件。
然后是中级medium.php
:
if( isset( $_POST[ 'Upload' ] ) ) {
// Where are we going to be writing to?
$target_path = DVWA_WEB_PAGE_TO_ROOT . "hackable/uploads/";
$target_path .= basename( $_FILES[ 'uploaded' ][ 'name' ] );
// File information
$uploaded_name = $_FILES[ 'uploaded' ][ 'name' ];
$uploaded_type = $_FILES[ 'uploaded' ][ 'type' ];
$uploaded_size = $_FILES[ 'uploaded' ][ 'size' ];
// Is it an image?
if( ( $uploaded_type == "image/jpeg" || $uploaded_type == "image/png" ) &&
( $uploaded_size < 100000 ) ) {
// Can we move the file to the upload folder?
if( !move_uploaded_file( $_FILES[ 'uploaded' ][ 'tmp_name' ], $target_path ) ) {
// No
$html .= 'Your image was not uploaded.
';
}
else {
// Yes!
$html .= "{$target_path} succesfully uploaded!
";
}
}
else {
// Invalid file
$html .= 'Your image was not uploaded. We can only accept JPEG or PNG images.
';
}
}
注意第 10 行和第 11 行,它验证了类型必须是jpg
或png
,并且尺寸必须小于一定数值。后者可以不用管它。刚才我说过类型是不可信的,我们可以抓包,将其中的类型改掉,之后提交。
因为这里要演示如何突破限制上传,并不演示如何利用上传的脚本,所以我直接新建了个PHP文件,随便写了点东西进去。打开 burpsuite 抓包,我们会在请求正文中看到这样的东西:
------WebKitFormBoundaryh4zhLV52OKhf6aJg
Content-Disposition: form-data; name="uploaded"; filename="a.php"
Content-Type: application/octet-stream
右键"send to repeater",然后把那个application/octet-stream
改成image/jpeg
。点击"go"发送。
最后是高级high.php
:
if( isset( $_POST[ 'Upload' ] ) ) {
// Where are we going to be writing to?
$target_path = DVWA_WEB_PAGE_TO_ROOT . "hackable/uploads/";
$target_path .= basename( $_FILES[ 'uploaded' ][ 'name' ] );
// File information
$uploaded_name = $_FILES[ 'uploaded' ][ 'name' ];
$uploaded_ext = substr( $uploaded_name, strrpos( $uploaded_name, '.' ) + 1);
$uploaded_size = $_FILES[ 'uploaded' ][ 'size' ];
$uploaded_tmp = $_FILES[ 'uploaded' ][ 'tmp_name' ];
// Is it an image?
if( ( strtolower( $uploaded_ext ) == "jpg" || strtolower( $uploaded_ext ) == "jpeg" || strtolower( $uploaded_ext ) == "png" ) &&
( $uploaded_size < 100000 ) &&
getimagesize( $uploaded_tmp ) ) {
// Can we move the file to the upload folder?
if( !move_uploaded_file( $uploaded_tmp, $target_path ) ) {
// No
$html .= 'Your image was not uploaded.
';
}
else {
// Yes!
$html .= "{$target_path} succesfully uploaded!
";
}
}
else {
// Invalid file
$html .= 'Your image was not uploaded. We can only accept JPEG or PNG images.
';
}
}
观察相同的位置,这回改成了使用后缀名判断。我们这个时候可以在后缀之前插入\0
,即a.php\0.jpg
,要注意它不是一个斜杠加上一个零,而是空字符。这样判断的时候,后缀是.jpg
,写到磁盘的时候就会截断为a.php
。既可以上传也可以执行。
我们同样抓包并送到repeter
。先把a.php
改成a.php.jpg
,然后切换到 hex 编辑模式来插入空字符。
鼠标拖动的范围就是a.php.jpg
,在第二个2e
格子上右键,点击"insert byte",会自动插入一个\0
。之后点击"go"就大功告成了。
注意,如果这里插入%00
,上传倒是能成功,但是访问的时候会直接当成图片,不会执行里面的内容。
0x03 解决方案
同目录下还有个impossible.php
,里面包含了正确的做法。大家可以看一下。里面的代码使用了$target_file = md5( uniqid() . $uploaded_name ) . '.' . $uploaded_ext;
来生成独立的文件名,这样就不会被原文件名中各种截断字符干扰了。
0x04 注
部分内容来源于白帽学院 - 代码审计。