h3llh0und

CVE-2026-113676, CVE-2026-113677 and CVE-2026-113678: the traffic job, a domain delete and an admin lookup in froxlor

froxlor's daily traffic job runs as root and hands each hosting customer their statistics folder. I found that a customer who replaced that folder with a symlink was handed whatever directory the link pointed to, from the server's TLS keys to Apache's configuration. Two smaller reports from the same day crossed the panel's other boundaries: a customer could delete a domain their reseller had assigned, and a reseller could read the super-admin's account. All three were fixed the day I sent them.

Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every request in it went to froxlor 2.3.14, installed unattended with the project’s own froxlor-cli installer in MariaDB and PHP/Apache containers on my own machine. The issues were reported privately through the project’s security advisory process. They are written up here after the advisories, the fixed release and the CVE records were public.

froxlor is a control panel for shared hosting: one server, many paying customers, each with their own websites, mail and databases. It is built on a hierarchy. Root runs the server and every service on it. The administrator configures the panel. Resellers are admin-type accounts without the right to change server settings, and they sell hosting to customers. A customer gets a home under /var/customers/webs/<login>/, and that directory is theirs to fill.

The panel’s whole job is to keep each account inside its own level. In September I sent froxlor three reports, and each one is an account reaching something that belongs to the level above it. The first one goes from a customer straight to root.

Once a day froxlor runs its traffic cron job as root. Among other things, it walks every customer that has a domain and calls makeChownWithNewStats(), which finds the web statistics folder inside the customer’s document root and gives it to the customer, so the reports a root job has just written belong to the account that serves the site:

// lib/Froxlor/Http/Statistics.php, 2.3.14
$dir = $row['documentroot'] . '/' . Settings::Get('system.traffictool') . '/';
if (file_exists($dir)) {
    FileDir::safe_exec('chown -R ' . escapeshellarg($user) . ':' . escapeshellarg($group)
        . ' ' . escapeshellarg(FileDir::makeCorrectDir($dir)));
}

On a default install the folder is goaccess, and it sits in a directory the customer owns. So the customer can delete it and put a symlink in its place, pointing anywhere on the server. file_exists() follows the link. The trailing slash makes chown -R follow it too. Root then makes the customer the owner of the link’s target and of everything under it.

The cron job is enabled in the schema froxlor ships, and the chown runs whether or not any statistics tool is installed. The customer does not have to do anything but wait for the next day’s run.

On 2.3.14, as the customer web1:

$ cat /etc/ssl/private/server.key
cat: /etc/ssl/private/server.key: Permission denied
$ chown web1:web1 /etc/ssl/private
chown: changing ownership of '/etc/ssl/private': Operation not permitted
$ rm -rf /var/customers/webs/web1/goaccess
$ ln -s /etc/ssl/private /var/customers/webs/web1/goaccess

Then root ran the traffic job, as cron does every day, and the directory had changed hands:

drwx------ 1 10000 10000   20 /etc/ssl/private
-rw------- 1 10000 10000 1704 /etc/ssl/private/server.key

The customer read the private key that root had written there before the test. With a real directory in place of the symlink, the same run left /etc/ssl/private owned by root. With the symlink back, it was handed over again.

What owning a directory is worth

By itself this is an ownership change, not code execution. What it becomes depends on which directory the customer points at, and a web server has plenty of good ones.

I ran it a second time against /etc/apache2/conf-enabled. After the job, the customer owned it and wrote an Apache configuration file into it. froxlor’s own cron jobs reload Apache when they regenerate virtual hosts, and Apache reads that directory as root when it does. Any other directory works the same way: mail spools, other customers’ document roots, the panel’s own installation.

That is why the advisory scores it High even though the customer has to wait a day, and why the CVE record scores it Critical.

The check that has to be asked for

froxlor already knows how to stop this. Its path helper, FileDir::makeCorrectDir(), has an optional second argument: the directory the path must stay inside. When a caller passes it, the helper walks the path and refuses any symlink that leads out. When a caller leaves it out, the helper only tidies the slashes.

The line above passes one argument. It goes through the containment function and never switches the containment on.

The line is old. It was written in June 2010, and the containment argument arrived thirteen years later, in 2023, as something each caller has to opt into. In August 2026 froxlor 2.3.11 fixed two bugs of exactly this shape, CVE-2026-41231 in the export job and CVE-2026-41236 in SSH-key sync, by adding the second argument at the other places where root touches a customer’s path. The traffic job was not one of them, and it differs from the export job in one way that matters: the export job ships disabled, and the traffic job ships enabled.

The fix in 2.3.15 passes the customer’s document root as the second argument. The same commit also reworked the rest of the traffic job and the FastCGI configuration writer, where my report had listed other root file operations on customer paths that I had read but not exploited.

One level down: a customer and the reseller’s domain

A customer’s domain list in froxlor holds two kinds of entry. A main domain is assigned by the reseller and counts against the reseller’s quota. A subdomain is one the customer created under it. Customers may delete subdomains but not main domains, and the customer panel says so:

