diff --git a/lib/ruby_smb/client.rb b/lib/ruby_smb/client.rb index 473293091..ea634af50 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. @@ -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 + + # 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 # @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/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/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/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/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_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 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..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 @@ -102,9 +168,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 +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 + response = RubySMB::SMB1::Packet::Trans2::FindFirst2Response.read(raw_response) unless response.valid? raise RubySMB::Error::InvalidPacket.new( @@ -141,9 +214,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 +244,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 +356,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