rapid7/ruby_smb

View on GitHub
lib/ruby_smb/smb1/tree.rb

Summary

Maintainability
C
1 day
Test Coverage
module RubySMB
  module SMB1
    # An SMB1 connected remote Tree, as returned by a
    # [RubySMB::SMB1::Packet::TreeConnectRequest]
    class Tree
      # The client this Tree is connected through
      # @!attribute [rw] client
      #   @return [RubySMB::Client]
      attr_accessor :client

      # The current Guest Share Permissions
      # @!attribute [rw] guest_permissions
      #   @return [RubySMB::SMB1::BitField::DirectoryAccessMask]
      attr_accessor :guest_permissions

      # The current Maximal Share Permissions
      # @!attribute [rw] permissions
      #   @return [RubySMB::SMB1::BitField::DirectoryAccessMask]
      attr_accessor :permissions

      # The share path associated with this Tree
      # @!attribute [rw] share
      #   @return [String]
      attr_accessor :share

      # The Tree ID for this Tree
      # @!attribute [rw] id
      #   @return [Integer]
      attr_accessor :id

      def initialize(client:, share:, response:)
        @client             = client
        @share              = share
        @id                 = response.smb_header.tid
        @guest_permissions  = response.parameter_block.guest_access_rights
        @permissions        = response.parameter_block.access_rights
      end

      # Disconnects this Tree from the current session
      #
      # @return [WindowsError::ErrorCode] the NTStatus sent back by the server.
      # @raise [RubySMB::Error::InvalidPacket] if the response is not a TreeDisconnectResponse packet
      def disconnect!
        request = RubySMB::SMB1::Packet::TreeDisconnectRequest.new
        request = set_header_fields(request)
        raw_response = client.send_recv(request)
        response = RubySMB::SMB1::Packet::TreeDisconnectResponse.read(raw_response)
        unless response.valid?
          raise RubySMB::Error::InvalidPacket.new(
            expected_proto: RubySMB::SMB1::SMB_PROTOCOL_ID,
            expected_cmd:   RubySMB::SMB1::Packet::TreeDisconnectResponse::COMMAND,
            packet:         response
          )
        end
        response.status_code
      end

      def open_pipe(opts)
        # Make sure we don't modify the caller's hash options
        opts = opts.dup
        opts[:filename] = opts[:filename].dup
        opts[:filename].prepend('\\') unless opts[:filename].start_with?('\\'.encode(opts[:filename].encoding))
        _open(**opts)
      end

      # Open a file on the remote share.
      #
      # @example
      #   tree = client.tree_connect("\\\\192.168.99.134\\Share")
      #   tree.open_file(filename: "myfile")
      #
      # @param filename [String] name of the file to be opened
      # @param flags [BinData::Struct, Hash] flags to setup the request (see {RubySMB::SMB1::Packet::NtCreateAndxRequest})
      # @param options [RubySMB::SMB1::BitField::CreateOptions, Hash] flags that defines how the file should be created
      # @param disposition [Integer] 32-bit field that defines how an already-existing file or a new file needs to be handled (constants are defined in {RubySMB::Dispositions})
      # @param impersonation [Integer] 32-bit field that defines the impersonation level (constants are defined in {RubySMB::ImpersonationLevels})
      # @param read [TrueClass, FalseClass] request a read access
      # @param write [TrueClass, FalseClass] request a write access
      # @param delete [TrueClass, FalseClass] request a delete access
      # @return [RubySMB::SMB1::File] handle to the created file
      # @raise [RubySMB::Error::InvalidPacket] if the response command is not SMB_COM_NT_CREATE_ANDX
      # @raise [RubySMB::Error::UnexpectedStatusCode] if the response NTStatus is not STATUS_SUCCESS
      def open_file(opts)
        # Make sure we don't modify the caller's hash options
        opts = opts.dup
        opts[:filename] = opts[:filename].dup
        opts[:filename] = opts[:filename][1..-1] if opts[:filename].start_with?('\\'.encode(opts[:filename].encoding))
        _open(**opts)
      end

      # List `directory` on the remote share.
      #
      # @example
      #   tree = client.tree_connect("\\\\192.168.99.134\\Share")
      #   tree.list(directory: "path\\to\\directory")
      #
      # @param directory [String] path to the directory to be listed
      # @param pattern [String] search pattern
      # @param type [Class] file information class
      # @return [Array] array of directory structures
      # @raise [RubySMB::Error::InvalidPacket] if the response is not a Trans2 packet
      # @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)
        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

        search_path = directory.dup
        search_path << '\\' unless search_path.end_with?('\\')
        search_path << pattern
        search_path = '\\' + search_path unless search_path.start_with?('\\')

        # Set the search parameters
        t2_params = find_first_request.data_block.trans2_parameters
        t2_params.search_attributes.hidden    = 1
        t2_params.search_attributes.system    = 1
        t2_params.search_attributes.directory = 1
        t2_params.flags.close_eos             = 1
        t2_params.flags.resume_keys           = 0
        t2_params.information_level           = type::CLASS_LEVEL
        t2_params.filename                    = search_path
        t2_params.search_count                = 10

        find_first_request = set_find_params(find_first_request)

        raw_response  = client.send_recv(find_first_request)
        response      = RubySMB::SMB1::Packet::Trans2::FindFirst2Response.read(raw_response)
        unless response.valid?
          raise RubySMB::Error::InvalidPacket.new(
            expected_proto: RubySMB::SMB1::SMB_PROTOCOL_ID,
            expected_cmd:   RubySMB::SMB1::Packet::Trans2::FindFirst2Response::COMMAND,
            packet:         response
          )
        end
        unless response.status_code == WindowsError::NTStatus::STATUS_SUCCESS
          raise RubySMB::Error::UnexpectedStatusCode, response.status_code
        end

        results = response.results(type, unicode: unicode)

        eos   = response.data_block.trans2_parameters.eos
        sid   = response.data_block.trans2_parameters.sid
        last  = results.last.file_name

        while eos.zero?
          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

          t2_params                             = find_next_request.data_block.trans2_parameters
          t2_params.sid                         = sid
          t2_params.flags.close_eos             = 1
          t2_params.flags.resume_keys           = 0
          t2_params.information_level           = type::CLASS_LEVEL
          t2_params.filename                    = last
          t2_params.search_count                = 10

          find_next_request = set_find_params(find_next_request)

          raw_response  = client.send_recv(find_next_request)
          response      = RubySMB::SMB1::Packet::Trans2::FindNext2Response.read(raw_response)
          unless response.valid?
            raise RubySMB::Error::InvalidPacket.new(
              expected_proto: RubySMB::SMB1::SMB_PROTOCOL_ID,
              expected_cmd:   RubySMB::SMB1::Packet::Trans2::FindNext2Response::COMMAND,
              packet:         response
            )
          end
          unless response.status_code == WindowsError::NTStatus::STATUS_SUCCESS
            raise RubySMB::Error::UnexpectedStatusCode, response.status_code
          end

          results += response.results(type, unicode: unicode)

          eos   = response.data_block.trans2_parameters.eos
          last  = results.last.file_name
        end

        results
      end

      # Sets a few preset header fields that will always be set the same
      # way for Tree operations. This is, the TreeID and Extended Attributes.
      #
      # @param [RubySMB::SMB::Packet] the request packet to modify
      # @return [RubySMB::SMB::Packet] the modified packet.
      def set_header_fields(request)
        request.smb_header.tid        = @id
        request.smb_header.flags2.eas = 1
        request
      end

      private

      def _open(filename:, flags: nil, options: nil, disposition: RubySMB::Dispositions::FILE_OPEN,
                impersonation: RubySMB::ImpersonationLevels::SEC_IMPERSONATE, read: true, write: false, delete: false)
        nt_create_andx_request = RubySMB::SMB1::Packet::NtCreateAndxRequest.new
        nt_create_andx_request = set_header_fields(nt_create_andx_request)

        nt_create_andx_request.parameter_block.ext_file_attributes.normal = 1

        if flags
          nt_create_andx_request.parameter_block.flags = flags
        else
          nt_create_andx_request.parameter_block.flags.request_extended_response = 1
        end

        if options
          nt_create_andx_request.parameter_block.create_options = options
        else
          nt_create_andx_request.parameter_block.create_options.directory_file     = 0
          nt_create_andx_request.parameter_block.create_options.non_directory_file = 1
        end

        if read
          nt_create_andx_request.parameter_block.share_access.share_read     = 1
          nt_create_andx_request.parameter_block.desired_access.read_data    = 1
          nt_create_andx_request.parameter_block.desired_access.read_ea      = 1
          nt_create_andx_request.parameter_block.desired_access.read_attr    = 1
          nt_create_andx_request.parameter_block.desired_access.read_control = 1
        end

        if write
          nt_create_andx_request.parameter_block.share_access.share_write   = 1
          nt_create_andx_request.parameter_block.desired_access.write_data  = 1
          nt_create_andx_request.parameter_block.desired_access.append_data = 1
          nt_create_andx_request.parameter_block.desired_access.write_ea    = 1
          nt_create_andx_request.parameter_block.desired_access.write_attr  = 1
        end

        if delete
          nt_create_andx_request.parameter_block.share_access.share_delete    = 1
          nt_create_andx_request.parameter_block.desired_access.delete_access = 1
        end

        nt_create_andx_request.parameter_block.impersonation_level = impersonation
        nt_create_andx_request.parameter_block.create_disposition  = disposition

        unicode_enabled = nt_create_andx_request.smb_header.flags2.unicode == 1
        nt_create_andx_request.data_block.file_name = add_null_termination(str: filename, unicode: unicode_enabled)

        raw_response = @client.send_recv(nt_create_andx_request)
        response = RubySMB::SMB1::Packet::NtCreateAndxResponse.read(raw_response)
        unless response.valid?
          if response.is_a?(RubySMB::SMB1::Packet::EmptyPacket) &&
               response.smb_header.protocol == RubySMB::SMB1::SMB_PROTOCOL_ID &&
               response.smb_header.command == response.original_command
            raise RubySMB::Error::InvalidPacket.new(
              'The response seems to be an SMB1 NtCreateAndxResponse but an '\
              'error occurs while parsing it. It is probably missing the '\
              'required extended information.'
            )
          end
          raise RubySMB::Error::InvalidPacket.new(
            expected_proto: RubySMB::SMB1::SMB_PROTOCOL_ID,
            expected_cmd:   RubySMB::SMB1::Packet::NtCreateAndxResponse::COMMAND,
            packet:         response
          )
        end
        unless response.status_code == WindowsError::NTStatus::STATUS_SUCCESS
          raise RubySMB::Error::UnexpectedStatusCode, response.status_code
        end

        case response.parameter_block.resource_type
        when RubySMB::SMB1::ResourceType::BYTE_MODE_PIPE, RubySMB::SMB1::ResourceType::MESSAGE_MODE_PIPE
          RubySMB::SMB1::Pipe.new(name: filename, tree: self, response: response)
        when RubySMB::SMB1::ResourceType::DISK
          RubySMB::SMB1::File.new(name: filename, tree: self, response: response)
        else
          raise RubySMB::Error::RubySMBError
        end
      end

      # Sets ParameterBlock options for FIND_FIRST2 and
      # FIND_NEXT2 requests. In particular we need to do this
      # to tell the server to ignore the Trans2DataBlock as we are
      # not sending any GEA lists in this instance.
      def set_find_params(request)
        request.parameter_block.data_count             = 0
        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
        request
      end

      # Add null termination to `str` in case it is not already null-terminated.
      #
      # @str [String] the string to be null-terminated
      # @unicode [TrueClass, FalseClass] True if the null-termination should be Unicode encoded
      # @return [String] the null-terminated string
      def add_null_termination(str:, unicode: false)
        null_termination = unicode ? "\x00".encode('UTF-16LE') : "\x00"
        if str.end_with?(null_termination)
          return str
        else
          return str + null_termination
        end
      end

    end
  end
end