Skip to content

Add NetBIOS name resolution fallback for session_request#296

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

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

Conversation

@Z6543
Copy link
Copy Markdown

@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.

Z6543 and others added 21 commits March 14, 2026 12:44
Win95 uses a pre-NTLMSSP dialect of SMB1 that differs from modern
Windows in several ways. This commit adds the minimum changes needed
to handle those differences across the negotiate, auth, tree connect,
and file listing phases.

Negotiate (negotiation.rb, negotiate_response.rb):
- Handle NegotiateResponse (non-extended security) in addition to
  NegotiateResponseExtended. Win95 returns a raw 8-byte challenge
  instead of a SPNEGO security blob.
- Override DataBlock#do_read in NegotiateResponse to tolerate
  byte_count=8 responses that omit domain_name and server_name
  (Win95 only sends the challenge).

Authentication (authentication.rb, session_setup_legacy_request.rb,
session_setup_legacy_response.rb):
- Add smb1_legacy_authenticate path triggered when the negotiate
  phase stored an 8-byte challenge (@smb1_negotiate_challenge).
  Computes LM and NTLM challenge-response hashes via Net::NTLM
  and sends them in SessionSetupLegacyRequest.
- Fix SessionSetupLegacyRequest DataBlock: account_name and
  primary_domain were fixed-length `string` (2 bytes) instead of
  null-terminated `stringz`, truncating the username and domain.
- Override DataBlock#do_read in SessionSetupLegacyResponse to
  handle byte_count=0 responses (Win95 returns no string fields).

NetBIOS session (client.rb, client_spec.rb):
- Use @local_workstation as the calling name in NetBIOS session
  requests. Win95 rejects sessions with an empty calling name.

Tree connect (tree_connect_response.rb):
- Make optional_support conditional on word_count >= 3. Win95
  returns a smaller ParameterBlock without this field.
- Override DataBlock#do_read to handle responses that omit
  native_file_system when byte_count only covers the service field.

File listing (tree.rb, find_information_level.rb,
find_info_standard.rb):
- Add FindInfoStandard struct for SMB_INFO_STANDARD (level 1),
  the LANMAN 2.0 information level used by Win95.
- When type is FindInfoStandard, disable unicode in the request,
  request up to 255 entries, and return early after FIND_FIRST2
  using raw byte parsing (parse_find_first2_info_standard) that
  bypasses BinData alignment issues with Win95 responses.
- Clamp max_data_count to server_max_buffer_size in set_find_params
  so requests don't exceed Win95's small buffer limit.
- Add defensive guards in the FIND_NEXT2 pagination loop: use safe
  navigation on results.last, check for empty batches, and require
  `last` to be non-nil before continuing. These prevent infinite
  loops when the server returns zero results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move three protocol features from the smb_browser application into the
ruby_smb library so they are reusable:

SMB_COM_OPEN_ANDX (0x2D) packet classes and Tree#open_andx:
  New OpenAndxRequest and OpenAndxResponse BinData packet classes
  implement the LANMAN 1.0 file-open command. Tree#open_andx uses
  these to open files on servers that lack NT_CREATE_ANDX, such as
  Windows 95/98/ME. Returns a standard SMB1::File handle that
  supports read/write/close via the existing ReadAndx/WriteAndx
  infrastructure.

Share-level password on tree_connect:
  Client#tree_connect and smb1_tree_connect now accept a password:
  keyword argument. When provided, the password is placed in the
  TreeConnectRequest data block with the null terminator and correct
  password_length, enabling connection to shares protected by
  share-level authentication (Windows 95/98/ME).

RAP share enumeration via Client#net_share_enum_rap:
  New method sends a NetShareEnum RAP request (function 0) over
  \PIPE\LANMAN to enumerate shares on servers that do not support
  DCERPC/srvsvc. Returns an array of {name:, type:} hashes.
  Automatically connects to and disconnects from IPC$.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The password field in TreeConnectRequest::DataBlock was declared as
BinData::Stringz, which always appends a null terminator when
serializing regardless of the length parameter. With password_length
set to N, the field wrote N+1 bytes (the password data plus a null),
corrupting the share path that follows in the packet.

