VYPR
High severity8.8GHSA Advisory· Published May 29, 2026· Updated May 29, 2026

Froxlor has an authorization bypass in FTP shell assignment via missing server-side `available_shells` enforcement

CVE-2026-41235

Description

Summary

Froxlor 2.3.6 lets administrators configure system.available_shells as the approved shell list that customers may assign to FTP users. However, the server-side FTP account handlers do not enforce that whitelist when processing add or edit requests.

As a result, an authenticated customer with shell delegation enabled can submit an arbitrary shell such as /bin/bash even when the panel UI only offers more restricted choices. In deployments that use the default nssextrausers integration, the attacker-controlled shell is then propagated into the system account database, leading to real host shell access.

Details

The customer-facing FTP account page builds the shell selector from system.available_shells, which shows that the product intends the setting to act as the authorization boundary:

// customer_ftp.php:138-149
$shells = [
    '/bin/false' => '/bin/false'
];
$availableshells = explode(',', Settings::Get('system.available_shells'));
if (is_array($availableshells) && !empty($availableshells)) {
    foreach ($availableshells as $shell) {
        $shells[trim($shell)] = trim($shell);
    }
}

The request handler forwards posted form data directly into the FTP API command implementation:

// customer_ftp.php:170-172
if ($action == 'edit' && Request::post('send') == 'send') {
    $result = $log->logAction(USR_ACTION, LOG_INFO, "edited ftp-account #" . $id);
    Commands::get()->apiCall('Ftps.update', Request::postAll());
}

On the server side, Ftps::add() and Ftps::update() only perform generic shell string validation. They do not verify that the submitted shell belongs to system.available_shells:

// lib/Froxlor/Api/Commands/Ftps.php:119-123
if (Settings::Get('system.allow_customer_shell') == '1' && $this->getUserDetail('shell_allowed') == '1') {
    $shell = Validate::validate(trim($shell), 'shell', '', '', [], true);
} else {
    $shell = '/bin/false';
}

The validated shell is stored into ftp_users.shell and later consumed by the root-owned cron task that rebuilds NSS extrausers files:

// lib/Froxlor/Cron/System/Extrausers.php:89-97
$passwd_entries[] = $user['username'] . ':x:' . $uid . ':' . $gid . ':' . $gecos . ':' . $homedir . ':' . $shell;

Because the default installer configuration sets system.nssextrausers=1, and the shipped Debian/Bookworm configuration enables extrausers in nsswitch.conf, the attacker-controlled shell becomes the effective login shell of the generated system user on standard supported deployments.

PoC

An attacker needs a normal customer account and a deployment where customer shell delegation is enabled for that customer.

Relevant runtime prerequisites:

  • system.allow_customer_shell=1
  • the attacking customer has shell_allowed=1
  • the deployment uses system.nssextrausers=1 with the shipped libnss-extrausers integration

Froxlor requires a valid CSRF token for POST requests, so the attacker performs the exploit from an authenticated session.

Complete PoC flow:

  1. Log in as a customer and obtain a valid csrf_token.
  2. Identify one FTP account owned by that customer.
  3. Submit an edit request that sets an arbitrary shell outside the administrator-approved system.available_shells list:
POST /customer_ftp.php?page=accounts&action=edit&id=17 HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
Cookie: 

csrf_token=VALID_CSRF_TOKEN&
send=send&
id=17&
username=test1ftp1&
ftp_description=poc&
path=/&
shell=/bin/bash&
login_enabled=1
  1. Wait for Froxlor's master cron to process the queued REBUILD_NSSUSERS task.

Result:

  • the request is accepted even if /bin/bash is not present in system.available_shells
  • ftp_users.shell is updated to /bin/bash
  • /var/lib/extrausers/passwd is regenerated with /bin/bash as the FTP user's login shell
  • the attacker can then authenticate to the host using that FTP user's credentials and obtain an interactive shell

Impact

