ruby_smb 3.3.24 → 3.3.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47dd07d1be2ceab9aa605d3664a5fef186ed19dee39e00d3797a992f430c6df2
4
- data.tar.gz: 68763fac839bb88f6dba2fab538d81492cd5975252012fddd2f875078580dbb5
3
+ metadata.gz: 2bda3450e062ff69f5c20600086e7c8686d539b44a339f560764c077c6a1a3cc
4
+ data.tar.gz: 3c2ec535d37e7880e4ba7d023ff8d381f33da0bb47c28424bc4f3828d13f9e2b
5
5
  SHA512:
6
- metadata.gz: bf4a4ff7e4f3c86289a61f6aabc138046b80b6e8819025cc10f563a5e685238784a387a6ab7cd65abcee82b8b67332e497be3202d99aaeeea744354f22609e84
7
- data.tar.gz: 7d63f42688208e9ce39785189b14f08eea77c73a32da1b9d967aed4ea4f39a220de7ee2bf6250419c3dd637fb04dbbae807ae81ea1e3d2e36c6676b5ba2b5f9d
6
+ metadata.gz: e57991b0b74090c01a6b6568dce52828e16045255ea2a3debc0afac2fa2f5bbff85e7319b21c36377a5e8a49e327bad518ebf58792e0e3c5a3d338d953064b1a
7
+ data.tar.gz: cb6a8ef6b78ca308df71d89b8c290dcce2ffcc64f155f41be7a66060713aa2c3c990ee4aa8e95d7308f0467e8b58d98ed0891523451086932f5205a26e1df214
@@ -350,7 +350,7 @@ module RubySMB
350
350
  @server_max_write_size = RubySMB::SMB2::File::MAX_PACKET_SIZE
351
351
  @server_max_transact_size = RubySMB::SMB2::File::MAX_PACKET_SIZE
352
352
  @server_supports_multi_credit = false
353
- @server_supports_nt_smbs = true
353
+ @server_supports_nt_smbs = true
354
354
 
355
355
  # SMB 3.x options
356
356
  # this merely initializes the default value for session encryption, it may be changed as necessary when a
@@ -668,6 +668,14 @@ module RubySMB
668
668
 
669
669
  # Requests a NetBIOS Session Service using the provided name.
670
670
  #
671
+ # On refusal the raised {RubySMB::Error::NetBiosSessionService} carries the
672
+ # numeric NBSS `error_code`. A {RubySMB::Nbss::CALLED_NAME_NOT_PRESENT} rejection of
673
+ # the default `'*SMBSERVER'` name means the server (e.g. Windows 9x) wants
674
+ # its real name: resolve it with {RubySMB::Nbss::NodeStatus.file_server_name},
675
+ # reconnect (the server drops the connection after a negative response), and
676
+ # retry. Reconnecting is left to the caller so the new socket is routed the
677
+ # same way as the original (e.g. through a Metasploit pivot).
678
+ #
671
679
  # @param name [String] the NetBIOS name to request
672
680
  # @return [TrueClass] if session request is granted
673
681
  # @raise [RubySMB::Error::NetBiosSessionService] if session request is refused
@@ -679,8 +687,11 @@ module RubySMB
679
687
  begin
680
688
  session_header = RubySMB::Nbss::SessionHeader.read(raw_response)
681
689
  if session_header.session_packet_type == RubySMB::Nbss::NEGATIVE_SESSION_RESPONSE
682
- negative_session_response = RubySMB::Nbss::NegativeSessionResponse.read(raw_response)
683
- raise RubySMB::Error::NetBiosSessionService, "Session Request failed: #{negative_session_response.error_msg}"
690
+ negative_session_response = RubySMB::Nbss::NegativeSessionResponse.read(raw_response)
691
+ raise RubySMB::Error::NetBiosSessionService.new(
692
+ "Session Request failed: #{negative_session_response.error_msg}",
693
+ error_code: negative_session_response.error_code.to_i
694
+ )
684
695
  end
685
696
  rescue IOError
686
697
  raise RubySMB::Error::InvalidPacket, 'Not a NBSS packet'
@@ -11,7 +11,17 @@ module RubySMB
11
11
 
12
12
  # Raised when there is a problem with communication over NetBios Session Service
13
13
  # @see https://wiki.wireshark.org/NetBIOS/NBSS
14
- class NetBiosSessionService < RubySMBError; end
14
+ class NetBiosSessionService < RubySMBError
15
+ # The numeric NBSS error code from a NEGATIVE_SESSION_RESPONSE, or nil
16
+ # if the error was raised outside that context.
17
+ # @return [Integer, nil]
18
+ attr_reader :error_code
19
+
20
+ def initialize(msg = nil, error_code: nil)
21
+ @error_code = error_code
22
+ super(msg)
23
+ end
24
+ end
15
25
 
16
26
  # Raised when trying to parse raw binary into a Packet and the data
17
27
  # is invalid.
