Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d49ec54
Make NetBIOS lookup socket creation pluggable
Z6543 Apr 23, 2026
facda14
Widen NetBIOS auto-lookup gate to any rejected called name
Z6543 Apr 23, 2026
db69cd8
Drop nmblookup shell-out from NetBIOS name lookup
Z6543 Apr 23, 2026
150c57a
Only auto-discover NetBIOS name when the caller used the default
Z6543 Apr 23, 2026
9b83361
Add pure-Ruby NBNS node-status helper (nmblookup -A equivalent)
Z6543 Apr 23, 2026
3d335fe
Use sendto when the UDP socket provides it
Z6543 Apr 23, 2026
ef7e0a3
Use native recv-with-timeout on Rex::Socket::Udp
Z6543 Apr 23, 2026
47a1bd2
Bind NBNS socket to local port 137 so Win9x replies are deliverable
Z6543 Apr 23, 2026
091893e
Skip 2-arg bind() on Rex::Socket::Udp
Z6543 Apr 23, 2026
c1a8ca3
Revert: don't force-bind NBNS socket to local port 137
Z6543 Apr 23, 2026
075c157
Re-add local bind to UDP/137 for Win9x NBNS replies
Z6543 Apr 23, 2026
ef3824c
Restore NodeStatusRequest/Response BinData structs and specs
Z6543 May 9, 2026
728a17c
Add raw-socket fallback for Win9x NBNS replies
Z6543 May 9, 2026
679da4d
Fall back to raw socket in NBNS auto-discovery
Z6543 May 9, 2026
fef4173
Simplify socket handling by just accepting a socket
smcintyre-r7 May 15, 2026
0e99467
Drop rex-socket UDP API workarounds
smcintyre-r7 Jun 24, 2026
2cabcd7
Let the caller own NetBIOS name-resolution reconnect
smcintyre-r7 Jun 24, 2026
18649b3
Merge pull request #3 from smcintyre-r7/pr/collab/296
Z6543 Jun 28, 2026
7e81f53
Define NBNS flag fields
smcintyre-r7 Jul 31, 2026
1e61448
Merge pull request #4 from smcintyre-r7/pr/collab/296-nbns-bitfields
Z6543 Aug 13, 2026
fa57a06
Harden NBNS Node Status query and NBSS error handling
Z6543 Aug 20, 2026
bea569d
Address NBNS Node Status review feedback
Z6543 Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions lib/ruby_smb/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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'
Expand Down
12 changes: 11 additions & 1 deletion lib/ruby_smb/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions lib/ruby_smb/nbss.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 17 additions & 0 deletions lib/ruby_smb/nbss/name_service_header_flags.rb
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions lib/ruby_smb/nbss/name_service_opcode.rb
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions lib/ruby_smb/nbss/name_service_result_code.rb
Original file line number Diff line number Diff line change
@@ -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
10 changes: 5 additions & 5 deletions lib/ruby_smb/nbss/negative_session_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
136 changes: 136 additions & 0 deletions lib/ruby_smb/nbss/node_status.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
require 'socket'
require 'ipaddr'
require 'securerandom'

module RubySMB
module Nbss
# Pure-Ruby implementation of `nmblookup -A <ip>`: 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<Entry>, 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
38 changes: 38 additions & 0 deletions lib/ruby_smb/nbss/node_status_request.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading