Analysis of CVE-2019-0708, a.k.a. BlueKeep, with REVEN: Another point of view

Bluekeep (CVE-2019-0708) is a security vulnerability that was discovered in Microsoft’s Remote Desktop Protocol, which allows remote code execution. At least one analysis already describes precisely this vulnerability with a specific approach. This blog post aims to demonstrate how REVEN can be used to analyze the crash, the root cause and the logical error itself. Especially, some non-trivial technical points related to this vulnerability are explained throughout, points that may be difficult to unveil with a common debugger.

Our starting point is the recording of a modified Proof of Concept from NCC Group. By launching the attack through the network, a Blue Screen Of Death is triggered, which we recorded with REVEN. Information to reproduce the crash are available in Appendix 1.

Crash analysis: From KeBugCheckEx

The first step in this analysis is obviously to start from the KeBugCheck. REVEN allows to quickly search through the entire trace for any calls, and explain the origin of the crash:





We can see that the crash comes from dereferencing [rsi+8] where rsi=1.

REVEN comes with a really powerful integrated taint engine, with which one can taint a byte and follow forward or backward where it has been moved or modified from a location to another.

In this case it will allow us to track back the origin of rsi. This feature is quite helpful when used in combination with the backtrace:





Tainting rsi is very effective and quick. At first we can see some link to CreateDesktop function, which makes no real sense, but then we directly face the allocation of the channel with _IcaAllocateChannel and ExAllocatePoolWithTag. Furthermore, reaching the IcaCreateChannel instantly discloses the name of the channel MS_T10 as it is manipulated.

Using the ‘%’ shortcut we can reach the end of ExAllocatePoolWithTag and analyze the returned pointer:





This is the address of the object for the channel named MS_T120. What would be interesting at this point is listing all the calls to ExAllocatePoolWithTag that returned this area. This can easily be performed with the simple following script using REVEN’s Analysis API:

import reven2 import percent # Connect to the server. This can be a remote server rvn = reven2 . RevenServer ( "127.0.0.1" , 40903 ) # Search calls to specified functions (here ExAllocatePoolWithTag*) # Note that RegExp are supported! queries = [ rvn . trace . search . symbol ( symbol ) for symbol in rvn . ossi . symbols ( pattern = "(ExAllocatePoolWithTag|" "ExFreePoolWithTag)" , binary_hint = "ntoskrnl.exe" )] # Go through each results for ctx in reven2 . util . collate ( queries ): # Reach the transition (i.e. instruction in time) of the end of the function with the percent plugin if "ExAllocatePoolWithTag" in str ( ctx . ossi . location ()): result_tr = percent . percent ( rvn , ctx . transition_before ()) if result_tr is None : continue result_ctx = result_tr . context_after () # Read the return value in rax, and compare it with the one we found through the taint. allocated_address = result_ctx . read ( reven2 . arch . x64 . rax , reven2 . types . USize ) if allocated_address == 0xfffffa8001d65010 : print ( "Block allocated at: {0}" . format ( result_tr . id )) elif "ExFreePoolWithTag" in str ( ctx . ossi . location ()): free_addr = ctx . read ( reven2 . arch . x64 . rcx , reven2 . types . USize ) if free_addr == 0xfffffa8001d65010 : print ( "Block freed at: {0}" . format ( ctx . transition_before () . id )) else : continue else : print ( "ERROR" ) print ( "Done.

" )

Here are the results:

Block allocated at: 8655429 Block freed at: 169672817 Block allocated at: 1141851788 Done.

This block of memory has been allocated, freed and reallocated and so far everything looks in order, but with more analysis we will see that it isn’t. If we look at the bigger picture, we can distinguish two contexts: the first one is the RDP network communication handling, and the second one, in the recording, is related to the CreateWindow function. A fair assumption would be that the RDP handler process is the one that created and freed the memory block, which is then used by a completely different process (the CreateWindow function), while the RDP process still holds a reference to this memory block. The memory gets used and modified by the CreateWindow context, so eventually the RDP process encounters invalid data and crashes. The next part of this post will demonstrate this assumption.