@@ -0,0 +1,18 @@
1
+ module RubySMB
2
+ module Nbss
3
+ # The NM_FLAGS field of the NetBIOS Name Service header, as defined in
4
+ # RFC 1002 section 4.2.1.1. The surrounding R/OPCODE and RCODE bits are
5
+ # declared inline in the header records that embed this field
6
+ # ({NodeStatusRequest} and {NodeStatusResponse}).
7
+ class NameServiceHeaderFlags < BinData::Record
8
+ endian :big
9
+
10
+ bit1 :authoritative_answer, label: 'Authoritative Answer', initial_value: 0
11
+ bit1 :truncated, label: 'Truncated', initial_value: 0
12
+ bit1 :recursion_desired, label: 'Recursion Desired', initial_value: 0
13
+ bit1 :recursion_available, label: 'Recursion Available', initial_value: 0
14
+ bit2 :reserved, label: 'Reserved', initial_value: 0
15
+ bit1 :broadcast, label: 'Broadcast', initial_value: 0
16
+ end
17
+ end
18
+ end
@@ -12,15 +12,15 @@ module RubySMB
12
12
 
13
13
  def error_msg
14
14
  case error_code
15
- when 0x80
15
+ when NOT_LISTENING_ON_CALLED_NAME
16
16
  'Not listening on called name'
17
- when 0x81
17
+ when NOT_LISTENING_FOR_CALLING_NAME
18
18
  'Not listening for calling name'
19
- when 0x82
19
+ when CALLED_NAME_NOT_PRESENT
20
20
  'Called name not present'
21
- when 0x83
21
+ when CALLED_NAME_INSUFFICIENT_RESOURCES
22
22
  'Called name present, but insufficient resources'
23
- when 0x8F
23
+ when UNSPECIFIED_ERROR
24
24
  'Unspecified error'
25
25
  end
26
26
  end
@@ -0,0 +1,137 @@
1
+ require 'socket'
2
+ require 'ipaddr'
3
+ require 'securerandom'
4
+
5
+ module RubySMB
6
+ module Nbss
7
+ # Pure-Ruby implementation of `nmblookup -A <ip>`: sends an NBNS Node
8
+ # Status Request (RFC 1002 4.2.17) over UDP/137 and returns the
9
+ # server's name table.
10
+ #
11
+ # No external binaries are invoked. Compare to Samba's `nmblookup`,
12
+ # which shells out and requires the `samba-common-bin` package to be
13
+ # installed.
14
+ module NodeStatus
15
+ NBNS_PORT = 137
16
+
17
+ # Default per-attempt receive timeout, in seconds.
18
+ DEFAULT_TIMEOUT = 2.0
19
+
20
+ # Default number of attempts before giving up.
21
+ DEFAULT_RETRIES = 3
22
+
23
+ # One entry in the returned name table.
24
+ #
25
+ # @!attribute [rw] name [String] the NetBIOS name (trimmed)
26
+ # @!attribute [rw] suffix [Integer] 1-byte NetBIOS suffix
27
+ # @!attribute [rw] group [Boolean] true for a group name, false for unique
28
+ # @!attribute [rw] active [Boolean] true if the name is registered
29
+ Entry = Struct.new(:name, :suffix, :group, :active) do
30
+ def unique?
31
+ !group
32
+ end
33
+
34
+ # Human-readable form like `WIN95 <20> UNIQUE ACTIVE`.
35
+ def to_s
36
+ flags = [group ? 'GROUP' : 'UNIQUE', active ? 'ACTIVE' : 'INACTIVE'].join(' ')
37
+ format('%-16s <%02X> %s', name, suffix, flags)
38
+ end
39
+ end
40
+
41
+ # Query a host for its NetBIOS name table.
42
+ #
43
+ # NBNS is IPv4-only and this is a unicast query, so `host` must be a
44
+ # numeric IPv4 address (e.g. `10.0.0.1`). A hostname is rejected because
45
+ # replies are matched against the numeric peer address returned by
46
+ # `recvfrom`, which a hostname would never equal.
47
+ #
48
+ # @param host [String] target IPv4 address (unicast — no broadcast)
49
+ # @param port [Integer] destination UDP port (default 137)
50
+ # @param timeout [Numeric] per-attempt receive timeout in seconds
51
+ # @param retries [Integer] total number of attempts
52
+ # @param udp_socket [UDPSocket, Rex::Socket::Udp] caller-owned UDP socket.
53
+ # The caller is responsible for binding and closing it. It must
54
+ # implement `#send(mesg, flags, host, port)` and `#recvfrom(maxlen)`,
55
+ # and leave `do_not_reverse_lookup` at its default so `recvfrom` does
56
+ # not perform a reverse DNS lookup per datagram.
57
+ # @return [Array<Entry>, nil] the name table, or nil on timeout/parse failure
58
+ # @raise [ArgumentError] if `host` is not a numeric IPv4 address
59
+ def self.query(host, port: NBNS_PORT, timeout: DEFAULT_TIMEOUT,
60
+ retries: DEFAULT_RETRIES, udp_socket:)
61
+ expected_address = begin
62
+ IPAddr.new(host).native
63
+ rescue IPAddr::InvalidAddressError
64
+ raise ArgumentError, "host must be an IPv4 address, got #{host.inspect}"
65
+ end
66
+ raise ArgumentError, "NBNS is IPv4-only, got #{host.inspect}" unless expected_address.ipv4?
67
+
68
+ request = NodeStatusRequest.new(transaction_id: SecureRandom.random_number(0x10000))
69
+ bytes = request.to_binary_s
70
+
71
+ retries.times do
72
+ udp_socket.send(bytes, 0, host, port)
73
+ next unless IO.select([udp_socket], nil, nil, timeout)
74
+
75
+ data, addr = udp_socket.recvfrom(4096)
76
+ next if data.nil? || data.empty?
77
+ next unless source_matches?(addr, expected_address)
78
+
79
+ response = NodeStatusResponse.read(data)
80
+ # Reject anything that isn't the response to our own query.
81
+ next unless response.transaction_id.to_i == request.transaction_id.to_i
82
+ next unless response.response.to_i == 1
83
+ next unless response.rr_type.to_i == NodeStatusRequest::QUESTION_TYPE_NBSTAT
84
+
85
+ return entries_from(response)
86
+ rescue IOError, EOFError, SystemCallError
87
+ next
88
+ end
89
+ nil
90
+ end
91
+
92
+ # Return the unique file-server name (suffix 0x20) from a host, or nil
93
+ # if the name table doesn't contain one. Convenience helper for the
94
+ # common case of "give me this host's file-server name."
95
+ #
96
+ # @param host [String] target IPv4 address
97
+ # @param kwargs [Hash] forwarded to {.query}
98
+ # @return [String, nil]
99
+ def self.file_server_name(host, **kwargs)
100
+ entries = query(host, **kwargs) or return nil
101
+ entry = entries.find { |e| e.suffix == 0x20 && e.unique? }
102
+ entry&.name
103
+ end
104
+
105
+ def self.entries_from(response)
106
+ response.node_names.map do |n|
107
+ Entry.new(
108
+ n.netbios_name.to_s.rstrip,
109
+ n.suffix.to_i,
110
+ n.group?,
111
+ n.active?
112
+ )
113
+ end
114
+ end
115
+ private_class_method :entries_from
116
+
117
+ # Accept a reply only when it comes from the queried address. A UDP
118
+ # socket's `#recvfrom` always returns the sender as
119
+ # `[address_family, port, host, numeric_host]`, so the numeric address
120
+ # lives at `addr[3]`, which is compared against the normalized target.
121
+ # Any datagram whose source cannot be determined (not an Array, or a nil
122
+ # numeric address) is rejected — an unverifiable source is untrusted.
123
+ def self.source_matches?(addr, expected_address)
124
+ source = addr.is_a?(Array) ? addr[3] : nil
125
+ return false if source.nil?
126
+
127
+ source_address = begin
128
+ IPAddr.new(source).native
129
+ rescue IPAddr::InvalidAddressError
130
+ return false
131
+ end
132
+ source_address == expected_address
133
+ end
134
+ private_class_method :source_matches?
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,38 @@
1
+ require 'ruby_smb/nbss/name_service_header_flags'
2
+
3
+ module RubySMB
4
+ module Nbss
5
+ # NetBIOS Name Service (NBNS) Node Status Request packet, as defined in
6
+ # [RFC 1002 4.2.17](https://tools.ietf.org/html/rfc1002#section-4.2.17).
7
+ # Sent over UDP to port 137 to retrieve a host's NetBIOS name table.
8
+ class NodeStatusRequest < BinData::Record
9
+ # NBSTAT question type, RFC 1002 4.2.1.3.
10
+ QUESTION_TYPE_NBSTAT = 0x0021
11
+ # Internet class.
12
+ QUESTION_CLASS_IN = 0x0001
13
+ # RFC 1002 4.2.17: a node status query always asks for the wildcard name,
14
+ # 16 bytes of 0x2A ('*') padded with 0x00.
15
+ WILDCARD_NAME = '*'.ljust(16, "\x00").freeze
16
+
17
+ endian :big
18
+
19
+ # 12-byte NBNS header (RFC 1002 4.2.1.1 and 4.2.1.2). The second word is
20
+ # R | OPCODE | NM_FLAGS | RCODE.
21
+ uint16 :transaction_id, label: 'Transaction ID'
22
+ bit1 :response, label: 'Response', initial_value: 0
23
+ bit4 :opcode, label: 'Opcode', initial_value: 0
24
+ name_service_header_flags :nm_flags, label: 'Flags'
25
+ bit4 :rcode, label: 'Result Code', initial_value: 0
26
+ uint16 :qdcount, label: 'QDCount', initial_value: 1
27
+ uint16 :ancount, label: 'ANCount', initial_value: 0
28
+ uint16 :nscount, label: 'NSCount', initial_value: 0
29
+ uint16 :arcount, label: 'ARCount', initial_value: 0
30
+
31
+ # Question section. For a node status query this is always the wildcard
32
+ # NetBIOS name, L1-encoded.
33
+ netbios_name :question_name, label: 'Question Name', initial_value: WILDCARD_NAME
34
+ uint16 :question_type, label: 'Question Type', initial_value: QUESTION_TYPE_NBSTAT
35
+ uint16 :question_class, label: 'Question Class', initial_value: QUESTION_CLASS_IN
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,85 @@
1
+ require 'ruby_smb/nbss/name_service_header_flags'
2
+
3
+ module RubySMB
4
+ module Nbss
5
+ # The NAME_FLAGS field for each Node Status Response name entry.
6
+ class NodeStatusNameFlags < BinData::Record
7
+ endian :big
8
+
9
+ bit1 :group, label: 'Group Name', initial_value: 0
10
+ bit2 :owner_node_type, label: 'Owner Node Type', initial_value: 0
11
+ bit1 :deregister, label: 'Deregister', initial_value: 0
12
+ bit1 :conflict, label: 'Conflict', initial_value: 0
13
+ bit1 :active, label: 'Active', initial_value: 0
14
+ bit1 :permanent, label: 'Permanent', initial_value: 0
15
+ bit9 :reserved, label: 'Reserved', initial_value: 0
16
+ end
17
+
18
+ # Single entry in the NODE_NAME_ARRAY of a Node Status Response,
19
+ # as defined in [RFC 1002 4.2.18](https://tools.ietf.org/html/rfc1002#section-4.2.18).
20
+ # Fixed 18-byte layout (15-byte name, 1-byte suffix, 16-bit flags).
21
+ class NodeStatusName < BinData::Record
22
+ # NAME_FLAGS bits (RFC 1002 4.2.18).
23
+ GROUP_BIT = 0x8000 # 1 = group name, 0 = unique name
24
+ ACTIVE_BIT = 0x0400 # 1 = name registered
25
+
26
+ endian :big
27
+
28
+ string :netbios_name, label: 'NetBIOS Name', length: 15
29
+ uint8 :suffix, label: 'Suffix'
30
+ node_status_name_flags :name_flags, label: 'Name Flags'
31
+
32
+ def group?
33
+ (raw_name_flags & GROUP_BIT) != 0
34
+ end
35
+
36
+ def unique?
37
+ !group?
38
+ end
39
+
40
+ def active?
41
+ (raw_name_flags & ACTIVE_BIT) != 0
42
+ end
43
+
44
+ private
45
+
46
+ # The 16-bit NAME_FLAGS field as a raw integer, for masking against
47
+ # the GROUP_BIT / ACTIVE_BIT constants.
48
+ def raw_name_flags
49
+ name_flags.to_binary_s.unpack1('n')
50
+ end
51
+ end
52
+
53
+ # NetBIOS Name Service (NBNS) Node Status Response packet, as defined in
54
+ # [RFC 1002 4.2.18](https://tools.ietf.org/html/rfc1002#section-4.2.18).
55
+ # Received over UDP from port 137 in reply to a {NodeStatusRequest}.
56
+ # Does not decode the trailing STATISTICS field; callers only need the
57
+ # name table.
58
+ class NodeStatusResponse < BinData::Record
59
+ endian :big
60
+
61
+ # 12-byte NBNS header. The second word is R | OPCODE | NM_FLAGS | RCODE.
62
+ uint16 :transaction_id, label: 'Transaction ID'
63
+ bit1 :response, label: 'Response', initial_value: 0
64
+ bit4 :opcode, label: 'Opcode', initial_value: 0
65
+ name_service_header_flags :nm_flags, label: 'Flags'
66
+ bit4 :rcode, label: 'Result Code', initial_value: 0
67
+ uint16 :qdcount, label: 'QDCount'
68
+ uint16 :ancount, label: 'ANCount'
69
+ uint16 :nscount, label: 'NSCount'
70
+ uint16 :arcount, label: 'ARCount'
71
+
72
+ # Answer section. Microsoft's implementation omits the question-echo,
73
+ # so the owner name appears directly after the header.
74
+ netbios_name :owner_name, label: 'Owner Name'
75
+ uint16 :rr_type, label: 'RR Type'
76
+ uint16 :rr_class, label: 'RR Class'
77
+ uint32 :ttl, label: 'TTL'
78
+ uint16 :rdlength, label: 'RDLENGTH'
79
+
80
+ # RDATA begins here. NODE_NAME_ARRAY is preceded by an 8-bit count.
81
+ uint8 :num_names, label: 'Number of Names'
82
+ array :node_names, type: :node_status_name, initial_length: :num_names
83
+ end
84
+ end
85
+ end
data/lib/ruby_smb/nbss.rb CHANGED
@@ -9,9 +9,20 @@ module RubySMB
9
9
  RETARGET_SESSION_RESPONSE = 0x84