This issue lets a low-privileged customer bypass an administrator-defined authorization boundary and promote an FTP-only account into a real shell account. On shared-hosting systems managed by Froxlor, that materially changes the trust model and can expose the host to lateral movement, local privilege-escalation follow-on attacks, data theft from colocated services, and persistence on the server.

Because the vulnerable flow is executed through the normal authenticated web interface and a root-owned provisioning task later materializes the chosen shell at the operating-system level, the vulnerability is stronger than a UI-only restriction bypass.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

In Froxlor 2.3.6, the FTP shell assignment does not enforce the `system.available_shells` whitelist server-side, allowing authenticated customers to obtain host shell access.

Vulnerability

In Froxlor 2.3.6, the system.available_shells setting is intended to define the approved shells that customers may assign to their FTP accounts. However, while the UI selector respects this list, the server-side API commands Ftps::add() and Ftps::update() only perform generic shell string validation and do not verify that the submitted shell belongs to system.available_shells [1][2]. As a result, an authenticated customer with shell delegation enabled can submit an arbitrary shell such as /bin/bash via a crafted HTTP request.

Exploitation

An attacker must be an authenticated Froxlor customer with the shell_allowed flag enabled and the system.allow_customer_shell setting equal to 1 [1][2]. The attacker submits a POST request to the FTP account add or edit handler with a shell field containing an arbitrary value (e.g., /bin/bash). The request is forwarded directly to the Froxlor API, which stores the attacker-controlled shell into ftp_users.shell without checking the system.available_shells whitelist [1][2].

Impact

A successful exploit allows the attacker to set an interactive system shell (such as /bin/bash) on their FTP account. In deployments using the default NSS extrausers integration, this shell is propagated into the system account database, granting the attacker real interactive shell access on the host [1][2]. The impact is a full privilege escalation from a restricted FTP user to a system shell with the privileges of that user, potentially leading to further host compromise.

Mitigation

Froxlor has not yet released a patched version for CVE-2026-41235 [1][2]. Administrators should disable the system.allow_customer_shell setting for all customers who do not require delegated shell access, or restrict the system.available_shells list to only contain /bin/false [1][2]. Regularly monitor system accounts for unexpected shells as a workaround until an official fix is available.

AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2
  • Froxlor/FroxlorGHSA2 versions
    = 2.3.6+ 1 more
    • (no CPE)range: = 2.3.6
    • (no CPE)range: <= 2.3.6

Patches

2
3dceec4650e0

secured regex for Dns LOC entries validation; remove invalid control characters in every dns content-field; ensure given shell exists in Ftps.add/update; ensure authorized_keys file for SshKeys is within the customers documentroot

https://github.com/froxlor/froxlorMichael KaufmannApr 21, 2026Fixed in 2.3.7via llm-release-walk
6 files changed · +74 21
  • install/updates/preconfig/preconfig_2.3.inc.php+5 5 modified
    @@ -82,12 +82,12 @@
     		$return['system_webserver_serveradmin'] = [
     			'type' => 'select',
     			'select_var' => [
    -				'customer' => lng('admin.webserver_serveradmin.customer'),
    -				'admin' => lng('admin.webserver_serveradmin.admin'),
    -				'global' => lng('admin.webserver_serveradmin.global'),
    -				'none' => lng('admin.webserver_serveradmin.none')
    +				'customer' => 'Customer email address (default)',
    +				'admin' => 'Admin email address',
    +				'global' => 'Panel admin email address',
    +				'none' => 'No ServerAdmin'
     			],
    -			'selected' => 'apache2',
    +			'selected' => 'customer',
     			'label' => $question,
     			'prior_infotext' => $description
     		];
    
  • lib/Froxlor/Api/Commands/DomainZones.php+5 2 modified
    @@ -145,8 +145,6 @@ public function add()
     			}
     		}
     
    -		// TODO regex validate content for invalid characters
    -
     		if ($ttl <= 0) {
     			$ttl = 18000;
     		}
    @@ -156,6 +154,11 @@ public function add()
     			$errors[] = lng('error.dns_content_empty');
     		}
     
    +		// remove invalid control characters (allow tab + printable ASCII)
    +		$content = preg_replace('/[^\x09\x20-\x7E]/', '', $content);
    +		// collapse excessive whitespace
    +		$content = preg_replace('/\s+/', ' ', $content);
    +
     		if ($type != 'CNAME') {
     			// check whether there is a CNAME-record for the same resource
     			foreach ($dom_entries as $existing_entries) {
    
  • lib/Froxlor/Api/Commands/Ftps.php+8 0 modified
    @@ -118,6 +118,10 @@ public function add()
     
     			if (Settings::Get('system.allow_customer_shell') == '1' && $customer['shell_allowed'] == '1') {
     				$shell = Validate::validate(trim($shell), 'shell', '', '', [], true);
    +				$availableshells = explode(',', Settings::Get('system.available_shells'));
    +				if (!is_array($availableshells) || empty($availableshells) || !in_array($shell, $availableshells)) {
    +					$shell = "/bin/false";
    +				}
     			} else {
     				$shell = "/bin/false";
     			}
    @@ -438,6 +442,10 @@ public function update()
     
     		if (Settings::Get('system.allow_customer_shell') == '1' && $customer['shell_allowed'] == '1') {
     			$shell = Validate::validate(trim($shell), 'shell', '', '', [], true);
    +			$availableshells = explode(',', Settings::Get('system.available_shells'));
    +			if (!is_array($availableshells) || empty($availableshells) || !in_array($shell, $availableshells)) {
    +				$shell = "/bin/false";
    +			}
     		} else {
     			$shell = "/bin/false";
     		}
    
  • lib/Froxlor/Cron/System/SshKeys.php+5 1 modified
    @@ -33,6 +33,9 @@
     
     class SshKeys
     {
    +	/**
    +	 * @throws \Exception
    +	 */
     	public static function generateFiles(&$cronlog)
     	{
     		if (intval(Settings::Get('system.allow_customer_shell')) == 0) {
    @@ -50,7 +53,8 @@ public static function generateFiles(&$cronlog)
     			SELECT `id`, `ssh_pubkey` FROM `" . TABLE_PANEL_USER_SSHKEYS . "` WHERE `ftp_user_id` = :fuid AND `customerid` = :cid
     		");
     		while ($usr = $sel_stmt->fetch(PDO::FETCH_ASSOC)) {
    -			$authkeysfile = FileDir::makeCorrectFile($usr['homedir'] . '/.ssh/authorized_keys');
    +			$userHomeDir = FileDir::makeCorrectDir($usr['homedir'] . '/.ssh', $usr['homedir']);
    +			$authkeysfile = FileDir::makeCorrectFile($userHomeDir . '/authorized_keys', $usr['homedir']);
     			$cronlog->logAction(FroxlorLogger::CRON_ACTION, LOG_NOTICE, 'Creating file ' . $authkeysfile);
     			// remove all entries with 'froxlor:id=...'
     			self::removeFroxlorKeys($authkeysfile, $cronlog);
    
  • lib/Froxlor/FileDir.php+39 1 modified
    @@ -370,10 +370,12 @@ public static function storeDefaultIndex(
     	 * Function which returns a correct filename, means to add a slash at the beginning if there wasn't one
     	 *
     	 * @param string $filename the filename
    +	 * @param string $fixed_homedir whether to check that the given file is within the fixed home-directory
     	 *
     	 * @return string the corrected filename
    +	 * @throws Exception
     	 */
    -	public static function makeCorrectFile(string $filename): string
    +	public static function makeCorrectFile(string $filename, string $fixed_homedir = ""): string
     	{
     		if (trim($filename) == '') {
     			$error = 'Given filename for function ' . __FUNCTION__ . ' is empty.' . "\n";
    @@ -389,6 +391,42 @@ public static function makeCorrectFile(string $filename): string
     			$filename = '/' . $filename;
     		}
     
    +		// if given, check that the target file is within the $fixed_homedir
    +		// by checking each folder and the file for being a symlink and whether it targets
    +		// the customers homedir or points outside of it
    +		if (!empty($fixed_homedir)) {
    +			$to_check = explode("/", substr($filename, strlen(self::makeCorrectDir($fixed_homedir))), -1);
    +			$check_dir = substr($fixed_homedir, -1) == '/' ? substr($fixed_homedir, 0, -1) : $fixed_homedir;
    +			// Symlink check
    +			foreach ($to_check as $sub_dir) {
    +				$check_dir .= '/' . $sub_dir;
    +				if (is_link($check_dir)) {
    +					$original_target = $check_dir;
    +					$check_dir = readlink($check_dir);
    +					$link_dir = dirname($original_target);
    +					// check whether the link is relative or absolute
    +					if (substr($check_dir, 0, 1) != '/') {
    +						// relative directory, prepend link_dir
    +						$check_dir = $link_dir . '/' . $check_dir;
    +					}
    +					if (substr($check_dir, 0, strlen($fixed_homedir)) != $fixed_homedir) {
    +						throw new Exception("Found symlink pointing outside of customer home directory: " . substr($original_target, strlen($fixed_homedir)));
    +					}
    +				}
    +			}
    +			// check for the path to be within the given homedir
    +			if (substr($filename, 0, strlen($fixed_homedir)) != $fixed_homedir) {
    +				throw new Exception("Target path/file not within the required customer home directory");
    +			}
    +			// check whether file is symlink itself
    +			if (is_link($filename)) {
    +				$check_dir = readlink($check_dir);
    +				if (substr($check_dir, 0, strlen($fixed_homedir)) != $fixed_homedir) {
    +					throw new Exception("Found symlink pointing outside of customer home directory: " . substr($filename, strlen($fixed_homedir)));
    +				}
    +			}
    +		}
    +
     		return self::makeSecurePath($filename);
     	}
     
    
  • lib/Froxlor/Validate/Validate.php+12 12 modified
    @@ -390,18 +390,18 @@ public static function validateBase64Image(string $base64string)
     	public static function validateDnsLoc(string $input)
     	{
     		$pattern = '/^
    -        (\d{1,2})\s+                # latitude degrees
    -        (\d{1,2})\s+                # latitude minutes
    -        (\d{1,2}(?:\.\d+)?)\s+      # latitude seconds
    -        ([NS])\s+                   # latitude direction
    -        (\d{1,3})\s+                # longitude degrees
    -        (\d{1,2})\s+                # longitude minutes
    -        (\d{1,2}(?:\.\d+)?)\s+      # longitude seconds
    -        ([EW])\s+                   # longitude direction
    +        (\d{1,2})[ \t]+                # latitude degrees
    +        (\d{1,2})[ \t]+                # latitude minutes
    +        (\d{1,2}(?:\.\d+)?)[ \t]+      # latitude seconds
    +        ([NS])[ \t]+                   # latitude direction
    +        (\d{1,3})[ \t]+                # longitude degrees
    +        (\d{1,2})[ \t]+                # longitude minutes
    +        (\d{1,2}(?:\.\d+)?)[ \t]+      # longitude seconds
    +        ([EW])[ \t]+                   # longitude direction
             (-?\d+(?:\.\d+)?)m          # altitude
    -        (?:\s+(\d+(?:\.\d+)?)m      # size (optional)
    -        (?:\s+(\d+(?:\.\d+)?)m      # horiz precision (optional)
    -        (?:\s+(\d+(?:\.\d+)?)m)?    # vert precision (optional)
    +        (?:[ \t]+(\d+(?:\.\d+)?)m      # size (optional)
    +        (?:[ \t]+(\d+(?:\.\d+)?)m      # horiz precision (optional)
    +        (?:[ \t]+(\d+(?:\.\d+)?)m)?    # vert precision (optional)
             )?)?$/x';
     
     		if (!preg_match($pattern, $input, $matches)) {
    @@ -551,7 +551,7 @@ public static function validateDnsTlsa(string $input)
     			return false; // SHA-512
     		}
     
    -		if ($matchingType === 0 && strlen($data) < 2) {
    +		if ($matchingType === 0 && (strlen($data) < 2 || strlen($data) > 4096)) {
     			return false; // at least 1 byte hex
     		}
     
    
070e537744f2

set version to 2.3.7 for upcoming security release

https://github.com/froxlor/froxlorMichael KaufmannMay 15, 2026Fixed in 2.3.7via release-tag
3 files changed · +7 2
  • install/froxlor.sql.php+1 1 modified
    @@ -748,7 +748,7 @@
     	('panel', 'logo_overridecustom', '0'),
     	('panel', 'settings_mode', '0'),
     	('panel', 'menu_collapsed', '1'),
    -	('panel', 'version', '2.3.6'),
    +	('panel', 'version', '2.3.7'),
     	('panel', 'db_version', '202603100');
     
     
    
  • install/updates/froxlor/update_2.3.inc.php+5 0 modified
    @@ -212,3 +212,8 @@
     	Update::showUpdateStep("Updating from 2.3.5 to 2.3.6", false);
     	Froxlor::updateToVersion('2.3.6');
     }
    +
    +if (Froxlor::isFroxlorVersion('2.3.6')) {
    +	Update::showUpdateStep("Updating from 2.3.6 to 2.3.7", false);
    +	Froxlor::updateToVersion('2.3.7');
    +}
    
  • lib/Froxlor/Froxlor.php+1 1 modified
    @@ -31,7 +31,7 @@ final class Froxlor
     {
     
     	// Main version variable
    -	const VERSION = '2.3.6';
    +	const VERSION = '2.3.7';
     
     	// Database version (YYYYMMDDC where C is a daily counter)
     	const DBVERSION = '202603100';
    

Vulnerability mechanics

Root cause

"Missing server-side enforcement of the `system.available_shells` whitelist in the FTP account add/edit API handlers allows an authenticated customer to submit an arbitrary shell value."

Attack vector

An authenticated customer with `shell_allowed=1` on a deployment where `system.allow_customer_shell=1` and `system.nssextrausers=1` can submit a crafted POST request to `customer_ftp.php` with an arbitrary shell value (e.g., `/bin/bash`) that is not in the administrator-approved `system.available_shells` list [ref_id=1][ref_id=2]. The server-side FTP API accepts the value because it only runs a generic shell format check instead of enforcing the whitelist. The shell is stored in the database and later propagated into `/var/lib/extrausers/passwd` by a root-owned cron job, giving the attacker an interactive system shell on the host.

Affected code

The vulnerability resides in `customer_ftp.php` (lines 138–172) and `lib/Froxlor/Api/Commands/Ftps.php` (lines 119–123). The UI builds a shell selector from `system.available_shells`, but the server-side `Ftps::add()` and `Ftps::update()` methods only perform generic shell string validation via `Validate::validate()` and never check whether the submitted shell is in the administrator-defined whitelist. The cron task `lib/Froxlor/Cron/System/Extrausers.php` (lines 89–97) then writes the stored shell into the system's NSS extrausers passwd file without further validation.

What the fix does

The patch set increments the version to 2.3.7 and adds an update step from 2.3.6 to 2.3.7 [patch_id=3101696]. The advisory does not show a code-level diff that adds the missing whitelist check in `Ftps.php`, but the version bump signals that the fix is included in the 2.3.7 release. The remediation closes the gap by ensuring the server-side FTP handlers validate the submitted shell against `system.available_shells` before storing it, preventing arbitrary shell values from reaching the NSS extrausers passwd file.

Preconditions

  • configsystem.allow_customer_shell must be set to 1
  • authThe attacking customer must have shell_allowed=1
  • configsystem.nssextrausers must be set to 1 with libnss-extrausers integration active
  • authAttacker must have a valid authenticated session with a CSRF token
  • inputAttacker must own at least one FTP account to edit

Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.