rapid7/metasploit-framework

View on GitHub
modules/post/multi/recon/sudo_commands.rb

Summary

Maintainability
C
1 day
Test Coverage
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Post

  include Msf::Post::File
  include Msf::Post::Linux::Priv
  include Msf::Auxiliary::Report

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Sudo Commands',
        'Description' => %q{
          This module examines the sudoers configuration for the session user
          and lists the commands executable via sudo.

          This module also inspects each command and reports potential avenues
          for privileged code execution due to poor file system permissions or
          permitting execution of executables known to be useful for privesc,
          such as utilities designed for file read/write, user modification,
          or execution of arbitrary operating system commands.

          Note, you may need to provide the password for the session user.
        },
        'License' => MSF_LICENSE,
        'Author' => [ 'bcoles' ],
        'Platform' => [ 'bsd', 'linux', 'osx', 'solaris', 'unix' ],
        'SessionTypes' => [ 'meterpreter', 'shell' ]
      )
    )
    register_options [
      OptString.new('SUDO_PATH', [ true, 'Path to sudo executable', '/usr/bin/sudo' ]),
      OptString.new('PASSWORD', [ false, 'Password for the current user', '' ])
    ]
  end

  def sudo_path
    datastore['SUDO_PATH'].to_s
  end

  def password
    datastore['PASSWORD'].to_s
  end

  def eop_bins
    %w[
      cat chgrp chmod chown cp echo find less ln mkdir more mv tail tar
      usermod useradd userdel
      env crontab
      awk gdb gawk lua irb ld node perl php python python2 python3 ruby tclsh wish
      ncat netcat netcat.traditional nc nc.traditional openssl socat telnet telnetd
      ash bash csh dash ksh sh zsh
      su sudo
      expect ionice nice script setarch strace taskset time
      wget curl ftp scp sftp ssh tftp
      nmap
      ed emacs man nano vi vim visudo
      dpkg rpm rpmquery
    ]
  end

  #
  # Check if a sudo command offers prvileged code execution
  #
  def check_eop(cmd)
    # drop args for simplicity (at the risk of false positives)
    cmd = cmd.split(/\s/).first

    if cmd.eql? 'ALL'
      print_good 'sudo any command!'
      return true
    end

    base_dir = File.dirname cmd
    base_name = File.basename cmd

    if file_exist? cmd
      if writable? cmd
        print_good "#{cmd} is writable!"
        return true
      end
    elsif writable? base_dir
      print_good "#{cmd} does not exist and #{base_dir} is writable!"
      return true
    end

    if eop_bins.include? base_name
      print_good "#{cmd} matches known privesc executable '#{base_name}' !"
      return true
    end

    false
  end

  #
  # Retrieve list of sudo commands for current session user
  #
  def sudo_list
    # try non-interactive (-n) without providing a password
    cmd = "#{sudo_path} -n -l"
    vprint_status "Executing: #{cmd}"
    output = cmd_exec(cmd).to_s

    if output.start_with?('usage:') || output.include?('illegal option') || output.include?('a password is required')
      # try with a password from stdin (-S)
      cmd = "echo #{password} | #{sudo_path} -S -l"
      vprint_status "Executing: #{cmd}"
      output = cmd_exec(cmd).to_s
    end

    output
  end

  #
  # Format sudo output and extract permitted commands
  #
  def parse_sudo(sudo_data)
    cmd_data = sudo_data.scan(/may run the following commands.*?$(.*)\z/m).flatten.first

    # remove leading whitespace from each line and remove linewraps
    formatted_data = ''
    cmd_data.split("\n").reject { |line| line.eql?('') }.each do |line|
      formatted_line = line.gsub(/^\s*/, '').to_s
      if formatted_line.start_with? '('
        formatted_data << "\n#{formatted_line}"
      else
        formatted_data << " #{formatted_line}"
      end
    end

    formatted_data.split("\n").reject { |line| line.eql?('') }.each do |line|
      run_as = line.scan(/^\((.+?)\)/).flatten.first

      if run_as.blank?
        print_warning "Could not parse sudoers entry: #{line.inspect}"
        next
      end

      user = run_as.split(':')[0].to_s.strip || ''
      group = run_as.split(':')[1].to_s.strip || ''
      no_passwd = false

      cmds = line.scan(/^\(.+?\) (.+)$/).flatten.first
      if cmds.start_with? 'NOPASSWD:'
        no_passwd = true
        cmds = cmds.gsub(/^NOPASSWD:\s*/, '')
      end

      # Commands are separated by commas but may also contain commas (escaped with a backslash)
      # so we temporarily replace escaped commas with some junk
      # later, we'll replace each instance of the junk with a comma
      junk = Rex::Text.rand_text_alpha(10)
      cmds = cmds.gsub('\, ', junk)

      cmds.split(', ').each do |cmd|
        cmd = cmd.gsub(junk, ', ').strip

        if cmd.start_with? '('
          run_as = cmd.scan(/^\((.+?)\)/).flatten.first

          if run_as.blank?
            print_warning "Could not parse sudo command: #{cmd.inspect}"
            next
          end

          user = run_as.split(':')[0].to_s.strip || ''
          group = run_as.split(':')[1].to_s.strip || ''
          cmd = cmd.scan(/^\(.+?\) (.+)$/).flatten.first
        end

        msg = "Command: #{cmd.inspect}"
        msg << " RunAsUsers: #{user}" unless user.eql? ''
        msg << " RunAsGroups: #{group}" unless group.eql? ''
        msg << ' without providing a password' if no_passwd
        vprint_status msg

        eop = check_eop cmd

        @results << [cmd, user, group, no_passwd ? '' : 'True', eop ? 'True' : '']
      end
    end
  rescue StandardError => e
    print_error "Could not parse sudo output: #{e.message}"
  end

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

    unless executable? sudo_path
      print_error 'Could not find sudo executable'
      return
    end

    output = sudo_list
    vprint_line output
    vprint_line

    if output.include? 'Sorry, try again'
      fail_with Failure::NoAccess, 'Incorrect password'
    end

    if output =~ /^Sorry, .* may not run sudo/
      fail_with Failure::NoAccess, 'Session user is not permitted to execute any commands with sudo'
    end

    if output !~ /may run the following commands/
      fail_with Failure::NoAccess, 'Incorrect password, or the session user is not permitted to execute any commands with sudo'
    end

    @results = Rex::Text::Table.new(
      'Header' => 'Sudo Commands',
      'Indent' => 2,
      'Columns' =>
        [
          'Command',
          'RunAsUsers',
          'RunAsGroups',
          'Password?',
          'Privesc?'
        ]
    )

    parse_sudo output

    if @results.rows.empty?
      print_status 'Found no sudo commands for the session user'
      return
    end

    print_line
    print_line @results.to_s

    path = store_loot(
      'sudo.commands',
      'text/csv',
      session,
      @results.to_csv,
      'sudo.commands.txt',
      'Sudo Commands'
    )

    print_good "Output stored in: #{path}"
  end
end