//nefariousplan

CVE-2026-41940: cPanel's Session File Is a Bus. The Basic Auth Password Wrote a Line to It.

pattern

cve

proof of concept

cPanel's session state for an in-flight login lives in a file at /var/cpanel/sessions/raw/:<id>. The file is a flat key=value list, one record per line. The internal-auth handler sets successful_internal_auth_with_timestamp by appending a line to it. The Basic auth handler sets pass by appending a line to it.

The Basic auth handler does not escape \r\n in the password value.

The session file is a key=value bus

When a request lands at cpsrvd with login credentials, cpsrvd creates a session file at /var/cpanel/sessions/raw/:<id>. The file is one record per line; each line is key=value. The session loader reads it back by splitting on \n and partitioning each line on =. From the IOC scanner that ships the same parser the server uses operationally:

for line in data.split(b"\n"):
    line = line.rstrip(b"\r")
    if b"=" not in line:
        continue
    key, _, val = line.partition(b"=")

The fields stored on disk include pass (the password, normally encoded), user (whose session this is), hasroot (whether the session has root), tfa_verified (whether 2FA cleared), cp_security_token (the /cpsessNNNNNNNNNN slug bound to the session), and a pair of timestamps. The two timestamps are successful_internal_auth_with_timestamp and successful_external_auth_with_timestamp. They mark "this session was authenticated by an internal codepath" (an SSO transfer, an account-linking handoff, a create_user_session call) and "this session was authenticated by an external IdP." The auth check that gates every WHM API call reads them: if either timestamp is set and not stale, password validation is skipped and AUTH_OK is returned unconditionally.

The session file is a bus. Every authority gate cPanel applies to a request reads its decision out of this file.

The writers to the file are several. The form-login handler writes method=handle_form_login and the password it received. The transfer-auth handler writes method=handle_auth_transfer for SSO carries. The user-session creator writes method=create_user_session. The Basic auth handler writes whatever Basic auth value arrived. Each writer appends key=value lines to the same file. cPanel's own IOC scanner enumerates the keys that should never appear on a method=badpass session; the list is the convention written down:

POST_AUTH_KEYS = {
    "cp_security_token", "token_denied", "hasroot", "user",
    "successful_internal_auth_with_timestamp",
    "successful_external_auth_with_timestamp",
}

The Basic auth writer is what this CVE is about.

When the obhex is absent, pass is written raw

The Basic auth handler in cpsrvd is short. From the watchTowr Labs writeup of the bug, which is the source of the public PoCs:

my ($authtype, $encoded) = split(/\s+/, $auth_header, 2);
if ($authtype =~ /^basic$/i) {
    my ($user, $pass) = split(/:/, decode_base64($encoded), 2);

$authtype is Basic. $encoded is the base64 value. After decode_base64, the result is split on the first colon. $user is everything before the colon. $pass is everything after, including any \r\n in the value. The only sanitization on $pass before it reaches the session writer is set_pass, which strips NUL bytes. \r\n survives.

When the session is saved back to disk, cpsrvd's saveSession runs the encoder:

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'};

$ob is the obfuscation-hex key carried in the session cookie's tail. The cookie cpsrvd issues has the form whostmgrsession=<base>,<obhex>. When $ob is present, $encoder is a real object, and pass is rewritten to a single opaque value. When $ob is empty, the boolean-and short-circuits, $encoder is the falsy value of $ob, the conditional if $encoder && ... is false, and pass is written to disk verbatim.

The protection against CRLF in pass was the encoder. The encoder required the obhex. The obhex came from the client.

Four lines turn the failed login into root

The exploit's first request is a deliberately-failing POST to /login/?login_only=1 with user=root&pass=wrong. cpsrvd creates a session for the in-flight attempt, writes a file at /var/cpanel/sessions/raw/:<id> with origin address=<ip>,app=whostmgrd,method=badpass, and returns Set-Cookie: whostmgrsession=<base>,<obhex>. The session is preauth; its only disposition so far is that someone tried to log in and got the password wrong.

The attacker takes the cookie value, splits it on the comma, and keeps only the <base> half. From the public PoC by Sina Kheirkhah of watchTowr, comment intact:

# strip the ",<obhex>" tail. that's what makes the encoder skip pass on stage 2.
if "," in cookie_value:
    session_base = cookie_value.split(",", 1)[0]

The second request is a GET / carrying the now-decapitated cookie and an Authorization: Basic <payload> header. The base64-decoded payload is:

root:x\r\nsuccessful_internal_auth_with_timestamp=9999999999\r\nuser=root\r\ntfa_verified=1\r\nhasroot=1

cpsrvd parses the Authorization header. $user = "root". $pass = "x\r\nsuccessful_internal_auth_with_timestamp=9999999999\r\nuser=root\r\ntfa_verified=1\r\nhasroot=1". The session's obhex is empty because the cookie didn't carry one, so the encoder does not fire. pass is written raw. The relevant slice of the disk file becomes:

pass=x
successful_internal_auth_with_timestamp=9999999999
user=root
tfa_verified=1
hasroot=1

Every line after the first is a top-level key on the session. successful_internal_auth_with_timestamp=9999999999 is a year-2286 timestamp; the auth check sees it and returns AUTH_OK without consulting any password. tfa_verified=1 clears the 2FA gate. hasroot=1 flips the role bit. user=root overwrites whose session this is. The 307 response to that second request leaks the cp_security_token in its Location header, the path-bound /cpsessNNNNNNNNNN slug attached to this session.

raw → cache and the do_token_denied gadget

cPanel's session loader reads from a JSON cache, not the raw line file, on every request after creation. The cache lives at /var/cpanel/sessions/cache/ and stores the session as a single JSON document. The raw file is canonical; the cache is what production reads.

On the second request the cache existed from before the injection. The injected lines went to the raw file. The cache JSON still encoded pass as a single string, including \r\n as part of the value rather than as record separators. The session loader saw pass = "x\r\nsuccessful_internal_auth..." as one field, and the injected keys were not top-level. The injection landed in the raw file but was invisible to the cache loader.

The third request fixes that. From the PoC:

def stage3_propagate(s, ...):
    print("[3] firing do_token_denied to propagate raw -> cache...")
    r = http(s, "GET", scheme, host, port, canonical, "/scripts2/listaccts",
             headers={"Cookie": f"whostmgrsession={cookie_enc}"})

/scripts2/listaccts is a WHM endpoint that requires a valid cp_security_token in the URL. The request as sent has no cpsessNNNNNNNNNN prefix. cpsrvd's do_token_denied handler runs. It instantiates Cpanel::Session::Modify->new($session) with nocache=>1, which forces a re-read of the raw file rather than the cache. It then calls $session_mod->save, which writes the parsed result to both the raw file and the cache JSON. The cache now reflects the line-split view of the session. Every injected line is a top-level key. successful_internal_auth_with_timestamp, hasroot, tfa_verified, user are now where the auth check looks.

The fourth request is /cpsessNNNNNNNNNN/json-api/version with the same cookie. The session loader pulls from the cache. The auth check sees the timestamp and returns AUTH_OK. The session has hasroot=1. The handler runs as root.

The four-request sequence is what every public PoC ships. The watchTowr exploit ends by calling /json-api/passwd to reset the real root password to a value the operator names on the command line; its README's last line is now just login to <target> and use the terminal option to get a root shell. The 35 forks on GitHub at last count add mass-target loops, file-list iterators, and a worker-pool variant. The four requests are the same four requests in every one.

successful_internal_auth_with_timestamp was internal-only by convention

This is the internal-only-by-convention pattern. A field the framework writes to itself from internal codepaths, read with security-relevant authority, with no enforcement of who wrote it. The "internal-only" status of successful_internal_auth_with_timestamp was held by the assumption that no external writer would ever land in the same file, in the same key=value format, on the same line discipline. The IOC scanner's list of post-auth keys is the convention spelled out as a Python set. None of them belong on a method=badpass session. The server reads them anyway, because the field name is what the auth check looks at and the file knows nothing about provenance.

The shape repeats across the catalog. x-middleware-subrequest was a header Vercel's framework wrote to itself between internal fetch() calls; reading it from inbound network requests was never gated. __raw was a property name MikroORM branded its own raw-SQL fragments with; an in check on a request-body object answered the same way. The scheduler RPC port in KTransformers bound on every interface because the only caller in the codebase was on localhost, and the project's docker run example in the docs put it on the network. X-Stat was the header babeld wrote to itself on every push and gitrpcd parsed for rails_env, custom_hooks_dir, and the pre-receive hook list; push options reached the same string by concatenation.

The cPanel surface is wider than any of those. The session file holds every field the auth check, the token check, the role check, and the 2FA check read from. The IOC scanner names six of them. Every one of them is settable by a writer that lands a line in the file. The Basic auth handler is one writer. The form-login handler is another. The SSO-transfer handler is another. The set of writers is whoever cpsrvd lets append to the file, and the convention that says "the line came from a trusted writer" is held by nothing.

The patch closes one writer

The fix landed across six maintenance branches at once: 11.110.0.97, 11.118.0.63, 11.126.0.54, 11.132.0.29, 11.134.0.20, 11.136.0.5. The CVE description says "cPanel and WHM versions after 11.40." cPanel 11.40 shipped in 2012. The vulnerable code is approximately fourteen years old. The same patch ships for the WP Squared (WP2) variant on the 136.1.x branch.

The change in Session.pm's saveSession:

- 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'};
+ filter_sessiondata($session_ref);
+ if ( length $session_ref->{'pass'} ) {
+     if ( defined $ob && 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:' . Cpanel::Session::Encoder->hex_encode_only( $session_ref->{'pass'} );
+     }
+ }

filter_sessiondata strips \r\n=, from session field values before write. The else branch hex-encodes pass even when the obhex is absent, prefixed with no-ob: so the loader knows to decode it on the way out. The Basic auth writer is now both filtered and unconditionally encoded; a line break in the password value cannot reach disk.

CISA added CVE-2026-41940 to the Known Exploited Vulnerabilities catalog with a remediation deadline of May 3, 2026. The CVE was published April 29, 2026. The watchTowr writeup describes "in-the-wild exploitation has been ongoing"; the bug was a zero-day before it had a number. cPanel's installed base is somewhere north of 70 million domains.

The patch fixes one writer. The other writers, form-login, transfer-auth, create_user_session, and any future addition, share the same file format and the same line discipline. The convention that says "no writer will produce a line that looks like another writer's record" is what was protecting the file before the patch and what is protecting it after. The Basic auth path violated the convention by allowing \r\n to ride a string into the file. The patch closes that path. The file format that made the path exploitable is the same.

PoC: Sachinart/CVE-2026-41940-cpanel-0day

The patch closes one writer. The file format is still a bus.