Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 181 additions & 5 deletions lib/ruby_smb/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Hash>] 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.
Expand All @@ -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
Expand Down
53 changes: 53 additions & 0 deletions lib/ruby_smb/client/authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
#
Expand Down
14 changes: 14 additions & 0 deletions lib/ruby_smb/client/negotiation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion lib/ruby_smb/client/tree_connect.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions lib/ruby_smb/smb1/commands.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/ruby_smb/smb1/packet.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
11 changes: 11 additions & 0 deletions lib/ruby_smb/smb1/packet/negotiate_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading