-
Notifications
You must be signed in to change notification settings - Fork 14.6k
Combine ssh_login and ssh_login_pubkey modules #20704
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| module Metasploit::Framework | ||
| class KeyCollection < Metasploit::Framework::CredentialCollection | ||
| attr_accessor :key_data | ||
| attr_accessor :key_path | ||
| attr_accessor :private_key | ||
| attr_accessor :error_list | ||
| attr_accessor :ssh_keyfile_b64 | ||
|
|
||
| # Override CredentialCollection#has_privates? | ||
| def has_privates? | ||
| @key_data.present? | ||
| end | ||
|
|
||
| def realm | ||
| nil | ||
| end | ||
|
|
||
| def valid? | ||
| @error_list = [] | ||
| @key_data = Set.new | ||
|
|
||
| if @private_key.present? | ||
| results = validate_private_key(@private_key) | ||
| elsif @key_path.present? | ||
| results = validate_key_path(@key_path) | ||
| else | ||
| @error_list << 'No key path or key provided' | ||
| raise RuntimeError, 'No key path or key provided' | ||
| end | ||
|
|
||
| if results[:key_data].present? | ||
| @key_data.merge(results[:key_data]) | ||
| else | ||
| @error_list.concat(results[:error_list]) if results[:error_list].present? | ||
| end | ||
|
|
||
| @key_data.present? | ||
| end | ||
|
|
||
| def validate_private_key(private_key) | ||
| key_data = Set.new | ||
| error_list = [] | ||
| begin | ||
| if Net::SSH::KeyFactory.load_data_private_key(private_key, @password, false).present? | ||
| key_data << private_key | ||
| end | ||
| rescue StandardError => e | ||
| error_list << "Error validating private key: #{e}" | ||
| end | ||
| {key_data: key_data, error_list: error_list} | ||
| end | ||
|
|
||
| def validate_key_path(key_path) | ||
| key_data = Set.new | ||
| error_list = [] | ||
|
|
||
| if File.file?(key_path) | ||
| key_files = [key_path] | ||
| elsif File.directory?(key_path) | ||
| key_files = Dir.entries(key_path).reject { |f| f =~ /^\x2e|\x2epub$/ }.map { |f| File.join(key_path, f) } | ||
| else | ||
| return {key_data: nil, error: "#{key_path} Invalid key path"} | ||
| end | ||
|
|
||
| key_files.each do |f| | ||
| begin | ||
| if read_key(f).present? | ||
| key_data << File.read(f) | ||
| end | ||
| rescue StandardError => e | ||
| error_list << "#{f}: #{e}" | ||
| end | ||
| end | ||
| {key_data: key_data, error_list: error_list} | ||
| end | ||
|
|
||
|
|
||
| def each | ||
| prepended_creds.each { |c| yield c } | ||
|
|
||
| if @user_file.present? | ||
| File.open(@user_file, 'rb') do |user_fd| | ||
| user_fd.each_line do |user_from_file| | ||
| user_from_file.chomp! | ||
| each_key do |key_data| | ||
| yield Metasploit::Framework::Credential.new(public: user_from_file, private: key_data, realm: realm, private_type: :ssh_key) | ||
| end | ||
| end | ||
| end | ||
| end | ||
|
|
||
| if @username.present? | ||
| each_key do |key_data| | ||
| yield Metasploit::Framework::Credential.new(public: @username, private: key_data, realm: realm, private_type: :ssh_key) | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def each_key | ||
| @key_data.each do |data| | ||
| yield data | ||
| end | ||
| end | ||
|
|
||
| def read_key(file_path) | ||
| @cache ||= {} | ||
| @cache[file_path] ||= Net::SSH::KeyFactory.load_private_key(file_path, password, false) | ||
| @cache[file_path] | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -7,6 +7,7 @@ | |||||
| require 'net/ssh/command_stream' | ||||||
| require 'metasploit/framework/login_scanner/ssh' | ||||||
| require 'metasploit/framework/credential_collection' | ||||||
| require 'metasploit/framework/key_collection' | ||||||
|
|
||||||
| class MetasploitModule < Msf::Auxiliary | ||||||
| include Msf::Auxiliary::AuthBrute | ||||||
|
|
@@ -16,6 +17,8 @@ class MetasploitModule < Msf::Auxiliary | |||||
| include Msf::Exploit::Remote::SSH::Options | ||||||
| include Msf::Sessions::CreateSessionOptions | ||||||
| include Msf::Auxiliary::ReportSummary | ||||||
| include Msf::Exploit::Deprecated | ||||||
| moved_from 'auxiliary/scanner/ssh/ssh_login_pubkey' | ||||||
|
|
||||||
| def initialize | ||||||
| super( | ||||||
|
|
@@ -26,7 +29,8 @@ def initialize | |||||
| and connected to a database this module will record successful | ||||||
| logins and hosts so you can track your access. | ||||||
| }, | ||||||
| 'Author' => ['todb'], | ||||||
| 'Author' => ['todb', 'RageLtMan'], | ||||||
| 'AKA' => ['ssh_login_pubkey'], | ||||||
| 'References' => [ | ||||||
| [ 'CVE', '1999-0502'], # Weak password | ||||||
| [ 'ATT&CK', Mitre::Attack::Technique::T1021_004_SSH ] | ||||||
|
|
@@ -37,7 +41,10 @@ def initialize | |||||
|
|
||||||
| register_options( | ||||||
| [ | ||||||
| Opt::RPORT(22) | ||||||
| Opt::RPORT(22), | ||||||
| OptPath.new('KEY_PATH', [false, 'Filename or directory of cleartext private keys. Filenames beginning with a dot, or ending in ".pub" will be skipped. Duplicate private keys will be ignored.']), | ||||||
| OptString.new('KEY_PASS', [false, 'Passphrase for SSH private key(s)']), | ||||||
| OptString.new('PRIVATE_KEY', [false, 'The string value of the private key that will be used. If you are using MSFConsole, this value should be set as file:PRIVATE_KEY_PATH. OpenSSH, RSA, DSA, and ECDSA private keys are supported.']) | ||||||
| ], self.class | ||||||
| ) | ||||||
|
|
||||||
|
|
@@ -55,7 +62,7 @@ def rport | |||||
| datastore['RPORT'] | ||||||
| end | ||||||
|
|
||||||
| def session_setup(result, scanner) | ||||||
| def session_setup(result, scanner, used_key: false) | ||||||
| return unless scanner.ssh_socket | ||||||
|
|
||||||
| platform = scanner.get_platform(result.proof) | ||||||
|
|
@@ -67,9 +74,24 @@ def session_setup(result, scanner) | |||||
| 'USERPASS_FILE' => nil, | ||||||
| 'USER_FILE' => nil, | ||||||
| 'PASS_FILE' => nil, | ||||||
| 'USERNAME' => result.credential.public, | ||||||
| 'PASSWORD' => result.credential.private | ||||||
| 'USERNAME' => result.credential.public | ||||||
| } | ||||||
| if used_key | ||||||
| merge_me.merge!( | ||||||
| { | ||||||
| 'PASSWORD' => nil | ||||||
| } | ||||||
| ) | ||||||
| else | ||||||
| merge_me.merge!( | ||||||
| { | ||||||
| 'PASSWORD' => result.credential.private, | ||||||
| 'PRIVATE_KEY' => nil, | ||||||
| 'KEY_FILE' => nil | ||||||
| } | ||||||
| ) | ||||||
| end | ||||||
|
|
||||||
| s = start_session(self, nil, merge_me, false, sess.rstream, sess) | ||||||
| self.sockets.delete(scanner.ssh_socket.transport.socket) | ||||||
|
|
||||||
|
|
@@ -92,6 +114,35 @@ def run_host(ip) | |||||
| @ip = ip | ||||||
| print_brute :ip => ip, :msg => 'Starting bruteforce' | ||||||
|
|
||||||
| if datastore['USER_FILE'].blank? && datastore['USERNAME'].blank? && datastore['USERPASS_FILE'].blank? | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you want to avoid chaining
Suggested change
|
||||||
| validation_reason = 'At least one of USER_FILE, USERPASS_FILE or USERNAME must be given' | ||||||
| raise Msf::OptionValidateError.new( | ||||||
| { | ||||||
| 'USER_FILE' => validation_reason, | ||||||
| 'USERNAME' => validation_reason, | ||||||
| 'USERPASS_FILE' => validation_reason | ||||||
| } | ||||||
| ) | ||||||
| end | ||||||
|
|
||||||
| unless attempt_password_login? || attempt_pubkey_login? | ||||||
| validation_reason = 'At least one of KEY_PATH, PRIVATE_KEY or PASSWORD must be given' | ||||||
| raise Msf::OptionValidateError.new( | ||||||
| { | ||||||
| 'KEY_PATH' => validation_reason, | ||||||
| 'PRIVATE_KEY' => validation_reason, | ||||||
| 'PASSWORD' => validation_reason | ||||||
| } | ||||||
| ) | ||||||
| end | ||||||
|
|
||||||
| do_login_creds(ip) if attempt_password_login? | ||||||
| do_login_pubkey(ip) if attempt_pubkey_login? | ||||||
| end | ||||||
|
|
||||||
| def do_login_creds(ip) | ||||||
| print_status("#{ip}:#{rport} SSH - Testing User/Pass combinations") | ||||||
|
|
||||||
| cred_collection = build_credential_collection( | ||||||
| username: datastore['USERNAME'], | ||||||
| password: datastore['PASSWORD'] | ||||||
|
|
@@ -130,7 +181,7 @@ def run_host(ip) | |||||
|
|
||||||
| if datastore['CreateSession'] | ||||||
| begin | ||||||
| session_setup(result, scanner) | ||||||
| session_setup(result, scanner, used_key: false) | ||||||
| rescue StandardError => e | ||||||
| elog('Failed to setup the session', error: e) | ||||||
| print_brute :level => :error, :ip => ip, :msg => "Failed to setup the session - #{e.class} #{e.message}" | ||||||
|
|
@@ -159,4 +210,110 @@ def run_host(ip) | |||||
| end | ||||||
| end | ||||||
| end | ||||||
|
|
||||||
| def do_login_pubkey(ip) | ||||||
| print_status("#{ip}:#{rport} SSH - Testing Cleartext Keys") | ||||||
|
|
||||||
| keys = Metasploit::Framework::KeyCollection.new( | ||||||
| key_path: datastore['KEY_PATH'], | ||||||
| password: datastore['KEY_PASS'], | ||||||
| user_file: datastore['USER_FILE'], | ||||||
| username: datastore['USERNAME'], | ||||||
| private_key: datastore['PRIVATE_KEY'] | ||||||
| ) | ||||||
|
|
||||||
| unless keys.valid? | ||||||
| print_error('Files that failed to be read:') | ||||||
| keys.error_list.each do |err| | ||||||
| print_line("\t- #{err}") | ||||||
| end | ||||||
| end | ||||||
|
|
||||||
| keys = prepend_db_keys(keys) | ||||||
|
|
||||||
| key_count = keys.key_data.count | ||||||
| key_sources = [] | ||||||
| unless datastore['KEY_PATH'].blank? | ||||||
| key_sources.append(datastore['KEY_PATH']) | ||||||
| end | ||||||
|
|
||||||
| unless datastore['PRIVATE_KEY'].blank? | ||||||
| key_sources.append('PRIVATE_KEY') | ||||||
| end | ||||||
|
|
||||||
| print_brute level: :vstatus, ip: ip, msg: "Testing #{key_count} #{'key'.pluralize(key_count)} from #{key_sources.join(' and ')}" | ||||||
| scanner = Metasploit::Framework::LoginScanner::SSH.new( | ||||||
| configure_login_scanner( | ||||||
| host: ip, | ||||||
| port: rport, | ||||||
| cred_details: keys, | ||||||
| stop_on_success: datastore['STOP_ON_SUCCESS'], | ||||||
| bruteforce_speed: datastore['BRUTEFORCE_SPEED'], | ||||||
| proxies: datastore['Proxies'], | ||||||
| connection_timeout: datastore['SSH_TIMEOUT'], | ||||||
| framework: framework, | ||||||
| framework_module: self, | ||||||
| skip_gather_proof: !datastore['GatherProof'] | ||||||
| ) | ||||||
| ) | ||||||
|
|
||||||
| scanner.verbosity = :debug if datastore['SSH_DEBUG'] | ||||||
|
|
||||||
| scanner.scan! do |result| | ||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. realising this section is very similar between the two login methods bar the slight different in printing, I can probably DRY this up too |
||||||
| credential_data = result.to_h | ||||||
| credential_data.merge!( | ||||||
| module_fullname: self.fullname, | ||||||
| workspace_id: myworkspace_id | ||||||
| ) | ||||||
| case result.status | ||||||
| when Metasploit::Model::Login::Status::SUCCESSFUL | ||||||
| print_brute level: :good, ip: ip, msg: "Success: '#{result.proof.to_s.gsub(/[\r\n\e\b\a]/, ' ')}'" | ||||||
| print_brute level: :vgood, ip: ip, msg: "#{result.credential}', ' ')}'" | ||||||
| begin | ||||||
| credential_core = create_credential(credential_data) | ||||||
| credential_data[:core] = credential_core | ||||||
| create_credential_login(credential_data) | ||||||
| rescue ::StandardError => e | ||||||
| print_brute level: :info, ip: ip, msg: "Failed to create credential: #{e.class} #{e}" | ||||||
| print_brute level: :warn, ip: ip, msg: 'We do not currently support storing password protected SSH keys: https://github.com/rapid7/metasploit-framework/issues/20598' | ||||||
| end | ||||||
|
|
||||||
| if datastore['CreateSession'] | ||||||
| session_setup(result, scanner, used_key: true) | ||||||
| end | ||||||
| if datastore['GatherProof'] && scanner.get_platform(result.proof) == 'unknown' | ||||||
| msg = 'While a session may have opened, it may be bugged. If you experience issues with it, re-run this module with' | ||||||
| msg << " 'set gatherproof false'. Also consider submitting an issue at github.com/rapid7/metasploit-framework with" | ||||||
| msg << ' device details so it can be handled in the future.' | ||||||
| print_brute level: :error, ip: ip, msg: msg | ||||||
| end | ||||||
| :next_user | ||||||
| when Metasploit::Model::Login::Status::UNABLE_TO_CONNECT | ||||||
| if datastore['VERBOSE'] | ||||||
| print_brute level: :verror, ip: ip, msg: "Could not connect: #{result.proof}" | ||||||
| end | ||||||
| scanner.ssh_socket.close if scanner.ssh_socket && !scanner.ssh_socket.closed? | ||||||
| invalidate_login(credential_data) | ||||||
| :abort | ||||||
| when Metasploit::Model::Login::Status::INCORRECT | ||||||
| if datastore['VERBOSE'] | ||||||
| print_brute level: :verror, ip: ip, msg: "Failed: '#{result.credential}'" | ||||||
| end | ||||||
| invalidate_login(credential_data) | ||||||
| scanner.ssh_socket.close if scanner.ssh_socket && !scanner.ssh_socket.closed? | ||||||
| else | ||||||
| invalidate_login(credential_data) | ||||||
| scanner.ssh_socket.close if scanner.ssh_socket && !scanner.ssh_socket.closed? | ||||||
| end | ||||||
| end | ||||||
| end | ||||||
|
|
||||||
| def attempt_pubkey_login? | ||||||
| datastore['KEY_PATH'].present? || datastore['PRIVATE_KEY'].present? | ||||||
| end | ||||||
|
|
||||||
| def attempt_password_login? | ||||||
| datastore['PASSWORD'].present? || datastore['PASS_FILE'].present? || datastore['USERPASS_FILE'].present? | ||||||
| end | ||||||
|
|
||||||
| end | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd extract the
mergefrom the conditional because we're doing it in both branches e.g.