module Metasploit
  module Framework
    module PasswordCracker
      class PasswordCrackerNotFoundError < StandardError
      end

      class Cracker
        include ActiveModel::Validations

        # @!attribute attack
        #   @return [String] The attack mode for hashcat to use (not applicable to John)
        attr_accessor :attack

        # @!attribute config
        #   @return [String] The path to an optional config file for John to use
        attr_accessor :config

        # @!attribute cracker
        #   @return [String] Which cracker to use.  'john' and 'hashcat' are valid
        attr_accessor :cracker

        # @!attribute cracker_path
        #   This attribute allows the user to specify a cracker binary to use.
        #   If not supplied, the Cracker will search the PATH for a suitable john or hashcat binary
        #   and finally fall back to the pre-compiled john versions shipped with Metasploit.
        #
        #   @return [String] The file path to an alternative cracker binary to use
        attr_accessor :cracker_path

        # @!attribute format
        #   If the cracker type is john, this format will automatically be translated
        #   to the hashcat equivalent via jtr_format_to_hashcat_format
        #
        #   @return [String] The hash format to try.
        attr_accessor :format

        # @!attribute fork
        #   If the cracker type is john, the amount of forks to specify
        #
        #   @return [String] The hash format to try.
        attr_accessor :fork

        # @!attribute hash_path
        #   @return [String] The path to the file containing the hashes
        attr_accessor :hash_path

        # @!attribute incremental
        #   @return [String] The incremental mode to use
        attr_accessor :incremental

        # @!attribute increment_length
        #   @return [Array] The incremental min and max to use
        attr_accessor :increment_length

        # @!attribute mask
        #  If the cracker type is hashcat, If set, the mask to use.  Should consist of the character sets
        #  pre-defined by hashcat, such as ?d ?s ?l etc
        #
        #   @return [String] The mask to use
        attr_accessor :mask

        # @!attribute max_runtime
        #   @return [Integer] An optional maximum duration of the cracking attempt in seconds
        attr_accessor :max_runtime

        # @!attribute max_length
        #   @return [Integer] An optional maximum length of password to attempt cracking
        attr_accessor :max_length

        # @!attribute optimize
        #   @return [Boolean] If the Optimize flag should be given to Hashcat
        attr_accessor :optimize

        # @!attribute pot
        #   @return [String] The file path to an alternative John pot file to use
        attr_accessor :pot

        # @!attribute rules
        #   @return [String] The wordlist mangling rules to use inside John/Hashcat
        attr_accessor :rules

        # @!attribute wordlist
        #   @return [String] The file path to the wordlist to use
        attr_accessor :wordlist

        validates :config, 'Metasploit::Framework::File_path': true, if: -> { config.present? }

        validates :cracker, inclusion: { in: %w[john hashcat] }

        validates :cracker_path, 'Metasploit::Framework::Executable_path': true, if: -> { cracker_path.present? }

        validates :fork,
                  numericality: {
                    only_integer: true,
                    greater_than_or_equal_to: 1
                  }, if: -> { fork.present? }

        validates :hash_path, 'Metasploit::Framework::File_path': true, if: -> { hash_path.present? }

        validates :pot, 'Metasploit::Framework::File_path': true, if: -> { pot.present? }

        validates :max_runtime,
                  numericality: {
                    only_integer: true,
                    greater_than_or_equal_to: 0
                  }, if: -> { max_runtime.present? }

        validates :max_length,
                  numericality: {
                    only_integer: true,
                    greater_than_or_equal_to: 0
                  }, if: -> { max_length.present? }

        validates :wordlist, 'Metasploit::Framework::File_path': true, if: -> { wordlist.present? }

        # @param attributes [Hash{Symbol => String,nil}]
        def initialize(attributes = {})
          attributes.each do |attribute, value|
            public_send("#{attribute}=", value)
          end
        end

        # This method takes a {framework.db.cred.private.jtr_format} (string), and
        # returns the string number associated to the hashcat format
        #
        # @param format [String] A jtr_format string
        # @return [String] The format number for Hashcat
        def jtr_format_to_hashcat_format(format)
          case format
          # nix
          when 'md5crypt'
            '500'
          when 'descrypt'
            '1500'
          when 'bsdicrypt'
            '12400'
          when 'sha256crypt'
            '7400'
          when 'sha512crypt'
            '1800'
          when 'bcrypt'
            '3200'
          # windows
          when 'lm', 'lanman'
            '3000'
          when 'nt', 'ntlm'
            '1000'
          when 'mscash'
            '1100'
          when 'mscash2'
            '2100'
          when 'netntlm'
            '5500'
          when 'netntlmv2'
            '5600'
          # dbs
          when 'mssql'
            '131'
          when 'mssql05'
            '132'
          when 'mssql12'
            '1731'
          # hashcat requires a format we dont have all the data for
          # in the current dumper, so this is disabled in module and lib
          # when 'oracle', 'des,oracle'
          #  return '3100'
          when 'oracle11', 'raw-sha1,oracle'
            '112'
          when 'oracle12c', 'pbkdf2,oracle12c'
            '12300'
          when 'postgres', 'dynamic_1034', 'raw-md5,postgres'
            '12'
          when 'mysql'
            '200'
          when 'mysql-sha1'
            '300'
          when 'PBKDF2-HMAC-SHA512' # osx 10.8+
            '7100'
          # osx
          when 'xsha' # osx 10.4-6
            '122'
          when 'xsha512' # osx 10.7
            '1722'
          # webapps
          when 'PBKDF2-HMAC-SHA1' # Atlassian
            '12001'
          when 'phpass' # Wordpress/PHPass, Joomla, phpBB3
            '400'
          when 'mediawiki' # mediawiki b type
            '3711'
          # mobile
          when 'android-samsung-sha1'
            '5800'
          when 'android-sha1'
            '110'
          when 'android-md5'
            '10'
          when 'hmac-md5'
            '10200'
          when 'dynamic_82'
            '1710'
          when 'ssha'
            '111'
          when 'raw-sha512'
            '1700'
          when 'raw-sha256'
            '1400'
          when 'raw-sha1'
            '100'
          when 'raw-md5'
            '0'
          when 'smd5'
            '6300'
          when 'ssha256'
            '1411'
          when 'ssha512'
            '1711'
          when 'Raw-MD5u'
            '30'
          end
        end

        # This method sets the appropriate parameters to run a cracker in incremental mode
        def mode_incremental
          self.increment_length = nil
          self.wordlist = nil
          self.mask = nil
          self.max_runtime = nil
          if cracker == 'john'
            self.rules = nil
            self.incremental = 'Digits'
          elsif cracker == 'hashcat'
            self.attack = '3'
            self.incremental = true
          end
        end

        # This method sets the appropriate parameters to run a cracker in wordlist mode
        #
        # @param file [String] A file location of the wordlist to use
        def mode_wordlist(file)
          self.increment_length = nil
          self.incremental = nil
          self.max_runtime = nil
          self.mask = nil
          if cracker == 'john'
            self.wordlist = file
            self.rules = 'wordlist'
          elsif cracker == 'hashcat'
            self.wordlist = file
            self.attack = '0'
          end
        end

        # This method sets the appropriate parameters to run a cracker in a pin mode (4-8 digits) on hashcat
        def mode_pin
          self.rules = nil
          if cracker == 'hashcat'
            self.attack = '3'
            self.mask = '?d' * 8
            self.incremental = true
            self.increment_length = [4, 8]
            self.max_runtime = 300 # 5min on an i7 got through 4-7 digits. 8digit was 32min more
          end
        end

        # This method sets the john to 'normal' mode
        def mode_normal
          if cracker == 'john'
            self.max_runtime = nil
            self.mask = nil
            self.wordlist = nil
            self.rules = nil
            self.incremental = nil
            self.increment_length = nil
          end
        end

        # This method sets the john to single mode
        #
        # @param file [String] A file location of the wordlist to use
        def mode_single(file)
          if cracker == 'john'
            self.wordlist = file
            self.rules = 'single'
            self.incremental = nil
            self.increment_length = nil
            self.mask = nil
          end
        end

        # This method follows a decision tree to determine the path
        # to the cracker binary we should use.
        #
        # @return [String, NilClass] Returns Nil if a binary path could not be found, or a String containing the path to the selected JTR binary on success.
        def binary_path
          # Always prefer a manually entered path
          if cracker_path && ::File.file?(cracker_path)
            return cracker_path
          else
            # Look in the Environment PATH for the john binary
            if cracker == 'john'
              path = Rex::FileUtils.find_full_path('john') ||
                     Rex::FileUtils.find_full_path('john.exe')
            elsif cracker == 'hashcat'
              path = Rex::FileUtils.find_full_path('hashcat') ||
                     Rex::FileUtils.find_full_path('hashcat.exe')
            else
              raise PasswordCrackerNotFoundError, 'No suitable Cracker was selected, so a binary could not be found on the system'
            end

            if path && ::File.file?(path)
              return path
            end

            raise PasswordCrackerNotFoundError, 'No suitable john/hashcat binary was found on the system'
          end
        end

        # This method runs the command from {#crack_command} and yields each line of output.
        #
        # @yield [String] a line of output from the cracker command
        # @return [void]
        def crack(&block)
          if cracker == 'john'
            results = john_crack_command
          elsif cracker == 'hashcat'
            results = hashcat_crack_command
          end
          ::IO.popen(results, 'rb') do |fd|
            fd.each_line(&block)
          end
        end

        # This method returns the version of John the Ripper or Hashcat being used.
        #
        # @raise [PasswordCrackerNotFoundError] if a suitable cracker binary was never found
        # @return [Sring] the version detected
        def cracker_version
          if cracker == 'john'
            cmd = binary_path
          elsif cracker == 'hashcat'
            cmd = binary_path
            cmd << (' -V')
          end
          ::IO.popen(cmd, 'rb') do |fd|
            fd.each_line do |line|
              if cracker == 'john'
                # John the Ripper 1.8.0.13-jumbo-1-bleeding-973a245b96 2018-12-17 20:12:51 +0100 OMP [linux-gnu 64-bit x86_64 AVX2 AC]
                # John the Ripper 1.9.0-jumbo-1 OMP [linux-gnu 64-bit x86_64 AVX2 AC]
                # John the Ripper password cracker, version 1.8.0.2-bleeding-jumbo_omp [64-bit AVX-autoconf]
                # John the Ripper password cracker, version 1.8.0
                return Regexp.last_match(1).strip if line =~ /John the Ripper(?: password cracker, version)? ([^\[]+)/
              elsif cracker == 'hashcat'
                # v5.1.0
                return Regexp.last_match(1) if line =~ /(v[\d.]+)/
              end
            end
          end
          nil
        end

        # This method is used to determine which format of the no log option should be used
        # --no-log vs --nolog https://github.com/openwall/john/commit/8982e4f7a2e874aab29807a05b421373015c9b61
        # We base this either on a date being in the version, or running the command and checking the output
        #
        # @return [String] The nolog format to use
        def john_nolog_format
          if /(\d{4}-\d{2}-\d{2})/ =~ cracker_version
            # we lucked out and theres a date, we'll check its older than the commit that changed the nolog
            if Date.parse(Regexp.last_match(1)) < Date.parse('2020-11-27')
              return '--nolog'
            end

            return '--no-log'
          end

          # no date, so lets give it a run with the old format and check if we raise an error
          # on *nix 'unknown option' goes to stderr
          ::IO.popen([binary_path, '--nolog', { err: %i[child out] }], 'rb') do |fd|
            return '--nolog' unless fd.read.include? 'Unknown option'
          end
          '--no-log'
        end

        # This method builds an array for the command to actually run the cracker.
        # It builds the command from all of the attributes on the class.
        #
        # @raise [PasswordCrackerNotFoundError] if a suitable John binary was never found
        # @return [Array] An array set up for {::IO.popen} to use
        def john_crack_command
          cmd_string = binary_path

          cmd = [cmd_string, '--session=' + cracker_session_id, john_nolog_format]

          if config.present?
            cmd << ('--config=' + config)
          else
            cmd << ('--config=' + john_config_file)
          end

          if pot.present?
            cmd << ('--pot=' + pot)
          else
            cmd << ('--pot=' + john_pot_file)
          end

          if fork.present? && fork > 1
            cmd << ('--fork=' + fork.to_s)
          end

          if format.present?
            cmd << ('--format=' + format)
          end

          if wordlist.present?
            cmd << ('--wordlist=' + wordlist)
          end

          if incremental.present?
            cmd << ('--incremental=' + incremental)
          end

          if rules.present?
            cmd << ('--rules=' + rules)
          end

          if max_runtime.present?
            cmd << ('--max-run-time=' + max_runtime.to_s)
          end

          if max_length.present?
            cmd << ('--max-len=' + max_length.to_s)
          end

          cmd << hash_path
        end

        # This method builds an array for the command to actually run the cracker.
        # It builds the command from all of the attributes on the class.
        #
        # @raise [PasswordCrackerNotFoundError] if a suitable Hashcat binary was never found
        # @return [Array] An array set up for {::IO.popen} to use
        def hashcat_crack_command
          cmd_string = binary_path
          cmd = [cmd_string, '--session=' + cracker_session_id, '--logfile-disable']

          if pot.present?
            cmd << ('--potfile-path=' + pot)
          else
            cmd << ('--potfile-path=' + john_pot_file)
          end

          if format.present?
            cmd << ('--hash-type=' + jtr_format_to_hashcat_format(format))
          end

          if optimize.present?
            # https://hashcat.net/wiki/doku.php?id=frequently_asked_questions#what_is_the_maximum_supported_password_length_for_optimized_kernels
            # Optimized Kernels has a large impact on speed.  Here are some stats from Hashcat 5.1.0:

            # Kali Linux on Dell Precision M3800
            ## hashcat -b -w 2 -m 0
            # * Device #1: Quadro K1100M, 500/2002 MB allocatable, 2MCU
            # Speed.#1.........:   185.9 MH/s (11.15ms) @ Accel:64 Loops:16 Thr:1024 Vec:1

            ## hashcat -b -w 2 -O -m 0
            # * Device #1: Quadro K1100M, 500/2002 MB allocatable, 2MCU
            # Speed.#1.........:   463.6 MH/s (8.92ms) @ Accel:64 Loops:32 Thr:1024 Vec:1

            # Windows 10
            # PS C:\hashcat-5.1.0> .\hashcat64.exe -b -O -w 2 -m 0
            # * Device #1: GeForce RTX 2070 SUPER, 2048/8192 MB allocatable, 40MCU
            # Speed.#1.........: 13914.0 MH/s (5.77ms) @ Accel:128 Loops:64 Thr:256 Vec:1

            # PS C:\hashcat-5.1.0> .\hashcat64.exe -b -O -w 2 -m 0
            # * Device #1: GeForce RTX 2070 SUPER, 2048/8192 MB allocatable, 40MCU
            # Speed.#1.........: 31545.6 MH/s (10.36ms) @ Accel:256 Loops:128 Thr:256 Vec:1

            # This change should result in 225%-250% speed boost at the sacrifice of some password length, which most likely
            # wouldn't be tested inside of MSF since most users are using the MSF modules for word list and easy cracks.
            # Anything of length where this would cut off is most likely being done independently (outside MSF)

            cmd << ('-O')
          end

          if incremental.present?
            cmd << ('--increment')
            if increment_length.present?
              cmd << ('--increment-min=' + increment_length[0].to_s)
              cmd << ('--increment-max=' + increment_length[1].to_s)
            else
              # anything more than max 4 on even des took 8+min on an i7.
              # maybe in the future this can be adjusted or made a variable
              # but current time, we'll leave it as this seems like reasonable
              # time expectation for a module to run
              cmd << ('--increment-max=4')
            end
          end

          if rules.present?
            cmd << ('--rules-file=' + rules)
          end

          if attack.present?
            cmd << ('--attack-mode=' + attack)
          end

          if max_runtime.present?
            cmd << ('--runtime=' + max_runtime.to_s)
          end

          cmd << hash_path

          if mask.present?
            cmd << mask.to_s
          end

          # must be last
          if wordlist.present?
            cmd << (wordlist)
          end
          cmd
        end

        # This runs the show command in john and yields cracked passwords.
        #
        # @return [Array] the output from teh command split on newlines
        def each_cracked_password
          ::IO.popen(show_command, 'rb').readlines
        end

        # This method returns the path to a default john.conf file.
        #
        # @return [String] the path to the default john.conf file
        def john_config_file
          ::File.join(::Msf::Config.data_directory, 'jtr', 'john.conf')
        end

        # This method returns the path to a default john.pot file.
        #
        # @return [String] the path to the default john.pot file
        def john_pot_file
          ::File.join(::Msf::Config.config_directory, 'john.pot')
        end

        # This method is a getter for a random Session ID for the cracker.
        # It allows us to dinstiguish between cracking sessions.
        #
        # @ return [String] the Session ID to use
        def cracker_session_id
          @session_id ||= ::Rex::Text.rand_text_alphanumeric(8)
        end

        # This method builds the command to show the cracked passwords.
        #
        # @raise [JohnNotFoundError] if a suitable John binary was never found
        # @return [Array] An array set up for {::IO.popen} to use
        def show_command
          cmd_string = binary_path

          pot_file = pot || john_pot_file
          if cracker == 'hashcat'
            cmd = [cmd_string, '--show', "--potfile-path=#{pot_file}", "--hash-type=#{jtr_format_to_hashcat_format(format)}"]
          elsif cracker == 'john'
            cmd = [cmd_string, '--show', "--pot=#{pot_file}", "--format=#{format}"]

            if config
              cmd << "--config=#{config}"
            else
              cmd << ('--config=' + john_config_file)
            end
          end
          cmd << hash_path
        end

      end
    end
  end
end
