rapid7/metasploit-framework

View on GitHub
lib/msf/core/post/file.rb

Summary

Maintainability
F
1 wk
Test Coverage
# -*- coding: binary -*-

require 'rex/post/meterpreter/extensions/stdapi/command_ids'
require 'rex/post/file_stat'

module Msf::Post::File
  include Msf::Post::Common

  def initialize(info = {})
    super(
      update_info(
        info,
        'Compat' => {
          'Meterpreter' => {
            'Commands' => %w[
              core_channel_eof
              core_channel_open
              core_channel_read
              core_channel_write
              stdapi_fs_chdir
              stdapi_fs_chmod
              stdapi_fs_delete_dir
              stdapi_fs_delete_file
              stdapi_fs_file_expand_path
              stdapi_fs_file_move
              stdapi_fs_getwd
              stdapi_fs_ls
              stdapi_fs_mkdir
              stdapi_fs_separator
              stdapi_fs_stat
            ]
          }
        }
      )
      )
  end

  #
  # Change directory in the remote session to +path+, which may be relative or
  # absolute.
  #
  # @return [void]
  def cd(path)
    e_path = begin
      expand_path(path)
    rescue StandardError
      path
    end
    if session.type == 'meterpreter'
      session.fs.dir.chdir(e_path)
    elsif session.type == 'powershell'
      cmd_exec("Set-Location -Path \"#{e_path}\";[System.IO.Directory]::SetCurrentDirectory($(Get-Location))")
    else
      session.shell_command_token("cd \"#{e_path}\"")
    end
    nil
  end

  #
  # Returns the current working directory in the remote session
  #
  # @note This may be inaccurate on shell sessions running on Windows before
  #   XP/2k3
  #
  # @return [String]
  def pwd
    if session.type == 'meterpreter'
      return session.fs.dir.getwd
    elsif session.type == 'powershell'
      return cmd_exec('(Get-Location).Path').strip
    elsif session.platform == 'windows'
      return session.shell_command_token('echo %CD%').to_s.strip
    # XXX: %CD% only exists on XP and newer, figure something out for NT4
    # and 2k
    elsif command_exists?('pwd')
      return session.shell_command_token('pwd').to_s.strip
    else
      # Result on systems without pwd command
      return session.shell_command_token('echo $PWD').to_s.strip
    end
  end

  # Returns a list of the contents of the specified directory
  # @param directory [String] the directory to list
  # @return [Array] the contents of the directory
  def dir(directory)
    if session.type == 'meterpreter'
      return session.fs.dir.entries(directory)
    end

    if session.type == 'powershell'
      return cmd_exec("Get-ChildItem \"#{directory}\" -Name").split(/[\r\n]+/)
    end

    if session.platform == 'windows'
      return session.shell_command_token("dir /b \"#{directory}\"")&.split(/[\r\n]+/)
    end

    if command_exists?('ls')
      return session.shell_command_token("ls #{directory}").split(/[\r\n]+/)
    end

    # Result on systems without ls command
    if directory[-1] != '/'
      directory += '/'
    end
    result = []
    data = session.shell_command_token("for fn in #{directory}*; do echo $fn; done")
    parts = data.split("\n")
    parts.each do |line|
      line = line.split('/')[-1]
      result.insert(-1, line)
    end

    result
  end

  alias ls dir

  # create and mark directory for cleanup
  def mkdir(path)
    result = nil
    vprint_status("Creating directory #{path}")
    if session.type == 'meterpreter'
      # behave like mkdir -p and don't throw an error if the directory exists
      result = session.fs.dir.mkdir(path) unless directory?(path)
    elsif session.type == 'powershell'
      result = cmd_exec("New-Item \"#{path}\" -itemtype directory")
    elsif session.platform == 'windows'
      result = cmd_exec("mkdir \"#{path}\"")
    else
      result = cmd_exec("mkdir -p '#{path}'")
    end
    vprint_status("#{path} created")
    register_dir_for_cleanup(path)
    result
  end

  #
  # See if +path+ exists on the remote system and is a directory
  #
  # @param path [String] Remote filename to check
  def directory?(path)
    if session.type == 'meterpreter'
      stat = begin
        session.fs.file.stat(path)
      rescue StandardError
        nil
      end
      return false unless stat

      return stat.directory?
    elsif session.type == 'powershell'
      return cmd_exec("Test-Path -Path \"#{path}\" -PathType Container").include?('True')
    else
      if session.platform == 'windows'
        f = cmd_exec("cmd.exe /C IF exist \"#{path}\\*\" ( echo true )")
      else
        f = session.shell_command_token("test -d '#{path}' && echo true")
      end
      return false if f.nil? || f.empty?
      return false unless f =~ /true/

      true
    end
  end

  #
  # Expand any environment variables to return the full path specified by +path+.
  #
  # @return [String]
  def expand_path(path)
    if session.type == 'meterpreter'
      return session.fs.file.expand_path(path)
    elsif session.type == 'powershell'
      return cmd_exec("[Environment]::ExpandEnvironmentVariables(\"#{path}\")")
    else
      return cmd_exec("echo #{path}")
    end
  end

  #
  # See if +path+ exists on the remote system and is a regular file
  #
  # @param path [String] Remote filename to check
  def file?(path)
    return false if path.nil?

    if session.type == 'meterpreter'
      stat = begin
        session.fs.file.stat(path)
      rescue StandardError
        nil
      end
      return false unless stat

      return stat.file?
    elsif session.type == 'powershell'
      return cmd_exec("[System.IO.File]::Exists( \"#{path}\")")&.include?('True')
    else
      if session.platform == 'windows'
        f = cmd_exec("cmd.exe /C IF exist \"#{path}\" ( echo true )")
        if f =~ /true/
          f = cmd_exec("cmd.exe /C IF exist \"#{path}\\\\\" ( echo false ) ELSE ( echo true )")
        end
      else
        f = session.shell_command_token("test -f \"#{path}\" && echo true")
      end
      return false if f.nil? || f.empty?
      return false unless f =~ /true/

      true
    end
  end

  alias file_exist? file?

  #
  # See if +path+ on the remote system is a setuid file
  #
  # @param path [String] Remote filename to check
  def setuid?(path)
    stat = stat(path)
    stat.setuid?
  end

  #
  # See if +path+ on the remote system exists and is executable
  #
  # @param path [String] Remote path to check
  #
  # @return [Boolean] true if +path+ exists and is executable
  #
  def executable?(path)
    raise "`executable?' method does not support Windows systems" if session.platform == 'windows'

    cmd_exec("test -x '#{path}' && echo true").to_s.include? 'true'
  end

  #
  # See if +path+ on the remote system exists and is writable
  #
  # @param path [String] Remote path to check
  #
  # @return [Boolean] true if +path+ exists and is writable
  #
  def writable?(path)
    verification_token = Rex::Text.rand_text_alpha_upper(8)
    if session.type == 'powershell' && file?(path)
      return cmd_exec("$a=[System.IO.File]::OpenWrite('#{path}');if($?){echo #{verification_token}};$a.Close()").include?(verification_token)
    end
    raise "`writable?' method does not support Windows systems" if session.platform == 'windows'

    cmd_exec("(test -w '#{path}' || test -O '#{path}') && echo true").to_s.include? 'true'
  end

  #
  # See if +path+ on the remote system exists and is immutable
  #
  # @param path [String] Remote path to check
  #
  # @return [Boolean] true if +path+ exists and is immutable
  #
  def immutable?(path)
    raise "`immutable?' method does not support Windows systems" if session.platform == 'windows'

    attributes(path).include?('Immutable')
  end

  #
  # See if +path+ on the remote system exists and is readable
  #
  # @param path [String] Remote path to check
  #
  # @return [Boolean] true if +path+ exists and is readable
  #
  def readable?(path)
    verification_token = Rex::Text.rand_text_alpha(8)
    return false unless exists?(path)

    if session.type == 'powershell'
      if directory?(path)
        return cmd_exec("[System.IO.Directory]::GetFiles('#{path}'); if($?) {echo #{verification_token}}").include?(verification_token)
      else
        return cmd_exec("[System.IO.File]::OpenRead(\"#{path}\");if($?){echo\
          #{verification_token}}").include?(verification_token)
      end
    end

    raise "`readable?' method does not support Windows systems" if session.platform == 'windows'

    cmd_exec("test -r '#{path}' && echo #{verification_token}").to_s.include?(verification_token)
  end

  #
  # Check for existence of +path+ on the remote file system
  #
  # @param path [String] Remote filename to check
  def exist?(path)
    if session.type == 'meterpreter'
      stat = begin
        session.fs.file.stat(path)
      rescue StandardError
        nil
      end
      return !!stat
    elsif session.type == 'powershell'
      return cmd_exec("Test-Path \"#{path}\"")&.include?('True')
    else
      if session.platform == 'windows'
        f = cmd_exec("cmd.exe /C IF exist \"#{path}\" ( echo true )")
      else
        f = cmd_exec("test -e \"#{path}\" && echo true")
      end
      return false if f.nil? || f.empty?
      return false unless f =~ /true/

      true
    end
  end

  alias exists? exist?

  #
  # Retrieve file attributes for +path+ on the remote system
  #
  # @param path [String] Remote filename to check
  def attributes(path)
    raise "`attributes' method does not support Windows systems" if session.platform == 'windows'

    cmd_exec("lsattr -l '#{path}'").to_s.scan(/^#{path}\s+(.+)$/).flatten.first.to_s.split(', ')
  end

  #
  # Writes a given string to a given local file
  #
  # @param local_file_name [String]
  # @param data [String]
  # @return [void]
  def file_local_write(local_file_name, data)
    fname = Rex::FileUtils.clean_path(local_file_name)
    unless ::File.exist?(fname)
      ::FileUtils.touch(fname)
    end
    output = ::File.open(fname, 'a')
    data.each_line do |d|
      output.puts(d)
    end
    output.close
  end

  #
  # Returns a MD5 checksum of a given remote file
  #
  # @note For shell sessions,
  #       this method downloads the file from the remote host
  #       unless a hashing utility for use on the remote host is specified.
  #
  # @param file_name [String] Remote file name
  # @option util [String] Remote file hashing utility
  # @return [String] Hex digest of file contents
  def file_remote_digestmd5(file_name, util: nil)
    if session.type == 'meterpreter'
      begin
        return session.fs.file.md5(file_name)&.unpack('H*').flatten.first
      rescue StandardError => e
        print_error("Exception while running #{__method__}: #{e}")
        return nil
      end
    end

    # Note: This will fail on files larger than 2GB
    if session.type == 'powershell'
      data = cmd_exec("$md5 = New-Object -TypeName System.Security.Cryptography.MD5CryptoServiceProvider; [System.BitConverter]::ToString($md5.ComputeHash([System.IO.File]::ReadAllBytes('#{file_name}')))")
      return unless data

      chksum = data.scan(/^([A-F0-9-]+)$/).flatten.first
      return chksum&.gsub(/-/, '')&.downcase
    end

    case util
    when 'md5'
      chksum = session.shell_command_token("md5 -q '#{file_name}'")&.strip
    when 'md5sum'
      chksum = session.shell_command_token("md5sum '#{file_name}'")&.strip.split.first
    when 'certutil'
      data = session.shell_command_token("certutil -hashfile \"#{file_name}\" MD5")
      return unless data
      chksum = data.scan(/^([a-f0-9 ]{47})\r?\n/).flatten.first&.gsub(/\s*/, '')
    else
      data = read_file(file_name)
      return unless data
      chksum = Digest::MD5.hexdigest(data)
    end

    return unless chksum =~ /\A[a-f0-9]{32}\z/

    chksum
  end

  #
  # Returns a SHA1 checksum of a given remote file
  #
  # @note For shell sessions,
  #       this method downloads the file from the remote host
  #       unless a hashing utility for use on the remote host is specified.
  #
  # @param file_name [String] Remote file name
  # @option util [String] Remote file hashing utility
  # @return [String] Hex digest of file contents
  def file_remote_digestsha1(file_name, util: nil)
    if session.type == 'meterpreter'
      begin
        return session.fs.file.sha1(file_name)&.unpack('H*').flatten.first
      rescue StandardError => e
        print_error("Exception while running #{__method__}: #{e}")
        return nil
      end
    end

    # Note: This will fail on files larger than 2GB
    if session.type == 'powershell'
      data = cmd_exec("$sha1 = New-Object -TypeName System.Security.Cryptography.SHA1CryptoServiceProvider; [System.BitConverter]::ToString($sha1.ComputeHash([System.IO.File]::ReadAllBytes('#{file_name}')))")
      return unless data
      chksum = data.scan(/^([A-F0-9-]+)$/).flatten.first
      return chksum&.gsub(/-/, '')&.downcase
    end

    case util
    when 'sha1'
      chksum = session.shell_command_token("sha1 -q '#{file_name}'")&.strip
    when 'sha1sum'
      chksum = session.shell_command_token("sha1sum '#{file_name}'")&.strip.split.first
    when 'certutil'
      data = session.shell_command_token("certutil -hashfile \"#{file_name}\" SHA1")
      return unless data
      chksum = data.scan(/^([a-f0-9 ]{59})\r?\n/).flatten.first&.gsub(/\s*/, '')
    else
      data = read_file(file_name)
      return unless data
      chksum = Digest::SHA1.hexdigest(data)
    end

    return unless chksum =~ /\A[a-f0-9]{40}\z/

    chksum
  end

  #
  # Returns a SHA2 checksum of a given remote file
  #
  # @note THIS DOWNLOADS THE FILE
  # @param file_name [String] Remote file name
  # @return [String] Hex digest of file contents
  def file_remote_digestsha2(file_name)
    data = read_file(file_name)
    chksum = nil
    if data
      chksum = Digest::SHA256.hexdigest(data)
    end
    return chksum
  end

  #
  # Platform-agnostic file read.  Returns contents of remote file +file_name+
  # as a String.
  #
  # @param file_name [String] Remote file name to read
  # @return [String] Contents of the file
  #
  # @return [Array] of strings(lines)
  #
  def read_file(file_name)
    if session.type == 'meterpreter'
      return _read_file_meterpreter(file_name)
    end

    return unless %w[shell powershell].include?(session.type)

    if session.type == 'powershell'
      return _read_file_powershell(file_name)
    end

    if session.platform == 'windows'
      return session.shell_command_token("type \"#{file_name}\"")
    end

    return nil unless readable?(file_name)

    if command_exists?('cat')
      return session.shell_command_token("cat \"#{file_name}\"")
    end

    # Result on systems without cat command
    session.shell_command_token("while read line; do echo $line; done <#{file_name}")
  end

  # Platform-agnostic file write. Writes given object content to a remote file.
  #
  # @param file_name [String] Remote file name to write
  # @param data [String] Contents to put in the file
  # @return bool
  def write_file(file_name, data)
    if session.type == 'meterpreter'
      return _write_file_meterpreter(file_name, data)
    elsif session.type == 'powershell'
      return _write_file_powershell(file_name, data)
    elsif session.respond_to? :shell_command_token
      if session.platform == 'windows'
        if _can_echo?(data)
          return _win_ansi_write_file(file_name, data)
        else
          return _win_bin_write_file(file_name, data)
        end
      else
        return _write_file_unix_shell(file_name, data)
      end
    else
      return false
    end
  end

  #
  # Platform-agnostic file append. Appends given object content to a remote file.
  #
  # @param file_name [String] Remote file name to write
  # @param data [String] Contents to put in the file
  # @return bool
  def append_file(file_name, data)
    if session.type == 'meterpreter'
      return _write_file_meterpreter(file_name, data, 'ab')
    elsif session.type == 'powershell'
      return _append_file_powershell(file_name, data)
    elsif session.respond_to? :shell_command_token
      if session.platform == 'windows'
        if _can_echo?(data)
          return _win_ansi_append_file(file_name, data)
        else
          return _win_bin_append_file(file_name, data)
        end
      else
        return _append_file_unix_shell(file_name, data)
      end
    end
  end

  #
  # Read a local file +local+ and write it as +remote+ on the remote file
  # system
  #
  # @param remote [String] Destination file name on the remote filesystem
  # @param local [String] Local file whose contents will be uploaded
  # @return (see #write_file)
  def upload_file(remote, local)
    write_file(remote, ::File.read(local, mode: 'rb'))
  end

  #
  # Upload a binary and write it as an executable file +remote+ on the
  # remote filesystem.
  #
  # @param path [String] Path to the destination file on the remote filesystem
  # @param data [String] Data to be uploaded
  def upload_and_chmodx(path, data)
    print_status "Writing '#{path}' (#{data.size} bytes) ..."
    write_file path, data
    chmod(path)
  end

  #
  # Sets the permissions on a remote file
  #
  # @param path [String] Path on the remote filesystem
  # @param mode [Fixnum] Mode as an octal number
  def chmod(path, mode = 0o700)
    if session.platform == 'windows'
      raise "`chmod' method does not support Windows systems"
    end

    if session.type == 'meterpreter' && session.commands.include?(Rex::Post::Meterpreter::Extensions::Stdapi::COMMAND_ID_STDAPI_FS_CHMOD)
      session.fs.file.chmod(path, mode)
    else
      cmd_exec("chmod #{mode.to_s(8)} '#{path}'")
    end
  end

  #
  # Read a local exploit file binary from the data directory
  #
  # @param data_directory [String] Name of data directory within the exploits folder
  # @param file [String] Filename in the data folder to use.
  def exploit_data(data_directory, file)
    file_path = ::File.join(::Msf::Config.data_directory, 'exploits', data_directory, file)
    ::File.binread(file_path)
  end

  #
  # Read a local exploit source file from the external exploits directory
  #
  # @param source_directory [String] Directory in the external/source/exploits directory to use as the source directory.
  # @param file [String] Filename in the source folder to use.
  def exploit_source(source_directory, file)
    file_path = ::File.join( Msf::Config.install_root, 'external', 'source', 'exploits', source_directory, file)
    ::File.read(file_path)
  end

  #
  # Delete remote files
  #
  # @param remote_files [Array<String>] List of remote filenames to
  #   delete
  # @return [void]
  def rm_f(*remote_files)
    remote_files.each do |remote|
      if session.type == 'meterpreter'
        session.fs.file.delete(remote) if file?(remote)
      elsif session.type == 'powershell'
        cmd_exec("[System.IO.File]::Delete(\"#{remote}\")") if file?(remote)
      elsif session.platform == 'windows'
        cmd_exec("del /q /f \"#{remote}\"")
      else
        cmd_exec("rm -f \"#{remote}\"")
      end
    end
  end

  #
  # Delete remote directories
  #
  # @param remote_dirs [Array<String>] List of remote directories to
  #   delete
  # @return [void]
  def rm_rf(*remote_dirs)
    remote_dirs.each do |remote|
      if session.type == 'meterpreter'
        session.fs.dir.rmdir(remote) if exist?(remote)
      elsif session.type == 'powershell'
        cmd_exec("Remove-Item -Path \"#{remote}\" -Force -Recurse")
      elsif session.platform == 'windows'
        cmd_exec("rd /s /q \"#{remote}\"")
      else
        cmd_exec("rm -rf \"#{remote}\"")
      end
    end
  end
  alias file_rm rm_f
  alias dir_rm rm_rf

  #
  # Renames a remote file. If the new file path is a directory, the file will be
  # moved into that directory with the same name.
  #
  # @param old_file [String] Remote file name to move
  # @param new_file [String] The new name for the remote file
  # @return [Boolean] Return true on success and false on failure
  def rename_file(old_file, new_file)
    verification_token = Rex::Text.rand_text_alphanumeric(8)
    if session.type == 'meterpreter'
      begin
        new_file = new_file + session.fs.file.separator + session.fs.file.basename(old_file) if directory?(new_file)
        return (session.fs.file.mv(old_file, new_file).result == 0)
      rescue Rex::Post::Meterpreter::RequestError => e
        return false
      end
    elsif session.type == 'powershell'
      cmd_exec("Move-Item \"#{old_file}\" \"#{new_file}\" -Force; if($?){echo #{verification_token}}").include?(verification_token)
    elsif session.platform == 'windows'
      return false unless file?(old_file) # adding this because when the old_file is not present it hangs for a while, should be removed after this issue is fixed.

      cmd_exec(%(move #{directory?(new_file) ? '' : '/y'} "#{old_file}" "#{new_file}" & if not errorlevel 1 echo #{verification_token})).include?(verification_token)
    else
      cmd_exec(%(mv #{directory?(new_file) ? '' : '-f'} "#{old_file}" "#{new_file}" && echo #{verification_token})).include?(verification_token)
    end
  end
  alias move_file rename_file
  alias mv_file rename_file

  #
  #
  # Copy a remote file.
  #
  # @param src_file [String] Remote source file name to copy
  # @param dst_file [String] The name for the remote destination file
  # @return [Boolean] Return true on success and false on failure
  def copy_file(src_file, dst_file)
    return false if directory?(dst_file) || directory?(src_file)

    verification_token = Rex::Text.rand_text_alpha_upper(8)
    if session.type == 'meterpreter'
      begin
        return (session.fs.file.cp(src_file, dst_file).result == 0)
      rescue Rex::Post::Meterpreter::RequestError => e # when the source file is not present meterpreter will raise an error
        return false
      end
    elsif session.type == 'powershell'
      cmd_exec("Copy-Item \"#{src_file}\" -Destination \"#{dst_file}\"; if($?){echo #{verification_token}}").include?(verification_token)
    elsif session.platform == 'windows'
      cmd_exec(%(copy /y "#{src_file}" "#{dst_file}" & if not errorlevel 1 echo #{verification_token})).include?(verification_token)
    else
      cmd_exec(%(cp -f "#{src_file}" "#{dst_file}" && echo #{verification_token})).include?(verification_token)
    end
  end
  alias cp_file copy_file

  protected

  def _append_file_powershell(file_name, data)
    _write_file_powershell(file_name, data, true)
  end

  def _write_file_powershell(file_name, data, append = false)
    offset = 0
    chunk_size = 1000
    loop do
      success = _write_file_powershell_fragment(file_name, data, offset, chunk_size, append)
      unless success
        unless offset == 0
          print_warning("Write partially succeeded then failed. May need to manually clean up #{file_name}")
        end
        return false
      end

      # Future writes will then append, regardless of whether this is an append or write operation
      append = true
      offset += chunk_size
      break if offset >= data.length
    end

    true
  end

  def _write_file_powershell_fragment(file_name, data, offset, chunk_size, append = false)
    token = "_#{::Rex::Text.rand_text_alpha(32)}"
    chunk = data[offset..(offset + chunk_size-1)]
    length = chunk.length
    compressed_chunk = Rex::Text.gzip(chunk)
    encoded_chunk = Base64.strict_encode64(compressed_chunk)
    if append
      file_mode = 'Append'
    else
      file_mode = 'Create'
    end
    pwsh_code = <<~PSH
      try {
      $encoded='#{encoded_chunk}';
      $gzip_bytes=[System.Convert]::FromBase64String($encoded);
      $mstream = New-Object System.IO.MemoryStream(,$gzip_bytes);
      $gzipstream = New-Object System.IO.Compression.GzipStream $mstream, ([System.IO.Compression.CompressionMode]::Decompress);
      $filestream = [System.IO.File]::Open('#{file_name}', [System.IO.FileMode]::#{file_mode});
      $file_bytes=[System.Byte[]]::CreateInstance([System.Byte],#{length});
      $gzipstream.Read($file_bytes,0,$file_bytes.Length);
      $filestream.Write($file_bytes,0,$file_bytes.Length);
      $filestream.Close();
      $gzipstream.Close();
      echo Done
      } catch {
      echo #{token}
      }
    PSH
    result = cmd_exec(pwsh_code)

    return result.include?(length.to_s) && !result.include?(token) && result.include?('Done')
  end

  def _read_file_powershell(filename)
    data = ''
    offset = 0
    chunk_size = 65536
    loop do
      chunk = _read_file_powershell_fragment(filename, chunk_size, offset)
      break if chunk.nil?

      data << chunk
      offset += chunk_size
      break if chunk.length < chunk_size
    end
    return data
  end

  def _read_file_powershell_fragment(filename, chunk_size, offset = 0)
    pwsh_code = <<~PSH
      $mstream = New-Object System.IO.MemoryStream;
      $gzipstream = New-Object System.IO.Compression.GZipStream($mstream, [System.IO.Compression.CompressionMode]::Compress);
      $get_bytes = [System.IO.File]::ReadAllBytes(\"#{filename}\")[#{offset}..#{offset + chunk_size - 1}];
      $gzipstream.Write($get_bytes, 0, $get_bytes.Length);
      $gzipstream.Close();
      [System.Convert]::ToBase64String($mstream.ToArray());
    PSH
    b64_data = cmd_exec(pwsh_code)
    return nil if b64_data.empty?

    uncompressed_fragment = Zlib::GzipReader.new(StringIO.new(Base64.decode64(b64_data))).read
    return uncompressed_fragment
  end

protected

  # Checks to see if there are non-printable characters in a given string
  #
  # @param data [String] String to check for non-printable characters
  # @return bool
  def _can_echo?(data)
    # Ensure all bytes are between ascii 0x20 to 0x7e (ie. [[:print]]), excluding quotes etc
    data.bytes.all? do|b|
      (b >= 0x20 && b <= 0x7e) &&
        b != '"'.ord &&
        b != '%'.ord &&
        b != '$'.ord
    end
  end

  #
  # Meterpreter-specific file write. Returns true on success
  #
  def _write_file_meterpreter(file_name, data, mode = 'wb')
    fd = session.fs.file.new(file_name, mode)
    fd.write(data)
    fd.close
    return true
  rescue ::Rex::Post::Meterpreter::RequestError => e
    return false
  end

  # Meterpreter-specific file read.  Returns contents of remote file
  # +file_name+ as a String or nil if there was an error
  #
  # You should never call this method directly.  Instead, call {#read_file}
  # which will call this if it is appropriate for the given session.
  #
  # @return [String]
  def _read_file_meterpreter(file_name)
    fd = session.fs.file.new(file_name, 'rb')

    data = ''.b
    data << fd.read
    data << fd.read until fd.eof?

    data
  rescue EOFError
    # Sometimes fd isn't marked EOF in time?
    data
  rescue ::Rex::Post::Meterpreter::RequestError => e
    print_error("Failed to open file: #{file_name}: #{e}")
    return nil
  ensure
    fd.close if fd
  end

  # Windows ANSI file write for shell sessions. Writes given object content to a remote file.
  #
  # NOTE: *This is not binary-safe on Windows shell sessions!*
  #
  # @param file_name [String] Remote file name to write
  # @param data [String] Contents to put in the file
  # @param chunk_size [int] max size for the data chunk to write at a time
  # @return [void]
  def _win_ansi_write_file(file_name, data, chunk_size = 5000)
    start_index = 0
    write_length = [chunk_size, data.length].min
    success = _shell_command_with_success_code("echo | set /p x=\"#{data[0, write_length]}\"> \"#{file_name}\"")
    return false unless success
    if data.length > write_length
      # just use append to finish the rest
      return _win_ansi_append_file(file_name, data[write_length, data.length], chunk_size)
    end

    true
  end

  # Windows ansi file append for shell sessions. Writes given object content to a remote file.
  #
  # NOTE: *This is not binary-safe on Windows shell sessions!*
  #
  # @param file_name [String] Remote file name to write
  # @param data [String] Contents to put in the file
  # @param chunk_size [int] max size for the data chunk to write at a time
  # @return [void]
  def _win_ansi_append_file(file_name, data, chunk_size = 5000)
    start_index = 0
    write_length = [chunk_size, data.length].min
    while start_index < data.length
      begin
        success = _shell_command_with_success_code("echo | set /p x=\"#{data[start_index, write_length]}\">> \"#{file_name}\"")
        unless success
          print_warning("Write partially succeeded then failed. May need to manually clean up #{file_name}") unless start_index == 0
          return false
        end
        start_index += write_length
        write_length = [chunk_size, data.length - start_index].min
      rescue ::Exception => e
        print_error("Exception while running #{__method__}: #{e}")
        print_warning("May need to manually clean up #{file_name}") unless start_index == 0
        file_rm(file_name)
        return false
      end
    end

    true
  end

  # Windows binary file write for shell sessions. Writes given object content to a remote file.
  #
  # @param file_name [String] Remote file name to write
  # @param data [String] Contents to put in the file
  # @param chunk_size [int] max size for the data chunk to write at a time
  # @return [void]
  def _win_bin_write_file(file_name, data, chunk_size = 5000)
    b64_data = Base64.strict_encode64(data)
    b64_filename = "#{file_name}.b64"
    begin
      success = _win_ansi_write_file(b64_filename, b64_data, chunk_size)
      return false unless success
      vprint_status("Uploaded Base64-encoded file. Decoding using certutil")
      success = _shell_command_with_success_code("certutil -f -decode #{b64_filename} #{file_name}")
      return false unless success
    rescue ::Exception => e
      print_error("Exception while running #{__method__}: #{e}")
      return false
    ensure
      file_rm(b64_filename)
    end

    true
  end

  # Windows binary file append for shell sessions. Appends given object content to a remote file.
  #
  # @param file_name [String] Remote file name to write
  # @param data [String] Contents to put in the file
  # @param chunk_size [int] max size for the data chunk to write at a time
  # @return [void]
  def _win_bin_append_file(file_name, data, chunk_size = 5000)
    b64_data = Base64.strict_encode64(data)
    b64_filename = "#{file_name}.b64"
    tmp_filename = "#{file_name}.tmp"
    begin
      success = _win_ansi_write_file(b64_filename, b64_data, chunk_size)
      return false unless success
      vprint_status("Uploaded Base64-encoded file. Decoding using certutil")
      success = _shell_command_with_success_code("certutil -decode #{b64_filename} #{tmp_filename}")
      return false unless success
      vprint_status("Certutil succeeded. Appending using copy")
      success = _shell_command_with_success_code("copy /b #{file_name}+#{tmp_filename} #{file_name}")
      return false unless success
    rescue ::Exception => e
      print_error("Exception while running #{__method__}: #{e}")
      return false
    ensure
      file_rm(b64_filename)
      file_rm(tmp_filename)
    end

    true
  end

  #
  # Append +data+ to the remote file +file_name+.
  #
  # You should never call this method directly. Instead, call {#append_file}
  # which will call this method if it is appropriate for the given session.
  #
  # @return [void]
  def _append_file_unix_shell(file_name, data)
    _write_file_unix_shell(file_name, data, true)
  end

  #
  # Write +data+ to the remote file +file_name+.
  #
  # Truncates if +append+ is false, appends otherwise.
  #
  # You should never call this method directly.  Instead, call {#write_file}
  # or {#append_file} which will call this if it is appropriate for the given
  # session.
  #
  # @return [void]
  def _write_file_unix_shell(file_name, data, append = false)
    redirect = (append ? '>>' : '>')

    # Short-circuit an empty string. The : builtin is part of posix
    # standard and should theoretically exist everywhere.
    if data.empty?
      return _shell_command_with_success_code(": #{redirect} #{file_name}")
    end

    d = data.dup
    d.force_encoding('binary') if d.respond_to? :force_encoding

    chunks = []
    command = nil
    encoding = :hex
    cmd_name = ''

    line_max = _unix_max_line_length
    # Leave plenty of room for the filename we're writing to and the
    # command to echo it out
    line_max -= file_name.length
    line_max -= 64

    # Ordered by descending likeliness to work
    [
      # POSIX standard requires %b which expands octal (but not hex)
      # escapes in the argument. However, some versions (notably
      # FreeBSD) truncate input on nulls, so "printf %b '\0\101'"
      # produces a 0-length string. Some also allow octal escapes
      # without a format string, and do not truncate, so start with
      # that and try %b if it doesn't work. The standalone version seems
      # to be more likely to work than the builtin version, so try it
      # first.
      #
      # Both of these work for sure on Linux and FreeBSD
      { cmd: %q{/usr/bin/printf 'CONTENTS'}, enc: :octal, name: 'printf' },
      { cmd: %q{printf 'CONTENTS'}, enc: :octal, name: 'printf' },
      # Works on Solaris
      { cmd: %q{/usr/bin/printf %b 'CONTENTS'}, enc: :octal, name: 'printf' },
      { cmd: %q{printf %b 'CONTENTS'}, enc: :octal, name: 'printf' },
      # Perl supports both octal and hex escapes, but octal is usually
      # shorter (e.g. 0 becomes \0 instead of \x00)
      { cmd: %q{perl -e 'print("CONTENTS")'}, enc: :octal, name: 'perl' },
      # POSIX awk doesn't have \xNN escapes, use gawk to ensure we're
      # getting the GNU version.
      { cmd: %q^gawk 'BEGIN {ORS="";print "CONTENTS"}' </dev/null^, enc: :hex, name: 'awk' },
      # xxd's -p flag specifies a postscript-style hexdump of unadorned hex
      # digits, e.g. ABCD would be 41424344
      { cmd: %q{echo 'CONTENTS'|xxd -p -r}, enc: :bare_hex, name: 'xxd' },
      # Use echo as a last resort since it frequently doesn't support -e
      # or -n.  bash and zsh's echo builtins are apparently the only ones
      # that support both.  Most others treat all options as just more
      # arguments to print. In particular, the standalone /bin/echo or
      # /usr/bin/echo appear never to have -e so don't bother trying
      # them.
      { cmd: %q{echo -ne 'CONTENTS'}, enc: :hex },
    ].each do |foo|
      # Some versions of printf mangle %.
      test_str = "\0\xff\xfe#{Rex::Text.rand_text_alpha_upper(4)}\x7f%%\r\n"
      # test_str = "\0\xff\xfe"
      case foo[:enc]
      when :hex
        cmd = foo[:cmd].sub('CONTENTS') { Rex::Text.to_hex(test_str) }
      when :octal
        cmd = foo[:cmd].sub('CONTENTS') { Rex::Text.to_octal(test_str) }
      when :bare_hex
        cmd = foo[:cmd].sub('CONTENTS') { Rex::Text.to_hex(test_str, '') }
      end
      a = session.shell_command_token(cmd.to_s)

      if test_str == a
        command = foo[:cmd]
        encoding = foo[:enc]
        cmd_name = foo[:name]
        break
      else
        vprint_status("#{cmd} Failed: #{a.inspect} != #{test_str.inspect}")
      end
    end

    if command.nil?
      raise RuntimeError, "Can't find command on the victim for writing binary data", caller
    end

    # each byte will balloon up to 4 when we encode
    # (A becomes \x41 or \101)
    max = line_max / 4

    i = 0
    while (i < d.length)
      slice = d.slice(i...(i + max))
      case encoding
      when :hex
        chunks << Rex::Text.to_hex(slice)
      when :octal
        chunks << Rex::Text.to_octal(slice)
      when :bare_hex
        chunks << Rex::Text.to_hex(slice, '')
      end
      i += max
    end

    vprint_status("Writing #{d.length} bytes in #{chunks.length} chunks of #{chunks.first.length} bytes (#{encoding}-encoded), using #{cmd_name}")

    # The first command needs to use the provided redirection for either
    # appending or truncating.
    cmd = command.sub('CONTENTS') { chunks.shift }
    succeeded = _shell_command_with_success_code("#{cmd} #{redirect} \"#{file_name}\"")
    return false unless succeeded

    # After creating/truncating or appending with the first command, we
    # need to append from here on out.
    chunks.each do |chunk|
      vprint_status("Next chunk is #{chunk.length} bytes")
      cmd = command.sub('CONTENTS') { chunk }

      succeeded = _shell_command_with_success_code("#{cmd} >> '#{file_name}'")
      unless succeeded
        print_warning("Write partially succeeded then failed. May need to manually clean up #{file_name}")
        return false
      end
    end

    true
  end

  def _shell_command_with_success_code(cmd)
    token = "_#{::Rex::Text.rand_text_alpha(32)}"
    result = session.shell_command_token("#{cmd} && echo #{token}")

    return result&.include?(token)
  end

  #
  # Calculate the maximum line length for a unix shell.
  #
  # @return [Integer]
  def _unix_max_line_length
    # Based on autoconf's arg_max calculator, see
    # http://www.in-ulm.de/~mascheck/various/argmax/autoconf_check.html
    calc_line_max = 'i=0 max= new= str=abcd; \
      while (test "X"`echo "X$str" 2>/dev/null` = "XX$str") >/dev/null 2>&1 && \
          new=`expr "X$str" : ".*" 2>&1` && \
          test "$i" != 17 && \
          max=$new; do \
        i=`expr $i + 1`; str=$str$str;\
      done; echo $max'
    line_max = session.shell_command_token(calc_line_max).to_i

    # Fall back to a conservative 4k which should work on even the most
    # restrictive of embedded shells.
    line_max = (line_max == 0 ? 4096 : line_max)
    vprint_status("Max line length is #{line_max}")

    line_max
  end

  def stat(filename)
    if session.type == 'meterpreter'
      return session.fs.file.stat(filename)
    else
      raise NotImplementedError if session.platform == 'windows'
      raise "`stat' command doesn't exist on target system" unless command_exists?('stat')

      return FileStat.new(filename, session)
    end
  end

  class FileStat < Rex::Post::FileStat

    attr_accessor :stathash

    def initialize(filename, session)
      data = session.shell_command_token("stat --format='%d,%i,%h,%u,%g,%t,%s,%B,%o,%X,%Y,%Z,%f' '#{filename}'").to_s.chomp
      raise 'format argument of stat command not behaving as expected' unless data =~ /(\d+,){12}\w+/

      data = data.split(',')
      @stathash = Hash.new
      @stathash['st_dev'] = data[0].to_i
      @stathash['st_ino'] = data[1].to_i
      @stathash['st_nlink'] = data[2].to_i
      @stathash['st_uid'] = data[3].to_i
      @stathash['st_gid'] = data[4].to_i
      @stathash['st_rdev'] = data[5].to_i
      @stathash['st_size'] = data[6].to_i
      @stathash['st_blksize'] = data[7].to_i
      @stathash['st_blocks'] = data[8].to_i
      @stathash['st_atime'] = data[9].to_i
      @stathash['st_mtime'] = data[10].to_i
      @stathash['st_ctime'] = data[11].to_i
      @stathash['st_mode'] = data[12].to_i(16) # stat command returns hex value of mode"
    end
  end
end