First allocation

The first call to ExAllocatePoolWithTag for this memory block uses the tag: TSic. If we look at the backtrace, we can see that this allocation comes from the function IcaCreateChannel and _IcaAllocateChannel. We will analyze the former.

As seen in the first part, IcaCreateChannel calls memchr on the string MS_T120, and quickly after calls IcaFindChannelByName. The name of the function is explicit and what we are looking at is the result: The MS_T120 channel doesn’t exist so IcaCreateChannel creates it and calls _IcaAllocateChannel. _IcaAllocateChannel calls multiple functions: ExAllocatePoolWithTag to allocate the memory block for the channel, but also _IcaFindVcBind and _IcaBindChannel.

_IcaFindVcBind goes through a chained list of channels and compares the name (MS_T120) with the existing ones. Once a match is found, it returns an identifier, which is actually later used as an index in IcaBindChannel to set the pointer to the memory block holding the channel MS_T120:





So, _IcaBindChannel sets the pointer to the allocated channel into an array at a specific index. What we can do is have a look at accesses to this pointer:





We can see that the last access is pretty close to the KeBugCheck call, giving more credit to our assumption.

Now that we have a pretty good understanding of this function, we can have an exhaustive look at calls, and especially their arguments. The following script searches for calls to _IcaBindChannel and displays the index and the pointer to the channel:

import reven2 # Connect to the server. This can be a remote server # To reproduce, replace with the right host address and port rvn = reven2 . RevenServer ( "127.0.0.1" , 40903 ) # Search calls to specified functions queries = [ rvn . trace . search . symbol ( symbol ) for symbol in rvn . ossi . symbols ( pattern = "_IcaBindChannel" , binary_hint = "termdd.sys" )] # Go through each results for ctx in reven2 . util . collate ( queries ): # Reach the transition (i.e. instruction in time) of the end of the function with the percent plugin idx = ctx . read ( reven2 . arch . x64 . r8 , reven2 . types . USize ) pointer = ctx . read ( reven2 . arch . x64 . rcx , reven2 . types . USize ) print ( "IcaBindChannel called with Pointer {0} to be set at Index {1}" . format ( hex ( pointer ), hex ( idx ))) print ( "Done.

" )

Here is the output:

IcaBindChannel called with Pointer 0xfffffa8001d65010L to be set at Index 0x1f IcaBindChannel called with Pointer 0xfffffa8001d65010L to be set at Index 0x1 IcaBindChannel called with Pointer 0xfffffa8001c44150L to be set at Index 0x7 IcaBindChannel called with Pointer 0xfffffa8002b006a0L to be set at Index 0x0 IcaBindChannel called with Pointer 0xfffffa8001d46010L to be set at Index 0x0 IcaBindChannel called with Pointer 0xfffffa8001c3f410L to be set at Index 0x0 IcaBindChannel called with Pointer 0xfffffa8001dd1010L to be set at Index 0x0 Done.

An attentive reader can notice that the two first calls set the same channel address, but at different indexes. As a matter of fact, we can have a look at the array after the second call and see these two pointers:



This behaviour is a piece of the explanation puzzle of this bug, as the problem is very probably that this is one too many. This is prone to bugs as one of these pointer may point to a freed memory at some point, so we will analyze the free now.

Free

We found from the script that ExFreePoolWithTag was called at the instruction #169672818. When analyzing the backtrace at this moment in time, we can see that this free originated from the network.

Actually, if we compare the content of the channel array when both pointers are defined, with its content after the call to ExFreePoolWithTag, we can see that one of the pointers has been zeroed in memory:





We can look at the history for this index in the array:





