2018年9月21日,某cms官方发布安全更新,修复了由用户“索马里的海贼”反馈的一个漏洞。
根据更新消息可知漏洞发生在头像上传处。处理头像上传的是 module/member/avatar.inc.php 文件。在会员中心处上传头像时抓包,部分内容如下:
对应着avatar.inc.php代码如下:
<?php
defined('IN_DESTOON') or exit('Access Denied');
login();
require DT_ROOT.'/module/'.$module.'/common.inc.php';
require DT_ROOT.'/include/post.func.php';
$avatar = useravatar($_userid, 'large', 0, 2);
switch($action) {
case 'upload':
if(!$_FILES['file']['size']) {
if($DT_PC) dheader('?action=html&reload='.$DT_TIME);
exit('{"error":1,"message":"Error FILE"}');
}
require DT_ROOT.'/include/upload.class.php';
$ext = file_ext($_FILES['file']['name']);
$name = 'avatar'.$_userid.'.'.$ext;
$file = DT_ROOT.'/file/temp/'.$name;
if(is_file($file)) file_del($file);
$upload = new upload($_FILES, 'file/temp/', $name, 'jpg|jpeg|gif|png');
$upload->adduserid = false;
if($upload->save()) {
...
} else {
...
}
break;
这里通过$_FILES['file']
依次获取了上传文件扩展名$ext
、保存临时文件名$name
、保存临时文件完整路径$file
变量。之后通过new upload();
创立一个upload对象,等到$upload->save()
时再将文件真正写入。
upload
对象构造函数如下,include/upload.class.php:25:
<?php
class upload {
function __construct($_file, $savepath, $savename = '', $fileformat = '') {
global $DT, $_userid;
foreach($_file as $file) {
$this->file = $file['tmp_name'];
$this->file_name = $file['name'];
$this->file_size = $file['size'];
$this->file_type = $file['type'];
$this->file_error = $file['error'];
}
$this->userid = $_userid;
$this->ext = file_ext($this->file_name);
$this->fileformat = $fileformat ? $fileformat : $DT['uploadtype'];
$this->maxsize = $DT['uploadsize'] ? $DT['uploadsize']*1024 : 2048*1024;
$this->savepath = $savepath;
$this->savename = $savename;
}
}
这里通过foreach($_file as $file)
来遍历初始化各项参数。而savepath
、savename
则是通过__construct($_file, $savepath, $savename = '', $fileformat = '')
直接传入参数指定。
因此考虑上传了两个文件,第一个文件名是1.php
,第二个文件是1.jpg
,只要构造合理的表单上传(参考:https://www.cnblogs.com/DeanChopper/p/4673577.html),则在avatar.inc.php中
$ext = file_ext($_FILES['file']['name']); // `$ext`即为`php`
$name = 'avatar'.$_userid.'.'.$ext; // $name 为 'avatar'.$_userid.'.'php'
$file = DT_ROOT.'/file/temp/'.$name; // $file 即为 xx/xx/xx/xx.php
而在upload
类中,由于多个文件上传,$this->file
、$this->file_name
、$this->file_type
将foreach在第二次循环中被置为jpg文件。测试如下:
回到avatar.inc.php
,当进行文件保存时调用$upload->save()
,include/upload.class.php:50:
<?php
class upload {
function save() {
include load('include.lang');
if($this->file_error) return $this->_('Error(21)'.$L['upload_failed'].' ('.$L['upload_error_'.$this->file_error].')');
if($this->maxsize > 0 && $this->file_size > $this->maxsize) return $this->_('Error(22)'.$L['upload_size_limit'].' ('.intval($this->maxsize/1024).'Kb)');
if(!$this->is_allow()) return $this->_('Error(23)'.$L['upload_not_allow']);
$this->set_savepath($this->savepath);
$this->set_savename($this->savename);
if(!is_writable(DT_ROOT.'/'.$this->savepath)) return $this->_('Error(24)'.$L['upload_unwritable']);
if(!is_uploaded_file($this->file)) return $this->_('Error(25)'.$L['upload_failed']);
if(!move_uploaded_file($this->file, DT_ROOT.'/'.$this->saveto)) return $this->_('Error(26)'.$L['upload_failed']);
$this->image = $this->is_image();
if(DT_CHMOD) @chmod(DT_ROOT.'/'.$this->saveto, DT_CHMOD);
return true;
}
}
先经过几个基本参数的检查,然后调用$this->is_allow()
来进行安全检查 include/upload.class.php:72:
<?php
function is_allow() {
if(!$this->fileformat) return false;
if(!preg_match("/^(".$this->fileformat.")$/i", $this->ext)) return false;
if(preg_match("/^(php|phtml|php3|php4|jsp|exe|dll|cer|shtml|shtm|asp|asa|aspx|asax|ashx|cgi|fcgi|pl)$/i", $this->ext)) return false;
return true;
}
可以看到这里仅仅对$this->ext
进行了检查,如前此时$this->ext
为jpg
,检查通过。
接着会进行真正的保存。通过$this->set_savepath($this->savepath); $this->set_savename($this->savename);
设置了$this->saveto
,然后通过move_uploaded_file($this->file, DT_ROOT.'/'.$this->saveto)
将file
保存到$this->saveto
,注意此时的savepath
、savename
、saveto
均以php为后缀,而$this->file
实际指的是第二个jpg文件。
综上, 注册一个普通用户,在个人会员中心头像上传处,上传两个文件,其中第一个文件以php为结尾如1.php
,用于设置后缀名为php
;第二个文件为1.jpg
,jpg用于绕过检测,其内容为php一句话木马(图片马)。
然后访问http://127.0.0.1/file/temp/avatar1.php 即可。其中1
是自己的_userid
不过实际利用上会有一定的限制。
第一点是该cms使用了伪静态规则,限制了file目录下php文件的执行。
第二点是avatar.inc.php中在$upload->save()
后,会再次对文件进行检查,然后重命名为xx.jpg
:
省略...
$img = array();
$img[1] = $dir.'.jpg';
$img[2] = $dir.'x48.jpg';
$img[3] = $dir.'x20.jpg';
$md5 = md5($_username);
$dir = DT_ROOT.'/file/avatar/'.substr($md5, 0, 2).'/'.substr($md5, 2, 2).'/_'.$_username;
$img[4] = $dir.'.jpg';
$img[5] = $dir.'x48.jpg';
$img[6] = $dir.'x20.jpg';
file_copy($file, $img[1]);
file_copy($file, $img[4]);
省略...
因此要利用成功就需要条件竞争了。
在upload的一开始,就进行一次后缀名的检查。其中is_image如下:
function is_image($file) {
return preg_match("/^(jpg|jpeg|gif|png|bmp)$/i", file_ext($file));
}
在__construct()
的foreach中使用了break,获取了第一个文件后就跳出循环。
在is_allow()
中增加对$this->savename
的二次检查。
嘛,祝各位大师傅中秋快乐!