Security Research
cPanel/WHM Pre-auth Root Remote Code Execution
CVE-2026-41940. An unauthenticated attacker can chain four flaws in cPanel/WHM's session handling to obtain an interactive root shell over port 2087 in under ten seconds. We walk through each link in the chain, show the working PoC with a video demo, and give detection rules built from the forensic footprint.
End-to-end: anonymous network access on port 2087 to an interactive root shell, in under ten seconds.
We hold a fully working PoC that drives the four-request chain and opens an interactive root shell over the WHM WebSocket terminal. We are not publishing it. The vulnerability has been patched, but the architectural primitives behind flaws 3 and 4 remain in place (see Patch Analysis), and a public exploit only lowers the bar for opportunistic abuse against unpatched hosts. For commercial use, red-team engagements, or vendor verification, contact us.
TL;DR
- CVE-2026-41940 / DVE-2026-014 — pre-authentication remote code execution against cPanel/WHM on port 2087.
- Yields an interactive root shell. No credentials, no user interaction, no prior knowledge of the target.
- Four bugs in
cpsrvd’s session handling chain together: a missing encoder, a missing sanitizer, a cache desynchronization, and an auth check that trusts a timestamp it should not. - Discovered against cPanel & WHM 11.134.0.11 on 2026-03-11. Confirmed vulnerable up to 11.134.0.19.
How sessions work in cPanel/WHM
WHM’s web service is cpsrvd, a B::C-compiled Perl daemon. Two facts about it shape the entire bug:
- Sessions are stored in two places at once. A “raw” file at
/var/cpanel/sessions/raw/<id>(written byFlushConfig::serializeaskey=value\nlines), and a JSON cache at/var/cpanel/sessions/cache/<id>. They are supposed to stay in sync. - Session cookies look like
<user>:<random>,<obfuscation_hex>. The,hexsuffix is an XOR key the server uses to obfuscate the password before writing it to disk.
Each of these has an unstated invariant attached. The chain works because every one of those invariants breaks at the same time.
The Chain
1. Password encoder is silently skipped
Cpanel::Session::saveSession reads the obfuscation key out of the cookie and only encodes the password if the key is present:
my $ob = get_ob_part(\$session);
my $encoder = $ob && Cpanel::Session::Encoder->new('secret' => $ob);
local $session_ref->{'pass'} = $encoder->encode_data($session_ref->{'pass'})
if $encoder && length $session_ref->{'pass'};
Drop the ,hex suffix from the cookie and $ob is undef. No encoder is created. The password is written to the raw file unencoded — with whatever bytes the attacker put in it. The session lookup still works because the filename never included the suffix anyway.
2. Newline sanitizer is missing on this path
FlushConfig::serialize writes key=value\n pairs without escaping the value. There is a sanitizer (Cpanel::Session::filter_sessiondata) that strips \r\n from session values — it is called by Session::create and Session::Modify::save, but not on the handle_auth -> saveSession path that processes Basic Auth on a fresh needs_auth=1 session.
Combine flaws 1 and 2 and a request like:
Cookie: whostmgrsession=root:RAND (no obfuscation suffix)
Authorization: Basic base64("root:x\nsuccessful_internal_auth_with_timestamp=<TIME>")
writes a raw file that LoadConfig parses as two separate keys:
pass=x
successful_internal_auth_with_timestamp=<TIME>
3. Cache and raw drift apart
A smuggled key in the raw file alone is not enough. loadSession reads the JSON cache first, and JSON serialization escapes newlines correctly — so on the cache side the injection is still trapped inside the pass value as a literal newline.
The two stores diverge in Cpanel::Session::Modify::new, which reads the raw file with LoadConfig:
my ($ref, $fh, $conflock) = Cpanel::Config::LoadConfig::loadConfig(
$session_file, undef, '=', undef, 0, 0,
{ 'skip_readable_check' => 1, 'nocache' => 1, 'keep_locked_open' => 1, 'rw' => 1 }
);
Now the smuggled key parses as a top-level field. When save() runs it serializes the in-memory hash back out to both stores, and the injection is now a real, first-class field of the JSON cache.
Two trigger paths reach Session::Modify without authentication: the OIDC handler at /openid_connect/<provider>, and do_token_denied (any request with a wrong cp_security_token). A single 403 from /json-api/version is enough.
4. The auth check accepts the smuggled key as proof
On the next request handle_auth loads the session. Because needs_auth was deleted in flaw 2, control flows to:
elsif (not($SESSION_ref->{'needs_auth'})) {
if ($SESSION_ref->{'successful_internal_auth_with_timestamp'}) {
$successful_internal_auth_with_timestamp =
$SESSION_ref->{'successful_internal_auth_with_timestamp'};
}
$auth->set_auth_type('session');
return; # returns WITHOUT calling docheckpass
}
check_authok_user then short-circuits:
if ($AUTHOPTS{'authable_user'}{'successful_internal_auth_with_timestamp'}) {
return $Cpanel::Server::AUTH_OK, 0; # no password check
}
No password is ever validated. The session is root. Code analysis suggests 2FA is also bypassed: the timestamp short-circuit fires before the 2FA gate.
The Chain in Four Requests
1. GET / -> Set-Cookie: <id>,<ob_hex>; needs_auth=1
2. GET / -> 307 redirect (leaks cp_security_token)
Cookie: <id> (suffix stripped -- flaws 1 + 2)
Authorization: Basic root:x\nsuccessful_internal_auth_with_timestamp=<TIME>
3. GET /json-api/version -> 403 (triggers Session::Modify -- flaw 3)
Cookie: <id>,<ob_hex>
4. GET /cpsessXXXX/json-api/version -> 200 OK, root (flaw 4)
Cookie: <id>,<ob_hex>
Once step 4 returns, the session is a normal authenticated WHM root session. From there: arbitrary WHM API, CGI execution, /3rdparty/phpMyAdmin, the dashboard via cookie injection, and — via the WHM terminal at /scripts12/terminal — an interactive root shell.
Affected Versions
CVE-2026-41940. CVSS 3.1 base score 9.8 (Critical). CWE-93 (CRLF injection) and CWE-287 (improper authentication).
- Confirmed versions:
- 11.134.0.16
- 11.134.0.12
- 11.134.0.11
- 11.130.0.17
- 11.120.0.22
- 11.110.0.92
- 11.86.0.40
The bug lives in the auth/session core, which has been broadly stable across these branches. Architecture is irrelevant. The only requirement is that WHM’s port (default 2087/tcp) is reachable.
Patch
cPanel published patched builds on 2026-04-28 across all supported branches:
- 11.86.0.41, 11.110.0.97, 11.118.0.63, 11.126.0.54, 11.130.0.19, 11.132.0.29, 11.134.0.20, 11.136.0.5
- WP Squared 136.1.7
Update via /scripts/upcp --force. Short-term mitigation: block inbound traffic on ports 2083, 2087, 2095, and 2096 at the firewall and stop cpsrvd / cpdavd.
Per KnownHost, a managed cPanel hosting provider, in-the-wild exploitation traces back to 2026-02-23 — roughly two months before the public advisory. Hosts that were exposed to the internet on a vulnerable build during that window should be considered potentially compromised; run the IoC checks below.
Patch Analysis
Of the four flaws, two received root-cause fixes. Two are architecturally unchanged and remain latent.
| Flaw | Status | Note |
|---|---|---|
| 1. Password encoder silently skipped | Patched | saveSession now hex-encodes the password with a no-ob: prefix when the obfuscation key is absent, so raw bytes can no longer reach the file. |
2. Newline sanitizer missing on the handle_auth path | Patched | filter_sessiondata is now called unconditionally at the top of saveSession, so \r\n is stripped on every write path. |
| 3. Raw vs cache parser drift | Unchanged (latent) | Session::Modify::new still parses the raw file with LoadConfig and propagates whatever it finds to the cache. The fix relies on flaws 1 + 2 keeping newlines out of the raw file. |
| 4. Magic key single-source auth | Unchanged (by design) | check_authok_user still returns AUTH_OK whenever successful_internal_auth_with_timestamp is set on the session. The fix relies on no path being able to inject that key. |
The patch is concentrated in Cpanel/Session.pm::saveSession, with a new hex_encode_only helper added to Cpanel/Session/Encoder.pm:
filter_sessiondata($session_ref); # <- flaw 2 fix
if (length $session_ref->{'pass'}) {
if (defined $ob and length $ob) {
my $encoder = Cpanel::Session::Encoder->new('secret', $ob);
$session_ref->{'pass'} = $encoder->encode_data($session_ref->{'pass'});
} else {
$session_ref->{'pass'} = 'no-ob:' # <- flaw 1 fix
. Cpanel::Session::Encoder->hex_encode_only($session_ref->{'pass'});
}
}
End-to-end verification on a patched build returns HTTP 403 at step 4: the smuggled successful_internal_auth_with_timestamp key never reaches the raw or cache stores, so the auth check falls back to the password path and rejects the session. A representative session file from the patched build:
pass=no-ob:8737573636563737665... (hex-encoded, single line, no \n)
user=root
origin_as_string=address=...,app=whostmgrd,method=badpass
(no successful_internal_auth_with_timestamp key)
Latent risk
Two architectural weaknesses survived the patch. Neither is exploitable today, but both are one bug away from re-arming the original chain.
- A new newline-injection sink would re-arm the chain.
Session::Modify::newstill trusts whateverLoadConfigparses out of the raw file, andFlushConfig::serializestill writeskey=value\nwithout escaping. Any futuresaveSessioncaller that bypassesfilter_sessiondata, or any unrelated primitive that writes the raw file directly, would resurrect flaw 3 — and from there, the rest of the chain. - A new way to set
successful_internal_auth_with_timestampwould re-arm the auth bypass. The patch removes one injection path; it does not change the contract that “the key is present” implies “the user is authenticated.” Any future code that writes that key on behalf of an attacker (a different injection sink, a write race, a forgotten internal API) is enough.
Hardening directions worth considering: retire FlushConfig::serialize for the session store and switch raw to JSON-only; have Session::Modify::new reject raw files containing unexpected keys; replace the timestamp-as-credential pattern with a signed marker (e.g. an HMAC over session id + issuance time) so a truthy key alone is no longer sufficient.
Patch analysis based on an independent audit, 2026-04-30.
Indicators of Compromise
The forensic footprint depends on which trigger path the exploit picks (OIDC vs do_token_denied). The OIDC path is nearly silent. The strongest evidence is not in the access log — it is in the session files themselves.
Access log — pattern of the chain
The four requests appear back-to-back from the same IP, in seconds. Step 2 is the strongest single-line indicator: root, 307, auth type b, immediately following an anonymous GET / 200.
IP - - "GET / HTTP/1.1" 200 # Step 1
IP - root "GET / HTTP/1.1" 307 "b" # Step 2 (primary IOC)
IP - - "GET /cpsessXXXX/openid_connect/cpanelid ..." 302 # Step 3 (OIDC path)
or
IP - root "GET /json-api/version HTTP/1.1" 403 # Step 3 (token_denied path)
IP - root "GET /cpsessXXXX/json-api/version HTTP/1.1" 200 "s" # Step 4
# Hunt rule: anonymous Basic Auth -> 307 with auth type b
grep -P '"GET / HTTP/1\.1" 307.*"b"' /usr/local/cpanel/logs/access_log
Login log
- OIDC path: no
login_logentries. do_token_denied(Vector A): oneDEFERRED LOGIN whostmgrd: security token missingentry.- POST
/login/variants (Vector B/C):FAILED LOGIN whostmgrd: Incorrect security tokenand... user password incorrectentries.
failedlogin(), successful_login(), session_log NEW, and cphulk are never triggered by the exploit itself, regardless of path — the auth check returns AUTH_OK before any failure or success event fires.
Session files — definitive IoC
A session file containing both origin_as_string=...method=badpass and successful_internal_auth_with_timestamp is the definitive indicator. A badpass session should never have an authentication timestamp.
# /var/cpanel/sessions/raw/:W9Xxm_AemSa8wYu2
origin_as_string=address=192.168.245.135,app=whostmgrd,method=badpass
successful_internal_auth_with_timestamp=1742400000
user=root
# Find all compromised sessions
sudo grep -rl 'successful_internal_auth_with_timestamp' /var/cpanel/sessions/raw/ \
| xargs -I{} sh -c 'grep -q "method=badpass" "{}" && echo "COMPROMISED: {}"'
# Classify by trigger path
for f in $(sudo grep -rl 'successful_internal_auth_with_timestamp' /var/cpanel/sessions/raw/); do
if grep -q 'openid_connect_state' "$f"; then echo "OIDC: $f"
elif grep -q 'token_denied=1' "$f"; then echo "TOKEN_DENIED: $f"
else echo "OTHER: $f"
fi
done
Preauth flag absence
When needs_auth is removed in step 2, the preauth flag at /var/cpanel/sessions/preauth/<id> is deleted. A badpass session whose raw/cache files exist but whose preauth flag is missing is suspicious:
for f in /var/cpanel/sessions/raw/*; do
id=$(basename "$f")
if grep -q 'method=badpass' "$f" && [ ! -f "/var/cpanel/sessions/preauth/$id" ]; then
echo "SUSPICIOUS: $f"
fi
done
Correlating session to access log
Session filenames and cpsess tokens differ. To map a token to a file, grep the session store for cp_security_token=/cpsessXXXX. access_log is UTC; session-file mtime is local time — convert before correlating.
Timeline
| Date | Event |
|---|---|
| 2026-03-11 | Vulnerability discovered independently against cPanel & WHM 11.134.0.11 |
| 2026-04-28 | cPanel publishes security advisory and patched builds across all supported branches |
| 2026-04-29 | CVE-2026-41940 assigned (CVSS 9.8) |
| 2026-04-30 | Del Security publishes this technical analysis (DVE-2026-014) |
DVE-2026-014 / CVE-2026-41940. Patched in cPanel & WHM 11.86.0.41 / 11.110.0.97 / 11.118.0.63 / 11.126.0.54 / 11.130.0.19 / 11.132.0.29 / 11.134.0.20 / 11.136.0.5 and WP Squared 136.1.7 (2026-04-28).
For commercial use or inquiries, please contact us.