10
10
  SESSION_KEEP_ALIVE = 0x85
11
11
 
12
+ # NBSS negative session response error codes (RFC 1002 section 4.3.6)
13
+ NOT_LISTENING_ON_CALLED_NAME = 0x80
14
+ NOT_LISTENING_FOR_CALLING_NAME = 0x81
15
+ CALLED_NAME_NOT_PRESENT = 0x82
16
+ CALLED_NAME_INSUFFICIENT_RESOURCES = 0x83
17
+ UNSPECIFIED_ERROR = 0x8F
18
+
12
19
  require 'ruby_smb/nbss/netbios_name'
13
20
  require 'ruby_smb/nbss/session_header'
14
21
  require 'ruby_smb/nbss/session_request'
15
22
  require 'ruby_smb/nbss/negative_session_response'
23
+ require 'ruby_smb/nbss/name_service_header_flags'
24
+ require 'ruby_smb/nbss/node_status_request'
25
+ require 'ruby_smb/nbss/node_status_response'
26
+ require 'ruby_smb/nbss/node_status'
16
27
  end
17
28
  end
@@ -1,3 +1,3 @@
1
1
  module RubySMB
2
- VERSION = '3.3.24'.freeze
2
+ VERSION = '3.3.25'.freeze
3
3
  end
