前段时间网上爆出 ThinkPHP 5.1.x 的 POP 链,早就想分析一下,正好最近有空,就记录一下吧
环境:
MacOS 10.13
MAMAP Pro
php 7.0.33 + xdebug
Visual Studio Code
前言 我所理解的 POP Chain:利用魔术方法并巧妙构造特殊属性调用一系列函数或类方法以执行某种敏感操作的调用堆栈
反序列化常用魔法函数__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/
受影响文件系统函数fileatimefilectimefile_existsfile_get_contentsfile_put_contentsfilefilegroupfopenfileinodefilemtimefileownerfilepermsis_diris_executableis_fileis_linkis_readableis_writableis_writeableparse_ini_filecopyunlinkstatreadfile如果对反序列化没有了解的话建议先学习下相关内容
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
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
的描述
file_exists ( string $filename ) : bool
如果传入的 $filename
是个反序列化的对象,在被 file_exists 当作字符串处理的时候就会触发其 __toString
方法(如果有的话)
所以下面就是找含 __toString
方法的类
来到 /thinkphp/library/think/model/concern/Conversion.php
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 链中的中间跳板
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 中找到函数定义
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 找到其定义
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 data[$name]
,我们需要的 $data
和 $append
分别位于 Attribute 和 Conversion,且两者都是 trait 类型
Trait 可以说是和 Class 相似,是 PHP 5.4.0 开始实现的一种代码复用的方法,可以使用 use 加载,举个例子
详情可以看官方手册 PHP: Trait - Manual
所以接下来是寻找一个同时使用了 Attribute 和 Conversion 的类
发现只有 /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;
. . . . . .
}
下面就需要找到一个没有 visible 方法却有 __call 方法的类作为执行点
找到 /thinkphp/library/think/Request.php 中的 Request 类
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
函数会把若干元素前置到数组的开头
$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
好执行命令
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,但是参数仍不可控,再往上寻找调用点看看
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', 'arrayReset($data);
}
} else {
$this->filterValue($data, $name, $filter);
}
. . . . . .
return $data;
}
Request 类中的 param 函数调用了 input 函数,但同样参数不可控,再往上寻找调用点
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 函数的定义
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 请求
// 当前请求参数和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]
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 函数正好给去掉了
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 名称和参数,再经这么一系列函数调用执行命令
__call() ---> call_user_func_array() ---> isAjax() ---> param() ---> input() ---> filterValue() ---> call_user_func()
画个图就直观多了
构造 Payload
由于 Model 类是 abstract 类型,无法实例化,而extends Model 的也只有一个 Pivot 类,所以就用它吧
关注
打赏
最近更新
- 深拷贝和浅拷贝的区别(重点)
- 【Vue】走进Vue框架世界
- 【云服务器】项目部署—搭建网站—vue电商后台管理系统
- 【React介绍】 一文带你深入React
- 【React】React组件实例的三大属性之state,props,refs(你学废了吗)
- 【脚手架VueCLI】从零开始,创建一个VUE项目
- 【React】深入理解React组件生命周期----图文详解(含代码)
- 【React】DOM的Diffing算法是什么?以及DOM中key的作用----经典面试题
- 【React】1_使用React脚手架创建项目步骤--------详解(含项目结构说明)
- 【React】2_如何使用react脚手架写一个简单的页面?


微信扫码登录