Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions lib/ruby_smb/nbss.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
29 changes: 29 additions & 0 deletions lib/ruby_smb/nbss/node_status_request.rb
Original file line number Diff line number Diff line change
@@ -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
66 changes: 66 additions & 0 deletions lib/ruby_smb/nbss/node_status_response.rb
Original file line number Diff line number Diff line change
@@ -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
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
37 changes: 37 additions & 0 deletions spec/lib/ruby_smb/nbss/node_status_request_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading