diff --git a/lib/ruby_smb/client.rb b/lib/ruby_smb/client.rb index 7313d3051..f78589a8d 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 + ) 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..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/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/lib/ruby_smb/nbss/node_status.rb b/lib/ruby_smb/nbss/node_status.rb new file mode 100644 index 000000000..207bf2436 --- /dev/null +++ b/lib/ruby_smb/nbss/node_status.rb @@ -0,0 +1,95 @@ +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 [UDPSocket, Rex::Socket::Udp] caller-owned UDP socket. + # The caller is responsible for binding and closing it. + # @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:) + request = NodeStatusRequest.new(transaction_id: rand(0xFFFF)) + request.question_name.set('*'.ljust(16, "\x00")) + bytes = request.to_binary_s + + retries.times do + udp_socket.send(bytes, 0, host, port) + next unless IO.select([udp_socket], nil, nil, timeout) + + data, = udp_socket.recvfrom(4096) + next if data.nil? || data.empty? + + response = NodeStatusResponse.read(data) + return entries_from(response) + end + nil + rescue IOError, EOFError + 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 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/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/client_spec.rb b/spec/lib/ruby_smb/client_spec.rb index 9eb199617..e7639af05 100644 --- a/spec/lib/ruby_smb/client_spec.rb +++ b/spec/lib/ruby_smb/client_spec.rb @@ -742,6 +742,20 @@ 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::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_i).to eq(RubySMB::Nbss::NegativeSessionResponse::NOT_LISTENING_ON_CALLED_NAME) + end + end + end end describe '#session_request_packet' do @@ -776,6 +790,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..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 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..05145ccc3 --- /dev/null +++ b/spec/lib/ruby_smb/nbss/node_status_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +RSpec.describe RubySMB::Nbss::NodeStatus do + let(:udp_sock) { double('UDPSocket') } + + 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 '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