Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
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
)
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
4 changes: 4 additions & 0 deletions lib/ruby_smb/nbss.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,9 @@ 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/name_service_header_flags'
require 'ruby_smb/nbss/node_status_request'
require 'ruby_smb/nbss/node_status_response'
require 'ruby_smb/nbss/node_status'
end
end
18 changes: 18 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,18 @@
module RubySMB
module Nbss
# NetBIOS Name Service header flags, as defined in RFC 1002 section 4.2.1.1.
class NameServiceHeaderFlags < BinData::Record
endian :big

bit1 :response, label: 'Response', initial_value: 0
bit4 :opcode, label: 'Opcode', initial_value: 0
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
bit4 :rcode, label: 'Result Code', initial_value: 0
end
end
end
17 changes: 12 additions & 5 deletions lib/ruby_smb/nbss/negative_session_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,29 @@ 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
uint8 :error_code, label: 'Error Code'

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
95 changes: 95 additions & 0 deletions lib/ruby_smb/nbss/node_status.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
require 'socket'

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 [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<Entry>, 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
31 changes: 31 additions & 0 deletions lib/ruby_smb/nbss/node_status_request.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
require 'ruby_smb/nbss/name_service_header_flags'

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'
name_service_header_flags :flags, label: 'Flags'
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
81 changes: 81 additions & 0 deletions lib/ruby_smb/nbss/node_status_response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
require 'ruby_smb/nbss/name_service_header_flags'

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?
name_flags[:group].to_i == 1
end

def unique?
!group?
end

def active?
name_flags[:active].to_i == 1
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_header_flags :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
15 changes: 15 additions & 0 deletions spec/lib/ruby_smb/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -776,6 +790,7 @@
expect(client.session_request_packet.called_name).to eq('*SMBSERVER ')
end
end

end

context 'Protocol Negotiation' do
Expand Down
Loading
Loading