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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is going to break when SMB 2 or 3 was negotiated. The entire method though probably shouldn't exist. The current pattern is that if this is a DCERPC method, then it should have corresponding DCERPC definitions for NetShareEnum below. It looks like you'd create a RAP folder with the definitions to mirror the MS-RAP spec where this is defined.

The pipe then needs to be updated for both SMB1 and SMB2 https://github.com/rapid7/ruby_smb/blob/master/lib/ruby_smb/smb1/pipe.rb#L16 which will declare your net_share_enum method.

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
Comment on lines +657 to +679
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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.
Comment on lines +704 to +707
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

#
# @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')

Comment on lines +714 to +717
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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
Comment on lines 638 to +770
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from organization custom instructions.

# 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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, definitely not going to work. We need to be doing everything in Ruby.

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')
Comment on lines +796 to +806
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These all need to use the existing definitions we have in lib/ruby_smb/nbss or add new ones.


sock = UDPSocket.new
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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
Comment on lines +123 to +124
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'

# 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"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NULL bytes need the #b method in Ruby to always be treated correctly.

Suggested change
pass_bytes = password + "\x00"
pass_bytes = password + "\x00".b

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
Loading