CVE-2020-3992-CVE-2021-21974

CVE-2020-3992 & CVE-2021-21974: PRE-AUTH REMOTE CODE EXECUTION IN VMWARE ESXI
March 02, 2021 | Lucas Leong
SUBSCRIBE
Last fall, I reported two critical-rated, pre-authentication remote code execution vulnerabilities in the VMware ESXi platform. Both of them reside within the same component, the Service Location Protocol (SLP) service. In October, VMware released a patch to address one of the vulnerabilities, but it was incomplete and could be bypassed. VMware released a second patch in November completely addressing the use-after-free (UAF) portion of these bugs. The UAF vulnerability was assigned CVE-2020-3992. After that, VMware released a third patch in February completely addressing the heap overflow portion of these bugs. The heap overflow was assigned CVE-2021-21974.

This blog takes a look at both bugs and how the heap overflow could be used for code execution. Here is a quick video demonstrating the exploit in action:


Service Location Protocol (SLP) is a network service that listens on TCP and UDP port 427 on default installations of VMware ESXi. The implementation VMware uses is based on OpenSLP 1.0.1. VMware maintains its own version and has added some hardening to it.

The service parses network input without authentication and runs as root, so a vulnerability in the ESXi SLP service may lead to pre-auth remote code execution as root. This vector could also be used as a virtual machine escape, since by default a guest can access the SLP service on the host.

The Use-After-Free Bug (CVE-2020-3992)

This bug exists only in VMware’s implementation of SLP. Here is the simplified pseudocode:

__int64 __fastcall SLPDProcessMessage(void *src, void *a2, __int64 a3)
{
// ...
msg = SLPMessageAlloc();
switch ( (unsigned __int64)*((unsigned int *)v27 + 33) )
{
// ...
case SLP_FUNCT_DAADVERT:
errorcode = ProcessDAAdvert((__int64 *)&msg, (__int64)&recvbuf, v3, 0); // <-- (1)
break;
// ...
}
// ...
if ( HIDWORD(v22) == SLP_FUNCT_DAADVERT || HIDWORD(v22) == SLP_FUNCT_SRVREG )
{
if ( !errorcode )
{
SLPMessageFree(msg); // <-- (4)
return errorcode;
}
// ...
}
// ...
}

__int64 __fastcall ProcessDAAdvert(__int64 *a1, __int64 a2, _QWORD *a3, unsigned int a4)
{
// ...
result = SLPDKnownDAAdd((void **)a1, (void **)a2); // <-- (2)
// ...
}

__int64 __fastcall SLPDKnownDAAdd(void **a1, void **a2)
{
// ...
v3 = (const char **)*a1;
// ...
v12 = SLPDatabaseEntryCreate((__int64)v3, (__int64)v11);
if ( v12 )
{
SLPDatabaseAdd(v4, v12); // <-- (3)
return v5;
}
// ...
}
view rawCVE-2020-3992-snippet-1.cpp hosted with ❤ by GitHub
At (3), if a SLP_FUNCT_DAADVERT or SLP_FUNCT_SRVREG request is handled correctly, it will save the allocated SLPMessage into the database. However, at (4), the SLPMessage is freed even though the handled request returns without error. It leaves a dangling pointer in the database. It is possible the free at (4) was added in the course of fixing some older bugs.

Bypassing the First Patch for CVE-2020-3992

The first patch (build-16850804) by VMware was interesting. VMware didn’t make any changes to the vulnerable code shown above. Instead, they added logic to check the source IP address before handling the request. The logic, which is in IsAddrLocal(), allows requests from a source IP address of localhost only.

_BOOL8 __fastcall IsAddrLocal(__int64 a1)
{
_BOOL8 result; // rax

result = 0LL;
if ( !a1 )
return result;
if ( *(_WORD *)a1 == 2 )
return *(_DWORD *)(a1 + 4) == 0x100007F;
if ( *(_WORD *)a1 == 10 ) // check sockaddr_in.sin_family == AF_INET6
result = (*(_DWORD *)(a1 + 8) & 0xC0FF) == 0x80FE; // check the prefix of ip address
return result;
}
view rawCVE-2020-3992-snippet-2.cpp hosted with ❤ by GitHub
After a few seconds, you might notice that it can still be accessed from an IPv6 link-local address via the LAN.

