漏洞分析
首先我们来看漏洞触发点在/apps/public/Lib/Action/AttachAction.class.php中第181行:
public function ajaxUpload()
{
//执行附件上传操作
$d['type_name'] = 11;
D('feedback_type')->add($d);
$attach_type = t($_REQUEST['type']);
$options['uid'] = $this->mid;
//加密传输这个字段,防止客户端乱设置.
$options['allow_exts'] = t(jiemi($_REQUEST['exts']));
$options['allow_size'] = t(jiemi($_REQUEST['size']));
$jiamiData = jiemi(t($_REQUEST['token']));
list($options['allow_exts'], $options['need_review'], $fid) = explode('||', $jiamiData);
$options['limit'] = intval(jiemi($_REQUEST['limit']));
$options['now_pageCount'] = intval($_REQUEST['now_pageCount']);
$data['upload_type'] = $attach_type;
$info = model('Attach')->upload($data, $options);
//上传成功
echo json_encode($info);
}
这就是罪魁祸首的函数,我们分析一下这个函数:
在设置限制后缀的时候,参数是我们可控的:
$options['allow_exts'] = t(jiemi($_REQUEST['exts']));
$options['allow_size'] = t(jiemi($_REQUEST['size']));
$jiamiData = jiemi(t($_REQUEST['token']));
list($options['allow_exts'], $options['need_review'], $fid) = explode('||', $jiamiData);
虽然可控,但是这里调用了一个jiemi函数,我们跟踪一下这个函数,在/src/old/OpenSociax/functions.inc.php中第2693行:
function jiemi($text, $key = null)
{
if (empty($key)) {
$key = C('SECURE_CODE');
}
return tsauthcode($text, 'DECODE', $key);
}
这里又调用了一个tsauthcode函数,我们继续跟踪,在/src/old/OpenSociax/functions.inc.php中第2703行:
function tsauthcode($string, $operation = 'DECODE', $key = '')
{
$ckey_length = 4;
$key = md5($key ? $key : SITE_URL);
$keya = md5(substr($key, 0, 16));
$keyb = md5(substr($key, 16, 16));
$keyc = $ckey_length ? ($operation == 'DECODE' ? substr($string, 0, $ckey_length) : substr(md5(microtime()), -$ckey_length)) : '';
$cryptkey = $keya.md5($keya.$keyc);
$key_length = strlen($cryptkey);
$string = $operation == 'DECODE' ? base64_decode(substr($string, $ckey_length)) : sprintf('%010d', $expiry ? $expiry + time() : 0).substr(md5($string.$keyb), 0, 16).$string;
$string_length = strlen($string);
$result = '';
$box = range(0, 255);
$rndkey = array();
for ($i = 0; $i <= 255; $i++) {
$rndkey[$i] = ord($cryptkey[$i % $key_length]);
}
for ($j = $i = 0; $i < 256; $i++) {
$j = ($j + $box[$i] + $rndkey[$i]) % 256;
$tmp = $box[$i];
$box[$i] = $box[$j];
$box[$j] = $tmp;
}
for ($a = $j = $i = 0; $i < $string_length; $i++) {
$a = ($a + 1) % 256;
$j = ($j + $box[$a]) % 256;
$tmp = $box[$a];
$box[$a] = $box[$j];
$box[$j] = $tmp;
$result .= chr(ord($string[$i]) ^ ($box[($box[$a] + $box[$j]) % 256]));
}
if ($operation == 'DECODE') {
if ((substr($result, 0, 10) == 0 || substr($result, 0, 10) - time() > 0) && substr($result, 10, 16) == substr(md5(substr($result, 26).$keyb), 0, 16)) {
return substr($result, 26);
} else {
return '';
}
} else {
return $keyc.str_replace('=', '', base64_encode($result));
}
}
这个解密函数我们就不要想着去逆向了,但是我们观察一下这个函数的返回值,可以看到是有返回值的,都是字符串,就算解密不成功也会返回一个空的字符串。
那么当我们无法构造正确密文的时候,这个函数会返回一个空的字符串,那么问题来了,我们回到第一个函数中去:
$options['allow_exts'] = t(jiemi($_REQUEST['exts']));
$options['allow_size'] = t(jiemi($_REQUEST['size']));
$jiamiData = jiemi(t($_REQUEST['token']));
list($options['allow_exts'], $options['need_review'], $fid) = explode('||', $jiamiData);
那么意思就是这里的$options[‘allow_exts’]我们可以设置为空。
然后我们继续看这个$options变量,递给了
$info = model('Attach')->upload($data, $options);
我们跟一下这个upload函数,在/addons/model/AttachModel.class.php中第162行:
public function upload($data = null, $input_options = null, $thumb = false)
{
//echo json_encode($data);
$system_default = model('Xdata')->get('admin_Config:attach');
if (empty($system_default['attach_path_rule']) || empty($system_default['attach_max_size']) || empty($system_default['attach_allow_extension'])) {
$system_default['attach_path_rule'] = 'Y/md/H/';
$system_default['attach_max_size'] = '2'; // 默认2M
$system_default['attach_allow_extension'] = 'jpg,gif,png,jpeg,bmp,zip,rar,doc,xls,ppt,docx,xlsx,pptx,pdf';
model('Xdata')->put('admin_Config:attach', $system_default);
}
// 上传若为图片,则修改为图片配置
if ($data['upload_type'] === 'image') {
$image_default = model('Xdata')->get('admin_Config:attachimage');
$system_default['attach_max_size'] = $image_default['attach_max_size'];
$system_default['attach_allow_extension'] = $image_default['attach_allow_extension'];
$system_default['auto_thumb'] = $image_default['auto_thumb'];
}
// 载入默认规则
$default_options = array();
$default_options['custom_path'] = date($system_default['attach_path_rule']); // 应用定义的上传目录规则:'Y/md/H/'
$default_options['max_size'] = floatval($system_default['attach_max_size']) * 1024 * 1024; // 单位: 兆
$default_options['allow_exts'] = $system_default['attach_allow_extension']; // 'jpg,gif,png,jpeg,bmp,zip,rar,doc,xls,ppt,docx,xlsx,pptx,pdf'
$default_options['save_path'] = UPLOAD_PATH.'/'.$default_options['custom_path'];
$default_options['save_name'] = ''; //指定保存的附件名.默认系统自动生成
$default_options['save_to_db'] = true;
//echo json_encode($default_options);exit;
// 定制化设这,覆盖默认设置
$options = is_array($input_options) ? array_merge($default_options, $input_options) : $default_options;
//云图片
if ($data['upload_type'] == 'image') {
$cloud = model('CloudImage');
if ($cloud->isOpen()) {
return $this->cloudImageUpload($options);
} else {
return $this->localUpload($options);
}
}
//云附件
else {
//if($data['upload_type']=='file'){
$cloud = model('CloudAttach');
if ($cloud->isOpen()) {
return $this->cloudAttachUpload($options);
} else {
return $this->localUpload($options);
}
}
}
我们看到这个地方:
_// 定制化设这,覆盖默认设置
$options = is_array($input_options) ? array_merge($default_options, $input_options) : $default_options;_
其中$input_option这个形参对应的实参就是我们刚才的$options变量,而$default_options变量是系统默认的上传配置,那么问题来了,可以看到这里如果存在定制化设置,也就是存在$input_options的时候,会覆盖掉默认设置,结合我们上面所说的我们可以将allow_exts设置成空字符串, 那么在这个地方,我们的allow_exts也已经被覆盖了,变成了空字符串。
我们继续往下看:
else {
//if($data['upload_type']=='file'){
$cloud = model('CloudAttach');
if ($cloud->isOpen()) {
return $this->cloudAttachUpload($options);
} else {
return $this->localUpload($options);
}
这里判断是否开启云,如果没有开启,就调用本地上传函数,这里我们不考虑开启的情况,只考虑本地上传的情况,好,我们开始跟踪localUpload这个函数,在这个文件的第269行:
private function localUpload($options)
{
// 初始化上传参数
$upload = new UploadFile($options['max_size'], $options['allow_exts'], $options['allow_types']);
// 设置上传路径
$upload->savePath = $options['save_path'];
// 启用子目录
$upload->autoSub = false;
// 保存的名字
$upload->saveName = $options['save_name'];
// 默认文件名规则
$upload->saveRule = $options['save_rule'];
// 是否缩略图
if ($options['auto_thumb'] == 1) {
$upload->thumb = true;
}
// 创建目录
mkdir($upload->save_path, 0777, true);
// 执行上传操作
if (!$upload->upload()) {
// 上传失败,返回错误
$return['status'] = false;
$return['info'] = $upload->getErrorMsg();
return $return;
} else {
$upload_info = $upload->getUploadFileInfo();
// 保存信息到附件表
$data = $this->saveInfo($upload_info, $options);
// 输出信息
$return['status'] = true;
$return['info'] = $data;
// 上传成功,返回信息
return $return;
}
}
我们首先看实例化上传类这里:
$upload = new UploadFile($options['max_size'],$options['allow_exts'], $options['allow_types']);
这里的$options[‘allow_exts’]从上文知道我们传入的是个空字符串,那么我们跟到这个类的构造函数中去看一下:
public function __construct($maxSize = '', $allowExts = '', $allowTypes = '', $savePath = UPLOAD_PATH, $saveRule = '')
{
if (!empty($maxSize) && is_numeric($maxSize)) {
$this->maxSize = $maxSize;
}
if (!empty($allowExts)) {
if (is_array($allowExts)) {
$this->allowExts = array_map('strtolower', $allowExts);
} else {
$this->allowExts = explode(',', strtolower($allowExts));
}
}
if (!empty($allowTypes)) {
if (is_array($allowTypes)) {
$this->allowTypes = array_map('strtolower', $allowTypes);
} else {
$this->allowTypes = explode(',', strtolower($allowTypes));
}
}
if (!empty($saveRule)) {
$this->saveRule = $saveRule;
} else {
$this->saveRule = C('UPLOAD_FILE_RULE');
}
$this->savePath = $savePath;
}
可以看到当我们传入的$allowexts不为空的时候才会重新设置allowexts,但是我们这里传入的是一个空字符串,那么便不会重新设置,而是保持该类的默认值,那么默认值是什么呢,在这个类声明allow_exts成员的时候就有了,在/addons/library/UploadFile.class.php中第34行:
// 允许上传的文件后缀
// 留空不作后缀检查
public $allowExts = array();
可以看到是留空的。好,现在限制后缀是空了,那么我们看一下上传函数检测后缀的地方在哪,在该文件的第227行:
// 自动检查附件
if ($this->autoCheck) {
if (!$this->check($file)) {
return false;
}
我们跟进去,看一下:
*/
private function check($file)
{
if ($file['error'] !== 0) {
//文件上传失败
//捕获错误代码
$this->error($file['error']);
return false;
}
//文件上传成功,进行自定义规则检查
//检查文件大小
if (!$this->checkSize($file['size'])) {
$this->error = '上传文件大小不符,文件不能超过 '.byte_format($this->maxSize);
return false;
}
//检查文件Mime类型
if (!$this->checkType($file['type'])) {
$this->error = '上传文件MIME类型不允许!';
return false;
}
//检查文件类型
if (!$this->checkExt($file['extension'])) {
$this->error = '上传文件类型不允许';
return false;
}
//检查是否合法上传
if (!$this->checkUpload($file['tmp_name'])) {
$this->error = '非法上传文件!';
return false;
}
return true;
}
然后我们跟进检查文件后缀名的函数中:
private function checkExt($ext)
{
if (in_array($ext, array('php', 'php3', 'exe', 'sh', 'html', 'asp', 'aspx'))) {
$this->error = '不允许上传可执行的脚本文件,如:php、exe、html后缀的文件';
return false;
}
if (!empty($this->allowExts)) {
return in_array(strtolower($ext), $this->allowExts, true);
}
return true;
}
这里我们传入的实参是$file[‘extension’],看一下这个变量的定义,在217行:
$file['extension'] = $this->getExt($file['name']);
跟进getext函数看看,第556行:
private function getExt($filename)
{
$pathinfo = pathinfo($filename);
return $pathinfo['extension'];
}
可以看到直接使用pathinfo取得后缀,并且未作大小写转换。我们继续看到检测后缀的那个函数:
private function checkExt($ext)
{
if (in_array($ext, array('php', 'php3', 'exe', 'sh', 'html', 'asp', 'aspx'))) {
$this->error = '不允许上传可执行的脚本文件,如:php、exe、html后缀的文件';
return false;
}
if (!empty($this->allowExts)) {
return in_array(strtolower($ext), $this->allowExts, true);
}
return true;
}
因为未做大小写转换和去除空格,那么第一个判断很好绕过,在windows下,可以使用大写的后缀名PHP,或者是在后缀名后跟一个空格php ,这样都可以绕过第一个判断,然后我们看第二个判断,当$this->allowexts不为空的时候才会进入该判断,上文说过了,这个时候的$this->allows是空的,所以根本不会进入判断。那么我们就绕过了后缀名检测了,可以上传php文件来getshell了
漏洞利用
在windows下就利用文件名末尾加空格或者大小写来绕过,linux下就用pht或者phtml来绕过。
先注册登录并构造一个上传表单:
<html>
<body>
<form action="http://localhost/thinksns434/index.php?app=public&mod=attach&act=ajaxUpload" method="post"
enctype="multipart/form-data">
<label for="file">Filename:</label>
<input type="file" name="upfile" id="file" />
<br />
<input type="submit" name="submit" value="Submit" />
</form>
</body>
</html>
然后呢,随便选择一个php文件,开始上传,同时打开Burpsuite开始抓包改包,其中要更改的地方如下:
然后就可以看到回显了上传文件的保存位置了,如图:
我们试着访问一下,成功getshell: