-
Notifications
You must be signed in to change notification settings - Fork 88
Add NetBIOS name resolution fallback for session_request #296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
d49ec54
facda14
db69cd8
150c57a
9b83361
3d335fe
ef7e0a3
47a1bd2
091893e
c1a8ca3
075c157
ef3824c
728a17c
679da4d
fef4173
0e99467
2cabcd7
18649b3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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 | ||
| 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 | ||
|
Comment on lines
+15
to
+19
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That flags should really be defined as a bit field using Bindata. This becomes important if an instance is returned to another namespace where the GROUP_BIT may not already be defined such as a Metasploit module That would allow the Metasploit module to do require 'ruby_smb' # first it has to be require'ed so RubySMB is even defined
...
# next you have to do the bit operation |=, presenting an opportunity to accidentally omit the | and thus introduce a subtle bug
node_status_name.name_flags |= RubySMB::Nbss::NodeStatusName::GROUP_BIT |
||
|
|
||
| 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' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same comment regarding 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 | ||
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same comment here regarding the
flagsfield. If they're never used because no flag is ever set, you could cut corners by just skipping this field and defining it as a padding. If it's used though because one or more flag is set, then it needs to be fully defined.