@@ -742,6 +742,28 @@ RSpec.describe RubySMB::Client do
742
742
  allow(RubySMB::Nbss::SessionHeader).to receive(:read).and_raise(IOError)
743
743
  expect { client.session_request }.to raise_error(RubySMB::Error::InvalidPacket)
744
744
  end
745
+
746
+ describe 'NetBiosSessionService error propagates the error_code' do
747
+ it 'attaches the numeric NBSS error code to the raised exception' do
748
+ negative = RubySMB::Nbss::NegativeSessionResponse.new
749
+ negative.session_header.session_packet_type = RubySMB::Nbss::NEGATIVE_SESSION_RESPONSE
750
+ negative.error_code = RubySMB::Nbss::NOT_LISTENING_ON_CALLED_NAME
751
+ allow(dispatcher).to receive(:recv_packet).and_return(negative.to_binary_s)
752
+ expect { client.session_request('OTHERNAME') }.to raise_error(RubySMB::Error::NetBiosSessionService) do |e|
753
+ expect(e.error_code.to_i).to eq(RubySMB::Nbss::NOT_LISTENING_ON_CALLED_NAME)
754
+ end
755
+ end
756
+
757
+ it 'reports CALLED_NAME_NOT_PRESENT so the caller can resolve and retry' do
758
+ negative = RubySMB::Nbss::NegativeSessionResponse.new
759
+ negative.session_header.session_packet_type = RubySMB::Nbss::NEGATIVE_SESSION_RESPONSE
760
+ negative.error_code = RubySMB::Nbss::CALLED_NAME_NOT_PRESENT
761
+ allow(dispatcher).to receive(:recv_packet).and_return(negative.to_binary_s)
762
+ expect { client.session_request }.to raise_error(RubySMB::Error::NetBiosSessionService) do |e|
763
+ expect(e.error_code.to_i).to eq(RubySMB::Nbss::CALLED_NAME_NOT_PRESENT)
764
+ end
765
+ end
766
+ end
745
767
  end
746
768
 
747
769
  describe '#session_request_packet' do
@@ -776,6 +798,7 @@ RSpec.describe RubySMB::Client do
776
798
  expect(client.session_request_packet.called_name).to eq('*SMBSERVER ')
777
799
  end
778
800
  end
801
+
779
802
  end
780
803
 
781
804
  context 'Protocol Negotiation' do
@@ -0,0 +1,52 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe RubySMB::Nbss::NodeStatusRequest do
4
+ subject(:request) { described_class.new(transaction_id: 0x1234) }
5
+
6
+ describe 'encoded bytes' do
7
+ let(:bytes) { request.to_binary_s }
8
+
9
+ it 'starts with a 12-byte NBNS header' do
10
+ expect(bytes[0, 2].unpack1('n')).to eq(0x1234)
11
+ expect(bytes[2, 2].unpack1('n')).to eq(0x0000) # flags
12
+ expect(bytes[4, 2].unpack1('n')).to eq(1) # qdcount
13
+ expect(bytes[6, 2].unpack1('n')).to eq(0) # ancount
14
+ expect(bytes[8, 2].unpack1('n')).to eq(0) # nscount
15
+ expect(bytes[10, 2].unpack1('n')).to eq(0) # arcount
16
+ end
17
+
18
+ it 'encodes the wildcard question name as 34 bytes (length + 32-char L1 + null)' do
19
+ expect(bytes[12].unpack1('C')).to eq(0x20) # label length
20
+ expect(bytes[13, 32]).to eq('CKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')
21
+ expect(bytes[45].unpack1('C')).to eq(0x00) # null label terminator
22
+ end
23
+
24
+ it 'ends with QTYPE=NBSTAT and QCLASS=IN' do
25
+ expect(bytes[46, 2].unpack1('n')).to eq(described_class::QUESTION_TYPE_NBSTAT)
26
+ expect(bytes[48, 2].unpack1('n')).to eq(described_class::QUESTION_CLASS_IN)
27
+ end
28
+
29
+ it 'is exactly 50 bytes long' do
30
+ expect(bytes.bytesize).to eq(50)
31
+ end
32
+ end
33
+
34
+ describe 'flags' do
35
+ it 'packs the OPCODE, NM_FLAGS and RCODE fields in RFC bit order' do
36
+ request.response = 1
37
+ request.nm_flags.authoritative_answer = 1
38
+ request.nm_flags.broadcast = 1
39
+ request.rcode = 0x5
40
+
41
+ expect(request.to_binary_s[2, 2].unpack1('n')).to eq(0x8415)
42
+ end
43
+
44
+ it 'defaults every request flag bit to zero' do
45
+ expect(request.to_binary_s[2, 2].unpack1('n')).to eq(0x0000)
46
+ expect(request.response.to_i).to eq(0)
47
+ expect(request.opcode.to_i).to eq(0)
48
+ expect(request.nm_flags.broadcast.to_i).to eq(0)
49
+ expect(request.rcode.to_i).to eq(0)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,75 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe RubySMB::Nbss::NodeStatusResponse do
4
+ def build_response(names)
5
+ data = ''.b
6
+ data << [0x1234].pack('n') # transaction_id
7
+ data << [0x8400].pack('n') # flags: response, authoritative
8
+ data << [0].pack('n') # qdcount
9
+ data << [1].pack('n') # ancount
10
+ data << [0].pack('n') << [0].pack('n') # nscount, arcount
11
+ data << [0x20].pack('C') << ('A' * 32) << "\x00".b # owner name L1
12
+ data << [0x0021].pack('n') # RR type NBSTAT
13
+ data << [0x0001].pack('n') # RR class IN
14
+ data << [0].pack('N') # TTL
15
+ data << [1 + names.length * 18 + 46].pack('n') # rdlength
16
+ data << [names.length].pack('C')
17
+ names.each do |name, suffix, flags|
18
+ data << name.to_s.ljust(15, ' ') << [suffix].pack('C') << [flags].pack('n')
19
+ end
20
+ data << ("\x00".b * 46) # statistics (unused)
21
+ data
22
+ end
23
+
24
+ describe 'parsing' do
25
+ it 'decodes the name table' do
26
+ response = described_class.read(build_response([
27
+ ['WIN95', 0x00, 0x0400],
28
+ ['WIN95', 0x20, 0x0400],
29
+ ['WORKGROUP', 0x00, 0x8400]
30
+ ]))
31
+ expect(response.num_names).to eq(3)
32
+ expect(response.node_names[0].netbios_name.to_s.rstrip).to eq('WIN95')
33
+ expect(response.node_names[0].suffix).to eq(0x00)
34
+ expect(response.node_names[1].suffix).to eq(0x20)
35
+ expect(response.node_names[2].group?).to be true
36
+ expect(response.response.to_i).to eq(1)
37
+ expect(response.nm_flags.authoritative_answer.to_i).to eq(1)
38
+ end
39
+ end
40
+
41
+ describe 'flags' do
42
+ it 'decodes the header OPCODE and NM_FLAGS fields from the second word' do
43
+ response = described_class.read(build_response([['WIN95', 0x20, 0x0400]]))
44
+
45
+ expect(response.response.to_i).to eq(1) # high bit of 0x8400
46
+ expect(response.nm_flags.authoritative_answer.to_i).to eq(1)
47
+ end
48
+
49
+ it 'defines NODE_NAME flags in RFC bit order' do
50
+ flags = RubySMB::Nbss::NodeStatusNameFlags.new
51
+ flags.group = 1
52
+ flags.active = 1
53
+
54
+ expect(flags.to_binary_s.unpack1('n')).to eq(0x8400)
55
+ expect(flags.group.to_i).to eq(1)
56
+ expect(flags.active.to_i).to eq(1)
57
+ end
58
+
59
+ it 'exposes parsed NODE_NAME flags as named fields' do
60
+ response = described_class.read(build_response([
61
+ ['WORKGROUP', 0x00, 0x8400],
62
+ ['FILESERVER', 0x20, 0x0400]
63
+ ]))
64
+
65
+ group_entry = response.node_names[0]
66
+ unique_entry = response.node_names[1]
67
+
68
+ expect(group_entry.name_flags.group.to_i).to eq(1)
69
+ expect(group_entry.name_flags.active.to_i).to eq(1)
70
+ expect(group_entry.group?).to be true
71
+ expect(unique_entry.name_flags.group.to_i).to eq(0)
72
+ expect(unique_entry.unique?).to be true
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,146 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe RubySMB::Nbss::NodeStatus do
4
+ let(:udp_sock) { double('UDPSocket') }
5
+ # #recvfrom returns the sender as [address_family, port, host, numeric_host];
6
+ # the numeric address at index 3 is matched against the queried target.
7
+ let(:peer) { ['AF_INET', 137, '10.0.0.2', '10.0.0.2'] }
8
+
9
+ # Node Status requests carry a random transaction ID; the response is only
10
+ # accepted when it echoes that ID back. Pin it so the fixtures match.
11
+ before { allow(SecureRandom).to receive(:random_number).and_return(0x1234) }
12
+
13
+ def build_response(names)
14
+ data = ''.b
15
+ data << [0x1234].pack('n') # transaction_id
16
+ data << [0x8400].pack('n') # flags
17
+ data << [0].pack('n') << [1].pack('n') # qdcount, ancount
18
+ data << [0].pack('n') << [0].pack('n') # nscount, arcount
19
+ data << [0x20].pack('C') << ('A' * 32) << "\x00".b # owner name
20
+ data << [0x0021].pack('n') << [0x0001].pack('n')
21
+ data << [0].pack('N') # TTL
22
+ data << [1 + names.length * 18 + 46].pack('n')
23
+ data << [names.length].pack('C')
24
+ names.each do |name, suffix, flags|
25
+ data << name.to_s.ljust(15, ' ') << [suffix].pack('C') << [flags].pack('n')
26
+ end
27
+ data << ("\x00".b * 46)
28
+ data
29
+ end
30
+
31
+ describe '.query' do
32
+ it 'sends the request with #send(mesg, flags, host, port) and reads via recvfrom(maxlen)' do
33
+ response_bytes = build_response([
34
+ ['WIN95', 0x00, 0x0400],
35
+ ['WIN95', 0x20, 0x0400],
36
+ ['WORKGROUP', 0x00, 0x8400]
37
+ ])
38
+
39
+ expect(udp_sock).to receive(:send) do |bytes, flags, host, port|
40
+ expect(flags).to eq(0)
41
+ expect(host).to eq('10.0.0.2')
42
+ expect(port).to eq(137)
43
+ expect(bytes.bytesize).to eq(50)
44
+ end
45
+ expect(IO).to receive(:select).and_return([udp_sock])
46
+ expect(udp_sock).to receive(:recvfrom).with(4096).and_return([response_bytes, peer])
47
+
48
+ entries = described_class.query('10.0.0.2', udp_socket: udp_sock)
49
+ expect(entries.size).to eq(3)
50
+ expect(entries[1].name).to eq('WIN95')
51
+ expect(entries[1].suffix).to eq(0x20)
52
+ expect(entries[1].unique?).to be true
53
+ expect(entries[2].group).to be true
54
+ end
55
+
56
+ it 'retries up to the configured limit before giving up' do
57
+ call_count = 0
58
+ allow(udp_sock).to receive(:send) { call_count += 1 }
59
+ allow(IO).to receive(:select).and_return(nil) # always time out
60
+
61
+ expect(described_class.query('10.0.0.2', retries: 4, timeout: 0.01, udp_socket: udp_sock)).to be_nil
62
+ expect(call_count).to eq(4)
63
+ end
64
+
65
+ it 'returns nil when the response can not be parsed' do
66
+ expect(udp_sock).to receive(:send)
67
+ expect(IO).to receive(:select).and_return([udp_sock])
68
+ expect(udp_sock).to receive(:recvfrom).and_return(["\xff\xff".b, peer])
69
+ expect(described_class.query('10.0.0.2', retries: 1, timeout: 0.01, udp_socket: udp_sock)).to be_nil
70
+ end
71
+
72
+ it 'retries after a malformed datagram and succeeds on a later attempt' do
73
+ good = build_response([['WIN95', 0x20, 0x0400]])
74
+ allow(udp_sock).to receive(:send)
75
+ allow(IO).to receive(:select).and_return([udp_sock])
76
+ # First datagram is truncated (NodeStatusResponse.read raises); the second
77
+ # is well-formed. The rescue must stay inside the retry loop for this.
78
+ allow(udp_sock).to receive(:recvfrom).and_return(["\xff\xff".b, peer], [good, peer])
79
+
80
+ entries = described_class.query('10.0.0.2', retries: 3, timeout: 0.01, udp_socket: udp_sock)
81
+ expect(entries).not_to be_nil
82
+ expect(entries.first.suffix).to eq(0x20)
83
+ end
84
+
85
+ it 'raises ArgumentError when host is not a numeric IPv4 address' do
86
+ expect { described_class.query('example.com', udp_socket: udp_sock) }.to raise_error(ArgumentError)
87
+ end
88
+
89
+ it 'rejects a reply whose transaction ID does not match the request' do
90
+ allow(SecureRandom).to receive(:random_number).and_return(0x1234)
91
+ mismatched = build_response([['WIN95', 0x20, 0x0400]])
92
+ mismatched[0, 2] = [0x9999].pack('n') # overwrite transaction_id
93
+ expect(udp_sock).to receive(:send)
94
+ expect(IO).to receive(:select).and_return([udp_sock])
95
+ expect(udp_sock).to receive(:recvfrom).and_return([mismatched, peer])
96
+ expect(described_class.query('10.0.0.2', retries: 1, timeout: 0.01, udp_socket: udp_sock)).to be_nil
97
+ end
98
+
99
+ it 'rejects a reply from an unexpected source address' do
100
+ spoofed = build_response([['WIN95', 0x20, 0x0400]])
101
+ expect(udp_sock).to receive(:send)
102
+ expect(IO).to receive(:select).and_return([udp_sock])
103
+ expect(udp_sock).to receive(:recvfrom).and_return([spoofed, ['AF_INET', 137, '10.0.0.9', '10.0.0.9']])
104
+ expect(described_class.query('10.0.0.2', retries: 1, timeout: 0.01, udp_socket: udp_sock)).to be_nil
105
+ end
106
+
107
+ it 'returns nil on IOError and does not close the socket' do
108
+ allow(udp_sock).to receive(:send).and_raise(IOError, 'boom')
109
+ expect(udp_sock).not_to receive(:close)
110
+ expect(described_class.query('10.0.0.2', retries: 1, udp_socket: udp_sock)).to be_nil
111
+ end
112
+ end
113
+
114
+ describe '.file_server_name' do
115
+ it 'returns the unique 0x20 entry' do
116
+ response_bytes = build_response([
117
+ ['WORKGROUP', 0x00, 0x8400],
118
+ ['FILESERVER', 0x20, 0x0400]
119
+ ])
120
+ allow(udp_sock).to receive(:send)
121
+ allow(IO).to receive(:select).and_return([udp_sock])
122
+ allow(udp_sock).to receive(:recvfrom).and_return([response_bytes, peer])
123
+
124
+ expect(described_class.file_server_name('10.0.0.2', udp_socket: udp_sock)).to eq('FILESERVER')
125
+ end
126
+
127
+ it 'returns nil when no unique 0x20 entry is present' do
128
+ response_bytes = build_response([['HOST', 0x00, 0x0400]])
129
+ allow(udp_sock).to receive(:send)
130
+ allow(IO).to receive(:select).and_return([udp_sock])
131
+ allow(udp_sock).to receive(:recvfrom).and_return([response_bytes, peer])
132
+
133
+ expect(described_class.file_server_name('10.0.0.2', udp_socket: udp_sock)).to be_nil
134
+ end
135
+ end
136
+
137
+ describe RubySMB::Nbss::NodeStatus::Entry do
138
+ it '#to_s formats like nmblookup output' do
139
+ entry = described_class.new('WIN95', 0x20, false, true)
140
+ expect(entry.to_s).to include('WIN95')
141
+ expect(entry.to_s).to include('<20>')
142
+ expect(entry.to_s).to include('UNIQUE')
143
+ expect(entry.to_s).to include('ACTIVE')
144
+ end
145
+ end
146
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_smb
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.24
4
+ version: 3.3.25
5
5
  platform: ruby
6
6
  authors:
7
7
  - Metasploit Hackers
@@ -13,7 +13,7 @@ authors:
13
13
  autorequire:
14
14
  bindir: bin
15
15
  cert_chain: []
16
- date: 2026-09-30 00:00:00.000000000 Z
16
+ date: 2026-10-01 00:00:00.000000000 Z
17
17
  dependencies:
18
18
  - !ruby/object:Gem::Dependency
19
19
  name: redcarpet
@@ -476,8 +476,12 @@ files:
476
476
  - lib/ruby_smb/gss/spnego_neg_token_targ.rb
477
477
  - lib/ruby_smb/impersonation_levels.rb
478
478
  - lib/ruby_smb/nbss.rb
479
+ - lib/ruby_smb/nbss/name_service_header_flags.rb
479
480
  - lib/ruby_smb/nbss/negative_session_response.rb
480
481
  - lib/ruby_smb/nbss/netbios_name.rb
482
+ - lib/ruby_smb/nbss/node_status.rb
483
+ - lib/ruby_smb/nbss/node_status_request.rb
484
+ - lib/ruby_smb/nbss/node_status_response.rb
481
485
  - lib/ruby_smb/nbss/session_header.rb
482
486
  - lib/ruby_smb/nbss/session_request.rb
483
487
  - lib/ruby_smb/ntlm.rb
