一道非常有意思的反序列化漏洞的题目
花费了我不少时间理解和记忆
这里简单记录其中精髓
首先打开是一个登陆页面
dirsearch扫描到了www.zip源码备份
update.php
1 <?php 2 require_once(‘class.php‘); 3 if($_SESSION[‘username‘] == null) { 4 die(‘Login First‘); 5 } 6 if($_POST[‘phone‘] && $_POST[‘email‘] && $_POST[‘nickname‘] && $_FILES[‘photo‘]) { 7 8 $username = $_SESSION[‘username‘]; 9 if(!preg_match(‘/^\d{11}$/‘, $_POST[‘phone‘])) 10 die(‘Invalid phone‘); 11 12 if(!preg_match(‘/^[_a-zA-Z0-9]{1,10}@[_a-zA-Z0-9]{1,10}\.[_a-zA-Z0-9]{1,10}$/‘, $_POST[‘email‘])) 13 die(‘Invalid email‘); 14 15 if(preg_match(‘/[^a-zA-Z0-9_]/‘, $_POST[‘nickname‘]) || strlen($_POST[‘nickname‘]) > 10) 16 die(‘Invalid nickname‘); 17 18 $file = $_FILES[‘photo‘]; 19 if($file[‘size‘] < 5 or $file[‘size‘] > 1000000) 20 die(‘Photo size error‘); 21 22 move_uploaded_file($file[‘tmp_name‘], ‘upload/‘ . md5($file[‘name‘])); 23 $profile[‘phone‘] = $_POST[‘phone‘]; 24 $profile[‘email‘] = $_POST[‘email‘]; 25 $profile[‘nickname‘] = $_POST[‘nickname‘]; 26 $profile[‘photo‘] = ‘upload/‘ . md5($file[‘name‘]); 27 28 $user->update_profile($username, serialize($profile)); 29 echo ‘Update Profile Success!<a href="profile.php">Your Profile</a>‘; 30 } 31 else { 32 ?> 33 <!DOCTYPE html> 34 <html> 35 <head> 36 <title>UPDATE</title> 37 <link href="static/bootstrap.min.css" rel="stylesheet"> 38 <script src="static/jquery.min.js"></script> 39 <script src="static/bootstrap.min.js"></script> 40 </head> 41 <body> 42 <div class="container" style="margin-top:100px"> 43 <form action="update.php" method="post" enctype="multipart/form-data" class="well" style="width:220px;margin:0px auto;"> 44 <img src="static/piapiapia.gif" class="img-memeda " style="width:180px;margin:0px auto;"> 45 <h3>Please Update Your Profile</h3> 46 <label>Phone:</label> 47 <input type="text" name="phone" style="height:30px"class="span3"/> 48 <label>Email:</label> 49 <input type="text" name="email" style="height:30px"class="span3"/> 50 <label>Nickname:</label> 51 <input type="text" name="nickname" style="height:30px" class="span3"> 52 <label for="file">Photo:</label> 53 <input type="file" name="photo" style="height:30px"class="span3"/> 54 <button type="submit" class="btn btn-primary">UPDATE</button> 55 </form> 56 </div> 57 </body> 58 </html> 59 <?php 60 } 61 ?>
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‘])); ?> <!DOCTYPE html> <html> <head> <title>Profile</title> <link href="static/bootstrap.min.css" rel="stylesheet"> <script src="static/jquery.min.js"></script> <script src="static/bootstrap.min.js"></script> </head> <body> <div class="container" style="margin-top:100px"> <img src="data:image/gif;base64,<?php echo $photo; ?>" class="img-memeda " style="width:180px;margin:0px auto;"> <h3>Hi <?php echo $nickname;?></h3> <label>Phone: <?php echo $phone;?></label> <label>Email: <?php echo $email;?></label> </div> </body> </html> <?php } ?>
class.php
<?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);
config.php
<?php $config[‘hostname‘] = ‘127.0.0.1‘; $config[‘username‘] = ‘root‘; $config[‘password‘] = ‘‘; $config[‘database‘] = ‘‘; $flag = ‘‘; ?>
本以为是传统的文件上传
尝试后发现不行
审计源码发现
文件上传后文件名被md5加密了,思路断掉
看了大佬wp后发现关键在于update页面上传的所有参数都被序列化后存入数据库
$user->update_profile($username, serialize($profile));
存入时先对序列化后的字符串进行了正则过滤
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); }
flag存放在config.php中
而update页面的数据提交后,会跳转到展示页面,其实就是读取了数据
$photo = base64_encode(file_get_contents($profile[‘photo‘]));
那也就是说只要能读取到config.php就能得到flag
这里就需要利用到反序列化字符串逃逸漏洞
这里引用大佬的例子来介绍原理https://www.cnblogs.com/litlife/p/11690918.html
看一个简单的序列化
<?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";}
成功注入序列化字符。
这道题的原理也是这样的
突破口
我们发现一个问题,我们反序列化字符逃逸,首先序列化的字符是可控的,还有前面的长度是可控的。但update.php将参数序列化,我们可控变量的长度就已经写死了,怎么才能去控制呢。这道题的突破口其实就是序列化过后数据过滤替换那里,看似更加安全,其实更加危险。
因为序列化后的字符串在存入数据库之前进行了过滤 ,我们查看源码发现,这里是将‘select‘, ‘insert‘, ‘update‘, ‘delete‘, ‘where‘替换成‘hacker‘,其中使得位数变长的只有where替换成hacker,长度多出了一位
我们写入where替换成hacker之后字符串实际的长度就+1,因此实际的长度大于序列化固定的长度(变量前面‘s’里的值)。利用反序列化字符串逃逸,反序列化时只能将字符串中nickname前面的s后面长度的字符串反序列化成功,这个是传参的时候就固定好了。剩下的字符串我们构造成class.php因为里面包含了flag,并且让他在photo位置上,然后把photo给扔掉,这样在profile.php中读取的photo就是我们构造的config.php了,也就是读取到了flag
我们要记住一点,我们的字符串是在某变量被反序列化得到的字符串受某函数的所谓过滤处理后得到的,而且经过处理之后,字符串的某一部分会加长,但描述其长度的数字没有改变(该数字由反序列化时变量的属性决定),就有可能导致PHP在按该数字读取相应长度字符串后,本来属于该字符串的内容逃逸出了该字符串的管辖范围,轻则反序列化失败,重则自成一家成为一个独立于原字符串的变量,若是这个独立出来的变量末尾是个 ";} ,则可能会导致反序列化成功结束,后面的内容也就被丢弃了。此处能逃逸的字符串的长度由经过滤后字符串增加的长度决定。
先确定好我们要读取的文件的序列化内容
s:5:"photo";s:10:"config.php";}
简单的理解就是利用后端的过滤函数替换字符,导致实际长度增加,增加的部分(config.php)被挤了出来,到了本来photo的位置上,然后闭合。(原本的photo被丢弃)
这里还有一个知识点
因为传入nickname时后端写了正则过滤
if(preg_match(‘/[^a-zA-Z0-9_]/‘, $_POST[‘nickname‘]) || strlen($_POST[‘nickname‘]) > 10) die(‘Invalid nickname‘);
限制了nickname的长度
所以通过传入数组来绕过长度限制(抓包修改nickname为nickname[])
又因为数组序列化后不同于数组,会变成{~~~~~~}的形式
所以构造payload时前面要加上";}来闭合数组
否则反序列化无法正常执行
那就获取不到任何后端传来的数据
简单的来说,要序列化的字符是由我们所确认的,而后台因为正则过滤字符导致字符串长度更长了,那么就会把我们后面的字符挤出去,长了多少就挤出去多少,挤出去的字符就脱离了nickname参数的控制,又因为被挤出去的字符最后是";},闭合了序列化字符串,所以原本的photo被丢弃,我们构造的恶意 photo就鸠占鹊巢,取而代之
那么最终的payload为
wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}
得得到
文件被请求到页面时被base64加密了
解密即可得到flag