From d49ec54b0d5035e526c68cb692c0367e19f36451 Mon Sep 17 00:00:00 2001 From: Z6543 Date: Thu, 23 Apr 2026 05:59:02 +0000 Subject: [PATCH 01/14] Make NetBIOS lookup socket creation pluggable Address PR #294 review comments #16 and #18. The session-request retry and UDP Node Status query previously hardcoded TCPSocket.new and UDPSocket.new, which breaks Metasploit pivoting because Metasploit needs every socket to come from Rex::Socket. Add tcp_socket_factory and udp_socket_factory attributes on Client. Both default to stdlib socket constructors for standalone use; callers that need custom socket creation (Rex::Socket, test doubles, TLS wrappers) can inject their own callable. Skip setsockopt on sockets that don't respond to it, so Rex-style sockets don't break the retry. --- lib/ruby_smb/client.rb | 119 ++++++++++++- lib/ruby_smb/error.rb | 12 +- .../nbss/negative_session_response.rb | 17 +- spec/lib/ruby_smb/client_spec.rb | 160 ++++++++++++++++++ 4 files changed, 300 insertions(+), 8 deletions(-) diff --git a/lib/ruby_smb/client.rb b/lib/ruby_smb/client.rb index 7313d3051..3f9a93353 100644 --- a/lib/ruby_smb/client.rb +++ b/lib/ruby_smb/client.rb @@ -317,6 +317,25 @@ class Client # @return [Boolean] attr_accessor :server_supports_nt_smbs + # Factory used to open a new TCP socket when the NetBIOS session-request + # retry path needs to reconnect to the server under a resolved NetBIOS + # name. Must be a callable that accepts (host, port) and returns an + # IO-like socket. Defaults to stdlib `TCPSocket.new`. Callers that need + # to control socket creation (e.g. Metasploit's Rex::Socket for pivoted + # connections) should inject their own factory. + # @!attribute [rw] tcp_socket_factory + # @return [#call] + attr_accessor :tcp_socket_factory + + # Factory used to open a UDP socket for the NetBIOS name-service lookup + # (port 137). Must be a callable with no arguments that returns a socket + # responding to `#send` / `#recvfrom` / `#close`. Defaults to stdlib + # `UDPSocket.new`. Inject your own (e.g. Rex::Socket::Udp) to avoid + # creating raw stdlib sockets. + # @!attribute [rw] udp_socket_factory + # @return [#call] + attr_accessor :udp_socket_factory + # @param dispatcher [RubySMB::Dispatcher::Socket] the packet dispatcher to use # @param smb1 [Boolean] whether or not to enable SMB1 support # @param smb2 [Boolean] whether or not to enable SMB2 support @@ -350,7 +369,9 @@ def initialize(dispatcher, smb1: true, smb2: true, smb3: true, username:, passwo @server_max_write_size = RubySMB::SMB2::File::MAX_PACKET_SIZE @server_max_transact_size = RubySMB::SMB2::File::MAX_PACKET_SIZE @server_supports_multi_credit = false - @server_supports_nt_smbs = true + @server_supports_nt_smbs = true + @tcp_socket_factory = ->(host, port) { TCPSocket.new(host, port) } + @udp_socket_factory = -> { UDPSocket.new } # SMB 3.x options # this merely initializes the default value for session encryption, it may be changed as necessary when a @@ -673,6 +694,40 @@ def wipe_state! # @raise [RubySMB::Error::NetBiosSessionService] if session request is refused # @raise [RubySMB::Error::InvalidPacket] if the response packet is not a NBSS packet def session_request(name = '*SMBSERVER') + send_session_request(name) + rescue RubySMB::Error::NetBiosSessionService => e + raise unless name == '*SMBSERVER' && + e.error_code == RubySMB::Nbss::NegativeSessionResponse::CALLED_NAME_NOT_PRESENT + + sock = dispatcher.tcp_socket + if sock.respond_to?(:peerhost) + host = sock.peerhost + port = sock.peerport + else + addr = sock.remote_address + host = addr.ip_address + port = addr.ip_port + end + + resolved = netbios_lookup_name(host) + raise unless resolved + + dispatcher.tcp_socket.close rescue nil + new_sock = tcp_socket_factory.call(host, port) + if new_sock.respond_to?(:setsockopt) + new_sock.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_KEEPALIVE, true) + end + dispatcher.tcp_socket = new_sock + send_session_request(resolved) + end + + private + + # Sends a single NetBIOS Session Request and reads the response. + # + # @param name [String] the NetBIOS name to request + # @return [TrueClass] if session request is granted + def send_session_request(name) session_request = session_request_packet(name) dispatcher.send_packet(session_request, nbss_header: false) raw_response = dispatcher.recv_packet(full_response: true) @@ -680,7 +735,10 @@ def session_request(name = '*SMBSERVER') session_header = RubySMB::Nbss::SessionHeader.read(raw_response) if session_header.session_packet_type == RubySMB::Nbss::NEGATIVE_SESSION_RESPONSE negative_session_response = RubySMB::Nbss::NegativeSessionResponse.read(raw_response) - raise RubySMB::Error::NetBiosSessionService, "Session Request failed: #{negative_session_response.error_msg}" + raise RubySMB::Error::NetBiosSessionService.new( + "Session Request failed: #{negative_session_response.error_msg}", + error_code: negative_session_response.error_code + ) end rescue IOError raise RubySMB::Error::InvalidPacket, 'Not a NBSS packet' @@ -689,6 +747,63 @@ def session_request(name = '*SMBSERVER') return true end + # Resolves a host's NetBIOS name. Tries nmblookup first (if + # available), then falls back to a raw UDP Node Status query. + # + # @param host [String] the IP address to query + # @return [String, nil] the NetBIOS name, or nil if lookup fails + def netbios_lookup_name(host) + netbios_lookup_nmblookup(host) || netbios_lookup_udp(host) + end + + # Resolves a NetBIOS name using the system nmblookup command. + # + # @param host [String] the IP address to query + # @return [String, nil] the file server NetBIOS name + def netbios_lookup_nmblookup(host) + output = IO.popen(['nmblookup', '-A', host], err: :close, &:read) + return nil unless $?.success? + + output.each_line do |line| + if line =~ /\A\s+(\S+)\s+<20>\s/ + return $1.strip + end + end + nil + rescue Errno::ENOENT + nil + end + + # Resolves a NetBIOS name via a UDP Node Status request (RFC 1002 4.2.17, + # port 137). Uses the {RubySMB::Nbss::NodeStatusRequest} and + # {RubySMB::Nbss::NodeStatusResponse} BinData structures. + # + # @param host [String] the IP address to query + # @return [String, nil] the file server NetBIOS name, or nil on timeout or + # when the host has no unique file-server name in its name table + def netbios_lookup_udp(host) + request = RubySMB::Nbss::NodeStatusRequest.new(transaction_id: rand(0xFFFF)) + request.question_name.set("*".ljust(16, "\x00")) + + sock = udp_socket_factory.call + sock.send(request.to_binary_s, 0, host, 137) + + return nil unless IO.select([sock], nil, nil, 3) + + data, = sock.recvfrom(4096) + return nil if data.nil? || data.empty? + + response = RubySMB::Nbss::NodeStatusResponse.read(data) + response.file_server_name + rescue IOError, EOFError + nil + ensure + sock&.close + end + + public + + # Crafts the NetBIOS SessionRequest packet to be sent for session request operations. # # @param name [String] the NetBIOS name to request diff --git a/lib/ruby_smb/error.rb b/lib/ruby_smb/error.rb index 00250b594..ec06fc7e8 100644 --- a/lib/ruby_smb/error.rb +++ b/lib/ruby_smb/error.rb @@ -11,7 +11,17 @@ class ASN1Encoding < RubySMBError; end # Raised when there is a problem with communication over NetBios Session Service # @see https://wiki.wireshark.org/NetBIOS/NBSS - class NetBiosSessionService < RubySMBError; end + class NetBiosSessionService < RubySMBError + # The numeric NBSS error code from a NEGATIVE_SESSION_RESPONSE, or nil + # if the error was raised outside that context. + # @return [Integer, nil] + attr_reader :error_code + + def initialize(msg = nil, error_code: nil) + @error_code = error_code + super(msg) + end + end # Raised when trying to parse raw binary into a Packet and the data # is invalid. diff --git a/lib/ruby_smb/nbss/negative_session_response.rb b/lib/ruby_smb/nbss/negative_session_response.rb index 07fa9bfc8..39fca254f 100644 --- a/lib/ruby_smb/nbss/negative_session_response.rb +++ b/lib/ruby_smb/nbss/negative_session_response.rb @@ -5,6 +5,13 @@ module Nbss # Representation of the NetBIOS Negative Session Service Response packet as defined in # [4.3.4 SESSION REQUEST PACKET](https://tools.ietf.org/html/rfc1002) class NegativeSessionResponse < BinData::Record + # NBSS error codes (RFC 1002 section 4.3.6) + NOT_LISTENING_ON_CALLED_NAME = 0x80 + NOT_LISTENING_FOR_CALLING_NAME = 0x81 + CALLED_NAME_NOT_PRESENT = 0x82 + CALLED_NAME_INSUFFICIENT_RESOURCES = 0x83 + UNSPECIFIED_ERROR = 0x8F + endian :big session_header :session_header @@ -12,15 +19,15 @@ class NegativeSessionResponse < BinData::Record def error_msg case error_code - when 0x80 + when NOT_LISTENING_ON_CALLED_NAME 'Not listening on called name' - when 0x81 + when NOT_LISTENING_FOR_CALLING_NAME 'Not listening for calling name' - when 0x82 + when CALLED_NAME_NOT_PRESENT 'Called name not present' - when 0x83 + when CALLED_NAME_INSUFFICIENT_RESOURCES 'Called name present, but insufficient resources' - when 0x8F + when UNSPECIFIED_ERROR 'Unspecified error' end end diff --git a/spec/lib/ruby_smb/client_spec.rb b/spec/lib/ruby_smb/client_spec.rb index 9eb199617..76da965b1 100644 --- a/spec/lib/ruby_smb/client_spec.rb +++ b/spec/lib/ruby_smb/client_spec.rb @@ -742,6 +742,115 @@ allow(RubySMB::Nbss::SessionHeader).to receive(:read).and_raise(IOError) expect { client.session_request }.to raise_error(RubySMB::Error::InvalidPacket) end + + context 'when the server rejects *SMBSERVER with CALLED_NAME_NOT_PRESENT' do + let(:first_failure) do + RubySMB::Error::NetBiosSessionService.new( + 'Session Request failed: Called name not present', + error_code: RubySMB::Nbss::NegativeSessionResponse::CALLED_NAME_NOT_PRESENT + ) + end + let(:new_sock) { double('NewSocket', setsockopt: nil) } + + before :example do + allow(sock).to receive(:peerhost).and_return('10.0.0.2') + allow(sock).to receive(:peerport).and_return(139) + allow(sock).to receive(:close) + client.tcp_socket_factory = ->(_host, _port) { new_sock } + end + + it 'looks up the real NetBIOS name and retries with it' do + call_count = 0 + allow(client).to receive(:send_session_request) do |name| + call_count += 1 + if call_count == 1 + raise first_failure + else + expect(name).to eq('FILESERVER') + true + end + end + allow(client).to receive(:netbios_lookup_name).with('10.0.0.2').and_return('FILESERVER') + expect(client.session_request).to be true + expect(call_count).to eq(2) + end + + it 'reconnects the TCP socket before retrying via the injected tcp_socket_factory' do + call_count = 0 + allow(client).to receive(:send_session_request) do + call_count += 1 + raise first_failure if call_count == 1 + true + end + allow(client).to receive(:netbios_lookup_name).and_return('FILESERVER') + factory = double('Factory') + client.tcp_socket_factory = factory + expect(factory).to receive(:call).with('10.0.0.2', 139).and_return(new_sock) + expect(new_sock).to receive(:setsockopt).with(::Socket::SOL_SOCKET, ::Socket::SO_KEEPALIVE, true) + client.session_request + expect(dispatcher.tcp_socket).to eq(new_sock) + end + + it 'skips setsockopt when the factory returns a socket that does not support it' do + call_count = 0 + allow(client).to receive(:send_session_request) do + call_count += 1 + raise first_failure if call_count == 1 + true + end + allow(client).to receive(:netbios_lookup_name).and_return('FILESERVER') + # Use a real plain object so respond_to?(:setsockopt) returns false. + rex_like_sock = Object.new + client.tcp_socket_factory = ->(_host, _port) { rex_like_sock } + expect { client.session_request }.not_to raise_error + expect(dispatcher.tcp_socket).to eq(rex_like_sock) + end + + it 'raises the original error when netbios_lookup_name returns nil' do + allow(client).to receive(:send_session_request).and_raise(first_failure) + allow(client).to receive(:netbios_lookup_name).and_return(nil) + expect { + client.session_request + }.to raise_error(RubySMB::Error::NetBiosSessionService) + end + + it 'does not retry when a non-*SMBSERVER name was requested' do + allow(client).to receive(:send_session_request).and_raise(first_failure) + expect(client).not_to receive(:netbios_lookup_name) + expect { + client.session_request('OTHERNAME') + }.to raise_error(RubySMB::Error::NetBiosSessionService) + end + end + + context 'when the server rejects with a non-CALLED_NAME_NOT_PRESENT code' do + it 'does not retry and re-raises' do + allow(client).to receive(:send_session_request).and_raise( + RubySMB::Error::NetBiosSessionService.new( + 'Session Request failed: Not listening on called name', + error_code: RubySMB::Nbss::NegativeSessionResponse::NOT_LISTENING_ON_CALLED_NAME + ) + ) + expect(client).not_to receive(:netbios_lookup_name) + expect { + client.session_request + }.to raise_error(RubySMB::Error::NetBiosSessionService, /Not listening on called name/) + end + end + + describe 'NetBiosSessionService error propagates the error_code' do + it 'attaches the numeric NBSS error code to the raised exception' do + negative = RubySMB::Nbss::NegativeSessionResponse.new + negative.session_header.session_packet_type = RubySMB::Nbss::NEGATIVE_SESSION_RESPONSE + negative.error_code = RubySMB::Nbss::NegativeSessionResponse::CALLED_NAME_NOT_PRESENT + allow(dispatcher).to receive(:recv_packet).and_return(negative.to_binary_s) + begin + client.session_request('OTHERNAME') + rescue RubySMB::Error::NetBiosSessionService => e + expect(e.error_code).to eq(RubySMB::Nbss::NegativeSessionResponse::CALLED_NAME_NOT_PRESENT) + end + end + end end describe '#session_request_packet' do @@ -776,6 +885,57 @@ expect(client.session_request_packet.called_name).to eq('*SMBSERVER ') end end + + describe '#netbios_lookup_udp' do + let(:udp_sock) { double('UDPSocket') } + + before :example do + allow(udp_sock).to receive(:send) + allow(udp_sock).to receive(:close) + allow(IO).to receive(:select).and_return([udp_sock]) + allow(udp_sock).to receive(:recvfrom).and_return(['', nil]) + end + + it 'obtains its socket from udp_socket_factory rather than UDPSocket.new directly' do + factory = double('UdpFactory') + client.udp_socket_factory = factory + expect(factory).to receive(:call).and_return(udp_sock) + # The response parse will fail on empty bytes; that is fine, we only + # care that the injected factory was consulted. + client.send(:netbios_lookup_udp, '10.0.0.2') + end + + it 'sends the encoded NodeStatusRequest to port 137' do + client.udp_socket_factory = ->() { udp_sock } + expect(udp_sock).to receive(:send) do |bytes, flags, host, port| + expect(flags).to eq(0) + expect(host).to eq('10.0.0.2') + expect(port).to eq(137) + expect(bytes.bytesize).to eq(50) + end + client.send(:netbios_lookup_udp, '10.0.0.2') + end + + it 'returns the file-server name from the NBNS response' do + response_bytes = ''.b + response_bytes << [0x1234].pack('n') # transaction_id + response_bytes << [0x8400].pack('n') # flags + response_bytes << [0].pack('n') # qdcount + response_bytes << [1].pack('n') # ancount + response_bytes << [0].pack('n') << [0].pack('n') # nscount, arcount + response_bytes << [0x20].pack('C') << ('A' * 32) << "\x00".b + response_bytes << [0x0021].pack('n') << [0x0001].pack('n') + response_bytes << [0].pack('N') << [1 + 18 + 46].pack('n') + response_bytes << [1].pack('C') + response_bytes << 'FILESERVER'.ljust(15, ' ') << [0x20].pack('C') << [0x0400].pack('n') + response_bytes << ("\x00".b * 46) + + allow(udp_sock).to receive(:recvfrom).and_return([response_bytes, nil]) + client.udp_socket_factory = ->() { udp_sock } + + expect(client.send(:netbios_lookup_udp, '10.0.0.2')).to eq('FILESERVER') + end + end end context 'Protocol Negotiation' do From facda14bffdd4e92dd5e339aea66237a41580039 Mon Sep 17 00:00:00 2001 From: Z6543 Date: Thu, 23 Apr 2026 06:15:05 +0000 Subject: [PATCH 02/14] Widen NetBIOS auto-lookup gate to any rejected called name The retry was gated on name == '*SMBSERVER', which missed the common case where a caller passes a specific NetBIOS name that the server rejects with CALLED_NAME_NOT_PRESENT. Drop the name gate and instead guard against retry loops by bailing when the resolved name matches what was just rejected (case-insensitive, whitespace-insensitive). Coerce error_code through to_i so the comparison works regardless of whether the attribute holds a plain Integer or a BinData primitive. --- lib/ruby_smb/client.rb | 9 +++++++-- spec/lib/ruby_smb/client_spec.rb | 26 +++++++++++++++++++++----- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/lib/ruby_smb/client.rb b/lib/ruby_smb/client.rb index 3f9a93353..8008b9d40 100644 --- a/lib/ruby_smb/client.rb +++ b/lib/ruby_smb/client.rb @@ -688,6 +688,11 @@ def wipe_state! end # Requests a NetBIOS Session Service using the provided name. + # When the server rejects the called name with NBSS error 0x82 + # (CALLED_NAME_NOT_PRESENT), this method automatically looks up the + # server's actual NetBIOS name via a Node Status query, reconnects + # the TCP socket, and retries once. The retry is skipped when the + # lookup fails or returns the same name that was just rejected. # # @param name [String] the NetBIOS name to request # @return [TrueClass] if session request is granted @@ -696,8 +701,7 @@ def wipe_state! def session_request(name = '*SMBSERVER') send_session_request(name) rescue RubySMB::Error::NetBiosSessionService => e - raise unless name == '*SMBSERVER' && - e.error_code == RubySMB::Nbss::NegativeSessionResponse::CALLED_NAME_NOT_PRESENT + raise unless e.error_code.to_i == RubySMB::Nbss::NegativeSessionResponse::CALLED_NAME_NOT_PRESENT sock = dispatcher.tcp_socket if sock.respond_to?(:peerhost) @@ -711,6 +715,7 @@ def session_request(name = '*SMBSERVER') resolved = netbios_lookup_name(host) raise unless resolved + raise if resolved.to_s.upcase.strip == name.to_s.upcase.strip dispatcher.tcp_socket.close rescue nil new_sock = tcp_socket_factory.call(host, port) diff --git a/spec/lib/ruby_smb/client_spec.rb b/spec/lib/ruby_smb/client_spec.rb index 76da965b1..aba03533a 100644 --- a/spec/lib/ruby_smb/client_spec.rb +++ b/spec/lib/ruby_smb/client_spec.rb @@ -814,11 +814,27 @@ }.to raise_error(RubySMB::Error::NetBiosSessionService) end - it 'does not retry when a non-*SMBSERVER name was requested' do + it 'also retries when the rejected name was not *SMBSERVER' do + call_count = 0 + allow(client).to receive(:send_session_request) do |name| + call_count += 1 + if call_count == 1 + raise first_failure + else + expect(name).to eq('FILESERVER') + true + end + end + allow(client).to receive(:netbios_lookup_name).and_return('FILESERVER') + expect(client.session_request('GUESSEDNAME')).to be true + expect(call_count).to eq(2) + end + + it 'does not retry when the lookup returns the same name that was just rejected' do allow(client).to receive(:send_session_request).and_raise(first_failure) - expect(client).not_to receive(:netbios_lookup_name) + allow(client).to receive(:netbios_lookup_name).and_return('WIN95') expect { - client.session_request('OTHERNAME') + client.session_request('win95') }.to raise_error(RubySMB::Error::NetBiosSessionService) end end @@ -842,12 +858,12 @@ it 'attaches the numeric NBSS error code to the raised exception' do negative = RubySMB::Nbss::NegativeSessionResponse.new negative.session_header.session_packet_type = RubySMB::Nbss::NEGATIVE_SESSION_RESPONSE - negative.error_code = RubySMB::Nbss::NegativeSessionResponse::CALLED_NAME_NOT_PRESENT + negative.error_code = RubySMB::Nbss::NegativeSessionResponse::NOT_LISTENING_ON_CALLED_NAME allow(dispatcher).to receive(:recv_packet).and_return(negative.to_binary_s) begin client.session_request('OTHERNAME') rescue RubySMB::Error::NetBiosSessionService => e - expect(e.error_code).to eq(RubySMB::Nbss::NegativeSessionResponse::CALLED_NAME_NOT_PRESENT) + expect(e.error_code.to_i).to eq(RubySMB::Nbss::NegativeSessionResponse::NOT_LISTENING_ON_CALLED_NAME) end end end From db69cd85a81faab3c45af8e7029393914fd91714 Mon Sep 17 00:00:00 2001 From: Z6543 Date: Thu, 23 Apr 2026 10:34:37 +0000 Subject: [PATCH 03/14] Drop nmblookup shell-out from NetBIOS name lookup Per PR #294 review comment, the library should do everything in Ruby. Remove netbios_lookup_nmblookup entirely; netbios_lookup_name now only uses the native NBSS UDP Node Status query. --- lib/ruby_smb/client.rb | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/lib/ruby_smb/client.rb b/lib/ruby_smb/client.rb index 8008b9d40..15b6c120a 100644 --- a/lib/ruby_smb/client.rb +++ b/lib/ruby_smb/client.rb @@ -752,31 +752,13 @@ def send_session_request(name) return true end - # Resolves a host's NetBIOS name. Tries nmblookup first (if - # available), then falls back to a raw UDP Node Status query. + # Resolves a host's NetBIOS name via a UDP Node Status query + # (RFC 1002 4.2.17, port 137). Pure Ruby — no external binaries. # # @param host [String] the IP address to query # @return [String, nil] the NetBIOS name, or nil if lookup fails def netbios_lookup_name(host) - netbios_lookup_nmblookup(host) || netbios_lookup_udp(host) - end - - # Resolves a NetBIOS name using the system nmblookup command. - # - # @param host [String] the IP address to query - # @return [String, nil] the file server NetBIOS name - def netbios_lookup_nmblookup(host) - output = IO.popen(['nmblookup', '-A', host], err: :close, &:read) - return nil unless $?.success? - - output.each_line do |line| - if line =~ /\A\s+(\S+)\s+<20>\s/ - return $1.strip - end - end - nil - rescue Errno::ENOENT - nil + netbios_lookup_udp(host) end # Resolves a NetBIOS name via a UDP Node Status request (RFC 1002 4.2.17, From 150c57ac4a5023f5946d797654445872458de0ee Mon Sep 17 00:00:00 2001 From: Z6543 Date: Thu, 23 Apr 2026 11:07:36 +0000 Subject: [PATCH 04/14] Only auto-discover NetBIOS name when the caller used the default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the caller supplied an explicit called name (anything other than the wildcard '*SMBSERVER' or an empty string), honor it and propagate the server's rejection instead of silently starting a UDP Node Status query and reconnecting the socket. This lets Metasploit's SMBName option do what a user expects — setting it bypasses auto-discovery entirely. Auto-discovery still kicks in for the default wildcard or an empty name, so no regression for callers that relied on it. --- lib/ruby_smb/client.rb | 18 +++++++++++---- spec/lib/ruby_smb/client_spec.rb | 38 ++++++++++++++++++-------------- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/lib/ruby_smb/client.rb b/lib/ruby_smb/client.rb index 15b6c120a..cded03810 100644 --- a/lib/ruby_smb/client.rb +++ b/lib/ruby_smb/client.rb @@ -688,11 +688,13 @@ def wipe_state! end # Requests a NetBIOS Session Service using the provided name. - # When the server rejects the called name with NBSS error 0x82 + # When the caller left the called name at its default (`'*SMBSERVER'` or + # empty) and the server rejects it with NBSS error 0x82 # (CALLED_NAME_NOT_PRESENT), this method automatically looks up the - # server's actual NetBIOS name via a Node Status query, reconnects - # the TCP socket, and retries once. The retry is skipped when the - # lookup fails or returns the same name that was just rejected. + # server's real NetBIOS name via a Node Status query, reconnects the + # TCP socket, and retries once. If the caller supplied a specific name + # (e.g. Metasploit's `SMBName` datastore option), the name is honored + # as-is and the exception propagates on rejection — no auto-discovery. # # @param name [String] the NetBIOS name to request # @return [TrueClass] if session request is granted @@ -701,6 +703,7 @@ def wipe_state! def session_request(name = '*SMBSERVER') send_session_request(name) rescue RubySMB::Error::NetBiosSessionService => e + raise unless default_called_name?(name) raise unless e.error_code.to_i == RubySMB::Nbss::NegativeSessionResponse::CALLED_NAME_NOT_PRESENT sock = dispatcher.tcp_socket @@ -728,6 +731,13 @@ def session_request(name = '*SMBSERVER') private + # True when the called name is empty or the wildcard `'*SMBSERVER'`. + # A specific caller-provided name (e.g. MSF's `SMBName`) short-circuits + # auto-discovery in {#session_request}. + def default_called_name?(name) + name.nil? || name.to_s.strip.empty? || name.to_s.strip.upcase == '*SMBSERVER' + end + # Sends a single NetBIOS Session Request and reads the response. # # @param name [String] the NetBIOS name to request diff --git a/spec/lib/ruby_smb/client_spec.rb b/spec/lib/ruby_smb/client_spec.rb index aba03533a..5e2e1ccb2 100644 --- a/spec/lib/ruby_smb/client_spec.rb +++ b/spec/lib/ruby_smb/client_spec.rb @@ -814,29 +814,33 @@ }.to raise_error(RubySMB::Error::NetBiosSessionService) end - it 'also retries when the rejected name was not *SMBSERVER' do - call_count = 0 - allow(client).to receive(:send_session_request) do |name| - call_count += 1 - if call_count == 1 - raise first_failure - else - expect(name).to eq('FILESERVER') - true - end - end - allow(client).to receive(:netbios_lookup_name).and_return('FILESERVER') - expect(client.session_request('GUESSEDNAME')).to be true - expect(call_count).to eq(2) + it 'does NOT retry when the caller supplied a specific name (not *SMBSERVER)' do + allow(client).to receive(:send_session_request).and_raise(first_failure) + expect(client).not_to receive(:netbios_lookup_name) + expect { + client.session_request('GUESSEDNAME') + }.to raise_error(RubySMB::Error::NetBiosSessionService) end - it 'does not retry when the lookup returns the same name that was just rejected' do + it 'does not retry when the lookup returns the same wildcard after a *SMBSERVER rejection' do allow(client).to receive(:send_session_request).and_raise(first_failure) - allow(client).to receive(:netbios_lookup_name).and_return('WIN95') + allow(client).to receive(:netbios_lookup_name).and_return('*SMBSERVER') expect { - client.session_request('win95') + client.session_request('*SMBSERVER') }.to raise_error(RubySMB::Error::NetBiosSessionService) end + + it 'treats an empty called name the same as the *SMBSERVER default' do + call_count = 0 + allow(client).to receive(:send_session_request) do + call_count += 1 + raise first_failure if call_count == 1 + true + end + allow(client).to receive(:netbios_lookup_name).and_return('FILESERVER') + expect(client.session_request('')).to be true + expect(call_count).to eq(2) + end end context 'when the server rejects with a non-CALLED_NAME_NOT_PRESENT code' do From 9b8336123ac0b927f6d730123de32ba166c91447 Mon Sep 17 00:00:00 2001 From: Z6543 Date: Thu, 23 Apr 2026 11:26:26 +0000 Subject: [PATCH 05/14] Add pure-Ruby NBNS node-status helper (nmblookup -A equivalent) New RubySMB::Nbss::NodeStatus module exposes two public entry points: RubySMB::Nbss::NodeStatus.query(host) # => [Entry, ...] full name table RubySMB::Nbss::NodeStatus.file_server_name(host) # => String (0x20 UNIQUE) No shell-out to Samba's nmblookup. Supports retries, a configurable timeout, and an injectable UDP socket factory so Metasploit callers can route the query through Rex::Socket instead of opening a raw stdlib UDPSocket. Client#netbios_lookup_udp now just delegates to NodeStatus.file_server_name, passing the client's udp_socket_factory. --- lib/ruby_smb/client.rb | 29 ++---- lib/ruby_smb/nbss.rb | 3 + lib/ruby_smb/nbss/node_status.rb | 102 +++++++++++++++++++ spec/lib/ruby_smb/nbss/node_status_spec.rb | 110 +++++++++++++++++++++ 4 files changed, 222 insertions(+), 22 deletions(-) create mode 100644 lib/ruby_smb/nbss/node_status.rb create mode 100644 spec/lib/ruby_smb/nbss/node_status_spec.rb diff --git a/lib/ruby_smb/client.rb b/lib/ruby_smb/client.rb index cded03810..575dafe5d 100644 --- a/lib/ruby_smb/client.rb +++ b/lib/ruby_smb/client.rb @@ -771,31 +771,16 @@ def netbios_lookup_name(host) netbios_lookup_udp(host) end - # Resolves a NetBIOS name via a UDP Node Status request (RFC 1002 4.2.17, - # port 137). Uses the {RubySMB::Nbss::NodeStatusRequest} and - # {RubySMB::Nbss::NodeStatusResponse} BinData structures. + # Resolves a host's file-server NetBIOS name via a UDP Node Status + # request. Thin wrapper around {RubySMB::Nbss::NodeStatus.file_server_name} + # that threads the client's injected UDP socket factory through. # # @param host [String] the IP address to query - # @return [String, nil] the file server NetBIOS name, or nil on timeout or - # when the host has no unique file-server name in its name table + # @return [String, nil] the file server NetBIOS name, or nil on timeout def netbios_lookup_udp(host) - request = RubySMB::Nbss::NodeStatusRequest.new(transaction_id: rand(0xFFFF)) - request.question_name.set("*".ljust(16, "\x00")) - - sock = udp_socket_factory.call - sock.send(request.to_binary_s, 0, host, 137) - - return nil unless IO.select([sock], nil, nil, 3) - - data, = sock.recvfrom(4096) - return nil if data.nil? || data.empty? - - response = RubySMB::Nbss::NodeStatusResponse.read(data) - response.file_server_name - rescue IOError, EOFError - nil - ensure - sock&.close + RubySMB::Nbss::NodeStatus.file_server_name( + host, udp_socket_factory: udp_socket_factory + ) end public diff --git a/lib/ruby_smb/nbss.rb b/lib/ruby_smb/nbss.rb index c43e26e14..03d12ef03 100644 --- a/lib/ruby_smb/nbss.rb +++ b/lib/ruby_smb/nbss.rb @@ -13,5 +13,8 @@ module Nbss require 'ruby_smb/nbss/session_header' require 'ruby_smb/nbss/session_request' require 'ruby_smb/nbss/negative_session_response' + require 'ruby_smb/nbss/node_status_request' + require 'ruby_smb/nbss/node_status_response' + require 'ruby_smb/nbss/node_status' end end diff --git a/lib/ruby_smb/nbss/node_status.rb b/lib/ruby_smb/nbss/node_status.rb new file mode 100644 index 000000000..5855951ec --- /dev/null +++ b/lib/ruby_smb/nbss/node_status.rb @@ -0,0 +1,102 @@ +require 'socket' + +module RubySMB + module Nbss + # Pure-Ruby implementation of `nmblookup -A `: sends an NBNS Node + # Status Request (RFC 1002 4.2.17) over UDP/137 and returns the + # server's name table. + # + # No external binaries are invoked. Compare to Samba's `nmblookup`, + # which shells out and requires the `samba-common-bin` package to be + # installed. + module NodeStatus + NBNS_PORT = 137 + + # Default per-attempt receive timeout, in seconds. + DEFAULT_TIMEOUT = 2.0 + + # Default number of attempts before giving up. + DEFAULT_RETRIES = 3 + + # One entry in the returned name table. + # + # @!attribute [r] name [String] the NetBIOS name (trimmed) + # @!attribute [r] suffix [Integer] 1-byte NetBIOS suffix + # @!attribute [r] group [Boolean] true for a group name, false for unique + # @!attribute [r] active [Boolean] true if the name is registered + Entry = Struct.new(:name, :suffix, :group, :active) do + def unique? + !group + end + + # Human-readable form like `WIN95 <20> UNIQUE ACTIVE`. + def to_s + flags = [group ? 'GROUP' : 'UNIQUE', active ? 'ACTIVE' : 'INACTIVE'].join(' ') + format('%-16s <%02X> %s', name, suffix, flags) + end + end + + # Query a host for its NetBIOS name table. + # + # @param host [String] target IP address (unicast — no broadcast) + # @param port [Integer] destination UDP port (default 137) + # @param timeout [Numeric] per-attempt receive timeout in seconds + # @param retries [Integer] total number of attempts + # @param udp_socket_factory [#call] callable returning a UDP-like socket. + # Default uses stdlib `UDPSocket.new`. Metasploit callers can inject + # `Rex::Socket::Udp.create`-based factories to pivot over a session. + # @return [Array, nil] the name table, or nil on timeout/parse failure + def self.query(host, port: NBNS_PORT, timeout: DEFAULT_TIMEOUT, + retries: DEFAULT_RETRIES, + udp_socket_factory: -> { UDPSocket.new }) + request = NodeStatusRequest.new(transaction_id: rand(0xFFFF)) + request.question_name.set('*'.ljust(16, "\x00")) + bytes = request.to_binary_s + + sock = udp_socket_factory.call + begin + retries.times do + sock.send(bytes, 0, host, port) + next unless IO.select([sock], nil, nil, timeout) + + data, = sock.recvfrom(4096) + next if data.nil? || data.empty? + + response = NodeStatusResponse.read(data) + return entries_from(response) + end + nil + rescue IOError, EOFError + nil + ensure + sock.close if sock.respond_to?(:close) + end + end + + # Return the unique file-server name (suffix 0x20) from a host, or nil + # if the name table doesn't contain one. Convenience helper for the + # common case of "give me this host's file-server name." + # + # @param host [String] target IP address + # @param kwargs [Hash] forwarded to {.query} + # @return [String, nil] + def self.file_server_name(host, **kwargs) + entries = query(host, **kwargs) or return nil + entry = entries.find { |e| e.suffix == 0x20 && e.unique? } + entry&.name + end + + # @!visibility private + def self.entries_from(response) + response.node_names.map do |n| + Entry.new( + n.netbios_name.to_s.rstrip, + n.suffix.to_i, + n.group?, + n.active? + ) + end + end + end + end +end diff --git a/spec/lib/ruby_smb/nbss/node_status_spec.rb b/spec/lib/ruby_smb/nbss/node_status_spec.rb new file mode 100644 index 000000000..ee66489ce --- /dev/null +++ b/spec/lib/ruby_smb/nbss/node_status_spec.rb @@ -0,0 +1,110 @@ +require 'spec_helper' + +RSpec.describe RubySMB::Nbss::NodeStatus do + let(:udp_sock) { double('UDPSocket') } + let(:factory) { -> { udp_sock } } + + def build_response(names) + data = ''.b + data << [0x1234].pack('n') # transaction_id + data << [0x8400].pack('n') # flags + data << [0].pack('n') << [1].pack('n') # qdcount, ancount + data << [0].pack('n') << [0].pack('n') # nscount, arcount + data << [0x20].pack('C') << ('A' * 32) << "\x00".b # owner name + data << [0x0021].pack('n') << [0x0001].pack('n') + data << [0].pack('N') # TTL + data << [1 + names.length * 18 + 46].pack('n') + data << [names.length].pack('C') + names.each do |name, suffix, flags| + data << name.to_s.ljust(15, ' ') << [suffix].pack('C') << [flags].pack('n') + end + data << ("\x00".b * 46) + data + end + + describe '.query' do + it 'sends a Node Status request and returns the parsed name table' do + response_bytes = build_response([ + ['WIN95', 0x00, 0x0400], + ['WIN95', 0x20, 0x0400], + ['WORKGROUP', 0x00, 0x8400] + ]) + + expect(udp_sock).to receive(:send) do |bytes, flags, host, port| + expect(flags).to eq(0) + expect(host).to eq('10.0.0.2') + expect(port).to eq(137) + expect(bytes.bytesize).to eq(50) + end + expect(IO).to receive(:select).and_return([udp_sock]) + expect(udp_sock).to receive(:recvfrom).and_return([response_bytes, nil]) + expect(udp_sock).to receive(:close) + + entries = described_class.query('10.0.0.2', udp_socket_factory: factory) + expect(entries.size).to eq(3) + expect(entries[1].name).to eq('WIN95') + expect(entries[1].suffix).to eq(0x20) + expect(entries[1].unique?).to be true + expect(entries[2].group).to be true + end + + it 'retries up to the configured limit before giving up' do + call_count = 0 + allow(udp_sock).to receive(:send) { call_count += 1 } + allow(IO).to receive(:select).and_return(nil) # always time out + allow(udp_sock).to receive(:close) + + expect(described_class.query('10.0.0.2', retries: 4, timeout: 0.01, udp_socket_factory: factory)).to be_nil + expect(call_count).to eq(4) + end + + it 'returns nil when the response can not be parsed' do + expect(udp_sock).to receive(:send) + expect(IO).to receive(:select).and_return([udp_sock]) + expect(udp_sock).to receive(:recvfrom).and_return(["\xff\xff".b, nil]) + expect(udp_sock).to receive(:close) + expect(described_class.query('10.0.0.2', retries: 1, timeout: 0.01, udp_socket_factory: factory)).to be_nil + end + + it 'closes the socket even on exception' do + allow(udp_sock).to receive(:send).and_raise(IOError, 'boom') + expect(udp_sock).to receive(:close) + expect(described_class.query('10.0.0.2', retries: 1, udp_socket_factory: factory)).to be_nil + end + end + + describe '.file_server_name' do + it 'returns the unique 0x20 entry' do + response_bytes = build_response([ + ['WORKGROUP', 0x00, 0x8400], + ['FILESERVER', 0x20, 0x0400] + ]) + allow(udp_sock).to receive(:send) + allow(IO).to receive(:select).and_return([udp_sock]) + allow(udp_sock).to receive(:recvfrom).and_return([response_bytes, nil]) + allow(udp_sock).to receive(:close) + + expect(described_class.file_server_name('10.0.0.2', udp_socket_factory: factory)).to eq('FILESERVER') + end + + it 'returns nil when no unique 0x20 entry is present' do + response_bytes = build_response([['HOST', 0x00, 0x0400]]) + allow(udp_sock).to receive(:send) + allow(IO).to receive(:select).and_return([udp_sock]) + allow(udp_sock).to receive(:recvfrom).and_return([response_bytes, nil]) + allow(udp_sock).to receive(:close) + + expect(described_class.file_server_name('10.0.0.2', udp_socket_factory: factory)).to be_nil + end + end + + describe RubySMB::Nbss::NodeStatus::Entry do + it '#to_s formats like nmblookup output' do + entry = described_class.new('WIN95', 0x20, false, true) + expect(entry.to_s).to include('WIN95') + expect(entry.to_s).to include('<20>') + expect(entry.to_s).to include('UNIQUE') + expect(entry.to_s).to include('ACTIVE') + end + end +end From 3d335fe76199fffe2443c1aabafdf779b6764d47 Mon Sep 17 00:00:00 2001 From: Z6543 Date: Thu, 23 Apr 2026 11:42:46 +0000 Subject: [PATCH 06/14] Use sendto when the UDP socket provides it Rex::Socket::Udp inherits `send(mesg, flags, [sockaddr])` from Socket, not stdlib UDPSocket's 4-arg variant, so calling `send(bytes, 0, host, port)` on it raises "wrong number of arguments". Route through `sendto(mesg, host, port)` when the socket exposes it; otherwise fall back to stdlib's 4-arg `send`. --- lib/ruby_smb/nbss/node_status.rb | 16 +++++++++++++++- spec/lib/ruby_smb/nbss/node_status_spec.rb | 19 ++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/lib/ruby_smb/nbss/node_status.rb b/lib/ruby_smb/nbss/node_status.rb index 5855951ec..f48dccc28 100644 --- a/lib/ruby_smb/nbss/node_status.rb +++ b/lib/ruby_smb/nbss/node_status.rb @@ -56,7 +56,7 @@ def self.query(host, port: NBNS_PORT, timeout: DEFAULT_TIMEOUT, sock = udp_socket_factory.call begin retries.times do - sock.send(bytes, 0, host, port) + send_datagram(sock, bytes, host, port) next unless IO.select([sock], nil, nil, timeout) data, = sock.recvfrom(4096) @@ -97,6 +97,20 @@ def self.entries_from(response) ) end end + + # Send `bytes` to `host:port` over `sock`. stdlib `UDPSocket#send` + # takes (mesg, flags, host, port); Rex::Socket::Udp's socket inherits + # `send(mesg, flags, [sockaddr])` from Socket and exposes the 4-arg + # form as `sendto(mesg, host, port)`. Prefer `sendto` when available. + # + # @!visibility private + def self.send_datagram(sock, bytes, host, port) + if sock.respond_to?(:sendto) + sock.sendto(bytes, host, port) + else + sock.send(bytes, 0, host, port) + end + end end end end diff --git a/spec/lib/ruby_smb/nbss/node_status_spec.rb b/spec/lib/ruby_smb/nbss/node_status_spec.rb index ee66489ce..399314a17 100644 --- a/spec/lib/ruby_smb/nbss/node_status_spec.rb +++ b/spec/lib/ruby_smb/nbss/node_status_spec.rb @@ -23,13 +23,15 @@ def build_response(names) end describe '.query' do - it 'sends a Node Status request and returns the parsed name table' do + it 'uses stdlib UDPSocket#send(mesg, flags, host, port) when sendto is not available' do response_bytes = build_response([ ['WIN95', 0x00, 0x0400], ['WIN95', 0x20, 0x0400], ['WORKGROUP', 0x00, 0x8400] ]) + # Pure test double doesn't respond to :sendto unless we stub it, so + # NodeStatus.query falls through to the stdlib 4-arg #send path. expect(udp_sock).to receive(:send) do |bytes, flags, host, port| expect(flags).to eq(0) expect(host).to eq('10.0.0.2') @@ -48,6 +50,21 @@ def build_response(names) expect(entries[2].group).to be true end + it 'uses sendto(mesg, host, port) when the socket provides it (Rex::Socket::Udp style)' do + response_bytes = build_response([['WIN95', 0x20, 0x0400]]) + expect(udp_sock).to receive(:sendto) do |bytes, host, port| + expect(host).to eq('10.0.0.2') + expect(port).to eq(137) + expect(bytes.bytesize).to eq(50) + end + allow(IO).to receive(:select).and_return([udp_sock]) + allow(udp_sock).to receive(:recvfrom).and_return([response_bytes, nil]) + allow(udp_sock).to receive(:close) + + entries = described_class.query('10.0.0.2', udp_socket_factory: factory) + expect(entries.first.name).to eq('WIN95') + end + it 'retries up to the configured limit before giving up' do call_count = 0 allow(udp_sock).to receive(:send) { call_count += 1 } From ef7e0a3a1ab38f3032b6726d95f9799bbc814d8c Mon Sep 17 00:00:00 2001 From: Z6543 Date: Thu, 23 Apr 2026 11:48:21 +0000 Subject: [PATCH 07/14] Use native recv-with-timeout on Rex::Socket::Udp IO.select([sock]) can miss wakeups on a Rex::Socket::Udp because Rex's own timed_read selects on the underlying fd rather than on self. Call sock.recvfrom(length, timeout) directly when the socket provides the sendto/recvfrom(..., timeout) pair, and only use IO.select for stdlib UDPSocket which has no per-call receive timeout. Fixes NBNS node-status query silently timing out when the socket factory returns a Rex::Socket::Udp (Metasploit module pivot path). --- lib/ruby_smb/nbss/node_status.rb | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/ruby_smb/nbss/node_status.rb b/lib/ruby_smb/nbss/node_status.rb index f48dccc28..3ddbfb4ea 100644 --- a/lib/ruby_smb/nbss/node_status.rb +++ b/lib/ruby_smb/nbss/node_status.rb @@ -57,9 +57,7 @@ def self.query(host, port: NBNS_PORT, timeout: DEFAULT_TIMEOUT, begin retries.times do send_datagram(sock, bytes, host, port) - next unless IO.select([sock], nil, nil, timeout) - - data, = sock.recvfrom(4096) + data = recv_datagram(sock, 4096, timeout) next if data.nil? || data.empty? response = NodeStatusResponse.read(data) @@ -111,6 +109,24 @@ def self.send_datagram(sock, bytes, host, port) sock.send(bytes, 0, host, port) end end + + # Read a datagram from `sock` with a timeout, picking the pattern + # appropriate for the socket. Rex::Socket::Udp#recvfrom takes a + # built-in timeout as the 2nd argument and selects on its internal + # fd (IO.select([sock]) can miss wakeups on wrapped sockets). stdlib + # UDPSocket#recvfrom has no timeout, so wrap it in IO.select. + # + # @!visibility private + def self.recv_datagram(sock, length, timeout) + if sock.respond_to?(:sendto) + data, = sock.recvfrom(length, timeout) + data + else + return nil unless IO.select([sock], nil, nil, timeout) + data, = sock.recvfrom(length) + data + end + end end end end From 47a1bd20c4f639bb11d09debc84933f507f6eb77 Mon Sep 17 00:00:00 2001 From: Z6543 Date: Thu, 23 Apr 2026 12:05:39 +0000 Subject: [PATCH 08/14] Bind NBNS socket to local port 137 so Win9x replies are deliverable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows 9x ignores the client source port on NBNS and always sends the Node Status response to destination port 137. On an ephemeral-port socket the kernel drops the reply, so the query appears to time out even though the server is answering (verified via tcpdump: the 229-byte reply goes to :137, not to our ephemeral port). Try to bind(0.0.0.0:137) on the local UDP socket before sending, same technique Samba's nmblookup uses. Silently fall back to the ephemeral bind when we lack the privilege (EACCES) or the port is already held by another listener (EADDRINUSE) — the query will still succeed against well-behaved NBNS servers that honor the request's source port. --- lib/ruby_smb/nbss/node_status.rb | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/lib/ruby_smb/nbss/node_status.rb b/lib/ruby_smb/nbss/node_status.rb index 3ddbfb4ea..b66b70afc 100644 --- a/lib/ruby_smb/nbss/node_status.rb +++ b/lib/ruby_smb/nbss/node_status.rb @@ -55,6 +55,16 @@ def self.query(host, port: NBNS_PORT, timeout: DEFAULT_TIMEOUT, sock = udp_socket_factory.call begin + # Windows 9x ignores the client's source port and replies to + # destination port 137 (RFC 1002's default), so the kernel will + # drop responses on an ephemeral-port socket. Try to bind locally + # to 137 — same trick Samba's nmblookup uses. Bind may fail when + # we don't have CAP_NET_BIND_SERVICE / root, or when another + # process already holds the port; either way we fall through and + # keep the ephemeral port, which still works against servers + # that honor the request's source port. + bind_local(sock, port) + retries.times do send_datagram(sock, bytes, host, port) data = recv_datagram(sock, 4096, timeout) @@ -96,6 +106,20 @@ def self.entries_from(response) end end + # Best-effort bind of a UDP socket's local port to `port` (default + # 137). Required for Win9x NBNS replies, which ignore the client's + # source port and always answer to destination port 137. Silently + # swallows EACCES (unprivileged) and EADDRINUSE (another listener) + # so the caller keeps the ephemeral bind. + # + # @!visibility private + def self.bind_local(sock, port) + return unless sock.respond_to?(:bind) + sock.bind('0.0.0.0', port) + rescue Errno::EACCES, Errno::EADDRINUSE, SystemCallError + # keep whatever source port the factory already assigned + end + # Send `bytes` to `host:port` over `sock`. stdlib `UDPSocket#send` # takes (mesg, flags, host, port); Rex::Socket::Udp's socket inherits # `send(mesg, flags, [sockaddr])` from Socket and exposes the 4-arg From 091893ec5c82ffede7abebd0584161ceedd50baf Mon Sep 17 00:00:00 2001 From: Z6543 Date: Thu, 23 Apr 2026 12:13:47 +0000 Subject: [PATCH 09/14] Skip 2-arg bind() on Rex::Socket::Udp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rex::Socket::Udp's bind takes a single sockaddr string, so calling sock.bind('0.0.0.0', 137) on it raised ArgumentError before we got to the actual query. Rex sockets bind their local endpoint at create time via 'LocalHost'/'LocalPort' instead — skip the post-create bind path entirely for anything that exposes sendto (the Rex-style API marker we were already using). Also widen the rescue to swallow ArgumentError in case another socket type surfaces a similarly incompatible bind signature. --- lib/ruby_smb/nbss/node_status.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/ruby_smb/nbss/node_status.rb b/lib/ruby_smb/nbss/node_status.rb index b66b70afc..3bc446101 100644 --- a/lib/ruby_smb/nbss/node_status.rb +++ b/lib/ruby_smb/nbss/node_status.rb @@ -112,11 +112,17 @@ def self.entries_from(response) # swallows EACCES (unprivileged) and EADDRINUSE (another listener) # so the caller keeps the ephemeral bind. # + # Only attempts this on stdlib-style sockets whose `bind` accepts + # `(host, port)`. Rex::Socket::Udp binds its local endpoint at + # create time via `'LocalHost'` / `'LocalPort'`; for those, the + # caller's factory is responsible for requesting port 137. + # # @!visibility private def self.bind_local(sock, port) + return if sock.respond_to?(:sendto) # Rex::Socket::Udp — see note above return unless sock.respond_to?(:bind) sock.bind('0.0.0.0', port) - rescue Errno::EACCES, Errno::EADDRINUSE, SystemCallError + rescue ArgumentError, Errno::EACCES, Errno::EADDRINUSE, SystemCallError # keep whatever source port the factory already assigned end From c1a8ca356940c823d732bf4872ae8d37758ad872 Mon Sep 17 00:00:00 2001 From: Z6543 Date: Thu, 23 Apr 2026 12:22:42 +0000 Subject: [PATCH 10/14] Revert: don't force-bind NBNS socket to local port 137 Earlier change assumed Win9x replies are delivered only when the client is bound to local port 137. In practice the response is received fine on an ephemeral-port socket (confirmed by tcpdump + nmblookup on the reporter's host), so the bind trick adds complexity without buying us anything and breaks on Rex::Socket::Udp whose bind signature differs. Keep node_status.rb lean: send, wait with a timeout appropriate to the socket type, parse. Fall back to `set SMBName ...` when a target still doesn't answer. --- lib/ruby_smb/nbss/node_status.rb | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/lib/ruby_smb/nbss/node_status.rb b/lib/ruby_smb/nbss/node_status.rb index 3bc446101..3ddbfb4ea 100644 --- a/lib/ruby_smb/nbss/node_status.rb +++ b/lib/ruby_smb/nbss/node_status.rb @@ -55,16 +55,6 @@ def self.query(host, port: NBNS_PORT, timeout: DEFAULT_TIMEOUT, sock = udp_socket_factory.call begin - # Windows 9x ignores the client's source port and replies to - # destination port 137 (RFC 1002's default), so the kernel will - # drop responses on an ephemeral-port socket. Try to bind locally - # to 137 — same trick Samba's nmblookup uses. Bind may fail when - # we don't have CAP_NET_BIND_SERVICE / root, or when another - # process already holds the port; either way we fall through and - # keep the ephemeral port, which still works against servers - # that honor the request's source port. - bind_local(sock, port) - retries.times do send_datagram(sock, bytes, host, port) data = recv_datagram(sock, 4096, timeout) @@ -106,26 +96,6 @@ def self.entries_from(response) end end - # Best-effort bind of a UDP socket's local port to `port` (default - # 137). Required for Win9x NBNS replies, which ignore the client's - # source port and always answer to destination port 137. Silently - # swallows EACCES (unprivileged) and EADDRINUSE (another listener) - # so the caller keeps the ephemeral bind. - # - # Only attempts this on stdlib-style sockets whose `bind` accepts - # `(host, port)`. Rex::Socket::Udp binds its local endpoint at - # create time via `'LocalHost'` / `'LocalPort'`; for those, the - # caller's factory is responsible for requesting port 137. - # - # @!visibility private - def self.bind_local(sock, port) - return if sock.respond_to?(:sendto) # Rex::Socket::Udp — see note above - return unless sock.respond_to?(:bind) - sock.bind('0.0.0.0', port) - rescue ArgumentError, Errno::EACCES, Errno::EADDRINUSE, SystemCallError - # keep whatever source port the factory already assigned - end - # Send `bytes` to `host:port` over `sock`. stdlib `UDPSocket#send` # takes (mesg, flags, host, port); Rex::Socket::Udp's socket inherits # `send(mesg, flags, [sockaddr])` from Socket and exposes the 4-arg From 075c1579a6d23ed34bcf52d6f0eb331443750d84 Mon Sep 17 00:00:00 2001 From: Z6543 Date: Thu, 23 Apr 2026 12:32:12 +0000 Subject: [PATCH 11/14] Re-add local bind to UDP/137 for Win9x NBNS replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverting the previous revert — Win9x NBNS does reply to destination port 137 regardless of the client source port, so the kernel drops the answer on an ephemeral-port socket. Bind locally to 137 before sending, same technique Samba's nmblookup uses (which works without root when the binary has CAP_NET_BIND_SERVICE or the system has net.ipv4.ip_unprivileged_port_start lowered). Skips the 2-arg bind on Rex::Socket::Udp (its bind signature differs); Rex callers bind via 'LocalPort' at create time. On EACCES/EADDRINUSE the bind silently fails and the caller keeps its ephemeral port — this still works against well-behaved NBNS servers that honor the request's source port. --- lib/ruby_smb/nbss/node_status.rb | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/lib/ruby_smb/nbss/node_status.rb b/lib/ruby_smb/nbss/node_status.rb index 3ddbfb4ea..f23cc7294 100644 --- a/lib/ruby_smb/nbss/node_status.rb +++ b/lib/ruby_smb/nbss/node_status.rb @@ -55,6 +55,18 @@ def self.query(host, port: NBNS_PORT, timeout: DEFAULT_TIMEOUT, sock = udp_socket_factory.call begin + # Windows 9x ignores the client's source port and always sends + # the Node Status response to destination port 137. On an + # ephemeral-port socket the kernel drops the reply. Try to bind + # locally to the NBNS port (same trick Samba's nmblookup uses) + # — succeeds without root if the binary/interpreter has + # CAP_NET_BIND_SERVICE or the system has + # net.ipv4.ip_unprivileged_port_start set below 137. Falls + # through to whatever the factory gave us on failure, which + # still works against well-behaved NBNS servers that honor the + # request's source port. + bind_local(sock, port) + retries.times do send_datagram(sock, bytes, host, port) data = recv_datagram(sock, 4096, timeout) @@ -96,6 +108,22 @@ def self.entries_from(response) end end + # Best-effort bind of the local UDP endpoint to `port` (default 137). + # Required for Win9x NBNS replies — they're sent to destination port + # 137 regardless of client source port. Skipped for Rex::Socket::Udp + # (its `bind` signature differs; the Rex factory sets `LocalPort` + # at create time instead). On EACCES/EADDRINUSE keeps the ephemeral + # bind the factory already assigned. + # + # @!visibility private + def self.bind_local(sock, port) + return if sock.respond_to?(:sendto) # Rex::Socket::Udp + return unless sock.respond_to?(:bind) + sock.bind('0.0.0.0', port) + rescue ArgumentError, Errno::EACCES, Errno::EADDRINUSE, SystemCallError + # keep whatever source port the factory already assigned + end + # Send `bytes` to `host:port` over `sock`. stdlib `UDPSocket#send` # takes (mesg, flags, host, port); Rex::Socket::Udp's socket inherits # `send(mesg, flags, [sockaddr])` from Socket and exposes the 4-arg From ef3824cdac070035bbf46b089fad82cd0ea15f35 Mon Sep 17 00:00:00 2001 From: Z6543 Date: Sat, 9 May 2026 12:52:34 +0000 Subject: [PATCH 12/14] Restore NodeStatusRequest/Response BinData structs and specs These files were dropped during the rebase onto upstream/master because the commit that introduced them (1845dac) was detected as already reachable from upstream, but 09c848c had reverted the actual files. --- lib/ruby_smb/nbss/node_status_request.rb | 29 ++++++++ lib/ruby_smb/nbss/node_status_response.rb | 66 +++++++++++++++++++ .../ruby_smb/nbss/node_status_request_spec.rb | 37 +++++++++++ .../nbss/node_status_response_spec.rb | 62 +++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 lib/ruby_smb/nbss/node_status_request.rb create mode 100644 lib/ruby_smb/nbss/node_status_response.rb create mode 100644 spec/lib/ruby_smb/nbss/node_status_request_spec.rb create mode 100644 spec/lib/ruby_smb/nbss/node_status_response_spec.rb diff --git a/lib/ruby_smb/nbss/node_status_request.rb b/lib/ruby_smb/nbss/node_status_request.rb new file mode 100644 index 000000000..ffecc9d07 --- /dev/null +++ b/lib/ruby_smb/nbss/node_status_request.rb @@ -0,0 +1,29 @@ +module RubySMB + module Nbss + # NetBIOS Name Service (NBNS) Node Status Request packet, as defined in + # [RFC 1002 4.2.17](https://tools.ietf.org/html/rfc1002#section-4.2.17). + # Sent over UDP to port 137 to retrieve a host's NetBIOS name table. + class NodeStatusRequest < BinData::Record + # NBSTAT question type, RFC 1002 4.2.1.3. + QUESTION_TYPE_NBSTAT = 0x0021 + # Internet class. + QUESTION_CLASS_IN = 0x0001 + + endian :big + + # 12-byte NBNS header (RFC 1002 4.2.1.1 and 4.2.1.2). + uint16 :transaction_id, label: 'Transaction ID' + uint16 :flags, label: 'Flags', initial_value: 0x0000 + uint16 :qdcount, label: 'QDCount', initial_value: 1 + uint16 :ancount, label: 'ANCount', initial_value: 0 + uint16 :nscount, label: 'NSCount', initial_value: 0 + uint16 :arcount, label: 'ARCount', initial_value: 0 + + # Question section. For a node status query this is always the wildcard + # NetBIOS name (16 bytes of 0x2A / 0x00), L1-encoded. + netbios_name :question_name, label: 'Question Name' + uint16 :question_type, label: 'Question Type', initial_value: QUESTION_TYPE_NBSTAT + uint16 :question_class, label: 'Question Class', initial_value: QUESTION_CLASS_IN + end + end +end diff --git a/lib/ruby_smb/nbss/node_status_response.rb b/lib/ruby_smb/nbss/node_status_response.rb new file mode 100644 index 000000000..c85cb6ae8 --- /dev/null +++ b/lib/ruby_smb/nbss/node_status_response.rb @@ -0,0 +1,66 @@ +module RubySMB + module Nbss + # Single entry in the NODE_NAME_ARRAY of a Node Status Response, + # as defined in [RFC 1002 4.2.18](https://tools.ietf.org/html/rfc1002#section-4.2.18). + # Fixed 18-byte layout (15-byte name, 1-byte suffix, 16-bit flags). + class NodeStatusName < BinData::Record + # NAME_FLAGS bits (RFC 1002 4.2.18). + GROUP_BIT = 0x8000 # 1 = group name, 0 = unique name + ACTIVE_BIT = 0x0400 # 1 = name registered + + endian :big + + string :netbios_name, label: 'NetBIOS Name', length: 15 + uint8 :suffix, label: 'Suffix' + uint16 :name_flags, label: 'Name Flags' + + def group? + (name_flags & GROUP_BIT) != 0 + end + + def unique? + !group? + end + + def active? + (name_flags & ACTIVE_BIT) != 0 + end + end + + # NetBIOS Name Service (NBNS) Node Status Response packet, as defined in + # [RFC 1002 4.2.18](https://tools.ietf.org/html/rfc1002#section-4.2.18). + # Received over UDP from port 137 in reply to a {NodeStatusRequest}. + # Does not decode the trailing STATISTICS field; callers only need the + # name table. + class NodeStatusResponse < BinData::Record + endian :big + + # 12-byte NBNS header. + uint16 :transaction_id, label: 'Transaction ID' + uint16 :flags, label: 'Flags' + uint16 :qdcount, label: 'QDCount' + uint16 :ancount, label: 'ANCount' + uint16 :nscount, label: 'NSCount' + uint16 :arcount, label: 'ARCount' + + # Answer section. Microsoft's implementation omits the question-echo, + # so the owner name appears directly after the header. + netbios_name :owner_name, label: 'Owner Name' + uint16 :rr_type, label: 'RR Type' + uint16 :rr_class, label: 'RR Class' + uint32 :ttl, label: 'TTL' + uint16 :rdlength, label: 'RDLENGTH' + + # RDATA begins here. NODE_NAME_ARRAY is preceded by an 8-bit count. + uint8 :num_names, label: 'Number of Names' + array :node_names, type: :node_status_name, initial_length: :num_names + + # Returns the unique (non-group) file-server name (suffix 0x20) if one + # is present in the name table, else nil. + def file_server_name + entry = node_names.find { |n| n.suffix == 0x20 && n.unique? } + entry&.netbios_name&.to_s&.rstrip + end + end + end +end diff --git a/spec/lib/ruby_smb/nbss/node_status_request_spec.rb b/spec/lib/ruby_smb/nbss/node_status_request_spec.rb new file mode 100644 index 000000000..6e200d029 --- /dev/null +++ b/spec/lib/ruby_smb/nbss/node_status_request_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +RSpec.describe RubySMB::Nbss::NodeStatusRequest do + subject(:request) { described_class.new(transaction_id: 0x1234) } + + before :example do + request.question_name.set("*".ljust(16, "\x00")) + end + + describe 'encoded bytes' do + let(:bytes) { request.to_binary_s } + + it 'starts with a 12-byte NBNS header' do + expect(bytes[0, 2].unpack1('n')).to eq(0x1234) + expect(bytes[2, 2].unpack1('n')).to eq(0x0000) # flags + expect(bytes[4, 2].unpack1('n')).to eq(1) # qdcount + expect(bytes[6, 2].unpack1('n')).to eq(0) # ancount + expect(bytes[8, 2].unpack1('n')).to eq(0) # nscount + expect(bytes[10, 2].unpack1('n')).to eq(0) # arcount + end + + it 'encodes the wildcard question name as 34 bytes (length + 32-char L1 + null)' do + expect(bytes[12].unpack1('C')).to eq(0x20) # label length + expect(bytes[13, 32]).to eq('CKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') + expect(bytes[45].unpack1('C')).to eq(0x00) # null label terminator + end + + it 'ends with QTYPE=NBSTAT and QCLASS=IN' do + expect(bytes[46, 2].unpack1('n')).to eq(described_class::QUESTION_TYPE_NBSTAT) + expect(bytes[48, 2].unpack1('n')).to eq(described_class::QUESTION_CLASS_IN) + end + + it 'is exactly 50 bytes long' do + expect(bytes.bytesize).to eq(50) + end + end +end diff --git a/spec/lib/ruby_smb/nbss/node_status_response_spec.rb b/spec/lib/ruby_smb/nbss/node_status_response_spec.rb new file mode 100644 index 000000000..32e92b4bc --- /dev/null +++ b/spec/lib/ruby_smb/nbss/node_status_response_spec.rb @@ -0,0 +1,62 @@ +require 'spec_helper' + +RSpec.describe RubySMB::Nbss::NodeStatusResponse do + def build_response(names) + data = ''.b + data << [0x1234].pack('n') # transaction_id + data << [0x8400].pack('n') # flags: response, authoritative + data << [0].pack('n') # qdcount + data << [1].pack('n') # ancount + data << [0].pack('n') << [0].pack('n') # nscount, arcount + data << [0x20].pack('C') << ('A' * 32) << "\x00".b # owner name L1 + data << [0x0021].pack('n') # RR type NBSTAT + data << [0x0001].pack('n') # RR class IN + data << [0].pack('N') # TTL + data << [1 + names.length * 18 + 46].pack('n') # rdlength + data << [names.length].pack('C') + names.each do |name, suffix, flags| + data << name.to_s.ljust(15, ' ') << [suffix].pack('C') << [flags].pack('n') + end + data << ("\x00".b * 46) # statistics (unused) + data + end + + describe 'parsing' do + it 'decodes the name table' do + response = described_class.read(build_response([ + ['WIN95', 0x00, 0x0400], + ['WIN95', 0x20, 0x0400], + ['WORKGROUP', 0x00, 0x8400] + ])) + expect(response.num_names).to eq(3) + expect(response.node_names[0].netbios_name.to_s.rstrip).to eq('WIN95') + expect(response.node_names[0].suffix).to eq(0x00) + expect(response.node_names[1].suffix).to eq(0x20) + expect(response.node_names[2].group?).to be true + end + end + + describe '#file_server_name' do + it 'returns the name with suffix 0x20 and the unique bit clear' do + response = described_class.read(build_response([ + ['FILESERVER', 0x20, 0x0400], + ['WORKGROUP', 0x00, 0x8400] + ])) + expect(response.file_server_name).to eq('FILESERVER') + end + + it 'ignores group names even when the suffix matches' do + response = described_class.read(build_response([ + ['OTHER', 0x20, 0x8400] # group bit set — should be skipped + ])) + expect(response.file_server_name).to be_nil + end + + it 'returns nil when no file-server name is present' do + response = described_class.read(build_response([ + ['HOST', 0x00, 0x0400] + ])) + expect(response.file_server_name).to be_nil + end + end +end From 728a17c1187d63f394c47be2acae6530d3eaa518 Mon Sep 17 00:00:00 2001 From: Z6543 Date: Sat, 9 May 2026 13:30:51 +0000 Subject: [PATCH 13/14] Add raw-socket fallback for Win9x NBNS replies Win9x always sends the Node Status response to destination port 137, so the reply is dropped when the client socket is on an ephemeral port. Binding to 137 fixes it but fails with EACCES unless the process has CAP_NET_BIND_SERVICE. A raw IPPROTO_UDP socket receives a copy of every incoming IP datagram regardless of destination port, so it works without an exclusive bind. query_via_raw_socket uses this approach when the caller has CAP_NET_RAW; falls back gracefully on EPERM/EACCES. --- lib/ruby_smb/nbss/node_status.rb | 67 ++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/lib/ruby_smb/nbss/node_status.rb b/lib/ruby_smb/nbss/node_status.rb index f23cc7294..90fc38506 100644 --- a/lib/ruby_smb/nbss/node_status.rb +++ b/lib/ruby_smb/nbss/node_status.rb @@ -108,6 +108,73 @@ def self.entries_from(response) end end + # Query a host for its NetBIOS name table using a raw IPPROTO_UDP socket. + # + # Unlike {.query}, this path does not require exclusive ownership of local + # UDP/137 — the kernel delivers a copy of every incoming IP datagram to a + # raw socket, so another process already bound to port 137 (e.g. nmbd) + # does not block reception. Requires root or CAP_NET_RAW. + # + # @param host [String] target IP address + # @param port [Integer] destination UDP port (default 137) + # @param timeout [Numeric] per-attempt receive timeout in seconds + # @param retries [Integer] total number of attempts + # @return [Array, nil] the name table, or nil on failure / no access + def self.query_via_raw_socket(host, port: NBNS_PORT, timeout: DEFAULT_TIMEOUT, retries: DEFAULT_RETRIES) + raw_sock = Socket.new(Socket::AF_INET, Socket::SOCK_RAW, Socket::IPPROTO_UDP) + udp_sock = UDPSocket.new + request = NodeStatusRequest.new(transaction_id: rand(0xFFFF)) + request.question_name.set('*'.ljust(16, "\x00")) + bytes = request.to_binary_s + + begin + retries.times do + udp_sock.send(bytes, 0, host, port) + data = recv_raw_udp(raw_sock, 65_535, timeout, from_host: host, from_port: port) + next if data.nil? || data.empty? + + response = NodeStatusResponse.read(data) + return entries_from(response) + end + nil + rescue IOError, EOFError + nil + ensure + raw_sock.close rescue nil # rubocop:disable Style/RescueModifier + udp_sock.close rescue nil # rubocop:disable Style/RescueModifier + end + rescue Errno::EPERM, Errno::EACCES, Errno::EPROTONOSUPPORT, SocketError + nil + end + + # Read one UDP payload from a raw IPPROTO_UDP socket, filtering by source + # host and port. Returns nil on timeout or if no matching packet arrives. + # @!visibility private + def self.recv_raw_udp(sock, max_len, timeout, from_host:, from_port:) + target_ip_n = from_host.split('.').map(&:to_i).pack('C4').unpack1('N') + deadline = Time.now + timeout + + loop do + remaining = deadline - Time.now + return nil if remaining <= 0 + return nil unless IO.select([sock], nil, nil, remaining) + + data = sock.recv(max_len) + next if data.bytesize < 28 # IP header (≥20 B) + UDP header (8 B) + + src_ip_n = data.byteslice(12, 4).unpack1('N') + next unless src_ip_n == target_ip_n + + ihl = (data.getbyte(0) & 0x0F) * 4 + next if data.bytesize < ihl + 8 + + src_port = data.byteslice(ihl, 2).unpack1('n') + next unless src_port == from_port + + return data.byteslice(ihl + 8, data.bytesize) + end + end + # Best-effort bind of the local UDP endpoint to `port` (default 137). # Required for Win9x NBNS replies — they're sent to destination port # 137 regardless of client source port. Skipped for Rex::Socket::Udp From 679da4dba6bb4256ff1b79527d5d0c6f8cc6fd76 Mon Sep 17 00:00:00 2001 From: Z6543 Date: Sat, 9 May 2026 17:17:45 +0000 Subject: [PATCH 14/14] Fall back to raw socket in NBNS auto-discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When nmbd owns UDP/137 on the attacker host it intercepts Win9x NBNS replies, causing netbios_lookup_udp to time out (3 retries × 2 s ≈ 6 s). That 6-second hang pushes NBSS session setup past Rex's ConnectTimeout, so the caller sees Rex::ConnectionTimeout instead of a clean NBSS error. Add netbios_lookup_raw_socket as a fallback: it calls NodeStatus.query_via_raw_socket which uses SOCK_RAW/IPPROTO_UDP and receives a kernel copy of every incoming UDP datagram regardless of which process owns port 137. netbios_lookup_name now chains both paths so the first one that returns a name wins. --- lib/ruby_smb/client.rb | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/ruby_smb/client.rb b/lib/ruby_smb/client.rb index 575dafe5d..21c212867 100644 --- a/lib/ruby_smb/client.rb +++ b/lib/ruby_smb/client.rb @@ -764,11 +764,14 @@ def send_session_request(name) # Resolves a host's NetBIOS name via a UDP Node Status query # (RFC 1002 4.2.17, port 137). Pure Ruby — no external binaries. + # Falls back to a raw IPPROTO_UDP socket when the normal UDP path + # fails (e.g. because nmbd already owns UDP/137 on this host and + # intercepts Win9x NBNS replies before they reach our socket). # # @param host [String] the IP address to query # @return [String, nil] the NetBIOS name, or nil if lookup fails def netbios_lookup_name(host) - netbios_lookup_udp(host) + netbios_lookup_udp(host) || netbios_lookup_raw_socket(host) end # Resolves a host's file-server NetBIOS name via a UDP Node Status @@ -783,6 +786,19 @@ def netbios_lookup_udp(host) ) end + # Resolves a host's file-server NetBIOS name via a raw IPPROTO_UDP + # socket. Requires root or CAP_NET_RAW; silently returns nil otherwise. + # + # @param host [String] the IP address to query + # @return [String, nil] the file server NetBIOS name, or nil on failure + def netbios_lookup_raw_socket(host) + entries = RubySMB::Nbss::NodeStatus.query_via_raw_socket(host) + return nil unless entries + + entry = entries.find { |e| e.suffix == 0x20 && e.unique? } + entry&.name + end + public