Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions lib/metasploit/framework/key_collection.rb
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
169 changes: 163 additions & 6 deletions modules/auxiliary/scanner/ssh/ssh_login.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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
],
Expand All @@ -36,7 +40,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
)

Expand All @@ -54,7 +61,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)
Expand All @@ -66,9 +73,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)

Expand All @@ -91,6 +113,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?
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']
Expand Down Expand Up @@ -129,7 +180,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}"
Expand Down Expand Up @@ -158,4 +209,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|
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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
Loading