# -*- coding: binary -*-
module Msf


###
#
# This module exposes methods that may be useful to exploits that deal with
# servers that speak the File Transfer Protocol (FTP).
#
###
module Exploit::Remote::Ftp

  include Exploit::Remote::Tcp
  include Msf::Auxiliary::Report

  #
  # Creates an instance of an FTP exploit module.
  #
  def initialize(info = {})
    super

    # Register the options that all FTP exploits may make use of.
    register_options(
      [
        Opt::RHOST,
        Opt::RPORT(21),
        OptString.new('FTPUSER', [ false, 'The username to authenticate as', 'anonymous'], fallbacks: ['USERNAME']),
        OptString.new('FTPPASS', [ false, 'The password for the specified username', 'mozilla@example.com'], fallbacks: ['PASSWORD']),
      ], Msf::Exploit::Remote::Ftp)

    register_advanced_options(
      [
        OptInt.new('FTPTimeout', [ true, 'The number of seconds to wait for a reply from an FTP command', 16]),
        OptBool.new('FTPDEBUG', [ false, 'Whether or not to print verbose debug statements', false ]),
        OptBool.new('PassiveMode', [ false, 'Set true for extended passive (EPSV) ftp mode.', false])
      ], Msf::Exploit::Remote::Ftp)

    register_autofilter_ports([ 21, 2121])
    register_autofilter_services(%W{ ftp })

    @ftpbuff = ""

  end

  #
  # This method establishes an FTP connection to host and port specified by
  # the 'rhost' and 'rport' methods. After connecting, the banner
  # message is read in and stored in the 'banner' attribute.
  #
  def connect(global = true, verbose = nil)
    verbose = datastore['FTPDEBUG'] || datastore['VERBOSE'] if verbose.nil?

    print_status("Connecting to FTP server...") if verbose

    begin
      fd = super(global)
    rescue ::Rex::ConnectionRefused
      report_host(host: rhost)
      raise
    end

    # Wait for a banner to arrive...
    self.banner = recv_ftp_resp(fd)
    remove_instance_variable(:@recog_banner) if instance_variable_defined?(:@recog_banner)

    print_status('Connected to target FTP server') if verbose

    # Only record the service and banner when the greeting looks like FTP (RFC 959)
    if self.banner&.match?(/^(120|220)[\s-]/)
      # Cleaned up FTP banner
      report_service(
        host: rhost,
        port: rport,
        proto: 'tcp',
        name: 'ftp',
        info: Rex::Text.to_hex_ascii(banner_version),
        parents: {
          host: rhost,
          port: rport,
          proto: 'tcp',
          name: 'tcp'
        }
      )

      # Raw FTP banner
      report_note(
        host: rhost,
        port: rport,
        proto: 'tcp',
        sname: 'ftp',
        type: 'ftp.banner',
        data: { banner: Rex::Text.to_hex_ascii(self.banner.strip) }
      )

      # Lookup FTP banner
      info = recog_banner
      if info
        os_info = {}
        info.each_pair do |k, v|
          case k
          when 'os.product' then os_info[:os_name] = v
          when 'os.vendor' then os_info[:os_flavor] = v
          when 'os.version' then os_info[:os_sp] = v
          when 'os.cpe23', 'service.cpe23'
            report_note(
              host: rhost,
              port: rport,
              proto: 'tcp',
              sname: 'ftp',
              type: 'ftp.cpe',
              data: { cpe: v },
              update: :unique_data
            )
          end
        end

        report_host({ host: rhost }.merge(os_info)) unless os_info.empty?
      end
    end

    # Return the file descriptor to the caller
    fd
  end

  #
  # Matches the FTP banner against the Recog ftp.banner fingerprint set
  # Tests each line of a multi-line banner after stripping the reply code prefix
  # Result is memoised
  #
  # @return [Hash, nil] Recog match, or nil if no fingerprint matched
  def recog_banner
    return nil unless banner
    return @recog_banner if instance_variable_defined?(:@recog_banner)

    @recog_banner = nil
    banner.to_s.each_line do |line|
      text = line.sub(/^\d{3}[\s-]/, '').strip
      next if text.empty?
      match = Recog::Nizer.match('ftp.banner', text)
      @recog_banner = match and break if match
    end
    @recog_banner
  end

  #
  # Returns a normalised version string from the FTP banner
  # Uses Recog - falls back to regex if no match
  #
  # @return [String, nil] version string, or nil if no banner
  def banner_version
    info = recog_banner
    return [info['service.product'], info['service.version']].compact.join(' ') if info

    # 220 (vsFTPd 2.3.4)\x0d\x0a                                    -> vsFTPd 2.3.4
    # 220 ProFTPD 1.3.1 Server (Debian) [::ffff:192.0.2.10]\x0d\x0a -> ProFTPD 1.3.1 Server (Debian)
    banner.to_s
          .sub(/^\d{3}[\s-]/, '')
          .strip
          .gsub(/\A\(|\)\z/, '')
          .gsub(/\s*\[(?:(?:\d{1,3}\.){3}\d{1,3}|[0-9A-Fa-f:]*:[0-9A-Fa-f:.]+)\]/, '')
  end

  #
  # Sends FEAT, STAT, and SYST and records the output as workspace notes
  # Skips SYST-based OS detection if Recog already identified os.product
  #
  # @param logged_in_as [String] current FTP session user, recorded in notes
  # @raise [Rex::ConnectionError] if the server closes the connection mid-fingerprint
  # @return [void]
  def ftp_fingerprint(logged_in_as: 'anonymous')
    print_status("Fingerprinting FTP service (as #{logged_in_as})")

    [
      ['FEAT', 'ftp.cmd.feat'], # server-level
      ['STAT', 'ftp.cmd.stat'], # user-level
      ['SYST', 'ftp.cmd.syst'] # server-level
    ].each do |cmd, note_type|
      vprint_status("Sending FTP command: #{cmd}")
      response = send_cmd([cmd], true)
      raise Rex::ConnectionError.new(rhost, rport) unless response
      next if response.empty?

      response.strip.each_line.with_index do |line, i|
        prefix = i == 0 ? "FTP #{cmd}: " : '  '
        vprint_status("#{prefix}#{line.strip}")
      end

      # Examples:
      #   215 UNIX Type: L8
      #   215 Windows_NT
      #   215 UNIX emulated by FileZilla     (Windows host but looks like *nix service)
      if cmd == 'SYST'
        os_name = if response.match?(/emulated/i) then nil
                  elsif response.match?(/Windows_NT/i) then 'Windows'
                  elsif response.match?(/UNIX/i) then '*nix'
                  else nil
                  end
        report_host(host: rhost, os_name: os_name) if os_name && !recog_banner&.key?('os.product')
      end

      report_note(
        host: rhost,
        port: rport,
        proto: 'tcp',
        sname: 'ftp',
        type: note_type,
        data: {
          username: logged_in_as,
          output: response.strip
        }
      )
    end
  end

  #
  # Lists the current remote directory and optionally stores the result as loot
  #
  # @param logged_in_as [String] current FTP session user, used to label the loot file
  # @param save_loot [Boolean] store the directory listing as loot
  # @return [String, nil] directory listing content, or nil on failure
  def ftp_list_directory(logged_in_as: 'anonymous', save_loot: false)
    print_status('Getting FTP root directory contents')

    listing = send_cmd_data(['LS'], nil)
    if listing.nil?
      print_warning('Could not retrieve directory listing (data connection failed)')
      return nil
    elsif listing[1].blank?
      vprint_status('Directory listing: (empty)')
      return nil
    end

    vprint_status('Directory listing:')
    listing[1].strip.each_line do |line|
      vprint_status("  #{line.strip}")
    end

    return listing[1] unless save_loot

    path = store_loot(
      'ftp.dir_listing',
      'text/plain',
      rhost,
      listing[1],
      "ftp_#{logged_in_as.to_s.downcase}.txt",
      "FTP directory listing for #{logged_in_as}"
    )
    print_good("Directory listing stored to: #{path}")

    listing[1]
  end

  #
  # This method handles establishing datasocket for data channel
  #
  def data_connect(mode = nil, nsock = self.sock)
    pass_mode = datastore['PassiveMode']

    if mode
      res = send_cmd([ 'TYPE' , mode ], true, nsock)
      return nil if not res =~ /^200/
    end

    # force datasocket to renegotiate
    self.datasocket.shutdown if self.datasocket != nil

    # Need to be able to do both extended and normal
    # passive modes.  normal passive mode is default
    # details of EPSV are in RFC2428
    # pass_mode = true is EPSV; false is PASV
    if pass_mode
      res = send_cmd(['EPSV'], true, nsock)
      return nil if not res =~ /^229/
      # 229 Entering Passive Mode (|||port|)
      if res =~ /\(\|\|\|(\d+)\|\)/
        # convert port to FTP syntax
        datahost = "#{rhost}"
        dataport = $1.to_i
        self.datasocket = Rex::Socket::Tcp.create(
          'PeerHost' => datahost,
          'PeerPort' => dataport,
          'Context'  => { 'Msf' => framework, 'MsfExploit' => self }
        )
      end
    else
      res = send_cmd(['PASV'], true, nsock)
      return nil if not res =~ /^227/
      # 227 Entering Passive Mode (127,0,0,1,196,5)
      if res =~ /\((\d+)\,(\d+),(\d+),(\d+),(\d+),(\d+)/
        # convert port to FTP syntax
        datahost = "#{$1}.#{$2}.#{$3}.#{$4}"
        dataport = ($5.to_i * 256) + $6.to_i
        self.datasocket = Rex::Socket::Tcp.create(
          'PeerHost' => datahost,
          'PeerPort' => dataport,
          'Context'  => { 'Msf' => framework, 'MsfExploit' => self }
        )
      end
    end

    self.datasocket
  end

  #
  # This method handles disconnecting our data channel
  #
  def data_disconnect
    begin
      if datasocket
        datasocket.shutdown
        datasocket.close
      end
    rescue IOError
    end
    datasocket = nil if datasocket
  end

  #
  # Connect and login to the remote FTP server using the credentials
  # that have been supplied in the exploit options.
  #
  def connect_login(global = true, verbose = nil)
    verbose = datastore['FTPDEBUG'] || datastore['VERBOSE'] if verbose.nil?

    ftpsock = ftp_connect(global, verbose)

    if !(user and pass)
      print_error("No username and password were supplied, unable to login")
      return false
    end

    print_status("Authenticating as #{user} with password #{pass}...") if verbose
    res = send_user(user, ftpsock)

    if (res !~ /^(331|2)/)
      print_error("The server rejected our username") if verbose
      return false
    end

    if (pass)
      print_status("Sending password...") if verbose
      res = send_pass(pass, ftpsock)
      if (res !~ /^2/)
        print_error("The server rejected our password") if verbose
        return false
      end
    end

    return true
  end

  #
  # This method logs in as the supplied user by transmitting the FTP
  # 'USER <user>' command.
  #
  def send_user(user, nsock = self.sock)
    raw_send("USER #{user}\r\n", nsock)
    recv_ftp_resp(nsock)
  end

  #
  # This method completes user authentication by sending the supplied
  # password using the FTP 'PASS <pass>' command.
  #
  def send_pass(pass, nsock = self.sock)
    raw_send("PASS #{pass}\r\n", nsock)
    recv_ftp_resp(nsock)
  end

  #
  # This method sends a QUIT command.
  #
  def send_quit(nsock = self.sock)
    raw_send("QUIT\r\n", nsock)
    recv_ftp_resp(nsock)
  end

  #
  # This method sends one command with zero or more parameters
  #
  def send_cmd(args, recv = true, nsock = self.sock)
    cmd = args.join(" ") + "\r\n"
    ret = raw_send(cmd, nsock)
    if (recv)
      return recv_ftp_resp(nsock)
    end
    return ret
  end

  #
  # This method transmits the command in args and receives / uploads DATA via data channel
  # For commands not needing data, it will fall through to the original send_cmd
  #
  # For commands that send data only, the return will be the server response.
  # For commands returning both data and a server response, an array will be returned.
  #
  # NOTE: This function always waits for a response from the server.
  #
  def send_cmd_data(args, data, mode = 'a', nsock = self.sock)
    type = nil
    # implement some aliases for various commands
    if (args[0] =~ /^DIR$/i || args[0] =~ /^LS$/i)
      # TODO || args[0] =~ /^MDIR$/i || args[0] =~ /^MLS$/i
      args[0] = "LIST"
      type = "get"
    elsif (args[0] =~ /^GET$/i)
      args[0] = "RETR"
      type = "get"
    elsif (args[0] =~ /^PUT$/i)
      args[0] = "STOR"
      type = "put"
    end

    # fall back if it's not a supported data command
    if not type
      return send_cmd(args, true, nsock)
    end

    # Set the transfer mode and connect to the remove server
    return nil if not data_connect(mode)

    # Our pending command should have got a connection now.
    res = send_cmd(args, true, nsock)
    # make sure could open port
    return nil unless res =~ /^(150|125) /

    # dispatch to the proper method
    if (type == "get")
      # failed listings just disconnect..
      begin
        data = datasocket.get(ftp_timeout, ftp_data_timeout)
      rescue ::EOFError
        data = nil
      end
    else
      sent = self.datasocket.put(data)
    end

    # close data channel so command channel updates
    data_disconnect

    # get status of transfer
    ret = nil
    if (type == "get")
      ret = recv_ftp_resp(nsock)
      ret = [ ret, data ]
    else
      ret = recv_ftp_resp(nsock)
    end

    ret
  end

  #
  # This method transmits a FTP command and waits for a response.  If one is
  # received, it is returned to the caller.
  #
  def raw_send_recv(cmd, nsock = self.sock)
    nsock.put(cmd)
    nsock.get_once(-1, ftp_timeout)
  end

  #
  # This method reads an FTP response based on FTP continuation stuff
  #
  def recv_ftp_resp(nsock = self.sock)
    found_end = false
    resp = ""
    left = ""
    if !@ftpbuff.empty?
      left << @ftpbuff
      @ftpbuff = ""
    end
    while true
      data = nsock.get_once(-1, ftp_timeout)
      if not data
        @ftpbuff << resp
        @ftpbuff << left
        return data
      end

      got = left + data
      left = ""

      # handle the end w/o newline case
      enlidx = got.rindex(0x0a.chr)
      if enlidx != (got.length-1)
        if not enlidx
          left << got
          next
        else
          left << got.slice!((enlidx+1)..got.length)
        end
      end

      # split into lines
      rarr = got.split(/\r?\n/)
      rarr.each do |ln|
        if not found_end
          resp << ln
          resp << "\r\n"
          if ln.length > 3 and ln[3,1] == ' ' and ln[0,3] =~ /\A\d{3}\z/
            found_end = true
          end
        else
          left << ln
          left << "\r\n"
        end
      end
      if found_end
        @ftpbuff << left
        print_status("FTP recv: #{resp.inspect}") if datastore['FTPDEBUG']
        return resp
      end
    end
  end

  #
  # This method transmits a FTP command and does not wait for a response
  #
  def raw_send(cmd, nsock = self.sock)
    print_status("FTP send: #{cmd.inspect}") if datastore['FTPDEBUG']
    nsock.put(cmd)
  end

  ##
  #
  # Wrappers for getters
  #
  ##

  #
  # Returns the user string from the 'FTPUSER' option.
  #
  def user
    datastore['FTPUSER']
  end

  #
  # Returns the user string from the 'FTPPASS' option.
  #
  def pass
    datastore['FTPPASS']
  end

  #
  # Returns the number of seconds to wait for a FTP reply
  #
  def ftp_timeout
    (datastore['FTPTimeout'] || 10).to_i
  end

  #
  # Returns the number of seconds to wait to get more FTP data
  #
  def ftp_data_timeout
    (datastore['FTPDataTimeout'] || 1).to_i
  end

  alias ftp_connect connect

protected

  #
  # This attribute holds the banner that was read in after a successful call
  # to connect or connect_login.
  #
  attr_accessor :banner, :datasocket

end

end