At this point we found a good explanation as for why the crash occurred: there are two references to the MS_T120 channel, yet it is closed at some point (from the ExFreePoolWithTag part). Near the end of the trace the second reference is accessed, leading to the crash a little bit after. We had a hint previously where we could see the different kinds of backtraces: we can now conclude that this allocation and reuse is just some external noise in the trace, which tampered with the RDP critical data. Hence, the dereferencing of the remaining dangling pointer fails because target memory has been overwritten by the CreateDesktop usage and makes no sense anymore to the Ica interface.

The next part will focus on analyzing the vulnerability itself.

The vulnerability

This part aims to analyze the logical error that lead to the vulnerability.

As explained previously, the channel MS_T120 is bound twice. We can also confirm this by listing calls to IcaFindChannelByName from IcaBindVirtualChannels. Namely, we want to search for execution of 0xfffff88002c03798. The REVEN search returns 6 results, in that order:

MS_T120 CTXTW rdpdr MS_T120 rdpsnd cliprdr

A question at this point could be where these MS_T120 strings come from, and this question can be simply answered by tainting a byte of each of those. The result is that the first one is hard coded and the second one originated from encrypted network communications:







Hence, we can assume that the exploit binds a channel that is normally handled by the system (hard coded string).

We can now have a look at the patch from IDA (actually, this screenshot is from malwaretech blog):





Simply enough, the patch ensures that if the channel is named MS_T120, then the index returned is the one from the system (0x1f) as we saw earlier, preventing an attacker from binding the same channel on another array slot, thus preventing the reuse of the freed memory from the dangling pointer.

Conclusion

If the binary diff can give a lot of information about a bug, we showed that with REVEN we were able to analyze quickly and precisely the root-cause and behaviour at system level. The taint, the memory history or the Python API allowed us to find the information we needed to explain the crash without having to launch again and again the exploit with WinDbg attached. More specifically, the tampering of the channel structure by the CreateWindow-related process can easily be seen with REVEN whilst a classical debugger would have been painful to use: as a matter of fact, following these kind of accesses imply using hardware breakpoints, which only allow a limited usage.

Generally speaking, since REVEN allows us to instantly time-travel, independently from the level (Kernel or User), we can unveil complex mechanisms such as the binding of RDP channels and observe logical errors with accuracy.

To go further

For more information about Reven, have a look at the following:

This blog entry from Tetrane, showing how to analyze a Use-After-Free in VLC

This blog entry from Cisco Talos, showing how to prove or disprove exploitability of a crash

This blog entry from Thanh Dinh TA, reversing a deeply obfuscated challenge

This white paper from Luc Reginato, presenting an updated analysis of PatchGuard on Windows 10 RS4

Or directly contact us at contact@tetrane.com!

Appendix 1: Proof of Concept diff to be applied to the BKScan

The following code is the patch to be applied to the BKScan code:

diff --git a/FreeRDP_scanner.patch b/FreeRDP_scanner.patch index 61516f7..8c004ba 100644 --- a/FreeRDP_scanner.patch +++ b/FreeRDP_scanner.patch @@ -299,8 +299,8 @@ diff '--exclude=build' -Naur FreeRDP_original/channels/mst120/client/mst120_main + } + + ++check_count; -+ mst120_send_check_packet(mst120, 0x20, 8); // 64-bit Windows -+ mst120_send_check_packet(mst120, 0x10, 4); // 32-bit Windows ++ mst120_send_check_packet(mst120, 0x200000, 8); // 64-bit Windows ++ mst120_send_check_packet(mst120, 0x20, 8); // 32-bit Windows +} + +static DWORD WINAPI mst120_virtual_channel_client_thread(LPVOID arg)

To apply this patch, copy it to a file (patch.patch) and then perform the following commands:

$ git clone https://github.com/nccgroup/BKScan $ cd BKScan $ git checkout 55bbb552a571ae287350d5d2e2deefe5f8bae5a7 $ git apply --ignore-space-change --ignore-whitespace patch.patch

Then you can build and launch the docker against the vulnerable machine, like so:

$ sudo docker build -t bkscan . $ sudo -E bash bkscan.sh -t [target_ip] -P [target_port] -u [vm_username] -p [vm_password]