开始之前 这是大约一个月前joomla的漏洞,现在写有点晚了,不得不说假期确实是消磨人性呀。由于对joomla的架构不熟悉,审计过程中走了不少弯路,文章可能写的比较啰嗦,利用方式直接掺在分析过程中一起写了。求大牛轻喷吧,233333。
漏洞点 payload出库 这次出现漏洞的地方代码比较简单,在administrator\templates\hathor\postinstall\hathormessage.php
中,这是joomla自带的一套模板。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 function hathormessage_postinstall_condition ( ) { $db = JFactory ::getDbo (); $user = JFactory ::getUser (); $globalTemplate = 'n/a' ; $template = 'n/a' ; $adminstyle = $user ->getParam ('admin_style' , '' ); if ($adminstyle != '' ) { $query = $db ->getQuery (true ) ->select ('template' ) ->from ($db ->quoteName ('#__template_styles' )) ->where ($db ->quoteName ('id' ) . ' = ' . $adminstyle [0 ]) ->where ($db ->quoteName ('client_id' ) . ' = 1' ); $template = $db ->setquery ($query )->loadResult (); } }
函数的功能注释已经写的很详细了,检查当前后台的模板是否为hathor。那么我们就可以大胆猜测,每次进入后台的时候这个函数都会被调用。事实证明确实是这样,具体的代码暂时不说。
漏洞出现的原因就是在获取到$admin_style
之后,没有对其进行类型强转或者过滤等操作,而是用了$admin_style[0]
这种奇怪的操作,导致了漏洞的出现,这是这个二次注入的第二步 。
我们先看一下$admin_style
是怎么来的。
一路跟踪getParam()
来到了libraries/vendor/joomla/registry/src/Registry.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public function get ($path , $default = null ) { if (empty ($path )) { return $default ; } if (!strpos ($path , $this ->separator)) { return (isset ($this ->data->$path ) && $this ->data->$path !== null && $this ->data->$path !== '' ) ? $this ->data->$path : $default ; } }
查看参数是否存在分隔符(默认为.),不存在就返回data对象的属性或者默认值(空)。再来看一下data对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public function __construct ($data = null ) { $this ->data = new \stdClass ; if (is_array ($data ) || is_object ($data )) { $this ->bindData ($this ->data, $data ); } elseif (!empty ($data ) && is_string ($data )) { $this ->loadString ($data ); } }
这里把data看做是一个存储数据的全局变量即可,再粗暴一点理解成一个全局数组也行,用法是一样的。
payload入库 既然是$admin_style
出了问题,那我们就全局搜一下admin_style
1 2 3 4 5 6 7 F:\PHP\Joomla_3.8.3-Stable-Full_Package\administrator\components\com_admin\models\forms\profile.xml: 102 103 <field 104: name="admin_style" 105 type="templatestyle" 106 label="COM_ADMIN_USER_FIELD_BACKEND_TEMPLATE_LABEL" ......
对joomla的架构不熟,这个地方花了不少时间。这个xml的作用就是post请求时要传递的参数以及他们的默认值,上面说到的获取默认值其实就是获取这里的值。根据xml所在位置可以判定关键点就在后台管理员修改个人信息 的地方(其实还有一处,即超级管理员修改用户信息的地方,但是所需权限太高,前者只需要进入后台的权限即可)。
访问/administrator/index.php?option=com_admin&view=profile&layout=edit&id=925
修改个人信息后抓包,我们看到了admin_style
参数。
但是如果直接这样构造payload是不管用的,还记得前面的神操作$admin_style[0]
吗,他会将payload作为字符串来处理,只取第一个字符,这么看来似乎还有点防御能力?如何让$admin_style[0]
取到全部的payload,php里简单的就像喝水一样。
此时payload已经存入数据库了
以上便是这个二次注入的第一步 。
关于joomla对于sql语句的处理,涉及到框架低层的东西,也不是这次的重点,所以没有深入。
漏洞触发点 一通操作之后回到主页,发现payload已经生效了:
到这里差不多就该结束了,但是我感觉这属于比较特殊的情况,正好在主页就有触发点,一般的二次注入都比较隐蔽,所以我决定再深入一下下,当然也不出意外的踩了许多坑。。。
访问administrator/index.php
默认路由到com_cpanel这个包
administrator/includes/helper.php
1 2 3 4 5 6 7 8 9 10 11 public static function findOption ( ) { if (empty ($option )) { $option = 'com_cpanel' ; } $app ->input->set ('option' , $option ); return $option ; }
再到administrator/components/com_cpanel/views/cpanel/view.html.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 public function display ($tpl = null ) { JToolbarHelper ::title (JText ::_ ('COM_CPANEL' ), 'home-2 cpanel' ); JToolbarHelper ::help ('screen.cpanel' ); $input = JFactory ::getApplication ()->input; $input ->set ('tmpl' , 'cpanel' ); $this ->modules = JModuleHelper ::getModules ('cpanel' ); try { $messages_model = FOFModel ::getTmpInstance ('Messages' , 'PostinstallModel' )->eid (700 ); $messages = $messages_model ->getItemList (); } catch (RuntimeException $e ) { $messages = array (); JFactory ::getApplication ()->enqueueMessage ($e ->getMessage (), 'error' ); } $this ->postinstall_message_count = count ($messages ); parent ::display ($tpl ); }
到这里目标就很明显了,继续跟getItemList()
libraries/fof/model/model.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public function &getItemList ($overrideLimits = false , $group = '' ) { if (empty ($this ->list )) { $query = $this ->buildQuery ($overrideLimits ); if (!$overrideLimits ) { $limitstart = $this ->getState ('limitstart' ); $limit = $this ->getState ('limit' ); $this ->list = $this ->_getList ((string ) $query , $limitstart , $limit , $group ); } else { $this ->list = $this ->_getList ((string ) $query , 0 , 0 , $group ); } } return $this ->list ; }
这里构造的sql语句为
1 SELECT `#__postinstall_messages`.* FROM `#__postinstall_messages` WHERE `extension_id` = '700' AND `enabled` = '1' ORDER BY `postinstall_message_id` ASC
执行结果
到这里整个流程就比较清晰了,后面就比较简单了。
1 2 3 4 5 6 7 8 9 protected function &_getList ($query , $limitstart = 0 , $limit = 0 , $group = '' ) { $this ->_db->setQuery ($query , $limitstart , $limit ); $result = $this ->_db->loadObjectList ($group ); $this ->onProcessList ($result ); return $result ; }
获取到postinstall_messsage,下面是$result
的部分输出结果
继续
administrator/components/com_postinstall/models/messages.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 protected function onProcessList (&$resultArray ) { $unset_keys = array (); $language_extensions = array (); $resultArray = array_reverse ($resultArray ); foreach ($resultArray as $key => $item ) { if (!empty ($item ->condition_file) && !empty ($item ->condition_method)) { jimport ('joomla.filesystem.file' ); $file = FOFTemplateUtils ::parsePath ($item ->condition_file, true ); if (JFile ::exists ($file )) { require_once $file ; $result = call_user_func ($item ->condition_method); if ($result === false ) { $unset_keys [] = $key ; } } } } }
遍历结果集,包含指定文件,执行函数,打完收工~
防御 当然是升级啦。
joomla 3.8.4 做了如下处理
1 2 3 4 5 6 7 8 9 10 11 $adminstyle = $user ->getParam ('admin_style' ); if ($adminstyle ){ $query = $db ->getQuery (true ) ->select ('template' ) ->from ($db ->quoteName ('#__template_styles' )) ->where ($db ->quoteName ('id' ) . ' = ' . (int ) $adminstyle ) ->where ($db ->quoteName ('client_id' ) . ' = 1' ); $template = $db ->setquery ($query )->loadResult (); }
参考文章 分析CVE-2018-6376 – Joomla!二阶SQL注入 文章还介绍了利用sqlmap进行自动化攻击
从补丁到漏洞分析 –记一次joomla漏洞应急