diff --git a/lib/ruby_smb/client.rb b/lib/ruby_smb/client.rb index 7313d3051..177c1ea68 100644 --- a/lib/ruby_smb/client.rb +++ b/lib/ruby_smb/client.rb @@ -350,7 +350,7 @@ 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 # SMB 3.x options # this merely initializes the default value for session encryption, it may be changed as necessary when a @@ -668,6 +668,14 @@ def wipe_state! # Requests a NetBIOS Session Service using the provided name. # + # On refusal the raised {RubySMB::Error::NetBiosSessionService} carries the + # numeric NBSS `error_code`. A `0x82` (CALLED_NAME_NOT_PRESENT) rejection of + # the default `'*SMBSERVER'` name means the server (e.g. Windows 9x) wants + # its real name: resolve it with {RubySMB::Nbss::NodeStatus.file_server_name}, + # reconnect (the server drops the connection after a negative response), and + # retry. Reconnecting is left to the caller so the new socket is routed the + # same way as the original (e.g. through a Metasploit pivot). + # # @param name [String] the NetBIOS name to request # @return [TrueClass] if session request is granted # @raise [RubySMB::Error::NetBiosSessionService] if session request is refused @@ -679,8 +687,11 @@ def session_request(name = '*SMBSERVER') begin 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}" + negative_session_response = RubySMB::Nbss::NegativeSessionResponse.read(raw_response) + raise RubySMB::Error::NetBiosSessionService.new( + "Session Request failed: #{negative_session_response.error_msg}", + error_code: negative_session_response.error_code.to_i + ) end rescue IOError raise RubySMB::Error::InvalidPacket, 'Not a NBSS packet' 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.rb b/lib/ruby_smb/nbss.rb index c43e26e14..b1383e6e0 100644 --- a/lib/ruby_smb/nbss.rb +++ b/lib/ruby_smb/nbss.rb @@ -9,9 +9,22 @@ module Nbss RETARGET_SESSION_RESPONSE = 0x84 SESSION_KEEP_ALIVE = 0x85 + # NBSS negative session response 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 + require 'ruby_smb/nbss/netbios_name' require 'ruby_smb/nbss/session_header' require 'ruby_smb/nbss/session_request' require 'ruby_smb/nbss/negative_session_response' + require 'ruby_smb/nbss/name_service_opcode' + require 'ruby_smb/nbss/name_service_header_flags' + require 'ruby_smb/nbss/name_service_result_code' + 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/name_service_header_flags.rb b/lib/ruby_smb/nbss/name_service_header_flags.rb new file mode 100644 index 000000000..6584abef7 --- /dev/null +++ b/lib/ruby_smb/nbss/name_service_header_flags.rb @@ -0,0 +1,17 @@ +module RubySMB + module Nbss + # The NM_FLAGS field of the NetBIOS Name Service header, as defined in + # RFC 1002 section 4.2.1.1. The surrounding OPCODE and RCODE fields are + # modelled separately by {NameServiceOpcode} and {NameServiceResultCode}. + class NameServiceHeaderFlags < BinData::Record + endian :big + + bit1 :authoritative_answer, label: 'Authoritative Answer', initial_value: 0 + bit1 :truncated, label: 'Truncated', initial_value: 0 + bit1 :recursion_desired, label: 'Recursion Desired', initial_value: 0 + bit1 :recursion_available, label: 'Recursion Available', initial_value: 0 + bit2 :reserved, label: 'Reserved', initial_value: 0 + bit1 :broadcast, label: 'Broadcast', initial_value: 0 + end + end +end diff --git a/lib/ruby_smb/nbss/name_service_opcode.rb b/lib/ruby_smb/nbss/name_service_opcode.rb new file mode 100644 index 000000000..38eb060f4 --- /dev/null +++ b/lib/ruby_smb/nbss/name_service_opcode.rb @@ -0,0 +1,12 @@ +module RubySMB + module Nbss + # The R (response) bit and 4-bit OPCODE that open the second word of the + # NetBIOS Name Service header, as defined in RFC 1002 section 4.2.1.1. + class NameServiceOpcode < BinData::Record + endian :big + + bit1 :response, label: 'Response', initial_value: 0 + bit4 :opcode, label: 'Opcode', initial_value: 0 + end + end +end diff --git a/lib/ruby_smb/nbss/name_service_result_code.rb b/lib/ruby_smb/nbss/name_service_result_code.rb new file mode 100644 index 000000000..3e7d2c6f9 --- /dev/null +++ b/lib/ruby_smb/nbss/name_service_result_code.rb @@ -0,0 +1,11 @@ +module RubySMB + module Nbss + # The 4-bit RCODE that closes the second word of the NetBIOS Name Service + # header, as defined in RFC 1002 section 4.2.1.1. + class NameServiceResultCode < BinData::Record + endian :big + + bit4 :rcode, label: 'Result Code', initial_value: 0 + end + end +end diff --git a/lib/ruby_smb/nbss/negative_session_response.rb b/lib/ruby_smb/nbss/negative_session_response.rb index 07fa9bfc8..85e65e709 100644 --- a/lib/ruby_smb/nbss/negative_session_response.rb +++ b/lib/ruby_smb/nbss/negative_session_response.rb @@ -12,15 +12,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/lib/ruby_smb/nbss/node_status.rb b/lib/ruby_smb/nbss/node_status.rb new file mode 100644 index 000000000..833ba5d6a --- /dev/null +++ b/lib/ruby_smb/nbss/node_status.rb @@ -0,0 +1,136 @@ +require 'socket' +require 'ipaddr' +require 'securerandom' + +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 [rw] name [String] the NetBIOS name (trimmed) + # @!attribute [rw] suffix [Integer] 1-byte NetBIOS suffix + # @!attribute [rw] group [Boolean] true for a group name, false for unique + # @!attribute [rw] 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. + # + # NBNS is IPv4-only and this is a unicast query, so `host` must be a + # numeric IPv4 address (e.g. `10.0.0.1`). A hostname is rejected because + # replies are matched against the numeric peer address returned by + # `recvfrom`, which a hostname would never equal. + # + # @param host [String] target IPv4 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 [UDPSocket, Rex::Socket::Udp] caller-owned UDP socket. + # The caller is responsible for binding and closing it. It must + # implement `#send(mesg, flags, host, port)` and `#recvfrom(maxlen)`, + # and leave `do_not_reverse_lookup` at its default so `recvfrom` does + # not perform a reverse DNS lookup per datagram. + # @return [Array, nil] the name table, or nil on timeout/parse failure + # @raise [ArgumentError] if `host` is not a numeric IPv4 address + def self.query(host, port: NBNS_PORT, timeout: DEFAULT_TIMEOUT, + retries: DEFAULT_RETRIES, udp_socket:) + expected_address = begin + IPAddr.new(host).native + rescue IPAddr::InvalidAddressError + raise ArgumentError, "host must be an IPv4 address, got #{host.inspect}" + end + raise ArgumentError, "NBNS is IPv4-only, got #{host.inspect}" unless expected_address.ipv4? + + request = NodeStatusRequest.new(transaction_id: SecureRandom.random_number(0x10000)) + bytes = request.to_binary_s + + retries.times do + begin + udp_socket.send(bytes, 0, host, port) + next unless IO.select([udp_socket], nil, nil, timeout) + + data, addr = udp_socket.recvfrom(4096) + next if data.nil? || data.empty? + next unless source_matches?(addr, expected_address) + + response = NodeStatusResponse.read(data) + # Reject anything that isn't the response to our own query. + next unless response.transaction_id.to_i == request.transaction_id.to_i + next unless response.opcode.response.to_i == 1 + next unless response.rr_type.to_i == NodeStatusRequest::QUESTION_TYPE_NBSTAT + + return entries_from(response) + rescue IOError, EOFError, SystemCallError + next + end + end + nil + 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 IPv4 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 + + 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 + private_class_method :entries_from + + # Accept a reply only when it comes from the queried address. `recvfrom` + # returns the numeric peer address, which is compared against the + # normalized target. A nil/unknown source (e.g. a mock socket) is allowed. + def self.source_matches?(addr, expected_address) + source = addr.is_a?(Array) ? addr[3] : nil + return true if source.nil? + + source_address = begin + IPAddr.new(source).native + rescue IPAddr::InvalidAddressError + return false + end + source_address == expected_address + end + private_class_method :source_matches? + end + end +end 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..414720850 --- /dev/null +++ b/lib/ruby_smb/nbss/node_status_request.rb @@ -0,0 +1,38 @@ +require 'ruby_smb/nbss/name_service_opcode' +require 'ruby_smb/nbss/name_service_header_flags' +require 'ruby_smb/nbss/name_service_result_code' + +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 + # RFC 1002 4.2.17: a node status query always asks for the wildcard name, + # 16 bytes of 0x2A ('*') padded with 0x00. + WILDCARD_NAME = '*'.ljust(16, "\x00").freeze + + endian :big + + # 12-byte NBNS header (RFC 1002 4.2.1.1 and 4.2.1.2). + uint16 :transaction_id, label: 'Transaction ID' + name_service_opcode :opcode, label: 'Opcode' + name_service_header_flags :nm_flags, label: 'Flags' + name_service_result_code :rcode, label: 'Result Code' + 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, L1-encoded. + netbios_name :question_name, label: 'Question Name', initial_value: WILDCARD_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..8eacfced5 --- /dev/null +++ b/lib/ruby_smb/nbss/node_status_response.rb @@ -0,0 +1,86 @@ +require 'ruby_smb/nbss/name_service_opcode' +require 'ruby_smb/nbss/name_service_header_flags' +require 'ruby_smb/nbss/name_service_result_code' + +module RubySMB + module Nbss + # The NAME_FLAGS field for each Node Status Response name entry. + class NodeStatusNameFlags < BinData::Record + endian :big + + bit1 :group, label: 'Group Name', initial_value: 0 + bit2 :owner_node_type, label: 'Owner Node Type', initial_value: 0 + bit1 :deregister, label: 'Deregister', initial_value: 0 + bit1 :conflict, label: 'Conflict', initial_value: 0 + bit1 :active, label: 'Active', initial_value: 0 + bit1 :permanent, label: 'Permanent', initial_value: 0 + bit9 :reserved, label: 'Reserved', initial_value: 0 + end + + # 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' + node_status_name_flags :name_flags, label: 'Name Flags' + + def group? + (raw_name_flags & GROUP_BIT) != 0 + end + + def unique? + !group? + end + + def active? + (raw_name_flags & ACTIVE_BIT) != 0 + end + + private + + # The 16-bit NAME_FLAGS field as a raw integer, for masking against + # the GROUP_BIT / ACTIVE_BIT constants. + def raw_name_flags + name_flags.to_binary_s.unpack1('n') + 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' + name_service_opcode :opcode, label: 'Opcode' + name_service_header_flags :nm_flags, label: 'Flags' + name_service_result_code :rcode, label: 'Result Code' + 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 + end + end +end diff --git a/spec/lib/ruby_smb/client_spec.rb b/spec/lib/ruby_smb/client_spec.rb index 9eb199617..acac008dd 100644 --- a/spec/lib/ruby_smb/client_spec.rb +++ b/spec/lib/ruby_smb/client_spec.rb @@ -742,6 +742,28 @@ allow(RubySMB::Nbss::SessionHeader).to receive(:read).and_raise(IOError) expect { client.session_request }.to raise_error(RubySMB::Error::InvalidPacket) 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::NOT_LISTENING_ON_CALLED_NAME + allow(dispatcher).to receive(:recv_packet).and_return(negative.to_binary_s) + expect { client.session_request('OTHERNAME') }.to raise_error(RubySMB::Error::NetBiosSessionService) do |e| + expect(e.error_code.to_i).to eq(RubySMB::Nbss::NOT_LISTENING_ON_CALLED_NAME) + end + end + + it 'reports CALLED_NAME_NOT_PRESENT so the caller can resolve and retry' do + negative = RubySMB::Nbss::NegativeSessionResponse.new + negative.session_header.session_packet_type = RubySMB::Nbss::NEGATIVE_SESSION_RESPONSE + negative.error_code = RubySMB::Nbss::CALLED_NAME_NOT_PRESENT + allow(dispatcher).to receive(:recv_packet).and_return(negative.to_binary_s) + expect { client.session_request }.to raise_error(RubySMB::Error::NetBiosSessionService) do |e| + expect(e.error_code.to_i).to eq(RubySMB::Nbss::CALLED_NAME_NOT_PRESENT) + end + end + end end describe '#session_request_packet' do @@ -776,6 +798,7 @@ expect(client.session_request_packet.called_name).to eq('*SMBSERVER ') end end + end context 'Protocol Negotiation' do 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..263a8c577 --- /dev/null +++ b/spec/lib/ruby_smb/nbss/node_status_request_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +RSpec.describe RubySMB::Nbss::NodeStatusRequest do + subject(:request) { described_class.new(transaction_id: 0x1234) } + + 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 + + describe 'flags' do + it 'packs the OPCODE, NM_FLAGS and RCODE fields in RFC bit order' do + request.opcode.response = 1 + request.nm_flags.authoritative_answer = 1 + request.nm_flags.broadcast = 1 + request.rcode.rcode = 0x5 + + expect(request.to_binary_s[2, 2].unpack1('n')).to eq(0x8415) + end + + it 'defaults every request flag bit to zero' do + expect(request.to_binary_s[2, 2].unpack1('n')).to eq(0x0000) + expect(request.opcode.response.to_i).to eq(0) + expect(request.opcode.opcode.to_i).to eq(0) + expect(request.nm_flags.broadcast.to_i).to eq(0) + expect(request.rcode.rcode.to_i).to eq(0) + 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..238583f17 --- /dev/null +++ b/spec/lib/ruby_smb/nbss/node_status_response_spec.rb @@ -0,0 +1,75 @@ +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 + expect(response.opcode.response.to_i).to eq(1) + expect(response.nm_flags.authoritative_answer.to_i).to eq(1) + end + end + + describe 'flags' do + it 'decodes the header OPCODE and NM_FLAGS fields from the second word' do + response = described_class.read(build_response([['WIN95', 0x20, 0x0400]])) + + expect(response.opcode.response.to_i).to eq(1) # high bit of 0x8400 + expect(response.nm_flags.authoritative_answer.to_i).to eq(1) + end + + it 'defines NODE_NAME flags in RFC bit order' do + flags = RubySMB::Nbss::NodeStatusNameFlags.new + flags.group = 1 + flags.active = 1 + + expect(flags.to_binary_s.unpack1('n')).to eq(0x8400) + expect(flags.group.to_i).to eq(1) + expect(flags.active.to_i).to eq(1) + end + + it 'exposes parsed NODE_NAME flags as named fields' do + response = described_class.read(build_response([ + ['WORKGROUP', 0x00, 0x8400], + ['FILESERVER', 0x20, 0x0400] + ])) + + group_entry = response.node_names[0] + unique_entry = response.node_names[1] + + expect(group_entry.name_flags.group.to_i).to eq(1) + expect(group_entry.name_flags.active.to_i).to eq(1) + expect(group_entry.group?).to be true + expect(unique_entry.name_flags.group.to_i).to eq(0) + expect(unique_entry.unique?).to be true + 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..f54306741 --- /dev/null +++ b/spec/lib/ruby_smb/nbss/node_status_spec.rb @@ -0,0 +1,143 @@ +require 'spec_helper' + +RSpec.describe RubySMB::Nbss::NodeStatus do + let(:udp_sock) { double('UDPSocket') } + + # Node Status requests carry a random transaction ID; the response is only + # accepted when it echoes that ID back. Pin it so the fixtures match. + before { allow(SecureRandom).to receive(:random_number).and_return(0x1234) } + + 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 the request with #send(mesg, flags, host, port) and reads via recvfrom(maxlen)' 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).with(4096).and_return([response_bytes, nil]) + + entries = described_class.query('10.0.0.2', udp_socket: udp_sock) + 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 + + expect(described_class.query('10.0.0.2', retries: 4, timeout: 0.01, udp_socket: udp_sock)).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(described_class.query('10.0.0.2', retries: 1, timeout: 0.01, udp_socket: udp_sock)).to be_nil + end + + it 'retries after a malformed datagram and succeeds on a later attempt' do + good = build_response([['WIN95', 0x20, 0x0400]]) + allow(udp_sock).to receive(:send) + allow(IO).to receive(:select).and_return([udp_sock]) + # First datagram is truncated (NodeStatusResponse.read raises); the second + # is well-formed. The rescue must stay inside the retry loop for this. + allow(udp_sock).to receive(:recvfrom).and_return(["\xff\xff".b, nil], [good, nil]) + + entries = described_class.query('10.0.0.2', retries: 3, timeout: 0.01, udp_socket: udp_sock) + expect(entries).not_to be_nil + expect(entries.first.suffix).to eq(0x20) + end + + it 'raises ArgumentError when host is not a numeric IPv4 address' do + expect { described_class.query('example.com', udp_socket: udp_sock) }.to raise_error(ArgumentError) + end + + it 'rejects a reply whose transaction ID does not match the request' do + allow(SecureRandom).to receive(:random_number).and_return(0x1234) + mismatched = build_response([['WIN95', 0x20, 0x0400]]) + mismatched[0, 2] = [0x9999].pack('n') # overwrite transaction_id + expect(udp_sock).to receive(:send) + expect(IO).to receive(:select).and_return([udp_sock]) + expect(udp_sock).to receive(:recvfrom).and_return([mismatched, nil]) + expect(described_class.query('10.0.0.2', retries: 1, timeout: 0.01, udp_socket: udp_sock)).to be_nil + end + + it 'rejects a reply from an unexpected source address' do + spoofed = build_response([['WIN95', 0x20, 0x0400]]) + expect(udp_sock).to receive(:send) + expect(IO).to receive(:select).and_return([udp_sock]) + expect(udp_sock).to receive(:recvfrom).and_return([spoofed, ['AF_INET', 137, '10.0.0.9', '10.0.0.9']]) + expect(described_class.query('10.0.0.2', retries: 1, timeout: 0.01, udp_socket: udp_sock)).to be_nil + end + + it 'returns nil on IOError and does not close the socket' do + allow(udp_sock).to receive(:send).and_raise(IOError, 'boom') + expect(udp_sock).not_to receive(:close) + expect(described_class.query('10.0.0.2', retries: 1, udp_socket: udp_sock)).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]) + + expect(described_class.file_server_name('10.0.0.2', udp_socket: udp_sock)).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]) + + expect(described_class.file_server_name('10.0.0.2', udp_socket: udp_sock)).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