@@ -866,6 +870,9 @@ files:
866
870
  - spec/lib/ruby_smb/gss/spnego_spec.rb
867
871
  - spec/lib/ruby_smb/nbss/negative_session_response_spec.rb
868
872
  - spec/lib/ruby_smb/nbss/netbios_name_spec.rb
873
+ - spec/lib/ruby_smb/nbss/node_status_request_spec.rb
874
+ - spec/lib/ruby_smb/nbss/node_status_response_spec.rb
875
+ - spec/lib/ruby_smb/nbss/node_status_spec.rb
869
876
  - spec/lib/ruby_smb/nbss/session_header_spec.rb
870
877
  - spec/lib/ruby_smb/nbss/session_request_spec.rb
871
878
  - spec/lib/ruby_smb/ntlm/client/session_spec.rb
@@ -1226,6 +1233,9 @@ test_files:
1226
1233
  - spec/lib/ruby_smb/gss/spnego_spec.rb
1227
1234
  - spec/lib/ruby_smb/nbss/negative_session_response_spec.rb
1228
1235
  - spec/lib/ruby_smb/nbss/netbios_name_spec.rb
1236
+ - spec/lib/ruby_smb/nbss/node_status_request_spec.rb
1237
+ - spec/lib/ruby_smb/nbss/node_status_response_spec.rb
1238
+ - spec/lib/ruby_smb/nbss/node_status_spec.rb
1229
1239
  - spec/lib/ruby_smb/nbss/session_header_spec.rb
1230
1240
  - spec/lib/ruby_smb/nbss/session_request_spec.rb
1231
1241
  - spec/lib/ruby_smb/ntlm/client/session_spec.rb