Conversation
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>
There was a problem hiding this comment.
Pull request overview
Adds SMB1 compatibility for legacy (pre-NT) SMB servers (notably Windows 95/98/ME) by supporting non-extended security negotiation/auth, RAP-based share enumeration, and additional SMB1 command/response handling.
Changes:
- Add SMB1 legacy protocol features: non-extended security negotiate + legacy SessionSetup, RAP share enumeration, and SMB_COM_OPEN_ANDX support.
- Improve SMB1 directory listing compatibility (SMB_INFO_STANDARD path and buffer sizing adjustments).
- Fix SMB1 TreeConnect password field serialization and add NetBIOS name-resolution retry when
*SMBSERVERis rejected.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| spec/lib/ruby_smb/smb1/tree_spec.rb | Updates expectation for max_data_count behavior to reflect negotiated server buffer size. |
| spec/lib/ruby_smb/client_spec.rb | Adjusts NetBIOS session request expectations for calling name formatting. |
| lib/ruby_smb/smb1/tree.rb | Adds SMB_COM_OPEN_ANDX helper and legacy SMB_INFO_STANDARD parsing/behavior for directory listing. |
| lib/ruby_smb/smb1/packet/tree_connect_response.rb | Makes TreeConnectResponse parsing tolerant of minimal Win9x responses. |
| lib/ruby_smb/smb1/packet/tree_connect_request.rb | Fixes TreeConnect password field serialization length behavior. |
| lib/ruby_smb/smb1/packet/trans2/find_information_level/find_info_standard.rb | Introduces SMB_INFO_STANDARD find info structure for legacy servers. |
| lib/ruby_smb/smb1/packet/trans2/find_information_level.rb | Loads the new SMB_INFO_STANDARD info level. |
| lib/ruby_smb/smb1/packet/session_setup_legacy_response.rb | Makes legacy SessionSetup response parsing tolerant of empty string blocks. |
| lib/ruby_smb/smb1/packet/session_setup_legacy_request.rb | Adjusts legacy SessionSetup request strings to be null-terminated string fields. |
| lib/ruby_smb/smb1/packet/open_andx_request.rb | Adds SMB_COM_OPEN_ANDX request packet definition. |
| lib/ruby_smb/smb1/packet/open_andx_response.rb | Adds SMB_COM_OPEN_ANDX response packet definition. |
| lib/ruby_smb/smb1/packet/negotiate_response.rb | Makes SMB1 negotiate parsing tolerant of challenge-only Win9x responses. |
| lib/ruby_smb/smb1/packet.rb | Requires the new OpenAndx packet classes. |
| lib/ruby_smb/smb1/commands.rb | Adds SMB_COM_OPEN_ANDX command constant. |
| lib/ruby_smb/client/tree_connect.rb | Adds SMB1 share-level password support for TreeConnect. |
| lib/ruby_smb/client/negotiation.rb | Supports SMB1 non-extended security negotiate responses and stores the legacy challenge. |
| lib/ruby_smb/client/authentication.rb | Adds SMB1 legacy (LM/NTLM challenge-response) authentication path. |
| lib/ruby_smb/client.rb | Adds RAP share enumeration and NetBIOS *SMBSERVER rejection fallback with name lookup + reconnect. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| break if offset + 23 > blob.length | ||
| name_len = blob[offset + 22].ord | ||
| if name_len == 0 | ||
| offset += 23 | ||
| next | ||
| end | ||
| break if offset + 23 + name_len > blob.length | ||
| entry = type.read(blob[offset, 23 + name_len]) | ||
| results << entry | ||
| entry_end = offset + 23 + name_len | ||
| if entry_end < blob.length && blob[entry_end].ord == 0 | ||
| offset = entry_end + 1 |
There was a problem hiding this comment.
blob[offset + 22].ord (and later blob[entry_end].ord) will raise NoMethodError because String#[] with an integer index returns an Integer in Ruby, not a 1-byte String. Use getbyte (e.g., blob.getbyte(offset + 22)) or slice a 1-byte substring before calling .ord to safely read these length/terminator bytes.
There was a problem hiding this comment.
Fixed in 1845dac by switching to getbyte. (Minor nit — str[i].ord actually does work in Ruby 1.9+ since String#[] returns a 1-byte String there, not an Integer, but getbyte is clearer anyway.) The whole parse_info_standard_blob path was subsequently removed in the same commit when FindInfoStandard became a proper BinData type parsed through FindFirst2Response#results — see the reply to #14.
| response = RubySMB::SMB1::Packet::Trans::Response.read( | ||
| raw_response | ||
| ) | ||
|
|
||
| rap_resp_params = response.data_block.trans_parameters.to_s | ||
| if rap_resp_params.length < 8 | ||
| raise RubySMB::Error::InvalidPacket, | ||
| 'Invalid RAP response parameters' | ||
| end | ||
|
|
||
| _status, _converter, entry_count, _available = | ||
| rap_resp_params.unpack('vvvv') | ||
|
|
||
| rap_data = response.data_block.trans_data.to_s | ||
| shares = [] | ||
| entry_count.times do |i| | ||
| offset = i * 20 | ||
| break if offset + 20 > rap_data.length | ||
| name = rap_data[offset, 13].delete("\x00") | ||
| type_val = rap_data[offset + 14, 2].unpack1('v') | ||
| shares << { name: name, type: type_val & 0x0FFFFFFF } | ||
| end | ||
| shares |
There was a problem hiding this comment.
net_share_enum_rap parses the transaction response without validating the SMB packet. Please verify response.valid? and that response.status_code indicates success before unpacking trans_parameters/trans_data, and consider failing fast if the RAP _status field is non-zero (it is currently ignored). Without these checks, malformed/failed responses can be silently misinterpreted as an empty/partial share list.
| send_session_request(name) | ||
| rescue RubySMB::Error::NetBiosSessionService => e | ||
| raise unless name == '*SMBSERVER' && e.message.include?('Called name not present') | ||
|
|
There was a problem hiding this comment.
The session-request retry logic keys off e.message.include?('Called name not present'), which is brittle (message text can change) and loses access to the underlying NBSS error_code (0x82). Since NegativeSessionResponse already exposes error_code, consider propagating the code in the raised exception (or returning it) so the retry condition can be based on the numeric code instead of substring matching.
There was a problem hiding this comment.
This code path was split out to a separate branch (netbios-name-resolution-fallback) per @smcintyre-r7's recommendation in #20 below. In that branch the retry keys on e.error_code (new typed attribute on NetBiosSessionService) rather than a message-substring match.
| @@ -644,35 +701,154 @@ def wipe_state! | |||
| end | |||
|
|
|||
| # Requests a NetBIOS Session Service using the provided name. | |||
| # When the name is '*SMBSERVER' and the server rejects it with | |||
| # "Called name not present", this method automatically looks up | |||
| # the server's actual NetBIOS name via a Node Status query, | |||
| # reconnects the TCP socket, and retries. | |||
| # | |||
| # @param name [String] the NetBIOS name to request | |||
| # @return [TrueClass] if session request is granted | |||
| # @raise [RubySMB::Error::NetBiosSessionService] if session request is refused | |||
| # @raise [RubySMB::Error::InvalidPacket] if the response packet is not a NBSS packet | |||
| def session_request(name = '*SMBSERVER') | |||
| send_session_request(name) | |||
| rescue RubySMB::Error::NetBiosSessionService => e | |||
| raise unless name == '*SMBSERVER' && e.message.include?('Called name not present') | |||
|
|
|||
| sock = dispatcher.tcp_socket | |||
| if sock.respond_to?(:peerhost) | |||
| host = sock.peerhost | |||
| port = sock.peerport | |||
| else | |||
| addr = sock.remote_address | |||
| host = addr.ip_address | |||
| port = addr.ip_port | |||
| end | |||
|
|
|||
| resolved = netbios_lookup_name(host) | |||
| raise unless resolved | |||
|
|
|||
| dispatcher.tcp_socket.close rescue nil | |||
| new_sock = TCPSocket.new(host, port) | |||
| new_sock.setsockopt( | |||
| ::Socket::SOL_SOCKET, ::Socket::SO_KEEPALIVE, true | |||
| ) | |||
| dispatcher.tcp_socket = new_sock | |||
| send_session_request(resolved) | |||
| end | |||
|
|
|||
| private | |||
|
|
|||
| # Sends a single NetBIOS Session Request and reads the response. | |||
| # | |||
| # @param name [String] the NetBIOS name to request | |||
| # @return [TrueClass] if session request is granted | |||
| def send_session_request(name) | |||
| session_request = session_request_packet(name) | |||
| dispatcher.send_packet(session_request, nbss_header: false) | |||
| raw_response = dispatcher.recv_packet(full_response: true) | |||
| 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) | |||
| negative_session_response = RubySMB::Nbss::NegativeSessionResponse.read(raw_response) | |||
| raise RubySMB::Error::NetBiosSessionService, "Session Request failed: #{negative_session_response.error_msg}" | |||
| end | |||
| rescue IOError | |||
| raise RubySMB::Error::InvalidPacket, 'Not a NBSS packet' | |||
| end | |||
|
|
|||
| return true | |||
| true | |||
| end | |||
|
|
|||
| # Resolves a host's NetBIOS name. Tries nmblookup first (if | |||
| # available), then falls back to a raw UDP Node Status query. | |||
| # | |||
| # @param host [String] the IP address to query | |||
| # @return [String, nil] the NetBIOS name, or nil if lookup fails | |||
| def netbios_lookup_name(host) | |||
| netbios_lookup_nmblookup(host) || netbios_lookup_udp(host) | |||
| end | |||
There was a problem hiding this comment.
New behavior paths introduced here (RAP share enumeration, NetBIOS name-resolution fallback/retry, and SMB_INFO_STANDARD parsing) are not covered by specs. Since this file already has comprehensive client/session tests, please add unit tests that exercise (1) RAP parsing including non-zero RAP status, and (2) the *SMBSERVER retry path when the NBSS error code is 0x82.
There was a problem hiding this comment.
Added in 1845dac. spec/lib/ruby_smb/rap/net_share_enum_spec.rb covers RAP parsing (success, non-zero RAP status, truncated params, SMB-level failure). spec/lib/ruby_smb/smb1/tree_spec.rb covers the SMB_INFO_STANDARD path in six examples. The *SMBSERVER retry specs live on the split netbios-name-resolution-fallback branch.
| @@ -120,11 +188,16 @@ def list(directory: '\\', pattern: '*', unicode: true, | |||
| t2_params.flags.resume_keys = 0 | |||
| t2_params.information_level = type::CLASS_LEVEL | |||
| t2_params.filename = search_path | |||
| t2_params.search_count = 10 | |||
| t2_params.search_count = info_standard ? 255 : 10 | |||
|
|
|||
| find_first_request = set_find_params(find_first_request) | |||
|
|
|||
| raw_response = client.send_recv(find_first_request) | |||
|
|
|||
| if info_standard | |||
| return parse_find_first2_info_standard(raw_response, type) | |||
| end | |||
|
|
|||
There was a problem hiding this comment.
The FindInfoStandard/info_standard branch adds a separate parsing path that bypasses the existing FindFirst2Response parsing and changes request behavior (unicode flag off, search_count 255). spec/lib/ruby_smb/smb1/tree_spec.rb has good coverage for #list, but there are no examples for this new path; adding a spec that feeds a raw FIND_FIRST2 response with SMB_INFO_STANDARD entries would help prevent regressions (especially around edge cases like trailing null padding).
There was a problem hiding this comment.
Done in 1845dac — new #list context in tree_spec.rb with six examples: sequential entries separated by the trailing-null pad Win9x inserts, single entry without padding, zero file_name_length sentinel, SMB-level failure, unicode-off + search_count=255 assertion, empty blob.
| data_offset = raw_response[pb_offset + 14, 2].unpack1('v') | ||
| data_count = raw_response[pb_offset + 12, 2].unpack1('v') |
There was a problem hiding this comment.
parse_find_first2_info_standard is extracting data_offset/data_count from the wrong positions in the TRANS2 response parameter block. In the TRANS2 Response parameter block layout (see lib/ruby_smb/smb1/packet/trans2/response.rb), data_count is at offset +14 and data_offset at +16 from the start of the parameter words; the current code reads +12/+14 (parameter_displacement/data_count), which will slice the wrong portion of the packet and can lead to incorrect parsing or exceptions.
| data_offset = raw_response[pb_offset + 14, 2].unpack1('v') | |
| data_count = raw_response[pb_offset + 12, 2].unpack1('v') | |
| data_count = raw_response[pb_offset + 14, 2].unpack1('v') | |
| data_offset = raw_response[pb_offset + 16, 2].unpack1('v') |
There was a problem hiding this comment.
Verified incorrect. Trans2::Response::ParameterBlock lays out total_parameter_count(2) + total_data_count(2) + reserved(2) + parameter_count(2) + parameter_offset(2) + parameter_displacement(2) = 12 bytes before data_count, so the original +12 / +14 reads were right. Moot regardless — the whole byte-slicing path was removed in 1845dac (see #14).
smcintyre-r7
left a comment
There was a problem hiding this comment.
This needs some work. There are still opaque DCERPC and NetBIOS structures and the BinData classes that are present are missing references to the structs that they represent and at least one is completely bypassed in favor of a bespoke parser.
I haven't gotten to testing it because this needs a bit more work but once things are in a better shape, I'll give it a whirl.
| when RubySMB::SMB1::Packet::NegotiateResponse | ||
| # Non-extended security (e.g. Windows 95/98/ME, old Samba). The server provides |
There was a problem hiding this comment.
You can de-duplicate a bunch of code by collapsing this and just extracting the challenge you need depending on the type:
when RubySMB::SMB1::Packet::NegotiateResponse,
RubySMB::SMB1::Packet::NegotiateResponseExtended
self.smb1 = true
self.smb2 = false
self.smb3 = false
self.signing_required = packet.parameter_block.security_mode.security_signatures_required == 1
self.dialect = packet.negotiated_dialect.to_s
# MaxBufferSize is largest message server will receive, measured from start of the SMB header.
# Subtract 260 for protocol overhead so this value can be used directly as max read/write size.
self.server_max_buffer_size = packet.parameter_block.max_buffer_size - 260
self.negotiated_smb_version = 1
self.session_encrypt_data = false
if packet.is_a?(RubySMB::SMB1::Packet::NegotiateResponseExtended)
self.negotiation_security_buffer = packet.data_block.security_blob
else
# Non-extended security (e.g. Windows 95/98/ME, old Samba). Server provides a raw
# 8-byte challenge instead of a SPNEGO blob; store it so auth can compute LM/NTLM responses.
@smb1_negotiate_challenge = packet.data_block.challenge.to_s
end
'SMB1'There was a problem hiding this comment.
Done in 1845dac, applying your suggested structure verbatim.
| request = RubySMB::SMB1::Packet::TreeConnectRequest.new | ||
| request.smb_header.tid = 65_535 | ||
| if password | ||
| pass_bytes = password + "\x00" |
There was a problem hiding this comment.
NULL bytes need the #b method in Ruby to always be treated correctly.
| pass_bytes = password + "\x00" | |
| pass_bytes = password + "\x00".b |
| module Packet | ||
| module Trans2 | ||
| module FindInformationLevel | ||
| # SMB_INFO_STANDARD find result entry (LANMAN 2.0). |
There was a problem hiding this comment.
The fields here don't match the definition of SMB_INFO_STANDARD as provided by Microsoft. The first 6 fields look consistent, (overlooking the lack of SMB_DATE and SMB_TIME data types) but start to diverge after that.
It's possible they're correct, I haven't tested things yet but do you have a link you used to write this definition?
There was a problem hiding this comment.
The doc you linked (3e6f3a13) is the SET_PATH/FILE_INFORMATION variant of SMB_INFO_STANDARD, which is a different structure from the FIND variant used by TRANS2_FIND_FIRST2/FIND_NEXT2. The FIND variant is at MS-CIFS 2.2.8.1.1. Its fields — CreationDate/Time, LastAccessDate/Time, LastWriteDate/Time, FileDataSize, AllocationSize, Attributes, FileNameLength, FileName — match our struct exactly. Link in the file updated to the correct doc in 1845dac.
| module SMB1 | ||
| module Packet | ||
| # A SMB1 SMB_COM_OPEN_ANDX Request Packet as defined in | ||
| # [2.2.4.41.1 Request](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-smb/0ab8c5c5-a8d1-4460-bab1-cdae4e18dab7) |
There was a problem hiding this comment.
This link is 404'ed do you have another one to the resource you used?
There was a problem hiding this comment.
Updated in 1845dac to MS-CIFS 2.2.4.41.1. WordCount 0x0F, 15 words: AndXCommand/Reserved/Offset, Flags, AccessMode, SearchAttrs, FileAttrs, CreationTime, OpenMode, AllocationSize, Timeout, Reserved[2] — matches our layout.
| module SMB1 | ||
| module Packet | ||
| # A SMB1 SMB_COM_OPEN_ANDX Response Packet as defined in | ||
| # [2.2.4.41.2 Response](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-smb/0ab8c5c5-a8d1-4460-bab1-cdae4e18dab7) |
There was a problem hiding this comment.
This link is also a 404. Do you have the correct one?
There was a problem hiding this comment.
Updated in 1845dac to MS-CIFS 2.2.4.41.2. Field layout matches. Our server_fid(4) + reserved(2) occupies the spec's Reserved[3] = 6 bytes; could be renamed in a follow-up but the wire layout is correct.
| raise unless resolved | ||
|
|
||
| dispatcher.tcp_socket.close rescue nil | ||
| new_sock = TCPSocket.new(host, port) |
There was a problem hiding this comment.
This isn't going to work for Metasploit. Metasploit needs to handle the socket creation so it's always Rex::Socket otherwise things will break when pivoting. That complicates this entire method. Unless it's required right now, I'd recommend raising an exception and forcing the caller to sort it out themselves. That would be a good thing to separate into a different PR as well and just require the name be correct right now.
There was a problem hiding this comment.
Moved to the separate branch netbios-name-resolution-fallback per #20. In that branch socket creation is injectable via Client#tcp_socket_factory — a callable (host, port) -> socket defaulting to stdlib TCPSocket.new for standalone use. Metasploit can inject a Rex::Socket::Tcp factory so pivoting keeps working; setsockopt is only called if the returned socket responds to it (Rex sockets don't always). Will open as a stacked PR once this one lands.
| # @param host [String] the IP address to query | ||
| # @return [String, nil] the file server NetBIOS name | ||
| def netbios_lookup_nmblookup(host) | ||
| output = IO.popen(['nmblookup', '-A', host], err: :close, &:read) |
There was a problem hiding this comment.
Nope, definitely not going to work. We need to be doing everything in Ruby.
There was a problem hiding this comment.
Fully pure-Ruby in the split branch. The IO.popen(['nmblookup', …]) path (netbios_lookup_nmblookup) is gone. Replaced with RubySMB::Nbss::NodeStatus — a public helper that implements nmblookup -A equivalence via new Nbss::NodeStatusRequest / Nbss::NodeStatusResponse BinData structures. No external binaries, ever.
| request << [0x00].pack('C') | ||
| request << [0x0021, 0x0001].pack('nn') | ||
|
|
||
| sock = UDPSocket.new |
There was a problem hiding this comment.
Same issue here, the socket needs to come from Rex::Socket for Metasploit. I would highly recommend you avoid anything that requires you to create new sockets if it is remotely possible.
There was a problem hiding this comment.
Same injection pattern as #16 — Client#udp_socket_factory in the split branch. Defaults to stdlib UDPSocket.new; MSF callers pass a Rex::Socket::Udp.create factory so UDP probes respect pivots and routes. Verified end-to-end against a live Win95 target via the matching MSF module.
| raw_name = "*" + "\x00" * 15 | ||
| encoded = raw_name.bytes.map { |b| | ||
| ((b >> 4) + 0x41).chr + ((b & 0x0F) + 0x41).chr | ||
| }.join | ||
|
|
||
| request = [rand(0xFFFF)].pack('n') | ||
| request << [0x0000, 1, 0, 0, 0].pack('nnnnn') | ||
| request << [0x20].pack('C') | ||
| request << encoded | ||
| request << [0x00].pack('C') | ||
| request << [0x0021, 0x0001].pack('nn') |
There was a problem hiding this comment.
These all need to use the existing definitions we have in lib/ruby_smb/nbss or add new ones.
There was a problem hiding this comment.
Done in the split branch. New lib/ruby_smb/nbss/node_status_request.rb and node_status_response.rb follow the existing NBSS pattern (session_request.rb, negative_session_response.rb). The UDP lookup is now ~10 lines of BinData glue; all the hand-crafted pack('n*') calls are gone.
| # When the name is '*SMBSERVER' and the server rejects it with | ||
| # "Called name not present", this method automatically looks up | ||
| # the server's actual NetBIOS name via a Node Status query, | ||
| # reconnects the TCP socket, and retries. |
There was a problem hiding this comment.
I highly recommend removing this automatic lookup functionality. It's going to significantly complicate this PR and should be a dedicated unit of work. If it's not absolutely essential to "Add(ing) Windows 95/98/ME SMB1 support" as the PR title states, it should be removed and moved into a separate PR.
There was a problem hiding this comment.
Done in 09c848c on this PR — all NetBIOS auto-lookup code is gone, branch is now strictly Win9x SMB1 support. The functionality is preserved on netbios-name-resolution-fallback, which will be opened as a stacked PR. It addresses your concerns #16/#18 (injectable Rex::Socket factories), #17 (pure Ruby, no nmblookup shell-out), and #19 (NBSS BinData structs) in one reviewable unit.
- 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
Per PR rapid7#294 review, the automatic NetBIOS name lookup and session-request retry do not belong with the Win9x SMB1 support work — they open raw sockets which breaks Metasploit's Rex::Socket pivoting, and need their own review cycle. The functionality (including the NBNS BinData structs and typed NetBiosSessionService error_code) moves to a dedicated branch.
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.
smcintyre-r7
left a comment
There was a problem hiding this comment.
Changes are looking pretty good now. I left a couple of comments and I can start testing this tomorrow. The larger outstanding concern is around https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-cifs/dbce00e7-68a1-41c6-982d-9483c902ad9b and the definition of fields we have as uint16 (e.g. SMB_FILE_ATTRIBUTES).
| module SMB1 | ||
| module Packet | ||
| # A SMB1 SMB_COM_OPEN_ANDX Response Packet as defined in | ||
| # [MS-CIFS 2.2.4.41.2 Response](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-cifs/dbce00e7-68a1-41c6-982d-9483c902ad9b) |
There was a problem hiding this comment.
The fields in here aren't aligned with the docs you're referencing here. For example :device_state is where I'd expect nmpipe_status to be. Also there's an SMB_NMPIPE_STATUS type defined that should be used instead of a uint if that's the correct field.
There was a problem hiding this comment.
Addressed in 650cf3a. The ParameterBlock is now aligned with MS-CIFS 2.2.4.41.2:
- Renamed
device_state→nmpipe_statusand typed it assmb_nmpipe_status(same bit_field theNT_CREATE_ANDXandTRANS2_OPEN2responses use). - Renamed
action→open_results,data_size→file_data_size,granted_access→access_rights,file_type→resource_type. - Typed
last_write_timeasutime. - Collapsed the misplaced
server_fid(4B) +reserved(2B) pair into a singlearray :reserved, type: :uint16, initial_length: 3matching the spec'sReserved[3]. Tree#build_open_andx_handleupdated for the renames.
Kept :file_attributes as the name for the smb_file_attributes field to stay consistent with the suggestion you already accepted on the request side (cfddc48).
| # Enumerates shares using the RAP NetShareEnum call over `\PIPE\LANMAN`. | ||
| # This is the only share-enumeration method supported by Windows | ||
| # 95/98/ME and other LAN Manager-era servers that predate DCERPC/srvsvc. | ||
| # RAP is an SMB1 feature; callers negotiating SMB2/3 should use | ||
| # {#net_share_enum_all} instead. | ||
| # | ||
| # @param host [String] the server hostname or IP | ||
| # @param password [String, nil] share-level password for IPC$ | ||
| # @return [Array<Hash>] each entry has :name (String) and :type (Integer) | ||
| # @raise [RubySMB::Error::RubySMBError] if SMB1 was not negotiated | ||
| def net_share_enum_rap(host, password: nil) |
There was a problem hiding this comment.
Let's hold off on adding this to the top level. It's unlikely to get a lot of usage given that 95/98/ME targets are only going to decline in popularity. While I know we already have a #net_share_enum_all method, most of the other methods are namespace'ed to the tree and not the client to keep the client itself on the thinner side.
There was a problem hiding this comment.
Done in 650cf3a — Client#net_share_enum_rap is gone. Callers now tree-connect to IPC$ and use the tree-scoped Tree#net_share_enum (from the RubySMB::Rap::NetShareEnum mixin added earlier in this PR).
The Metasploit module that uses this (scanner/smb/cve_2000_0979) has been updated accordingly — it now does:
tree = simple.client.tree_connect("\\\\#{rhost}\\IPC$")
tree.net_share_enum.each { |s| ... }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.
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.
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.
RubySMB removed Client#net_share_enum_rap (rapid7/ruby_smb#294 review comment r3134040652). Tree-connect to IPC\$ here and call the tree-scoped helper directly, mirroring the pattern used for other SMB1 tree ops.
|
Alright things are looking pretty good. I spent this afternoon testing these cases with the following harness:
I sent a PR to your branch to fix a couple of bugs. If you wouldn't mind testing that and landing it (assuming you find no issues with it) then we can advance this. At this point, with the bugs I found fixed, and assuming the CI tests pass, I'd be comfortable merging things. Let me know if you have any questions, thanks for your work on this! Test Harness#!/usr/bin/ruby
# Demo script for PR #294 (Windows 95/98/ME SMB1 support).
#
# Exercises the new code paths added by the branch when run against a Win9x
# target, and verifies existing SMB1 + SMB2/3 behavior is unchanged when run
# against a modern host (e.g. Windows Server 2019 with SMB1 still enabled).
#
# New paths exercised:
# 1. SMB1 negotiate tolerates non-extended-security responses
# (NegotiateResponse::DataBlock#do_read, byte_count == 8 case).
# 2. Client#parse_negotiate_response stashes the legacy 8-byte challenge
# into @smb1_negotiate_challenge when extended security is absent, and
# populates supports_nt_smbs from the capabilities word.
# 3. Client::Authentication#authenticate dispatches to
# #smb1_legacy_authenticate (LM + NTLM response, no NTLMSSP) whenever a
# legacy challenge was captured above.
# 4. Client::TreeConnect#smb1_tree_connect honors the `password:` keyword
# for share-level auth, writing exactly password_length bytes (no trailing
# null from the old BinData::Stringz).
# 5. TreeConnectResponse + SessionSetupLegacyResponse both tolerate short /
# empty DataBlocks emitted by Win95/98.
# 6. Tree#open_file auto-dispatches SMB_COM_OPEN_ANDX (OpenAndxRequest /
# OpenAndxResponse) when the server did not advertise NT SMBs.
# 7. Tree#list handles SMB_INFO_STANDARD results (FindInfoStandard) via the
# sequential-entry parser in FindFirst2Response#results.
# 8. Tree#net_share_enum (RubySMB::Rap::NetShareEnum) issues the RAP
# NetShareEnum transaction on \PIPE\LANMAN directly off the IPC$ tree.
#
# Regression coverage against Server 2019 (SMB1 + SMB2/3):
# - SMB1 with extended security still negotiates and authenticates via
# NTLMSSP; Tree#open_file still uses NT_CREATE_ANDX because
# supports_nt_smbs is true.
# - RAP share enumeration still works over SMB1 against a modern server.
# - Directory listing with the default FindFileFullDirectoryInfo info level
# still works.
# - SMB2/3 path is untouched; same demo runs on port 445 with --no-smbv1.
#
# Example invocations:
#
# # Windows ME over NetBIOS (139/tcp). Share-level auth, password "secret":
# ruby examples/win9x_demo.rb \
# --target 192.168.50.10 --port 139 \
# --no-smbv2 --no-smbv3 \
# --share-password secret \
# --share SHARED --file README.TXT
#
# # Windows Server 2019 with SMB1 still enabled, 445/tcp:
# ruby examples/win9x_demo.rb \
# --target 192.168.50.20 --port 445 \
# --username Administrator --password 'P@ssw0rd!' \
# --no-smbv2 --no-smbv3 \
# --share C$ --file Windows\\System32\\drivers\\etc\\hosts
#
# # Windows Server 2019 over SMB2/3 (regression check):
# ruby examples/win9x_demo.rb \
# --target 192.168.50.20 --port 445 \
# --username Administrator --password 'P@ssw0rd!' \
# --no-smbv1 \
# --share C$ --file Windows\\System32\\drivers\\etc\\hosts
require 'bundler/setup'
require 'optparse'
require 'ruby_smb'
options = {
target: nil,
port: 445,
smbv1: true,
smbv2: true,
smbv3: true,
username: '',
password: '',
domain: '.',
share: nil,
share_password: nil,
file: nil,
session_request: false,
netbios_name: '*SMBSERVER',
read_bytes: 256
}
parser = OptionParser.new do |opts|
opts.banner = "Usage: #{File.basename(__FILE__)} [options]"
opts.on('--target HOST', 'Target IP or hostname (required)') { |v| options[:target] = v }
opts.on('--port PORT', Integer, "TCP port (default #{options[:port]}, use 139 for Win9x)") { |v| options[:port] = v }
opts.on('--username USER', 'Account username (default: empty)') { |v| options[:username] = v }
opts.on('--password PASS', 'Account password (default: empty)') { |v| options[:password] = v }
opts.on('--domain DOMAIN', 'Account domain (default: .)') { |v| options[:domain] = v }
opts.on('--share NAME', 'Share name, e.g. SHARED or C$') { |v| options[:share] = v }
opts.on('--share-password PASS', 'Share-level password (Win9x only)') { |v| options[:share_password] = v }
opts.on('--file PATH', 'File to open/read on the share') { |v| options[:file] = v }
opts.on('--read-bytes N', Integer, "Max bytes to read from --file (default #{options[:read_bytes]})") { |v| options[:read_bytes] = v }
opts.on('--[no-]smbv1', "Enable SMBv1 (default: #{options[:smbv1]})") { |v| options[:smbv1] = v }
opts.on('--[no-]smbv2', "Enable SMBv2 (default: #{options[:smbv2]})") { |v| options[:smbv2] = v }
opts.on('--[no-]smbv3', "Enable SMBv3 (default: #{options[:smbv3]})") { |v| options[:smbv3] = v }
opts.on('--session-request', 'Send a NetBIOS session request before SMB negotiate (usually needed on 139)') { options[:session_request] = true }
opts.on('--netbios-name NAME', "Called NetBIOS name (default #{options[:netbios_name]})") { |v| options[:netbios_name] = v }
end
parser.parse!
abort(parser.help) if options[:target].nil?
options[:session_request] = true if options[:port] == 139 && !options[:session_request]
def banner(text)
line = '=' * text.length
puts "\n#{line}\n#{text}\n#{line}"
end
def info(label, value)
printf " %-28s %s\n", label + ':', value
end
def safely(label)
yield
rescue => e
puts " [!] #{label} failed: #{e.class}: #{e.message}"
nil
end
# Pretty-print a binary blob as offset / hex / ASCII rows. Used to dump the
# raw FIND_FIRST2 buffer when debugging Win9x's SMB_INFO_STANDARD layout.
def hexdump(bytes, indent: ' ')
bytes = bytes.b
bytes.bytes.each_slice(16).with_index do |row, i|
hex = row.map { |b| format('%02x', b) }.each_slice(8).map { |g| g.join(' ') }.join(' ')
ascii = row.map { |b| (32..126).cover?(b) ? b.chr : '.' }.join
printf "%s%08x %-49s |%s|\n", indent, i * 16, hex, ascii
end
end
# Wrap client.send_recv so the next call's raw response is captured. Returns
# a [thunk_to_install, accessor_to_get_captured_bytes] pair. Caller is
# responsible for restoring the original method afterwards.
def capture_next_response(client)
captured = nil
original = client.method(:send_recv)
client.define_singleton_method(:send_recv) do |*args, **kwargs|
raw = original.call(*args, **kwargs)
captured ||= raw.dup
raw
end
[-> { client.singleton_class.send(:remove_method, :send_recv) }, -> { captured }]
end
# Same idea but also captures the request packet that was sent.
def capture_next_exchange(client)
req = nil
resp = nil
original = client.method(:send_recv)
client.define_singleton_method(:send_recv) do |packet, *args, **kwargs|
req ||= packet
raw = original.call(packet, *args, **kwargs)
resp ||= raw.dup
raw
end
[-> { client.singleton_class.send(:remove_method, :send_recv) }, -> { [req, resp] }]
end
# Decode an SMB1 NT-status word as either an NT status name or DOS-style
# class.code, whichever fits.
def decode_smb1_status(raw)
return '(no response)' if raw.nil? || raw.bytesize < 9
status_dword = raw.byteslice(5, 4).unpack1('V')
klass = status_dword & 0xff
code = (status_dword >> 16) & 0xffff
nt = WindowsError::NTStatus.find_by_retval(status_dword).first
if nt
"0x#{status_dword.to_s(16).rjust(8, '0')} (#{nt.name})"
else
"0x#{status_dword.to_s(16).rjust(8, '0')} DOS-style: ErrorClass=0x#{klass.to_s(16)} ErrorCode=0x#{code.to_s(16).rjust(4, '0')}"
end
end
banner "Connecting to #{options[:target]}:#{options[:port]}"
sock = TCPSocket.new(options[:target], options[:port])
dispatcher = RubySMB::Dispatcher::Socket.new(sock, read_timeout: 30)
client = RubySMB::Client.new(
dispatcher,
smb1: options[:smbv1],
smb2: options[:smbv2],
smb3: options[:smbv3],
username: options[:username],
password: options[:password],
domain: options[:domain]
)
if options[:session_request]
banner "NetBIOS session request (called name: #{options[:netbios_name]})"
client.session_request(options[:netbios_name])
puts " [+] NBSS session granted"
end
banner 'Negotiate'
protocol = client.negotiate
info 'Negotiated protocol', protocol
info 'Dialect', client.dialect
info 'Max buffer size', client.server_max_buffer_size
# #4 supports_nt_smbs: true on SMB2/3 and extended-security SMB1; false on Win9x SMB1.
info 'Supports NT SMBs', client.supports_nt_smbs
legacy = client.instance_variable_get(:@smb1_negotiate_challenge)
if legacy
info 'Legacy 8-byte challenge', legacy.unpack1('H*')
puts ' [+] Non-extended-security path taken (Win9x/LANMAN style)'
else
puts ' [+] Extended-security path taken (NTLMSSP available)'
end
banner 'Authenticate'
status = client.authenticate
info 'NT status', status
unless status == WindowsError::NTStatus::STATUS_SUCCESS
abort(" [!] Authentication failed, aborting")
end
info 'Peer native OS', client.peer_native_os
info 'Peer native LM', client.peer_native_lm
info 'Primary domain', client.primary_domain
# --- RAP share enumeration off the IPC$ tree -------------------------------
# Win9x does not support DCERPC/SRVSVC, so Tree#net_share_enum (mixed in from
# RubySMB::Rap::NetShareEnum) is the only path that works there. The same
# call should still succeed against Server 2019 with SMB1 enabled.
ipc_path = "\\\\#{options[:target]}\\IPC$"
banner "RAP NetShareEnum via #{ipc_path}"
if client.negotiated_smb_version == 1
ipc_tree = safely('tree_connect IPC$') { client.tree_connect(ipc_path) }
if ipc_tree
shares = safely('tree.net_share_enum (RAP)') { ipc_tree.net_share_enum }
if shares
if shares.empty?
puts ' (no shares reported)'
else
shares.each { |s| printf " %-20s type=%s\n", s[:name], s[:type] }
end
end
# Also exercise the DCERPC SRVSVC path. Expected to fail on Win9x and
# succeed on modern Windows. Only the RAP path is new, but running both
# side-by-side here makes regressions obvious.
puts
puts ' SRVSVC NetShareEnumAll (DCERPC, expected to fail on Win9x):'
srv_shares = safely('net_share_enum_all (SRVSVC)') { client.net_share_enum_all(options[:target]) }
if srv_shares
srv_shares.each { |s| puts " #{s.inspect}" }
end
safely('IPC$ tree disconnect') { ipc_tree.disconnect! }
end
else
puts " [skip] Tree#net_share_enum is an SMB1 mixin (RAP over \\PIPE\\LANMAN);"
puts " negotiated #{client.dialect}. Using SRVSVC instead."
srv_shares = safely('net_share_enum_all (SRVSVC)') { client.net_share_enum_all(options[:target]) }
(srv_shares || []).each { |s| puts " #{s.inspect}" }
end
# --- Tree-connect with share-level password --------------------------------
if options[:share]
share_path = "\\\\#{options[:target]}\\#{options[:share]}"
label = "tree_connect #{share_path}"
label += " (password-protected)" if options[:share_password]
banner label
tree = safely('tree_connect') do
client.tree_connect(share_path, password: options[:share_password])
end
if tree
# --- list with the default (FindFileFullDirectoryInfo) info level ----
# Skip on Win9x: the default info level (FindFileFullDirectoryInfo) is
# an NT-only class. Win9x rejects it with STATUS_OS2_INVALID_LEVEL
# (0x00060001), which is expected, not a regression.
if client.supports_nt_smbs
banner "list #{share_path} (default info level)"
# SMB1::Tree#list wants directory: '\\' for the share root; SMB2::Tree#list
# wants nil (it treats '\\' as a sub-directory name → STATUS_INVALID_PARAMETER).
root_dir = client.negotiated_smb_version == 1 ? '\\' : nil
default_listing = safely('tree.list(default)') { tree.list(directory: root_dir) }
(default_listing || []).first(10).each do |e|
name = e.file_name.to_s
name = name.force_encoding('UTF-16LE').encode('UTF-8', invalid: :replace, undef: :replace) rescue name
size = e.respond_to?(:end_of_file) ? e.end_of_file : '?'
puts " #{name.ljust(40)} size=#{size}"
end
puts " ... (#{default_listing.length} total)" if default_listing && default_listing.length > 10
else
banner "list #{share_path} (default info level)"
puts ' [skip] server does not support NT SMBs; default info level (FindFileFullDirectoryInfo)'
puts ' would be rejected with STATUS_OS2_INVALID_LEVEL. Falling through to'
puts ' SMB_INFO_STANDARD below, which is the LANMAN-era equivalent.'
end
# --- list with SMB_INFO_STANDARD (the new FindInfoStandard class) ----
# SMB1-only: FindInfoStandard is an SMB1 Trans2 FIND_INFO_LEVEL class,
# and SMB1::Tree#list's `unicode:`/`type:` signature doesn't exist on
# SMB2::Tree.
banner "list #{share_path} (SMB_INFO_STANDARD)"
info_std = RubySMB::SMB1::Packet::Trans2::FindInformationLevel::FindInfoStandard
if client.negotiated_smb_version != 1
puts " [skip] SMB_INFO_STANDARD is an SMB1 info level; negotiated #{client.dialect}."
raw = nil
std_listing = nil
else
restore, captured = capture_next_response(client)
std_listing = safely('tree.list(FindInfoStandard)') do
tree.list(directory: '\\', type: info_std, unicode: false)
end
restore.call
raw = captured.call
end
if std_listing && !std_listing.empty?
std_listing.first(10).each do |e|
name = e.file_name.to_s.force_encoding('ASCII-8BIT')
puts " #{name.ljust(40)} size=#{e.data_size.to_i} attrs=0x#{e.file_attributes.to_i.to_s(16).rjust(4, '0')}"
end
puts " ... (#{std_listing.length} total)" if std_listing.length > 10
elsif std_listing
puts ' (parsed 0 entries; raw FIND_FIRST2 response below)'
end
if raw
begin
resp = RubySMB::SMB1::Packet::Trans2::FindFirst2Response.read(raw)
pb = resp.parameter_block
t2p = resp.data_block.trans2_parameters
puts
puts ' --- FIND_FIRST2 response (parsed) ---'
info 'raw response bytes', raw.bytesize
info 'word_count', pb.word_count
info 'NT status', resp.status_code
info 'parameter_count', pb.parameter_count
info 'parameter_offset', pb.parameter_offset
info 'data_count', pb.data_count
info 'data_offset', pb.data_offset
info 'byte_count abs off', resp.data_block.byte_count.abs_offset
info 'trans2_params bd off',resp.data_block.trans2_parameters.abs_offset
info 'trans2_data bd off', resp.data_block.trans2_data.abs_offset
info 'BinData buffer size', resp.data_block.trans2_data.buffer.to_binary_s.bytesize
info 'sid', format('0x%04x', t2p.sid)
info 'search_count', t2p.search_count
info 'eos', t2p.eos
info 'last_name_offset', t2p.last_name_offset
puts
puts ' --- raw response, full hex dump ---'
hexdump(raw)
if pb.data_count.to_i > 0 && raw.bytesize >= pb.data_offset.to_i + pb.data_count.to_i
buf = raw.byteslice(pb.data_offset.to_i, pb.data_count.to_i)
puts
puts " --- trans2_data, sliced at server-reported offset #{pb.data_offset} (#{buf.bytesize} bytes) ---"
hexdump(buf)
# Try parsing the entries straight from the server-reported buffer,
# bypassing BinData's data_block layout (which assumes 4-byte
# alignment Win9x doesn't honor).
puts
puts ' --- entries parsed manually from the server-reported buffer ---'
work = buf.dup
idx = 0
loop do
break if work.empty?
fi = info_std.new
fi.read(work)
consumed = fi.num_bytes
break if consumed.zero? || consumed > work.bytesize
work.slice!(0, consumed)
break if fi.file_name_length.zero?
puts " [#{idx}] #{fi.file_name.to_s.inspect} size=#{fi.data_size.to_i} attrs=0x#{fi.file_attributes.to_i.to_s(16).rjust(4, '0')}"
work.slice!(0, 1) if work.bytesize > 0 && work.getbyte(0) == 0
idx += 1
break if idx > 50
end
puts " (#{idx} entries from #{buf.bytesize}-byte server buffer)"
else
puts ' (server returned an empty trans2_data buffer)'
end
rescue => e
puts " [!] failed to re-parse captured response: #{e.class}: #{e.message}"
puts ' --- raw response bytes ---'
hexdump(raw)
end
end
# --- open + read a file. Auto-dispatches OPEN_ANDX on Win9x and -------
# NT_CREATE_ANDX on everything else (both end up returning an
# RubySMB::SMB1::File with a usable FID).
if options[:file]
banner "open_file + read #{options[:file]}"
restore, captured = capture_next_exchange(client)
file = safely('tree.open_file') do
tree.open_file(filename: options[:file], read: true)
end
restore.call
open_req, open_raw = captured.call
if file
cmd = case client.negotiated_smb_version
when 1 then client.supports_nt_smbs ? 'NT_CREATE_ANDX' : 'OPEN_ANDX'
else 'SMB2 CREATE'
end
info 'Wire command', cmd
if file.respond_to?(:fid)
info 'FID', format('0x%04x', file.fid)
elsif file.respond_to?(:guid)
info 'File GUID', file.guid.to_s.unpack1('H*')
end
info 'File size', file.size
data = safely('file.read') { file.read(bytes: [options[:read_bytes], file.size.to_i].min) }
if data
preview = data.to_s[0, 120]
puts " preview: #{preview.inspect}"
end
safely('file.close') { file.close }
else
# Surface what we sent vs. the raw error so the failure is diagnosable.
cmd = client.supports_nt_smbs ? 'NT_CREATE_ANDX' : 'OPEN_ANDX'
puts
puts " --- #{cmd} request that was rejected ---"
if open_req
info 'request class', open_req.class.name
if open_req.respond_to?(:parameter_block)
pb = open_req.parameter_block
info 'access_mode', format('0x%04x', pb.access_mode) if pb.respond_to?(:access_mode)
info 'search_attributes', format('0x%04x', pb.search_attributes.to_binary_s.unpack1('v')) if pb.respond_to?(:search_attributes)
info 'file_attributes', format('0x%04x', pb.file_attributes.to_binary_s.unpack1('v')) if pb.respond_to?(:file_attributes)
info 'open_mode', format('0x%04x', pb.open_mode) if pb.respond_to?(:open_mode)
end
info 'request bytes', open_req.to_binary_s.bytesize
puts ' --- request hex ---'
hexdump(open_req.to_binary_s)
end
if open_raw
puts " --- raw response (#{open_raw.bytesize} bytes), status #{decode_smb1_status(open_raw)} ---"
hexdump(open_raw)
end
end
end
safely('tree.disconnect!') { tree.disconnect! }
end
end
banner 'Disconnect'
client.disconnect!
puts ' [+] done'Test Output |
Fixes #293
Summary
TreeConnectRequestpassword field serialization bug that corrupted packets when callers setpassword_lengthwithout a trailing null*SMBSERVERwildcard is rejectedDetails
Windows 95/98/ME SMB1 support (
d0d2607)Windows 9x hosts differ from NT-based systems in several ways that RubySMB did not handle:
extended_securityflag, no NTLMSSP)SessionSetupLegacyRequest/SessionSetupLegacyResponseinstead of NTLMSSP blobsnative_file_systemfield; the response DataBlock now handles short readsSMB_FIND_FILE_STANDARDinformation level for TRANS2 directory listingsERRDOS/ERRnomemfrom TRANS2 by falling back to core protocol commandsSMB_COM_OPEN_ANDX, share-level auth, and RAP (
10ed8be)net_share_enum_rap: enumerate shares via RAP (Remote Administration Protocol) over\PIPE\LANMAN, the only method Windows 9x supports (DCERPC/SRVSVC fails withDBG_CONTINUE)smb1_tree_connectpassword support: accept apassword:keyword for share-level authentication where passwords are per-share, not per-userSMB_COM_OPEN_ANDX: add request/response packets andTree#open_file/Tree#read_file/Tree#write_filefor Win95 hosts that lack NT_CREATE_ANDXFix TreeConnectRequest password field (
541321c)The password field was declared as
BinData::Stringz, which always appends a null terminator when serializing, regardless of thelengthparameter. Withpassword_length=N, the field wrote N+1 bytes, shifting the share path and corrupting the packet.Changed to
BinData::Stringwhich respects the length exactly. Existing callers are unaffected sincesmb1_tree_connectalready appends"\x00"explicitly.NetBIOS name resolution fallback (
b5269e0)Windows 95 rejects NetBIOS session requests using the
*SMBSERVERwildcard. When the server responds with "Called name not present", the client now resolves the server's actual NetBIOS name vianmblookupor a raw UDP Node Status query (RFC 1002),reconnects, and retries.
Test plan
bundle exec rspec— all existing specs pass (verified: 407 examples, 0 failures)direct: false,versions: [1]smb_loginwith empty credentials (share-level auth)net_share_enum_rapreturns share listtree_connectwithpassword:keyword to a password-protected shareTreeConnectRequestwithpassword_length=1serializes exactly 1 password byte (no extra null)*SMBSERVERresolves the name and retries