rapid7/metasploit-framework

View on GitHub
plugins/nexpose.rb

Summary

Maintainability
F
6 days
Test Coverage
require 'English'
require 'nexpose'

module Msf
  Nexpose_yaml = "#{Msf::Config.config_directory}/nexpose.yaml".freeze # location of the nexpose.yml containing saved nexpose creds

  # This plugin provides integration with Rapid7 Nexpose
  class Plugin::Nexpose < Msf::Plugin
    class NexposeCommandDispatcher
      include Msf::Ui::Console::CommandDispatcher

      def name
        'Nexpose'
      end

      def commands
        {
          'nexpose_connect' => 'Connect to a running Nexpose instance ( user:pass@host[:port] )',
          'nexpose_save' => 'Save credentials to a Nexpose instance',
          'nexpose_activity' => 'Display any active scan jobs on the Nexpose instance',

          'nexpose_scan' => 'Launch a Nexpose scan against a specific IP range and import the results',
          'nexpose_discover' => 'Launch a scan but only perform host and minimal service discovery',
          'nexpose_exhaustive' => 'Launch a scan covering all TCP ports and all authorized safe checks',
          'nexpose_dos' => 'Launch a scan that includes checks that can crash services and devices (caution)',

          'nexpose_disconnect' => 'Disconnect from an active Nexpose instance',

          'nexpose_sites' => 'List all defined sites',
          'nexpose_site_devices' => 'List all discovered devices within a site',
          'nexpose_site_import' => 'Import data from the specified site ID',
          'nexpose_report_templates' => 'List all available report templates',
          'nexpose_command' => 'Execute a console command on the Nexpose instance',
          'nexpose_sysinfo' => 'Display detailed system information about the Nexpose instance'

          # @TODO:
          # nexpose_stop_scan
        }
      end

      def nexpose_verify_db
        if !(framework.db && framework.db.usable && framework.db.active)
          print_error('No database has been configured, please use db_connect first')
          return false
        end

        true
      end

      def nexpose_verify
        return false if !nexpose_verify_db

        if !@nsc
          print_error("No active Nexpose instance has been configured, please use 'nexpose_connect'")
          return false
        end

        true
      end

      def cmd_nexpose_save(*args)
        # if we are logged in, save session details to nexpose.yaml
        if args[0] == '-h'
          print_status('Usage: ')
          print_status('       nexpose_save')
          return
        end

        if args[0]
          print_status('Usage: ')
          print_status('       nexpose_save')
          return
        end

        group = 'default'

        if ((@user && !@user.empty?) && (@host && !@host.empty?) && (@port && !@port.empty? && (@port.to_i > 0)) && (@pass && !@pass.empty?))
          config = { group.to_s => { 'username' => @user, 'password' => @pass, 'server' => @host, 'port' => @port, 'trust_cert' => @trust_cert } }
          ::File.open(Nexpose_yaml.to_s, 'wb') { |f| f.puts YAML.dump(config) }
          print_good("#{Nexpose_yaml} created.")
        else
          print_error('Missing username/password/server/port - relogin and then try again.')
          return
        end
      end

      def cmd_nexpose_connect(*args)
        return if !nexpose_verify_db

        if !args[0] && ::File.readable?(Nexpose_yaml.to_s)
          lconfig = YAML.load_file(Nexpose_yaml.to_s)
          @user = lconfig['default']['username']
          @pass = lconfig['default']['password']
          @host = lconfig['default']['server']
          @port = lconfig['default']['port']
          @trust_cert = lconfig['default']['trust_cert']
          unless @trust_cert
            @sslv = 'ok' # TODO: Not super-thrilled about bypassing the SSL warning...
          end
          nexpose_login
          return
        end

        if (args.empty? || args[0].empty? || (args[0] == '-h'))
          nexpose_usage
          return
        end

        @user = @pass = @host = @port = @sslv = @trust_cert = @trust_cert_file = nil

        case args.length
        when 1, 2
          cred, _split, targ = args[0].rpartition('@')
          @user, @pass = cred.split(':', 2)
          targ ||= '127.0.0.1:3780'
          @host, @port = targ.split(':', 2)
          @port ||= '3780'
          unless args.length == 1
            @trust_cert_file = args[1]
            if File.exist? @trust_cert_file
              @trust_cert = File.read(@trust_cert_file)
            else
              @sslv = @trust_cert_file
            end
          end
        when 4, 5
          @user, @pass, @host, @port, @trust_cert = args
          unless args.length == 4
            @trust_cert_file = @trust_cert
            if File.exist? @trust_cert_file
              @trust_cert = File.read(@trust_cert_file)
            else
              @sslv = @trust_cert_file
            end
          end
        else
          nexpose_usage
          return
        end
        nexpose_login
      end

      def nexpose_usage
        print_status('Usage: ')
        print_status('       nexpose_connect username:password@host[:port] <ssl-confirm || trusted_cert_file>')
        print_status('        -OR- ')
        print_status('       nexpose_connect username password host port <ssl-confirm || trusted_cert_file>')
      end

      def nexpose_login
        if !((@user && !@user.empty?) && (@host && !@host.empty?) && (@port && !@port.empty? && (@port.to_i > 0)) && (@pass && !@pass.empty?))
          nexpose_usage
          return
        end

        if ((@host != 'localhost') && (@host != '127.0.0.1') && (@trust_cert.nil? && @sslv != 'ok'))
          # consider removing this message and replacing with check on trust_store, and if trust_store is not found validate @host already has a truly trusted cert?
          print_error('Warning: SSL connections are not verified in this release, it is possible for an attacker')
          print_error('         with the ability to man-in-the-middle the Nexpose traffic to capture the Nexpose')
          print_error("         credentials. If you are running this on a trusted network, please pass in 'ok'")
          print_error('         as an additional parameter to this command.')
          return
        end

        # Wrap this so a duplicate session does not prevent a new login
        begin
          cmd_nexpose_disconnect
        rescue ::Interrupt
          raise $ERROR_INFO
        rescue ::Exception
        end

        begin
          print_status("Connecting to Nexpose instance at #{@host}:#{@port} with username #{@user}...")
          nsc = Nexpose::Connection.new(@host, @user, @pass, @port, nil, nil, @trust_cert)
          nsc.login
        rescue ::Nexpose::APIError => e
          print_error("Connection failed: #{e.reason}")
          return
        end

        @nsc = nsc
        nexpose_compatibility_check
        nsc
      end

      def cmd_nexpose_activity(*_args)
        return if !nexpose_verify

        scans = @nsc.scan_activity || []
        case scans.length
        when 0
          print_status('There are currently no active scan jobs on this Nexpose instance')
        when 1
          print_status('There is 1 active scan job on this Nexpose instance')
        else
          print_status("There are currently #{scans.length} active scan jobs on this Nexpose instance")
        end

        scans.each do |scan|
          print_status("    Scan ##{scan.scan_id} is running on Engine ##{scan.engine_id} against site ##{scan.site_id} since #{scan.start_time}")
        end
      end

      def cmd_nexpose_sites(*_args)
        return if !nexpose_verify

        sites = @nsc.list_sites || []
        case sites.length
        when 0
          print_status('There are currently no active sites on this Nexpose instance')
        end

        sites.each do |site|
          print_status("    Site ##{site.id} '#{site.name}' Risk Factor: #{site.risk_factor} Risk Score: #{site.risk_score}")
        end
      end

      def cmd_nexpose_site_devices(*args)
        return if !nexpose_verify

        site_id = args.shift
        if !site_id
          print_error('No site ID was specified')
          return
        end

        devices = @nsc.list_site_devices(site_id) || []
        case devices.length
        when 0
          print_status('There are currently no devices within this site')
        end

        devices.each do |device|
          print_status("    Host: #{device.address} ID: #{device.id} Risk Factor: #{device.risk_factor} Risk Score: #{device.risk_score}")
        end
      end

      def cmd_nexpose_report_templates(*_args)
        return if !nexpose_verify

        res = @nsc.list_report_templates || []

        res.each do |report|
          print_status("    Template: #{report.id} Name: '#{report.name}' Description: #{report.description}")
        end
      end

      def cmd_nexpose_command(*args)
        return if !nexpose_verify

        if args.empty?
          print_error('No command was specified')
          return
        end

        res = @nsc.console_command(args.join(' ')) || ''

        print_status('Command Output')
        print_line(res)
        print_line('')
      end

      def cmd_nexpose_sysinfo(*_args)
        return if !nexpose_verify

        res = @nsc.system_information

        print_status('System Information')
        res.each_pair do |k, v|
          print_status("    #{k}: #{v}")
        end
      end

      def nexpose_compatibility_check
        res = @nsc.console_command('ver')
        if res !~ /^(NSC|Console) Version ID:\s*4[89]0\s*$/m
          print_error('')
          print_error('Warning: This version of Nexpose has not been tested with Metasploit!')
          print_error('')
        end
      end

      def cmd_nexpose_site_import(*args)
        site_id = args.shift
        if !site_id
          print_error('No site ID was specified')
          return
        end

        msfid = Time.now.to_i

        report_formats = ['raw-xml-v2', 'ns-xml']
        report_format = report_formats.shift

        report = Nexpose::ReportConfig.build(@nsc, site_id, "Metasploit Export #{msfid}", 'pentest-audit', report_format, true)
        report.delivery = Nexpose::Delivery.new(true)

        begin
          report.format = report_format
          report.save(@nsc)
        rescue ::Exception => e
          report_format = report_formats.shift
          if report_format
            retry
          end
          raise e
        end

        print_status('Generating the export data file...')
        last_report = nil
        until last_report
          last_report = @nsc.last_report(report.id)
          select(nil, nil, nil, 1.0)
        end
        url = last_report.uri

        print_status('Downloading the export data...')
        data = @nsc.download(url)

        # Delete the temporary report ID
        @nsc.delete_report_config(report.id)

        print_status('Importing Nexpose data...')
        process_nexpose_data(report_format, data)
      end

      def cmd_nexpose_discover(*args)
        args << '-h' if args.empty?
        args << '-t'
        args << 'aggressive-discovery'
        cmd_nexpose_scan(*args)
      end

      def cmd_nexpose_exhaustive(*args)
        args << '-h' if args.empty?
        args << '-t'
        args << 'exhaustive-audit'
        cmd_nexpose_scan(*args)
      end

      def cmd_nexpose_dos(*args)
        args << '-h' if args.empty?
        args << '-t'
        args << 'dos-audit'
        cmd_nexpose_scan(*args)
      end

      def cmd_nexpose_scan(*args)
        opts = Rex::Parser::Arguments.new(
          '-h' => [ false, 'This help menu'],
          '-t' => [ true, 'The scan template to use (default:pentest-audit options:full-audit,exhaustive-audit,discovery,aggressive-discovery,dos-audit)'],
          '-c' => [ true, 'Specify credentials to use against these targets (format is type:user:pass'],
          '-n' => [ true, 'The maximum number of IPs to scan at a time (default is 32)'],
          '-s' => [ true, 'The directory to store the raw XML files from the Nexpose instance (optional)'],
          '-P' => [ false, 'Leave the scan data on the server when it completes (this counts against the maximum licensed IPs)'],
          '-v' => [ false, 'Display diagnostic information about the scanning process'],
          '-d' => [ false, 'Scan hosts based on the contents of the existing database'],
          '-I' => [ true, 'Only scan systems with an address within the specified range'],
          '-E' => [ true, 'Exclude hosts in the specified range from the scan']
        )

        opt_template = 'pentest-audit'
        opt_maxaddrs = 32
        opt_verbose = false
        opt_savexml = nil
        opt_preserve = false
        opt_rescandb = false
        opt_addrinc = nil
        opt_addrexc = nil
        opt_scanned = []
        opt_credentials = []

        opt_ranges = []

        opts.parse(args) do |opt, _idx, val|
          case opt
          when '-h'
            print_line('Usage: nexpose_scan [options] <Target IP Ranges>')
            print_line(opts.usage)
            return
          when '-t'
            opt_template = val
          when '-n'
            opt_maxaddrs = val.to_i
          when '-s'
            opt_savexml = val
          when '-c'
            if (val =~ /^([^:]+):([^:]+):(.+)/)
              type = Regexp.last_match(1)
              user = Regexp.last_match(2)
              pass = Regexp.last_match(3)
              msfid = Time.now.to_i
              newcreds = Nexpose::SiteCredentials.for_service("Metasploit Site Credential #{msfid}", nil, nil, nil, nil, type)
              newcreds.user_name = user
              newcreds.password = pass
              opt_credentials << newcreds
            else
              print_error("Unrecognized Nexpose scan credentials: #{val}")
              return
            end
          when '-v'
            opt_verbose = true
          when '-P'
            opt_preserve = true
          when '-d'
            opt_rescandb = true
          when '-I'
            opt_addrinc = OptAddressRange.new('TEMPRANGE', [ true, '' ]).normalize(val)
          when '-E'
            opt_addrexc = OptAddressRange.new('TEMPRANGE', [ true, '' ]).normalize(val)
          else
            opt_ranges << val
          end
        end

        return if !nexpose_verify

        # Include all database hosts as scan targets if specified
        if opt_rescandb
          print_status('Loading scan targets from the active database...') if opt_verbose
          framework.db.hosts.each do |host|
            next if host.state != ::Msf::HostState::Alive

            opt_ranges << host.address
          end
        end

        possible_files = opt_ranges # don't allow DOS by circular reference
        possible_files.each do |file|
          next unless ::File.readable? file

          print_status "Parsing ranges from #{file}"
          range_list = ::File.open(file, 'rb') { |f| f.read f.stat.size }
          range_list.each_line { |subrange| opt_ranges << subrange }
          opt_ranges.delete(file)
        end

        opt_ranges = opt_ranges.join(' ')

        if opt_ranges.strip.empty?
          print_line('Usage: nexpose_scan [options] <Target IP Ranges>')
          print_line(opts.usage)
          return
        end

        if opt_verbose
          print_status("Creating a new scan using template #{opt_template} and #{opt_maxaddrs} concurrent IPs against #{opt_ranges}")
        end

        range_inp = ::Msf::OptAddressRange.new('TEMPRANGE', [ true, '' ]).normalize(opt_ranges)
        range = ::Rex::Socket::RangeWalker.new(range_inp)
        include_range = opt_addrinc ? ::Rex::Socket::RangeWalker.new(opt_addrinc) : nil
        exclude_range = opt_addrexc ? ::Rex::Socket::RangeWalker.new(opt_addrexc) : nil

        completed = 0
        total = range.num_ips
        count = 0

        print_status("Scanning #{total} addresses with template #{opt_template} in sets of #{opt_maxaddrs}")

        while (completed < total)
          count += 1
          queue = []

          while ((ip = range.next_ip) && (queue.length < opt_maxaddrs))

            if (exclude_range && exclude_range.include?(ip))
              print_status(" >> Skipping host #{ip} due to exclusion") if opt_verbose
              next
            end

            if (include_range && !include_range.include?(ip))
              print_status(" >> Skipping host #{ip} due to inclusion filter") if opt_verbose
              next
            end

            opt_scanned << ip
            queue << ip
          end

          break if queue.empty?

          print_status("Scanning #{queue[0]}-#{queue[-1]}...") if opt_verbose

          msfid = Time.now.to_i

          # Create a temporary site
          site = Nexpose::Site.new(nil, opt_template)
          site.name = "Metasploit-#{msfid}"
          site.description = 'Autocreated by the Metasploit Framework'
          site.included_addresses = queue
          site.site_credentials = opt_credentials
          site.save(@nsc)

          print_status(" >> Created temporary site ##{site.id}") if opt_verbose

          report_formats = ['raw-xml-v2', 'ns-xml']
          report_format = report_formats.shift

          report = Nexpose::ReportConfig.build(@nsc, site.id, site.name, opt_template, report_format, true)
          report.delivery = Nexpose::Delivery.new(true)

          begin
            report.format = report_format
            report.save(@nsc, true)
          rescue ::Exception => e
            report_format = report_formats.shift
            if report_format
              retry
            end
            raise e
          end

          print_status(" >> Created temporary report configuration ##{report.id}") if opt_verbose

          # Run the scan
          begin
            res = site.scan(@nsc)
          rescue Nexpose::APIError => e
            nexpose_error_message = e.message
            nexpose_error_message.gsub!(/NexposeAPI: Action failed: /, '')
            print_error nexpose_error_message.to_s
            return
          end

          sid = res.id

          print_status(" >> Scan has been launched with ID ##{sid}") if opt_verbose

          rep = true
          begin
            prev = nil
            while true
              info = @nsc.scan_statistics(sid)
              break if info.status != 'running'

              stat = "Found #{info.nodes.live} devices and #{info.nodes.dead} unresponsive"
              if (stat != prev) && opt_verbose
                print_status(" >> #{stat}")
              end
              prev = stat
              select(nil, nil, nil, 5.0)
            end
            print_status(" >> Scan has been completed with ID ##{sid}") if opt_verbose
          rescue ::Interrupt
            rep = false
            print_status(" >> Terminating scan ID ##{sid} due to console interrupt") if opt_verbose
            @nsc.stop_scan(sid)
            break
          end

          # Wait for the automatic report generation to complete
          if rep
            print_status(' >> Waiting on the report to generate...') if opt_verbose
            last_report = nil
            until last_report
              last_report = @nsc.last_report(report.id)
              select(nil, nil, nil, 1.0)
            end
            url = last_report.uri

            print_status(' >> Downloading the report data from Nexpose...') if opt_verbose
            data = @nsc.download(url)

            if opt_savexml
              ::FileUtils.mkdir_p(opt_savexml)
              path = ::File.join(opt_savexml, "nexpose-#{msfid}-#{count}.xml")
              print_status(" >> Saving scan data into #{path}") if opt_verbose
              ::File.open(path, 'wb') { |fd| fd.write(data) }
            end

            process_nexpose_data(report_format, data)
          end

          next if opt_preserve

          # Make sure the scan has finished clean up before attempting to delete the site
          loop do
            info = @nsc.scan_statistics(sid)
            break if info.status == 'stopped' || info.status == 'finished'

            select(nil, nil, nil, 5.0)
          end
          print_status(' >> Deleting the temporary site and report...') if opt_verbose
          begin
            @nsc.delete_site(site.id)
          rescue ::Nexpose::APIError => e
            print_status(" >> Deletion of temporary site and report failed: #{e.inspect}")
          end
        end

        print_status("Completed the scan of #{total} addresses")
      end

      def cmd_nexpose_disconnect(*_args)
        @nsc.logout if @nsc
        @nsc = nil
      end

      def process_nexpose_data(fmt, data)
        case fmt
        when 'raw-xml-v2'
          framework.db.import({ data: data })
        when 'ns-xml'
          framework.db.import({ data: data })
        else
          print_error("Unsupported Nexpose data format: #{fmt}")
        end
      end

      #
      # Nexpose vuln lookup
      #
      def nexpose_vuln_lookup(doc, vid, refs, host, serv = nil)
        doc.elements.each("/NexposeReport/VulnerabilityDefinitions/vulnerability[@id = '#{vid}']]") do |vulndef|
          title = vulndef.attributes['title']
          # pci_severity = vulndef.attributes['pciSeverity']
          # cvss_score = vulndef.attributes['cvssScore']
          # cvss_vector = vulndef.attributes['cvssVector']

          vulndef.elements['references'].elements.each('reference') do |ref|
            if ref.attributes['source'] == 'BID'
              refs['BID-' + ref.text] = true
            elsif ref.attributes['source'] == 'CVE'
              # ref.text is CVE-$ID
              refs[ref.text] = true
            elsif ref.attributes['source'] == 'MS'
              refs['MSB-MS-' + ref.text] = true
            end
          end

          refs['NEXPOSE-' + vid.downcase] = true

          vuln = framework.db.find_or_create_vuln(
            host: host,
            service: serv,
            name: 'NEXPOSE-' + vid.downcase,
            data: title
          )

          rids = []
          refs.each_key do |r|
            rids << framework.db.find_or_create_ref(name: r)
          end

          vuln.refs << (rids - vuln.refs)
        end
      end

    end

    #
    # Plugin initialization
    #

    def initialize(framework, opts)
      super

      add_console_dispatcher(NexposeCommandDispatcher)
      banner = ['0a205f5f5f5f202020202020202020202020205f20202020205f205f5f5f5f5f2020205f2020205f20202020205f5f20205f5f2020202020202020202020202020202020202020200a7c20205f205c205f5f205f205f205f5f20285f29205f5f7c207c5f5f5f20207c207c205c207c207c205f5f5f5c205c2f202f5f205f5f2020205f5f5f20205f5f5f20205f5f5f200a7c207c5f29202f205f60207c20275f205c7c207c2f205f60207c20202f202f20207c20205c7c207c2f205f205c5c20202f7c20275f205c202f205f205c2f205f5f7c2f205f205c0a7c20205f203c20285f7c207c207c5f29207c207c20285f7c207c202f202f2020207c207c5c20207c20205f5f2f2f20205c7c207c5f29207c20285f29205c5f5f205c20205f5f2f0a7c5f7c205c5f5c5f5f2c5f7c202e5f5f2f7c5f7c5c5f5f2c5f7c2f5f2f202020207c5f7c205c5f7c5c5f5f5f2f5f2f5c5f5c202e5f5f2f205c5f5f5f2f7c5f5f5f2f5c5f5f5f7c0a20202020202020202020207c5f7c20202020202020202020202020202020202020202020202020202020202020202020207c5f7c202020202020202020202020202020202020200a0a0a'].pack('H*')

      # Do not use this UTF-8 encoded high-ascii art for non-UTF-8 or windows consoles
      lang = Rex::Compat.getenv('LANG')
      if (lang && lang =~ (/UTF-8/))
        # Cygwin/Windows should not be reporting UTF-8 either...
        # (! (Rex::Compat.is_windows or Rex::Compat.is_cygwin))
        banner = ['202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200a20e29684e29684e29684202020e29684e29684202020202020202020202020e29684e29684e296842020e29684e29684e2968420202020202020202020202020202020202020202020202020202020202020202020202020202020200a20e29688e29688e29688202020e29688e2968820202020202020202020202020e29688e2968820e29684e29688e296882020202020202020202020202020202020202020202020202020202020202020202020202020202020200a20e29688e29688e29680e296882020e29688e29688202020e29684e29688e29688e29688e29688e296842020202020e29688e29688e29688e2968820202020e29688e29688e29684e29688e29688e29688e2968420202020e29684e29688e29688e29688e29688e29684202020e29684e29684e29688e29688e29688e29688e29688e29684202020e29684e29688e29688e29688e29688e2968420200a20e29688e2968820e29688e2968820e29688e296882020e29688e29688e29684e29684e29684e29684e29688e296882020202020e29688e296882020202020e29688e29688e296802020e29680e29688e296882020e29688e29688e296802020e29680e29688e296882020e29688e29688e29684e29684e29684e2968420e296802020e29688e29688e29684e29684e29684e29684e29688e29688200a20e29688e296882020e29688e29684e29688e296882020e29688e29688e29680e29680e29680e29680e29680e2968020202020e29688e29688e29688e2968820202020e29688e2968820202020e29688e296882020e29688e2968820202020e29688e29688202020e29680e29680e29680e29680e29688e29688e296842020e29688e29688e29680e29680e29680e29680e29680e29680200a20e29688e29688202020e29688e29688e296882020e29680e29688e29688e29684e29684e29684e29684e29688202020e29688e296882020e29688e29688202020e29688e29688e29688e29684e29684e29688e29688e296802020e29680e29688e29688e29684e29684e29688e29688e296802020e29688e29684e29684e29684e29684e29684e29688e296882020e29680e29688e29688e29684e29684e29684e29684e29688200a20e29680e29680202020e29680e29680e2968020202020e29680e29680e29680e29680e29680202020e29680e29680e296802020e29680e29680e296802020e29688e2968820e29680e29680e29680202020202020e29680e29680e29680e296802020202020e29680e29680e29680e29680e29680e296802020202020e29680e29680e29680e29680e2968020200a20202020202020202020202020202020202020202020202020202020202020e29688e29688202020202020202020202020202020202020202020202020202020202020202020202020200a202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200a'].pack('H*')
      end
      print(banner)
      print_status('Nexpose integration has been activated')
    end

    def cleanup
      remove_console_dispatcher('Nexpose')
    end

    def name
      'nexpose'
    end

    def desc
      'Integrates with the Rapid7 Nexpose vulnerability management product'
    end
  end
end

module Nexpose
  class IPRange
    def to_json(*_args)
      if @to.present?
        "#{@from} - #{@to}".to_json
      else
        @from.to_json
      end
    end
  end
end