From d0d2607f2b51b4c9e39d6b0d27a2619c97dd8856 Mon Sep 17 00:00:00 2001 From: Z6543 Date: Sat, 14 Mar 2026 12:44:12 +0000 Subject: [PATCH 1/4] Add Windows 95/98/ME SMB1 support 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 --- lib/ruby_smb/client.rb | 2 +- lib/ruby_smb/client/authentication.rb | 53 ++++++++++++++++ lib/ruby_smb/client/negotiation.rb | 14 +++++ .../smb1/packet/negotiate_response.rb | 11 ++++ .../packet/session_setup_legacy_request.rb | 4 +- .../packet/session_setup_legacy_response.rb | 11 ++++ .../packet/trans2/find_information_level.rb | 1 + .../find_info_standard.rb | 34 +++++++++++ .../smb1/packet/tree_connect_response.rb | 11 +++- lib/ruby_smb/smb1/tree.rb | 61 +++++++++++++++++-- spec/lib/ruby_smb/client_spec.rb | 2 +- spec/lib/ruby_smb/smb1/tree_spec.rb | 6 +- 12 files changed, 197 insertions(+), 13 deletions(-) create mode 100644 lib/ruby_smb/smb1/packet/trans2/find_information_level/find_info_standard.rb diff --git a/lib/ruby_smb/client.rb b/lib/ruby_smb/client.rb index 473293091..8771ee7f1 100644 --- a/lib/ruby_smb/client.rb +++ b/lib/ruby_smb/client.rb @@ -672,7 +672,7 @@ def session_request(name = '*SMBSERVER') # @return [RubySMB::Nbss::SessionRequest] the SessionRequest packet def session_request_packet(name = '*SMBSERVER') called_name = "#{name.upcase.ljust(15)}\x20" - calling_name = "#{''.ljust(15)}\x00" + calling_name = "#{@local_workstation.upcase.ljust(15)}\x00" session_request = RubySMB::Nbss::SessionRequest.new session_request.session_header.session_packet_type = RubySMB::Nbss::SESSION_REQUEST diff --git a/lib/ruby_smb/client/authentication.rb b/lib/ruby_smb/client/authentication.rb index 4be985354..b64ab9fb2 100644 --- a/lib/ruby_smb/client/authentication.rb +++ b/lib/ruby_smb/client/authentication.rb @@ -15,6 +15,10 @@ def authenticate if smb1 if username.empty? && password.empty? smb1_anonymous_auth + elsif @smb1_negotiate_challenge + # Non-extended security negotiated (e.g. Windows 95/98). Use legacy + # LM/NTLM challenge-response rather than NTLMSSP. + smb1_legacy_authenticate else smb1_authenticate end @@ -198,6 +202,55 @@ def smb1_type2_message(response_packet) [type2_blob].pack('m') end + # Handles SMB1 authentication against servers that negotiated non-extended + # (legacy) security — Windows 95/98/ME and old Samba builds. These hosts + # provide a raw 8-byte challenge in the Negotiate response and expect + # LM + NTLM hash responses in SessionSetupLegacyRequest. + def smb1_legacy_authenticate + challenge = @smb1_negotiate_challenge + lm_hash = Net::NTLM.lm_hash(@password) + ntlm_hash = Net::NTLM.ntlm_hash(@password) + lm_resp = Net::NTLM.lm_response(lm_hash: lm_hash, challenge: challenge) + ntlm_resp = Net::NTLM.ntlm_response(ntlm_hash: ntlm_hash, challenge: challenge) + + packet = smb1_legacy_auth_request(lm_resp, ntlm_resp) + raw_response = send_recv(packet) + response = smb1_legacy_auth_response(raw_response) + response_code = response.status_code + + if response_code == WindowsError::NTStatus::STATUS_SUCCESS + self.user_id = response.smb_header.uid + self.peer_native_os = response.data_block.native_os.to_s + self.peer_native_lm = response.data_block.native_lan_man.to_s + self.primary_domain = response.data_block.primary_domain.to_s + end + + response_code + end + + def smb1_legacy_auth_request(lm_response, ntlm_response) + packet = RubySMB::SMB1::Packet::SessionSetupLegacyRequest.new + packet.parameter_block.max_buffer_size = self.max_buffer_size + packet.parameter_block.max_mpx_count = 50 + packet.data_block.oem_password = lm_response + packet.data_block.unicode_password = ntlm_response + packet.data_block.account_name = @username.encode('ASCII', invalid: :replace, undef: :replace) + packet.data_block.primary_domain = @domain.encode('ASCII', invalid: :replace, undef: :replace) + packet + end + + def smb1_legacy_auth_response(raw_response) + packet = RubySMB::SMB1::Packet::SessionSetupLegacyResponse.read(raw_response) + unless packet.valid? + raise RubySMB::Error::InvalidPacket.new( + expected_proto: RubySMB::SMB1::SMB_PROTOCOL_ID, + expected_cmd: RubySMB::SMB1::Packet::SessionSetupLegacyResponse::COMMAND, + packet: packet + ) + end + packet + end + # # SMB 2 Methods # diff --git a/lib/ruby_smb/client/negotiation.rb b/lib/ruby_smb/client/negotiation.rb index f71c31aa5..d5e41f218 100644 --- a/lib/ruby_smb/client/negotiation.rb +++ b/lib/ruby_smb/client/negotiation.rb @@ -120,6 +120,20 @@ def parse_negotiate_response(packet) self.session_encrypt_data = false self.negotiation_security_buffer = packet.data_block.security_blob 'SMB1' + when RubySMB::SMB1::Packet::NegotiateResponse + # Non-extended security (e.g. Windows 95/98/ME, old Samba). The server provides + # a raw 8-byte challenge in the negotiate response instead of a SPNEGO blob. + 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 + self.server_max_buffer_size = packet.parameter_block.max_buffer_size - 260 + self.negotiated_smb_version = 1 + self.session_encrypt_data = false + # Store the 8-byte challenge so authentication can compute LM/NTLM responses. + @smb1_negotiate_challenge = packet.data_block.challenge.to_s + 'SMB1' when RubySMB::SMB2::Packet::NegotiateResponse self.smb1 = false unless packet.dialect_revision.to_i == RubySMB::SMB2::SMB2_WILDCARD_REVISION diff --git a/lib/ruby_smb/smb1/packet/negotiate_response.rb b/lib/ruby_smb/smb1/packet/negotiate_response.rb index 11c806fa5..2e9047523 100644 --- a/lib/ruby_smb/smb1/packet/negotiate_response.rb +++ b/lib/ruby_smb/smb1/packet/negotiate_response.rb @@ -22,10 +22,21 @@ class ParameterBlock < RubySMB::SMB1::ParameterBlock end # An SMB_Data Block as defined by the {NegotiateResponse} + # Windows 95/98/ME may only return the challenge with no domain/server names. class DataBlock < RubySMB::SMB1::DataBlock string :challenge, label: 'Auth Challenge', length: 8 stringz16 :domain_name, label: 'Primary Domain' stringz16 :server_name, label: 'Server Name' + + # Override to handle Win95 responses that only contain the challenge + # (byte_count=8) without domain_name or server_name fields. + def do_read(io) + byte_count.do_read(io) + challenge.do_read(io) + return unless byte_count > 8 + domain_name.do_read(io) + server_name.do_read(io) + end end smb_header :smb_header diff --git a/lib/ruby_smb/smb1/packet/session_setup_legacy_request.rb b/lib/ruby_smb/smb1/packet/session_setup_legacy_request.rb index ebb0450a9..07641441c 100644 --- a/lib/ruby_smb/smb1/packet/session_setup_legacy_request.rb +++ b/lib/ruby_smb/smb1/packet/session_setup_legacy_request.rb @@ -27,8 +27,8 @@ class DataBlock < RubySMB::SMB1::DataBlock string :oem_password, label: 'OEM Password' string :unicode_password, label: 'Unicode password' string :padding, label: 'Padding' - string :account_name, label: 'Account Name(username)', length: 2 - string :primary_domain, label: 'Primary Domain', length: 2 + stringz :account_name, label: 'Account Name(username)' + stringz :primary_domain, label: 'Primary Domain' stringz :native_os, label: 'Native OS', initial_value: 'Windows 7 Ultimate N 7601 Service Pack 1' stringz :native_lan_man, label: 'Native LAN Manager', initial_value: 'Windows 7 Ultimate N 6.1' end diff --git a/lib/ruby_smb/smb1/packet/session_setup_legacy_response.rb b/lib/ruby_smb/smb1/packet/session_setup_legacy_response.rb index fdd5fa805..2764e477b 100644 --- a/lib/ruby_smb/smb1/packet/session_setup_legacy_response.rb +++ b/lib/ruby_smb/smb1/packet/session_setup_legacy_response.rb @@ -13,11 +13,22 @@ class ParameterBlock < RubySMB::SMB1::ParameterBlock end # Represents the specific layout of the DataBlock for a {SessionSetupResponse} Packet. + # Windows 95/98/ME may return byte_count=0 with no string fields. class DataBlock < RubySMB::SMB1::DataBlock string :pad, label: 'Padding', length: 0 stringz :native_os, label: 'Native OS' stringz :native_lan_man, label: 'Native LAN Manager' stringz :primary_domain, label: 'Primary Domain' + + # Override to handle Win95 responses with byte_count=0. + def do_read(io) + byte_count.do_read(io) + return unless byte_count > 0 + pad.do_read(io) + native_os.do_read(io) + native_lan_man.do_read(io) + primary_domain.do_read(io) + end end smb_header :smb_header diff --git a/lib/ruby_smb/smb1/packet/trans2/find_information_level.rb b/lib/ruby_smb/smb1/packet/trans2/find_information_level.rb index c65f2b807..ed5d1b315 100644 --- a/lib/ruby_smb/smb1/packet/trans2/find_information_level.rb +++ b/lib/ruby_smb/smb1/packet/trans2/find_information_level.rb @@ -38,6 +38,7 @@ def self.name(value) require 'ruby_smb/smb1/packet/trans2/find_information_level/find_file_both_directory_info' require 'ruby_smb/smb1/packet/trans2/find_information_level/find_file_full_directory_info' + require 'ruby_smb/smb1/packet/trans2/find_information_level/find_info_standard' end end end diff --git a/lib/ruby_smb/smb1/packet/trans2/find_information_level/find_info_standard.rb b/lib/ruby_smb/smb1/packet/trans2/find_information_level/find_info_standard.rb new file mode 100644 index 000000000..d88b071b8 --- /dev/null +++ b/lib/ruby_smb/smb1/packet/trans2/find_information_level/find_info_standard.rb @@ -0,0 +1,34 @@ +module RubySMB + module SMB1 + module Packet + module Trans2 + module FindInformationLevel + # SMB_INFO_STANDARD find result entry (LANMAN 2.0). + # Used by TRANS2_FIND_FIRST2/FIND_NEXT2 on legacy servers + # (e.g. Windows 95) that don't support NT LANMAN info levels. + # + # Unlike NT info levels, these entries have no next_offset field; + # they are packed sequentially with a variable-length filename. + class FindInfoStandard < BinData::Record + CLASS_LEVEL = FindInformationLevel::SMB_INFO_STANDARD + + endian :little + + uint16 :creation_date, label: 'Creation Date (SMB_DATE)' + uint16 :creation_time, label: 'Creation Time (SMB_TIME)' + uint16 :last_access_date, label: 'Last Access Date' + uint16 :last_access_time, label: 'Last Access Time' + uint16 :last_write_date, label: 'Last Write Date' + uint16 :last_write_time, label: 'Last Write Time' + uint32 :data_size, label: 'File Size' + uint32 :allocation_size, label: 'Allocation Size' + uint16 :file_attributes, label: 'File Attributes' + uint8 :file_name_length, label: 'File Name Length' + string :file_name, label: 'File Name', + read_length: -> { file_name_length } + end + end + end + end + end +end diff --git a/lib/ruby_smb/smb1/packet/tree_connect_response.rb b/lib/ruby_smb/smb1/packet/tree_connect_response.rb index 7eb0e07ff..e5a1f0e69 100644 --- a/lib/ruby_smb/smb1/packet/tree_connect_response.rb +++ b/lib/ruby_smb/smb1/packet/tree_connect_response.rb @@ -9,15 +9,24 @@ class TreeConnectResponse < RubySMB::GenericPacket # A SMB1 Parameter Block as defined by the {SessionSetupResponse} class ParameterBlock < RubySMB::SMB1::ParameterBlock and_x_block :andx_block - optional_support :optional_support + optional_support :optional_support, onlyif: -> { word_count >= 3 } directory_access_mask :access_rights, label: 'Maximal Share Access Rights', onlyif: -> { word_count >= 5 } directory_access_mask :guest_access_rights, label: 'Guest Share Access Rights', onlyif: -> { word_count == 7 } end # Represents the specific layout of the DataBlock for a {SessionSetupResponse} Packet. + # Windows 95/98/ME may return a minimal DataBlock without the native file system. class DataBlock < RubySMB::SMB1::DataBlock stringz :service, label: 'Service Type' stringz :native_file_system, label: 'Native File System' + + # Override to handle Win95 responses that may omit native_file_system. + def do_read(io) + byte_count.do_read(io) + return unless byte_count > 0 + service.do_read(io) + native_file_system.do_read(io) if byte_count > service.num_bytes + end end smb_header :smb_header diff --git a/lib/ruby_smb/smb1/tree.rb b/lib/ruby_smb/smb1/tree.rb index f6b4d4759..3b2514641 100644 --- a/lib/ruby_smb/smb1/tree.rb +++ b/lib/ruby_smb/smb1/tree.rb @@ -102,9 +102,11 @@ def open_file(opts) # @raise [RubySMB::Error::UnexpectedStatusCode] if the response NTStatus is not STATUS_SUCCESS def list(directory: '\\', pattern: '*', unicode: true, type: RubySMB::SMB1::Packet::Trans2::FindInformationLevel::FindFileFullDirectoryInfo) + info_standard = (type == RubySMB::SMB1::Packet::Trans2::FindInformationLevel::FindInfoStandard) + find_first_request = RubySMB::SMB1::Packet::Trans2::FindFirst2Request.new find_first_request = set_header_fields(find_first_request) - find_first_request.smb_header.flags2.unicode = 1 if unicode + find_first_request.smb_header.flags2.unicode = 1 if unicode && !info_standard search_path = directory.dup search_path << '\\' unless search_path.end_with?('\\') @@ -120,11 +122,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 + response = RubySMB::SMB1::Packet::Trans2::FindFirst2Response.read(raw_response) unless response.valid? raise RubySMB::Error::InvalidPacket.new( @@ -141,9 +148,9 @@ def list(directory: '\\', pattern: '*', unicode: true, eos = response.data_block.trans2_parameters.eos sid = response.data_block.trans2_parameters.sid - last = results.last.file_name + last = results.last&.file_name - while eos.zero? + 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 @@ -171,8 +178,10 @@ def list(directory: '\\', pattern: '*', unicode: true, raise RubySMB::Error::UnexpectedStatusCode, response.status_code end - results += response.results(type, unicode: unicode) + batch = response.results(type, unicode: unicode) + break if batch.empty? + results += batch eos = response.data_block.trans2_parameters.eos last = results.last.file_name end @@ -281,10 +290,50 @@ def set_find_params(request) request.parameter_block.data_offset = 0 request.parameter_block.total_parameter_count = request.parameter_block.parameter_count request.parameter_block.max_parameter_count = request.parameter_block.parameter_count - request.parameter_block.max_data_count = 16_384 + max_data = [16_384, client.server_max_buffer_size].min + request.parameter_block.max_data_count = max_data request end + # Parse a FIND_FIRST2 response with SMB_INFO_STANDARD directly from + # raw bytes, bypassing BinData padding that can misalign on Win95. + def parse_find_first2_info_standard(raw_response, type) + status = raw_response[5, 4].unpack1('V') + unless status == 0 + raise RubySMB::Error::UnexpectedStatusCode, status + end + + pb_offset = 33 + data_offset = raw_response[pb_offset + 14, 2].unpack1('v') + data_count = raw_response[pb_offset + 12, 2].unpack1('v') + + blob = raw_response[data_offset, data_count] + parse_info_standard_blob(blob, type) + end + + def parse_info_standard_blob(blob, type) + results = [] + offset = 0 + while offset < blob.length + 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 + else + offset = entry_end + end + end + results + end + # Add null termination to `str` in case it is not already null-terminated. # # @str [String] the string to be null-terminated diff --git a/spec/lib/ruby_smb/client_spec.rb b/spec/lib/ruby_smb/client_spec.rb index e9dcc7a51..b818e232e 100644 --- a/spec/lib/ruby_smb/client_spec.rb +++ b/spec/lib/ruby_smb/client_spec.rb @@ -754,7 +754,7 @@ it 'sets the expected fields of the SessionRequest packet' do name = 'NBNAMESPEC' called_name = 'NBNAMESPEC ' - calling_name = " \x00" + calling_name = "WORKSTATION \x00" session_packet = client.session_request_packet(name) expect(session_packet).to be_a(RubySMB::Nbss::SessionRequest) diff --git a/spec/lib/ruby_smb/smb1/tree_spec.rb b/spec/lib/ruby_smb/smb1/tree_spec.rb index 88274d6cf..3b2f3b3af 100644 --- a/spec/lib/ruby_smb/smb1/tree_spec.rb +++ b/spec/lib/ruby_smb/smb1/tree_spec.rb @@ -492,8 +492,10 @@ expect(modified_request.parameter_block.max_parameter_count).to eq 10 end - it 'sets #max_data_count to 16,384' do - expect(modified_request.parameter_block.max_data_count).to eq 16_384 + it 'sets #max_data_count to the minimum of 16,384 and server_max_buffer_size' do + expect(modified_request.parameter_block.max_data_count).to eq( + [16_384, client.server_max_buffer_size].min + ) end end From 10ed8befc8ef4ee0b2b5c0edbbf5b96a7a3c624d Mon Sep 17 00:00:00 2001 From: Z6543 Date: Sat, 14 Mar 2026 13:05:21 +0000 Subject: [PATCH 2/4] Add SMB_COM_OPEN_ANDX, share-level auth, and RAP share enumeration 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 --- lib/ruby_smb/client.rb | 61 ++++++++++++++++- lib/ruby_smb/client/tree_connect.rb | 9 ++- lib/ruby_smb/smb1/commands.rb | 1 + lib/ruby_smb/smb1/packet.rb | 2 + lib/ruby_smb/smb1/packet/open_andx_request.rb | 39 +++++++++++ .../smb1/packet/open_andx_response.rb | 40 +++++++++++ lib/ruby_smb/smb1/tree.rb | 66 +++++++++++++++++++ 7 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 lib/ruby_smb/smb1/packet/open_andx_request.rb create mode 100644 lib/ruby_smb/smb1/packet/open_andx_response.rb diff --git a/lib/ruby_smb/client.rb b/lib/ruby_smb/client.rb index 8771ee7f1..6c261fddf 100644 --- a/lib/ruby_smb/client.rb +++ b/lib/ruby_smb/client.rb @@ -603,13 +603,15 @@ def recv_packet(encrypt: false) # Connects to the supplied share # # @param share [String] the path to the share in `\\server\share_name` format + # @param password [String, nil] share-level password (SMB1 only, for + # servers using share-level auth such as Windows 95/98/ME) # @return [RubySMB::SMB1::Tree] if talking over SMB1 # @return [RubySMB::SMB2::Tree] if talking over SMB2 - def tree_connect(share) + def tree_connect(share, password: nil) connected_tree = if smb2 || smb3 smb2_tree_connect(share) else - smb1_tree_connect(share) + smb1_tree_connect(share, password: password) end @tree_connects << connected_tree connected_tree @@ -625,6 +627,61 @@ def net_share_enum_all(host) named_pipe.net_share_enum_all(host) end + # Enumerates shares using the RAP (Remote Administration Protocol). + # This is the only share-enumeration method supported by Windows + # 95/98/ME and old Samba builds that lack DCERPC/srvsvc. + # + # @param host [String] the server hostname or IP + # @param password [String, nil] share-level password for IPC$ + # @return [Array] each entry has :name (String) and :type (Integer) + # @raise [RubySMB::Error::UnexpectedStatusCode] on transport errors + def net_share_enum_rap(host, password: nil) + tree = tree_connect("\\\\#{host}\\IPC$", password: password) + begin + request = RubySMB::SMB1::Packet::Trans::Request.new + request.smb_header.tid = tree.id + request.smb_header.flags2.unicode = 0 + + rap_params = [0].pack('v') # Function: NetShareEnum (0) + rap_params << "WrLeh\x00" # Param descriptor + rap_params << "B13BWz\x00" # Return descriptor + rap_params << [1].pack('v') # Info level 1 + rap_params << [0x1000].pack('v') # Receive buffer size + + request.data_block.name = "\\PIPE\\LANMAN\x00" + request.data_block.trans_parameters = rap_params + request.parameter_block.max_data_count = 0x1000 + request.parameter_block.max_parameter_count = 8 + + raw_response = send_recv(request) + 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 + ensure + tree.disconnect! + end + end + # Resets all of the session state on the client, setting it # back to scratch. Should only be called when a session is no longer # valid. diff --git a/lib/ruby_smb/client/tree_connect.rb b/lib/ruby_smb/client/tree_connect.rb index 83950b69d..c1c5df1c9 100644 --- a/lib/ruby_smb/client/tree_connect.rb +++ b/lib/ruby_smb/client/tree_connect.rb @@ -11,10 +11,17 @@ module TreeConnect # {RubySMB::SMB1::Tree} # # @param share [String] the share path to connect to + # @param password [String, nil] share-level password for servers using + # share-level authentication (e.g. Windows 95/98/ME) # @return [RubySMB::SMB1::Tree] the connected Tree - def smb1_tree_connect(share) + def smb1_tree_connect(share, password: nil) request = RubySMB::SMB1::Packet::TreeConnectRequest.new request.smb_header.tid = 65_535 + if password + pass_bytes = password + "\x00" + request.parameter_block.password_length = pass_bytes.length + request.data_block.password = pass_bytes + end request.data_block.path = share raw_response = send_recv(request) response = RubySMB::SMB1::Packet::TreeConnectResponse.read(raw_response) diff --git a/lib/ruby_smb/smb1/commands.rb b/lib/ruby_smb/smb1/commands.rb index d261b5070..ad9bab616 100644 --- a/lib/ruby_smb/smb1/commands.rb +++ b/lib/ruby_smb/smb1/commands.rb @@ -4,6 +4,7 @@ module Commands SMB_COM_CLOSE = 0x04 SMB_COM_TRANSACTION = 0x25 SMB_COM_ECHO = 0x2B + SMB_COM_OPEN_ANDX = 0x2D SMB_COM_READ_ANDX = 0x2E SMB_COM_WRITE_ANDX = 0x2F SMB_COM_TRANSACTION2 = 0x32 diff --git a/lib/ruby_smb/smb1/packet.rb b/lib/ruby_smb/smb1/packet.rb index 644c287f1..165151eb3 100644 --- a/lib/ruby_smb/smb1/packet.rb +++ b/lib/ruby_smb/smb1/packet.rb @@ -22,6 +22,8 @@ module Packet require 'ruby_smb/smb1/packet/trans' require 'ruby_smb/smb1/packet/trans2' require 'ruby_smb/smb1/packet/nt_trans' + require 'ruby_smb/smb1/packet/open_andx_request' + require 'ruby_smb/smb1/packet/open_andx_response' require 'ruby_smb/smb1/packet/nt_create_andx_request' require 'ruby_smb/smb1/packet/nt_create_andx_response' require 'ruby_smb/smb1/packet/read_andx_request' diff --git a/lib/ruby_smb/smb1/packet/open_andx_request.rb b/lib/ruby_smb/smb1/packet/open_andx_request.rb new file mode 100644 index 000000000..aa27e106d --- /dev/null +++ b/lib/ruby_smb/smb1/packet/open_andx_request.rb @@ -0,0 +1,39 @@ +module RubySMB + 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) + # + # This is the LANMAN 1.0 file-open command, supported by all SMB1 servers + # including Windows 95/98/ME which lack NT_CREATE_ANDX (0xA2). + class OpenAndxRequest < RubySMB::GenericPacket + COMMAND = RubySMB::SMB1::Commands::SMB_COM_OPEN_ANDX + + # A SMB1 Parameter Block as defined by the {OpenAndxRequest} + class ParameterBlock < RubySMB::SMB1::ParameterBlock + endian :little + + and_x_block :andx_block + uint16 :flags, label: 'Flags' + uint16 :access_mode, label: 'Access Mode' + uint16 :search_attributes, label: 'Search Attributes' + uint16 :file_attributes, label: 'File Attributes' + uint32 :creation_time, label: 'Creation Time' + uint16 :open_mode, label: 'Open Mode' + uint32 :allocation_size, label: 'Allocation Size' + uint32 :timeout, label: 'Timeout' + uint32 :reserved, label: 'Reserved' + end + + # Represents the specific layout of the DataBlock for an {OpenAndxRequest} Packet. + class DataBlock < RubySMB::SMB1::DataBlock + stringz :file_name, label: 'File Name' + end + + smb_header :smb_header + parameter_block :parameter_block + data_block :data_block + end + end + end +end diff --git a/lib/ruby_smb/smb1/packet/open_andx_response.rb b/lib/ruby_smb/smb1/packet/open_andx_response.rb new file mode 100644 index 000000000..93da2f979 --- /dev/null +++ b/lib/ruby_smb/smb1/packet/open_andx_response.rb @@ -0,0 +1,40 @@ +module RubySMB + 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) + class OpenAndxResponse < RubySMB::GenericPacket + COMMAND = RubySMB::SMB1::Commands::SMB_COM_OPEN_ANDX + + # A SMB1 Parameter Block as defined by the {OpenAndxResponse} + class ParameterBlock < RubySMB::SMB1::ParameterBlock + endian :little + + and_x_block :andx_block + uint16 :fid, label: 'FID' + uint16 :file_attributes, label: 'File Attributes' + uint32 :last_write_time, label: 'Last Write Time' + uint32 :data_size, label: 'File Data Size' + uint16 :granted_access, label: 'Granted Access' + uint16 :file_type, label: 'File Type' + uint16 :device_state, label: 'Device State' + uint16 :action, label: 'Action Taken' + uint32 :server_fid, label: 'Server FID' + uint16 :reserved, label: 'Reserved' + end + + class DataBlock < RubySMB::SMB1::DataBlock + end + + smb_header :smb_header + parameter_block :parameter_block + data_block :data_block + + def initialize_instance + super + smb_header.flags.reply = 1 + end + end + end + end +end diff --git a/lib/ruby_smb/smb1/tree.rb b/lib/ruby_smb/smb1/tree.rb index 3b2514641..f34fc5edf 100644 --- a/lib/ruby_smb/smb1/tree.rb +++ b/lib/ruby_smb/smb1/tree.rb @@ -88,6 +88,72 @@ def open_file(opts) _open(**opts) end + # Open a file using SMB_COM_OPEN_ANDX (0x2D). This is the LANMAN 1.0 + # file-open command supported by all SMB1 servers including Windows + # 95/98/ME which lack NT_CREATE_ANDX. + # + # @param filename [String] path to the file on the share + # @param disposition [Symbol, Integer] :open, :create, :overwrite, or raw OpenMode integer + # @param read [Boolean] request read access + # @param write [Boolean] request write access + # @return [RubySMB::SMB1::File] handle to the opened file + # @raise [RubySMB::Error::InvalidPacket] if the response is not valid + # @raise [RubySMB::Error::UnexpectedStatusCode] if the response NTStatus is not STATUS_SUCCESS + def open_andx(filename:, disposition: :open, + read: true, write: false) + request = RubySMB::SMB1::Packet::OpenAndxRequest.new + request = set_header_fields(request) + request.smb_header.flags2.unicode = 0 + + access = 0x0040 # sharing: deny-nothing + if read && write + access |= 0x02 + elsif write + access |= 0x01 + end + + open_mode = case disposition + when :open then 0x0001 + when :create then 0x0010 + when :overwrite then 0x0012 + else disposition + end + + request.parameter_block.access_mode = access + request.parameter_block.search_attributes = 0x0016 + request.parameter_block.file_attributes = write ? 0x0020 : 0x0000 + request.parameter_block.open_mode = open_mode + + fname = filename.dup + fname.prepend('\\') unless fname.start_with?('\\') + request.data_block.file_name = fname + + raw_response = @client.send_recv(request) + response = RubySMB::SMB1::Packet::OpenAndxResponse.read( + raw_response + ) + unless response.valid? + raise RubySMB::Error::InvalidPacket.new( + expected_proto: RubySMB::SMB1::SMB_PROTOCOL_ID, + expected_cmd: RubySMB::SMB1::Packet::OpenAndxResponse::COMMAND, + packet: response + ) + end + unless response.status_code == WindowsError::NTStatus::STATUS_SUCCESS + raise RubySMB::Error::UnexpectedStatusCode, + response.status_code + end + + file = RubySMB::SMB1::File.allocate + file.tree = self + file.name = filename + file.fid = response.parameter_block.fid + file.size = response.parameter_block.data_size + file.size_on_disk = response.parameter_block.data_size + file.attributes = response.parameter_block.file_attributes + file + end + # List `directory` on the remote share. # # @example From 541321c8d4c1dc74869a4d617fd126247387274b Mon Sep 17 00:00:00 2001 From: Z6543 Date: Sat, 14 Mar 2026 14:34:47 +0000 Subject: [PATCH 3/4] Fix TreeConnectRequest password field writing extra null byte 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 --- lib/ruby_smb/smb1/packet/tree_connect_request.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ruby_smb/smb1/packet/tree_connect_request.rb b/lib/ruby_smb/smb1/packet/tree_connect_request.rb index b1c29ed0c..338d257d7 100644 --- a/lib/ruby_smb/smb1/packet/tree_connect_request.rb +++ b/lib/ruby_smb/smb1/packet/tree_connect_request.rb @@ -15,7 +15,7 @@ class ParameterBlock < RubySMB::SMB1::ParameterBlock # The {RubySMB::SMB1::DataBlock} specific to this packet type. class DataBlock < RubySMB::SMB1::DataBlock - stringz :password, label: 'Password Field', initial_value: '', length: -> { parent.parameter_block.password_length } + string :password, label: 'Password Field', initial_value: "\x00", length: -> { parent.parameter_block.password_length } choice :path, selection: -> { parent.smb_header.flags2.unicode } do stringz 0 stringz16 1 From b5269e093d1145f3a8c592133b518ee22447f542 Mon Sep 17 00:00:00 2001 From: Z6543 Date: Sat, 14 Mar 2026 14:43:29 +0000 Subject: [PATCH 4/4] Add NetBIOS name resolution fallback for session request 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 --- lib/ruby_smb/client.rb | 123 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 2 deletions(-) diff --git a/lib/ruby_smb/client.rb b/lib/ruby_smb/client.rb index 6c261fddf..ea634af50 100644 --- a/lib/ruby_smb/client.rb +++ b/lib/ruby_smb/client.rb @@ -701,28 +701,147 @@ 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 + + # Resolves a NetBIOS name using the system nmblookup command. + # + # @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) + return nil unless $?.success? + + output.each_line do |line| + if line =~ /\A\s+(\S+)\s+<20>\s/ + return $1.strip + end + end + nil + rescue Errno::ENOENT + nil + end + + # Resolves a NetBIOS name via a raw UDP Node Status request + # (RFC 1002, port 137). + # + # @param host [String] the IP address to query + # @return [String, nil] the file server NetBIOS name + def netbios_lookup_udp(host) + 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') + + sock = UDPSocket.new + sock.send(request, 0, host, 137) + + return nil unless IO.select([sock], nil, nil, 3) + + data, = sock.recvfrom(4096) + return nil if data.nil? || data.length < 57 + + offset = 12 + while offset < data.length + len = data[offset].ord + break if len == 0 + offset += len + 1 + end + offset += 1 # label terminator + offset += 10 # Type(2) + Class(2) + TTL(4) + RDLENGTH(2) + return nil if offset >= data.length + + num_names = data[offset].ord + offset += 1 + + num_names.times do + break if offset + 18 > data.length + nb_name = data[offset, 15].rstrip + suffix = data[offset + 15].ord + flags = data[offset + 16, 2].unpack1('n') + offset += 18 + return nb_name if suffix == 0x20 && (flags & 0x8000) == 0 + end + + nil + ensure + sock&.close end + public + # Crafts the NetBIOS SessionRequest packet to be sent for session request operations. # # @param name [String] the NetBIOS name to request