This broke any use case requiring exact control over the password byte
count, such as CVE-2000-0979 exploitation. Windows 95 uses
password_length to decide how many bytes to validate. With the extra
null, the server read the correct password bytes but then parsed the
null as the start of the share path, causing every tree connect to
fail with a path error rather than a password result.

Change the field from stringz to string. BinData::String respects the
length parameter exactly, writing precisely password_length bytes with
no trailing null. The initial_value changes from '' to "\x00" to
preserve the default behavior: when no password is set, the field
writes one null byte (matching the default password_length of 1).

Existing callers are unaffected because smb1_tree_connect already
appends the null terminator explicitly (password + "\x00") and sets
password_length to include it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Windows 95 rejects NetBIOS session requests using the wildcard name
'*SMBSERVER'. When the server responds with "Called name not present",
look up the server's actual NetBIOS name via nmblookup or a raw UDP
Node Status query (RFC 1002, port 137), reconnect the TCP socket, and
retry the session request with the resolved name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix blob.getbyte, validate RAP response, use numeric NBSS error_code
- Add specs for RAP + *SMBSERVER retry + SMB_INFO_STANDARD parsing
- Collapse SMB1 negotiate response branches; use "\x00".b in tree_connect
- Fix 404 doc links on OPEN_ANDX request/response and FindInfoStandard
- Make FindInfoStandard round-trip via BinData; drop byte-slicing path
- Auto-select OPEN_ANDX inside open_file via supports_nt_smbs capability
- Move RAP NetShareEnum into lib/ruby_smb/rap/ + SMB1::Pipe mixin
- Replace hand-crafted UDP Node Status bytes with NBNS BinData structs
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.
The RAP refactor moved net_share_enum onto SMB1::Pipe, but Metasploit
modules that predate this change still call Client#net_share_enum_rap.
Restore that entry point as a thin wrapper; guard it so it raises
cleanly when SMB2/3 was negotiated (addresses smcintyre-r7's original
concern about the method breaking in the SMB2/3 path).

Also let the RAP NetShareEnum module be mixed into SMB1::Tree directly,
via a rap_tree helper that resolves self-or-tree. Win9x servers do not
allow OPEN_ANDX on \PIPE\LANMAN, so callers cannot always instantiate a
Pipe first; Tree-level access handles that.
Windows 95 packs Trans response parameters immediately after byte_count
(parameter_offset = 55), with no 4-byte-alignment padding. BinData's
Trans::Response::DataBlock always inserts a 1-byte pad1 before reading
trans_parameters, so the parser reads bytes 56-63 instead of 55-62 and
mis-decodes status as 0x9f00 instead of the real 0x0000.

Slice the parameter and data sections from the raw response using the
parameter_offset and data_offset fields the server set, which are
authoritative. BinData-generated responses (including our own test
fixtures) put the same offsets, so existing specs are unaffected.

Add a regression spec that constructs a Win9x-style response with
parameter_offset=55.
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.
Co-authored-by: Spencer McIntyre <58950994+smcintyre-r7@users.noreply.github.com>
Co-authored-by: Spencer McIntyre <58950994+smcintyre-r7@users.noreply.github.com>
OpenAndxResponse (comment r3133976375):
- Align field names with MS-CIFS 2.2.4.41.2: granted_access->access_rights,
  file_type->resource_type, device_state->nmpipe_status, action->open_results,
  data_size->file_data_size, file_attributes->file_attrs.
- Use SMB_NMPIPE_STATUS bit_field for the nmpipe_status field instead of a
  plain uint16 (same type the NT_CREATE_ANDX / TRANS2_OPEN2 responses use).
- Use SMB_FILE_ATTRIBUTES bit_field for file_attrs and UTIME for
  last_write_time — consistent with the other SMB1 open responses.
- Collapse the misplaced 'server_fid'(4) + 'reserved'(2) pair into a single
  Reserved[3] array of uint16s, as the spec defines.
- Update Tree#build_open_andx_handle callers for the renamed fields.

Client#net_share_enum_rap (comment r3134040652):
- Drop from the Client namespace. Callers use tree.net_share_enum on an
  IPC$ tree instead, mirroring the rest of the tree-scoped helpers.
@Z6543 Z6543 force-pushed the netbios-name-resolution-fallback branch from 9a3987d to 8c74913 Compare April 24, 2026 06:50
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.

1 participant