Skip to content

Add NetBIOS name resolution fallback for session_request#296

Open
Z6543 wants to merge 14 commits intorapid7:masterfrom
Z6543:netbios-name-resolution-fallback
Open

Add NetBIOS name resolution fallback for session_request#296
Z6543 wants to merge 14 commits intorapid7:masterfrom
Z6543:netbios-name-resolution-fallback

Conversation

@Z6543
Copy link
Copy Markdown
Contributor

@Z6543 Z6543 commented Apr 23, 2026

Summary

Follow-up to #294 addressing review comments #16, #17, #18, #19 and #20 from @smcintyre-r7. Splits the NetBIOS auto-discovery functionality that was removed from #294 into a dedicated reviewable unit.

On CALLED_NAME_NOT_PRESENT (NBSS error 0x82) when the caller used the default *SMBSERVER wildcard, Client#session_request now performs a pure-Ruby NBNS Node Status query, reconnects the TCP socket, and retries once with the resolved name. A caller-supplied name (e.g. Metasploit's SMBName) short-circuits auto-discovery.

Relationship to #294

This branch contains the commits from #294 plus the NetBIOS-specific additions on top. Review is easiest after #294 merges — at that point the diff here will be only the NetBIOS work. If you'd prefer, I can rebase onto master directly once #294 lands.

What's in the diff

  • RubySMB::Nbss::NodeStatus.query(host) / .file_server_name(host) — public pure-Ruby nmblookup -A equivalent, built on new Nbss::NodeStatusRequest and Nbss::NodeStatusResponse BinData structures. No external binaries.
  • Client#tcp_socket_factory and Client#udp_socket_factory — injectable callables for socket creation. Default to stdlib TCPSocket.new / UDPSocket.new; Metasploit passes Rex::Socket::Tcp.create / Rex::Socket::Udp.create factories so pivoted connections still work.
  • NetBiosSessionService#error_code — typed NBSS error code attribute plus named constants (CALLED_NAME_NOT_PRESENT = 0x82, etc). Retry matches on the number, not a message substring.
  • Local bind to UDP/137 before sending the Node Status query. Win9x ignores the client's source port and always replies to destination port 137, so an ephemeral-port socket never receives the answer. Bind silently falls through on EACCES/EADDRINUSE — the query still works against NBNS servers that honor the source port. The unprivileged bind succeeds when the Ruby interpreter has CAP_NET_BIND_SERVICE or net.ipv4.ip_unprivileged_port_start is 137 or lower.
  • respond_to?(:sendto) branches between the Rex-style sendto(mesg, host, port) / recvfrom(len, timeout) API and the stdlib send(mesg, flags, host, port) / IO.select + recvfrom(len) pattern.

Addresses PR #294 review comments

Test plan

  • bundle exec rspec — 12,292 examples, 0 failures (+14 new specs: node_status_request_spec.rb, node_status_response_spec.rb, node_status_spec.rb, plus *SMBSERVER retry coverage in client_spec.rb).
  • End-to-end against a live Windows 95 target (via Metasploit's cve_2000_0979 module): session_request retry fires, NodeStatus resolves the real name, tree-connect proceeds.
  • Verified pivoted path works when the MSF caller supplies a Rex::Socket::Udp.create factory.
  • Reviewer concurrence on the socket-factory abstraction.

@smcintyre-r7
Copy link
Copy Markdown
Contributor

Since you split this out there's conflicts. It looks like there was some shared commits in this branch and in #294 which has been landed. Can you please rebase this on master so it'll merge cleanly? It should include those changes and then I can get to reviewing and testing this. It'll also make review easier because it won't keep changes that have already been merged.

@smcintyre-r7 smcintyre-r7 self-assigned this May 7, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds NetBIOS Node Status–based name resolution and uses it as a fallback retry for Client#session_request when *SMBSERVER is rejected, while also extending SMB1 support for legacy (Win9x/LANMAN-era) servers (RAP share enumeration, OPEN_ANDX, and more tolerant packet parsing).

Changes:

  • Add pure-Ruby NBNS Node Status query support and session_request retry logic on NBSS CALLED_NAME_NOT_PRESENT (0x82), with injectable TCP/UDP socket factories.
  • Add/extend SMB1 legacy compatibility: SMB_INFO_STANDARD directory parsing, OPEN_ANDX support, RAP NetShareEnum over \PIPE\LANMAN, and tolerant parsing for short/variant SMB1 responses.
  • Extend client APIs for legacy servers (e.g., share-level password support in SMB1 tree connect; legacy non-extended-security SMB1 auth path).

Impact Analysis:

  • Blast radius: High — touches RubySMB::Client connection/session establishment (session_request, negotiation/auth), SMB1 tree operations (list, open_file/open_pipe), and introduces new public RAP/NBNS helpers likely consumed by external callers.
  • Data and contract effects: Adds new injectable factories and new keyword args (e.g., tree_connect(..., password:)), and changes behavior of session_request to reconnect/retry under specific NBSS failures; compatibility risk if callers relied on previous one-shot behavior or on requiring ruby_smb/smb1 standalone.
  • Rollback and test focus: Rollback should be straightforward (feature is largely additive) but validate: (1) SMB1 session establishment on port 139 with Win9x, (2) directory listing pagination via FIND_FIRST2/FIND_NEXT2 with SMB_INFO_STANDARD, (3) OPEN_ANDX handling for non-disk resources, (4) RAP NetShareEnum request serialization and truncated-response handling.

Reviewed changes

Copilot reviewed 33 out of 33 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
spec/lib/ruby_smb/smb1/tree_spec.rb Adds specs for SMB_INFO_STANDARD listing behavior and updated max_data_count expectation.
spec/lib/ruby_smb/rap/net_share_enum_spec.rb New specs for RAP NetShareEnum request/response handling and Win9x offset quirks.
spec/lib/ruby_smb/nbss/node_status_spec.rb New specs for Node Status UDP query, retries, parsing failures, and socket API variants.
spec/lib/ruby_smb/nbss/node_status_response_spec.rb New specs for Node Status Response decoding and file-server-name selection.
spec/lib/ruby_smb/nbss/node_status_request_spec.rb New specs verifying Node Status Request encoding.
spec/lib/ruby_smb/client_spec.rb Adds specs for *SMBSERVER rejection retry path, socket factory usage, and UDP lookup wiring.
lib/ruby_smb/smb1/tree.rb Includes RAP module, updates listing logic for SMB_INFO_STANDARD, caps max_data_count, and adds OPEN_ANDX path.
lib/ruby_smb/smb1/pipe.rb Extends Pipe to support RAP NetShareEnum when opened as \PIPE\LANMAN.
lib/ruby_smb/smb1/packet/tree_connect_response.rb Makes parsing tolerant of short Win9x TreeConnect responses (missing fields).
lib/ruby_smb/smb1/packet/tree_connect_request.rb Fixes password field serialization to respect configured length exactly.
lib/ruby_smb/smb1/packet/trans2/find_information_level/find_info_standard.rb Adds BinData structure for SMB_INFO_STANDARD entries.
lib/ruby_smb/smb1/packet/trans2/find_information_level.rb Requires the new SMB_INFO_STANDARD info-level structure.
lib/ruby_smb/smb1/packet/trans2/find_first2_response.rb Adds parsing mode for sequential (no next_offset) info levels with optional pad bytes.
lib/ruby_smb/smb1/packet/session_setup_legacy_response.rb Makes legacy session-setup response parsing tolerant of byte_count=0.
lib/ruby_smb/smb1/packet/session_setup_legacy_request.rb Updates legacy session-setup request strings to null-terminated fields.
lib/ruby_smb/smb1/packet/open_andx_response.rb Adds SMB_COM_OPEN_ANDX response packet structure.
lib/ruby_smb/smb1/packet/open_andx_request.rb Adds SMB_COM_OPEN_ANDX request packet structure.
lib/ruby_smb/smb1/packet/negotiate_response.rb Makes negotiate parsing tolerant of short Win9x response layouts.
lib/ruby_smb/smb1/packet.rb Requires new OPEN_ANDX packet classes.
lib/ruby_smb/smb1/commands.rb Adds SMB_COM_OPEN_ANDX command constant.
lib/ruby_smb/rap/net_share_enum.rb Implements RAP NetShareEnum over SMB_COM_TRANSACTION and Win9x offset-aware parsing.
lib/ruby_smb/rap.rb Introduces RAP namespace entry point.
lib/ruby_smb/nbss/node_status.rb Implements pure-Ruby NBNS Node Status query with retry/timeout and socket-factory injection.
lib/ruby_smb/nbss/node_status_response.rb Defines Node Status Response BinData structures and file-server-name helper.
lib/ruby_smb/nbss/node_status_request.rb Defines Node Status Request BinData structure.
lib/ruby_smb/nbss/negative_session_response.rb Adds NBSS error code constants and uses them in error message mapping.
lib/ruby_smb/nbss.rb Requires new NBNS Node Status request/response/query modules.
lib/ruby_smb/error.rb Extends NetBiosSessionService error to carry numeric NBSS error_code.
lib/ruby_smb/client/tree_connect.rb Adds optional SMB1 share-level password: to tree connect request building.
lib/ruby_smb/client/negotiation.rb Tracks supports_nt_smbs and captures non-extended-security SMB1 challenge bytes.
lib/ruby_smb/client/authentication.rb Adds legacy SMB1 auth path using LM/NTLM challenge-response when non-extended security is negotiated.
lib/ruby_smb/client.rb Adds socket factories, supports_nt_smbs, share-level tree_connect password plumbing, and NBSS retry logic with Node Status lookup.
lib/ruby_smb.rb Requires the new RAP module at top-level.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/ruby_smb/smb1/tree.rb
Comment on lines +6 to +10
# Exposes #net_share_enum directly on the tree for callers that need
# RAP against \PIPE\LANMAN without opening the pipe (Win9x servers do
# not permit OPEN_ANDX on it).
include RubySMB::Rap::NetShareEnum

Comment thread lib/ruby_smb/smb1/tree.rb
while eos.zero? && last
find_next_request = RubySMB::SMB1::Packet::Trans2::FindNext2Request.new
find_next_request = set_header_fields(find_next_request)
find_next_request.smb_header.flags2.unicode = 1 if unicode
Comment thread lib/ruby_smb/smb1/tree.rb
Comment on lines +355 to +366
unless response.parameter_block.resource_type == RubySMB::SMB1::ResourceType::DISK
raise RubySMB::Error::RubySMBError,
"SMB_COM_OPEN_ANDX resource type 0x#{response.parameter_block.resource_type.to_s(16)} not supported"
end
file = RubySMB::SMB1::File.allocate
file.tree = self
file.name = filename
file.fid = response.parameter_block.fid
file.size = response.parameter_block.file_data_size
file.size_on_disk = response.parameter_block.file_data_size
file.attributes = response.parameter_block.file_attributes
file
request = RubySMB::SMB1::Packet::Trans::Request.new
request.smb_header.tid = rap_tree.id
request.smb_header.flags2.unicode = 0
request.data_block.name = "\\PIPE\\LANMAN\x00".b
Comment on lines +133 to +139
break [] if offset + ShareInfo1.new.num_bytes > data_bytes.bytesize
entry = ShareInfo1.read(data_bytes[offset, ShareInfo1.new.num_bytes])
{
name: entry.netname.to_s.delete("\x00"),
type: entry.share_type
}
end.compact
Z6543 added 11 commits May 9, 2026 12:02
Address PR rapid7#294 review comments rapid7#16 and rapid7#18. The session-request retry
and UDP Node Status query previously hardcoded TCPSocket.new and
UDPSocket.new, which breaks Metasploit pivoting because Metasploit
needs every socket to come from Rex::Socket.

Add tcp_socket_factory and udp_socket_factory attributes on Client.
Both default to stdlib socket constructors for standalone use; callers
that need custom socket creation (Rex::Socket, test doubles, TLS
wrappers) can inject their own callable. Skip setsockopt on sockets
that don't respond to it, so Rex-style sockets don't break the retry.
The retry was gated on name == '*SMBSERVER', which missed the common
case where a caller passes a specific NetBIOS name that the server
rejects with CALLED_NAME_NOT_PRESENT. Drop the name gate and instead
guard against retry loops by bailing when the resolved name matches
what was just rejected (case-insensitive, whitespace-insensitive).
Coerce error_code through to_i so the comparison works regardless of
whether the attribute holds a plain Integer or a BinData primitive.
Per PR rapid7#294 review comment, the library should do everything in Ruby.
Remove netbios_lookup_nmblookup entirely; netbios_lookup_name now only
uses the native NBSS UDP Node Status query.
If the caller supplied an explicit called name (anything other than the
wildcard '*SMBSERVER' or an empty string), honor it and propagate the
server's rejection instead of silently starting a UDP Node Status query
and reconnecting the socket. This lets Metasploit's SMBName option do
what a user expects — setting it bypasses auto-discovery entirely.

Auto-discovery still kicks in for the default wildcard or an empty name,
so no regression for callers that relied on it.
New RubySMB::Nbss::NodeStatus module exposes two public entry points:

  RubySMB::Nbss::NodeStatus.query(host) # => [Entry, ...] full name table
  RubySMB::Nbss::NodeStatus.file_server_name(host) # => String (0x20 UNIQUE)

No shell-out to Samba's nmblookup. Supports retries, a configurable
timeout, and an injectable UDP socket factory so Metasploit callers can
route the query through Rex::Socket instead of opening a raw stdlib
UDPSocket.

Client#netbios_lookup_udp now just delegates to
NodeStatus.file_server_name, passing the client's udp_socket_factory.
Rex::Socket::Udp inherits `send(mesg, flags, [sockaddr])` from Socket,
not stdlib UDPSocket's 4-arg variant, so calling `send(bytes, 0, host,
port)` on it raises "wrong number of arguments". Route through
`sendto(mesg, host, port)` when the socket exposes it; otherwise fall
back to stdlib's 4-arg `send`.
IO.select([sock]) can miss wakeups on a Rex::Socket::Udp because Rex's
own timed_read selects on the underlying fd rather than on self. Call
sock.recvfrom(length, timeout) directly when the socket provides the
sendto/recvfrom(..., timeout) pair, and only use IO.select for stdlib
UDPSocket which has no per-call receive timeout.

Fixes NBNS node-status query silently timing out when the socket factory
returns a Rex::Socket::Udp (Metasploit module pivot path).
Windows 9x ignores the client source port on NBNS and always sends the
Node Status response to destination port 137. On an ephemeral-port
socket the kernel drops the reply, so the query appears to time out
even though the server is answering (verified via tcpdump: the 229-byte
reply goes to :137, not to our ephemeral port).

Try to bind(0.0.0.0:137) on the local UDP socket before sending, same
technique Samba's nmblookup uses. Silently fall back to the ephemeral
bind when we lack the privilege (EACCES) or the port is already held
by another listener (EADDRINUSE) — the query will still succeed against
well-behaved NBNS servers that honor the request's source port.
Rex::Socket::Udp's bind takes a single sockaddr string, so calling
sock.bind('0.0.0.0', 137) on it raised ArgumentError before we got to
the actual query. Rex sockets bind their local endpoint at create time
via 'LocalHost'/'LocalPort' instead — skip the post-create bind path
entirely for anything that exposes sendto (the Rex-style API marker we
were already using).

Also widen the rescue to swallow ArgumentError in case another socket
type surfaces a similarly incompatible bind signature.
Earlier change assumed Win9x replies are delivered only when the client
is bound to local port 137. In practice the response is received fine
on an ephemeral-port socket (confirmed by tcpdump + nmblookup on the
reporter's host), so the bind trick adds complexity without buying us
anything and breaks on Rex::Socket::Udp whose bind signature differs.

Keep node_status.rb lean: send, wait with a timeout appropriate to the
socket type, parse. Fall back to `set SMBName ...` when a target still
doesn't answer.
Reverting the previous revert — Win9x NBNS does reply to destination
port 137 regardless of the client source port, so the kernel drops the
answer on an ephemeral-port socket. Bind locally to 137 before sending,
same technique Samba's nmblookup uses (which works without root when
the binary has CAP_NET_BIND_SERVICE or the system has
net.ipv4.ip_unprivileged_port_start lowered).

Skips the 2-arg bind on Rex::Socket::Udp (its bind signature differs);
Rex callers bind via 'LocalPort' at create time. On EACCES/EADDRINUSE
the bind silently fails and the caller keeps its ephemeral port — this
still works against well-behaved NBNS servers that honor the request's
source port.
@Z6543 Z6543 force-pushed the netbios-name-resolution-fallback branch from 8c74913 to 075c157 Compare May 9, 2026 12:11
@Z6543
Copy link
Copy Markdown
Contributor Author

Z6543 commented May 9, 2026

The Copilot comments are no longer valid for this PR after the rebase.

There is one important "challenge" with this PR. Metasploit has to be able to listen on port 137. Even though the smb client sends the UDP packet from a random high port to port 137, the smb server responds to port 137 from port 137.

But on the other hand, this PR is only needed if the Metasploit user does not provide the SMBSERVER parameter and it is left on default.

Here is the output of nmblookup -A 192.168.124.178

Request:
12:23:09.064726 IP 192.168.124.69.49411 > 192.168.124.178.137: UDP, length 50
        0x0000:  4500 004e a7fc 4000 4011 185a c0a8 7c45  E..N..@.@..Z..|E
        0x0010:  c0a8 7cb2 c103 0089 003a 7a94 1db8 0000  ..|......:z.....
        0x0020:  0001 0000 0000 0000 2043 4b41 4141 4141  .........CKAAAAA
        0x0030:  4141 4141 4141 4141 4141 4141 4141 4141  AAAAAAAAAAAAAAAA
        0x0040:  4141 4141 4141 4141 4100 0021 0001       AAAAAAAAA..!..

Response, see ports: 
12:23:09.071411 IP 192.168.124.178.137 > 192.168.124.69.137: UDP, length 229
        0x0000:  4500 0101 9103 0000 2011 8ea0 c0a8 7cb2  E.............|.
        0x0010:  c0a8 7c45 0089 0089 00ed d867 1db8 8400  ..|E.......g....
        0x0020:  0000 0001 0000 0000 2043 4b41 4141 4141  .........CKAAAAA
        0x0030:  4141 4141 4141 4141 4141 4141 4141 4141  AAAAAAAAAAAAAAAA
        0x0040:  4141 4141 4141 4141 4100 0021 0001 0000  AAAAAAAAA..!....
        0x0050:  0000 009b 0644 4546 4155 4c54 2020 2020  .....DEFAULT....
        0x0060:  2020 2020 0044 0057 4f52 4b47 524f 5550  .....D.WORKGROUP
....                                  

Z6543 added 3 commits May 9, 2026 12:52
These files were dropped during the rebase onto upstream/master because
the commit that introduced them (1845dac) was detected as already
reachable from upstream, but 09c848c had reverted the actual files.
Win9x always sends the Node Status response to destination port 137,
so the reply is dropped when the client socket is on an ephemeral port.
Binding to 137 fixes it but fails with EACCES unless the process has
CAP_NET_BIND_SERVICE. A raw IPPROTO_UDP socket receives a copy of every
incoming IP datagram regardless of destination port, so it works without
an exclusive bind. query_via_raw_socket uses this approach when the
caller has CAP_NET_RAW; falls back gracefully on EPERM/EACCES.
When nmbd owns UDP/137 on the attacker host it intercepts Win9x NBNS
replies, causing netbios_lookup_udp to time out (3 retries × 2 s ≈ 6 s).
That 6-second hang pushes NBSS session setup past Rex's ConnectTimeout,
so the caller sees Rex::ConnectionTimeout instead of a clean NBSS error.

Add netbios_lookup_raw_socket as a fallback: it calls
NodeStatus.query_via_raw_socket which uses SOCK_RAW/IPPROTO_UDP and
receives a kernel copy of every incoming UDP datagram regardless of
which process owns port 137. netbios_lookup_name now chains both paths
so the first one that returns a name wins.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants