How I Found My First Ever ZeroDay (In RDP)
Up until recently, I’d never tried the bug hunting part of vulnerability research. I’ve been reverse engineering Windows malware for over a decade, and I’d done the occasional patch analysis, but I never saw a point in bug hunting on a major OS. After all, there are teams of vulnerability researchers with decades of experience auditing every inch of code, so what’s the chance someone new like me finds anything at all? The odds seemed impossible, so I never bothered trying. It wasn’t until I started reversing the BlueKeep patch that my views on bug hunting started to change.
The BlueKeep Patch
In May 2019 Microsoft released an unprecedented warning about an upcoming security patch. Following the chaos created by WannaCry in 2017 (which used a patched vulnerability known as EternalBlue), Microsoft wasn’t taking any chances. The warning told of an upcoming fix for a major wormable vulnerability, and encouraged Windows users to install the patch as soon as it becomes available.
The patched bug itself was a doozy. It was a pre-auth RCE vulnerability affecting all Windows versions prior to Windows 8. In fact, it was so bad, that Microsoft even pushed patches for discontinued operating systems like XP and Server 2003. Whilst it may have been easy to write this bug off as another epic vulnerability come and gone, it told a story about fundamental flaws in RDP.
If you’re unfamiliar with how BlueKeep works, I highly recommend reading my write-up here.
As a quick refresher: to trigger BlueKeep, a malicious client connects to the channel “MS_T120”, which is only used internally by the server. The channel protocol implements a close request, which the client can send. Due to the fact the channel is internal, the server only expects it to be closed when the server closes it. By closing the channel prematurely, the client triggers a Use-After-Free condition, which is exploitable.
The patch fixes the Use-After-Free (UAF) condition by adding better handling of channel close request, but it highlights a bigger more widespread problem.
An RDP Attack Surface Appears
RDP is not really a single protocol, but more a protocol for protocols. Implemented is a channel interface, which allows different components to talk to each other however they please. For example, there’s a USB channel, sound channel, and a graphics channel. Channels generally fit into two categories which I refer to as “internal” and “external”. Internal channels are for communication between different RDP components on the server. External channels are for communication between the RDP client and the RDP server.
Internal channels are somewhat of an implicit trust boundary. They are only expected to be used by code already running on the server; therefore, they less likely to properly handle untrusted data.
The problem BlueKeep highlighted is that RDP doesn’t differentiate between internal and external channels. The client can just hop into any channel and start slapping things around. Due to the fact the patch addressed the symptoms (the UAF), rather than the cause (the fact that internal channels are externally accessible), I figured there was more where that came from.
In Windows 8 the RDP codebase underwent a complete refactor, meaning that the BlueKeep flaw only existed in Windows 7 and below. I figured that while BlueKeep never existed, it couldn’t hurt to investigate if the new RDP stack suffered from the same fundamental flaw as its predecessor. I decided to focus my research on Widows 10, and by extension all Windows version above 8.
Through a combination of reverse engineering, dynamic analysis, and google, I build up a solid list of known RDP channels. Next, I went through the protocol documentation and tried to figure out which channels were external or post-authentication and excluded those. Finally, with the list I got to work auditing each channels’ corresponding handler.
A Likely Candidate
The channel “rdpinpt” caught my eye, because the handling code looks like this.
Just from a glance, it’s already very clear we’re not supposed to be here. The first line reads a pointer to a structure from “channel_msg + 12”, which is user controlled data. In no circumstances should a client ever be in possession of server memory addresses (because that’d break ASLR), so that’s a red flag. Not only that, but the address received doesn’t undergo any sanity checks. This code just tries to read data from any address passed to it. This should be fun.
To test my theory, I needed to code an RDP client capable of connecting to arbitrary channels and sending arbitrary data. The client would take weeks to write due to the complexity of the RDP protocol, but I was determined to prove my theory. Ironically the code ended up taking significantly longer to write than it took me to find the vulnerablity.
Triggering the bug should pretty simple. I just need to send a 20 byte request. The First Two DWORDs are ignore. The 3rd DWORD has to be 12 (to pass the mouse_input_len check). Then the last 8 bytes is the address the function will try to read (I’ve set this to 0x1337133713371337 for extra leet).
Nice! At the time of discovery, Microsoft were paying up to $10,000 for remote denial of service exploits. I decided not to report the bug as I wanted to see if I could turn the bug into something better.
More Bugs More Problems
Looking at the code some more, I noticed the message length field was also not validated. By specifying a length greater than the actual length of the message, I could trigger an Out-Of-Bounds (OOB) read. The problem was that the address pointer is at offset 12, and the heap header is 16 bytes. The heap header doesn’t contain any addresses, so there was no way to get a valid address into the pointer field.
After even more digging, I came across the following code:
If the DWORD at channel_msg+1 is set, the user specified addresses and length are passed to CTiMouse::SendInput. This function essentially does the same as the previous code snippet, but from the kernel. Due to the fact the kernel validates user mode addresses, the function will soft fail instead of crashing RDP if the address doesn’t exist. If the address does however exists, the kernel will try to interpret its’ content as a mouse input packet.
As long as the data at the address loosely fits this simplistic mouse input data structure, the mouse state will be set accordingly. Essentially, we should now be able to weaken ASLR by being able to safely check if an address is allocated or not. Furthermore, we can also remotely scan for a signatures in memory, as long as it conforms to the packet specification. We could break ASLR by heap spraying valid mouse input packets, then using the bug to bruteforce the memory address of one.
Whilst not particularly useful, there is a third use which I found to be absolutely hilarious. If the data at the address starts with 0x00000000 or 0x0001000, the system will move the mouse cursor to whatever value follows. The cursor position is displayed via RDP, so we can actually leak small fragments of memory via the cursor position, like some kind of RDP Ouija board.
At this point we have an arbitrary read, OOB read, and info leak, all from the same function. In some cases I’ve seen people awarded multiple CVEs despite all stemming from the same core issue. If this was the case here, the bounty could be up to $30,000. Unfortunately, 4 months after I found the bugs, the bounty for DoS and Info Leak bugs was lowered from $10,000 to $1,000. The client took a month to code, the reversing took a further month, and on top of that I’d have to take some hours to write a bug report, so I decided to pass.
Instead, I partnered with someone to help me see if we could maybe trigger a kernel bug, or chain one of the bugs into a full RCE.
Overstaying My Welcome
I found the first bug in December 2019, 6 months after the BlueKeep patch and decided to sit on it. In September 2020 the bugs were reported by another researcher, resulting in them being patched in the October 2020 security update (or so Microsoft thought). I believe the CVEs assigned were CVE-2020-16927 & CVE-2020-16896.
The patch is interesting in that it actually (sort of) addresses the root cause, rather than the symptoms.
Instead of just patching the OOB or arbitrary read, the code prevents internal channels from being joined at all by the client. Unfortunately, the patch doesn’t work.
The CRdpDynVCMgr::IsFakeChannel() check is called from CRdpDynVCMgr::CreateChannelInternal(), which is called during the “drdynvc” channel initialization. By simply not joining the drdynvc channel, we skip the initialization, thus are free to join the banned channels unencumbered.
It wasn’t until this month’s security patch (December 2020) that the bug was patched for good. The IsFakeChannel() function was moved to the main RDP initialization, closing off access to the channels completely. Due to the fact the final fix was not listed in the patch notes, it took a lot of head scratching to figure out exactly what happened to my bug.
Overall I don’t regret not reporting the bug when I first found it. Whilst some money would have been nice, I had way more fun taking my first dive into real bug hunting. I was surprised that wasting days reversing such a public vulnerability patch lead me to finding zerodays of my own. Hopefully in future I’ll find something better and get more time to work on it.
Cryptographic proof (SHA 256):
Just a note for some time in the future.— Marcus Hutchins (@MalwareTechBlog) January 13, 2020
Hashed Data: https://pastebin.com/ZazpTTbU