The Second Patch for CVE-2020-3992

Just over two weeks later, the second patch (build-17119627) was released. This time, they improved the IP source address check logic.

_BOOL8 __fastcall IsAddrLocal(__int64 a1)
{
_BOOL8 result; // rax

result = 0LL;
if ( !a1 )
return result;
if ( *(_WORD *)a1 == 2 )
return *(_DWORD *)(a1 + 4) == 0x100007F;
if ( *(_WORD *)a1 == 10 && !*(_DWORD *)(a1 + 8) && !*(_DWORD *)(a1 + 12) && !*(_DWORD *)(a1 + 16) )
result = *(_DWORD *)(a1 + 20) == 0x1000000;
return result;
}
view rawCVE-2020-3992-snippet-3.cpp hosted with ❤ by GitHub
This change does eliminate the IPv6 vector. Additionally, they patched the root cause of the UAF bug by clearing the pointer to the SLPMessage after adding it to the database.

__int64 __fastcall SLPDKnownDAAdd(void **a1, void **a2)
{
// ...
v3 = (const char **)*a1;
// ...
v12 = SLPDatabaseEntryCreate((__int64)v3, (__int64)v11);
if ( v12 )
{
*a1 = 0LL; // clear the pointer so it won’t be freed
SLPDatabaseAdd(v4, v12);
return v5;
}
// ...
}
view rawCVE-2020-3992-snippet-4.cpp hosted with ❤ by GitHub
The Heap Overflow Bug (CVE-2021-21974)

Like the previous bug, this bug exists only in VMware’s implementation of SLP. Here is the simplified pseudocode:

__int64 __fastcall SLPParseSrvUrl(int srvurllen, const char *srvurl, _QWORD *a3)
{
// ...
obuf = calloc(1uLL, srvurllen + 53LL);
if ( !obuf )
return 12LL;
v6 = strstr(srvurl, ":/"); // <-- (5)
if ( !v6 )
{
free(obuf);
return 22LL;
}
memcpy((char *)obuf + 41, srvurl, v6 - srvurl); // <-- (6)
// ...
}
view rawCVE-2021-21974-snippet-1.cpp hosted with ❤ by GitHub
At (5), srvurl comes from network input, but the function does not terminate srvurl with a NULL byte before using strstr(). The out-of-bounds string search leads to a heap overflow at (6). This happened because VMware did not merge an update from the original OpenSLP project.

The Patch for CVE-2021-21974

Six weeks later, the third patch (build- 17325551) was released. It addressed the root cause of the heap overflow bug by checking the length before the memcpy at (6).

__int64 __fastcall SLPParseSrvUrl(int srvurllen, const char *srvurl, _QWORD *a3)
{
// ...
v5 = srvurllen + 5;
obuf = calloc(1uLL, v5 + 48LL);
if ( !obuf )
return 12LL;
v6 = strstr(srvurl, ":/");
if ( !v6 || v5 - 1 < (unsigned __int64)(v6 - srvurl) ) // return with error if the length is too large
{
free(obuf);
return 22LL;
}
// ...
}
view rawCVE-2021-21974-snippet-2.cpp hosted with ❤ by GitHub
Exploitation

All Linux exploit mitigations are enabled for /bin/slpd, and most notably, Position Independent Executables (PIE). This makes it difficult to achieve code execution without first disclosing some addresses from memory. At first, I considered using the UAF, but I could not figure out an effective method to get a memory disclosure. Therefore, I moved my focus to the heap overflow bug instead.

Upgrading the Overflow

SLP uses struct SLPBuffer to handle events that it sends and receives. One SLPBuffer* sendbuf and one SLPBuffer* recvbuf are allocated for each SLPDSocket* connection.

typedef struct _SLPBuffer
{
SLPListItem listitem;
size_t allocated;
unsigned char* start;
unsigned char* curpos;
unsigned char* end;
// buffer data is appended
}*SLPBuffer;