// customer_domains.php
if (isset($result['parentdomainid']) && $result['parentdomainid'] != '0' && $alias_check['count'] == 0) {
    ...
    SubDomains::getLocal($userinfo, Request::postAll())->delete();
    ...
} else {
    Response::standardError('domains_cantdeletemaindomain');
}

The page checks the entry first, and only then calls the API command SubDomains.delete to do the work. The command itself never makes that check. A customer who calls the API directly, with their own API key, skips the page:

$ curl -u CUSTKEY:CUSTSECRET \
    -d '{"command":"SubDomains.delete","params":{"id":4}}' http://127.0.0.1/api.php

Domain 4 was a main domain that the reseller had assigned to this customer. After the call it was gone from the database, and none of the bookkeeping the panel does when an administrator deletes a domain had happened. The reseller’s count of used domains stayed at 1. The customer’s count of used subdomains went from 0 to −1, because the command subtracted a subdomain that had never been counted as one. That counter has no floor, so a customer who repeats the trick earns extra subdomain allowance.

The damage stays inside the customer’s own account. The deletion is still scoped to the caller, so one customer cannot delete another’s domains. What they can do is take apart what the reseller set up for them, and leave the reseller’s quota counting a domain that no longer exists.

The fix puts the page’s check into the command, with the same error message.

Another level up: a reseller and the super-admin

Admins.get returns an admin account’s record. A reseller may read their own and nobody else’s. The command takes the account either by numeric id or by loginname, and checks the caller like this:

if ($this->isAdmin() && ($this->getUserDetail('change_serversettings') == 1
        || ($this->getUserDetail('adminid') == $id
            || $this->getUserDetail('loginname') == $loginname))) {
    $result_stmt = Database::prepare("
        SELECT * FROM `" . TABLE_PANEL_ADMINS . "`
        WHERE " . ($id > 0 ? "`adminid` = :idln" : "`loginname` = :idln"));

The check passes if either identifier belongs to the caller. The query uses id whenever there is one. So a reseller who sends someone else’s id together with their own loginname passes on the name and gets the row for the number:

$ curl -u RESELLERKEY:RESELLERSECRET \
    -d '{"command":"Admins.get","params":{"id":1}}' http://127.0.0.1/api.php
{ "message": "Not allowed to execute given command." }

$ curl -u RESELLERKEY:RESELLERSECRET \
    -d '{"command":"Admins.get","params":{"id":1,"loginname":"reseller1"}}' http://127.0.0.1/api.php
{ "adminid": 1, "loginname": "admin", "name": "Administrator", "email": "admin@example.test",
  "change_serversettings": 1, "api_allowed": 1, "ip": "-1", "customers": -1, "domains": -1, … }

Account 1 is the super-admin on a default install. The response carries their contact address, resource limits and usage counters, the IP allow-list, the API and panel access flags, private notes and login history. It does not carry the password hash or the two-factor secret, which froxlor strips from every response that leaves over HTTP. So this is a read of someone else’s account, not a way into it.

froxlor’s own Customers.get handles the same pair of identifiers correctly: it refuses the request if either one is not the caller’s, before it queries anything. The fix gives Admins.get the same rule.

The same day

I sent all three reports on 17 September. Michael Kaufmann, froxlor’s maintainer, accepted each of them within the hour, and the three fixes were committed the same day. They shipped in froxlor 2.3.15 on 4 October, listed under Security in the release notes, and the three advisories were published on 20 October, crediting me as reporter.

The advisories went out without CVE identifiers. GitHub is the CNA for froxlor’s repository and assigns one only when the maintainer asks, so I asked VulnCheck, and the three records were published that afternoon, crediting me as finder.

If you host customers on froxlor, 2.3.15 or later closes all three. The traffic job is the one that matters. Any customer who can create a symlink in their own web folder, with a shell or with a script on their own site, could have used it.


Advisory GHSA-vwjv-x8ff-4cvv · CVE CVE-2026-113678 · CVSS 8.8 High (v3.1) on the advisory; 9.9 Critical (v3.1) and 9.4 Critical (v4.0) on the record · Fix 0805a7e · Affected < 2.3.15 · Fixed 2.3.15

Advisory GHSA-pp2f-frcg-w8rv · CVE CVE-2026-113677 · CVSS 5.4 Medium (v3.1), 5.3 Medium (v4.0) · Fix 078e3f4 · Affected >= 0.10.0, < 2.3.15 · Fixed 2.3.15

Advisory GHSA-m7gc-8c8g-4f89 · CVE CVE-2026-113676 · CVSS 4.3 Medium (v3.1), 5.3 Medium (v4.0) · Fix 981400f · Affected >= 0.10.0, < 2.3.15 · Fixed 2.3.15

All three reported 17 September · accepted and fixed on 17 September · 2.3.15 released 4 October · advisories published 20 October, crediting me as reporter · CVE records assigned and published by VulnCheck 20 October.

Eurico Nicacio — @h3llh0und

← all posts Eurico Nicacio · h3llh0und