typedef struct _SLPDSocket
{
SLPListItem listitem;
int fd;
time_t age;
int state;
// ...
SLPBuffer recvbuf; /* Incoming socket stuff */
SLPBuffer sendbuf;
// ...
}SLPDSocket;
view rawCVE-2021-21974-snippet-3.cpp hosted with ❤ by GitHub
The plan is to partially overwrite the start or curpos pointer in SLPBuffer and leak some memory on the next message reply. However, the sendbuf is emptied and updated before each reply. Fortunately, there is a timeslot during which sendbuf can survive due to the select-based socket model:

Fill a socket send buffer without receiving until the send buffer is full.
Partially overwrite sendbuf->curpos for that socket.
Start to receive from the socket. The leaked memory will be appended at the end.
There are some additional challenges, though:

-- Due to the use of strstr(), you cannot overflow with a NULL byte.
-- The overflowed buffer (obuf) will be automatically freed very soon after the return of SLPParseSrvUrl().

Together, this means that the overwrite can only extend partway through the next chunk header. Otherwise, the size of the next free chunk will be set to a very large value (four non-NULL bytes), and shortly after obuf is freed, the process will abort.

The following layout overcomes these challenges:

View fullsize
layout3.PNG
Assume that the target is sendbuf. In (F1), each chunk marked “IN USE” can be either a SLPBuffer or a SLPDSocket. A hole is prepared for obuf in (F2). After triggering the overflow in (F4), the next freed chunk is enlarged and overlapped onto the target. Next, obuf is then freed in (F5). Now, you can allocate a new recvbuf from a new connection to overwrite the target in (F6). This time the overwrite can include NULL bytes.

There is an additional problem:

-- Many malloc() functions from OpenSLP are replaced with calloc() by VMware.

The recvbuf in (F6) is also allocated from calloc(), which zero-initializes memory. This means that partial pointer overwrites are not possible when recvbuf overlaps the target. There is a trick to get around that, though: You can first overwrite the IS_MAPPED flag on the freed chunk in (F4). This causes calloc() to skip the zero initialization on the next allocation. This is a general method that is useful in many situations where you want to perform an overwrite on target.

Putting It All Together

Overwrite a connection state (connection->state) as STREAM_WRITE_FIRST. This is necessary so that sendbuf->curpos will get reset to sendbuf->start in preparation for the memory disclosure.
Partially overwrite sendbuf->start with 2 NULL bytes, where sendbuf belongs to the connection mentioned in step 1. Start receiving from the connection. You can then get memory disclosure, including the address of sendbuf.
Overwrite sendbuf->curpos from a new connection to leak the address of a recvbuf, which is allocated from mmap(). Once you have an mmapped address, it becomes possible to infer the libc base address.
Overwrite recvbuf->curpos from a new connection, setting it to the address of free_hook. Start sending on the connection. You can then overwrite free_hook.
Close a connection, invoking free_hook to start the ROP chain.
These steps may not be the optimized form.

Privilege Level Obtained

If everything goes fine, you can execute arbitrary code with root permission on the target ESXi system. In ESXi 7, a new feature called DaemonSandboxing was prepared for SLP. It uses an AppArmor-like sandbox to isolate the SLP daemon. However, I find that this is disabled by default in my environment.

[root@localhost:~] python /usr/lib/vmware/feature-state/feature-state-wrapper.py DaemonSandboxing
disabled
view rawCVE-2021-21974-snippet-5.console hosted with ❤ by GitHub
This suggests that a sandbox escape stage will be required in the future.

Conclusion

VMware ESXi is a popular infrastructure for cloud service providers and many others. Because of its popularity, these bugs may be exploited in the wild at some point. To defend against this vulnerability, you can either apply the relevant patches or implement the workaround. You should consider applying both to ensure your systems are adequately protected. Additionally, VMware now recommends disabling the OpenSLP service in ESXi if it is not used.

We look forward to seeing other methods to exploit these bugs as well as other ESXi vulnerabilities in general. Until then, you can find me on Twitter @_wmliang_, and follow the team for the latest in exploit techniques and security patches.