-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The session file is a key=value bus\n\nWhen a request lands at cpsrvd with login credentials, cpsrvd creates a session file at `/var/cpanel/sessions/raw/:`. 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:\n\n```python\nfor line in data.split(b\"\\n\"):\n line = line.rstrip(b\"\\r\")\n if b\"=\" not in line:\n continue\n key, _, val = line.partition(b\"=\")\n```\n\nThe 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.\n\nThe session file is a bus. Every authority gate cPanel applies to a request reads its decision out of this file.\n\nThe 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:\n\n```python\nPOST_AUTH_KEYS = {\n \"cp_security_token\", \"token_denied\", \"hasroot\", \"user\",\n \"successful_internal_auth_with_timestamp\",\n \"successful_external_auth_with_timestamp\",\n}\n```\n\nThe Basic auth writer is what this CVE is about.\n\n## When the obhex is absent, `pass` is written raw\n\nThe Basic auth handler in cpsrvd is short. From the watchTowr Labs writeup of the bug, which is the source of the public PoCs:\n\n```perl\nmy ($authtype, $encoded) = split(/\\s+/, $auth_header, 2);\nif ($authtype =~ /^basic$/i) {\n my ($user, $pass) = split(/:/, decode_base64($encoded), 2);\n```\n\n`$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.\n\nWhen the session is saved back to disk, cpsrvd's `saveSession` runs the encoder:\n\n```perl\nmy $encoder = $ob && Cpanel::Session::Encoder->new( 'secret' => $ob );\nlocal $session_ref->{'pass'} = $encoder->encode_data( $session_ref->{'pass'} )\n if $encoder && length $session_ref->{'pass'};\n```\n\n`$ob` is the obfuscation-hex key carried in the session cookie's tail. The cookie cpsrvd issues has the form `whostmgrsession=,`. 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.\n\nThe protection against CRLF in `pass` was the encoder. The encoder required the obhex. The obhex came from the client.\n\n## Four lines turn the failed login into root\n\nThe 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/:` with origin `address=,app=whostmgrd,method=badpass`, and returns `Set-Cookie: whostmgrsession=,`. The session is preauth; its only disposition so far is that someone tried to log in and got the password wrong.\n\nThe attacker takes the cookie value, splits it on the comma, and keeps only the `` half. From the public PoC by Sina Kheirkhah of watchTowr, comment intact:\n\n```python\n# strip the \",\" tail. that's what makes the encoder skip pass on stage 2.\nif \",\" in cookie_value:\n session_base = cookie_value.split(\",\", 1)[0]\n```\n\nThe second request is a `GET /` carrying the now-decapitated cookie and an `Authorization: Basic ` header. The base64-decoded payload is:\n\n```\nroot:x\\r\\nsuccessful_internal_auth_with_timestamp=9999999999\\r\\nuser=root\\r\\ntfa_verified=1\\r\\nhasroot=1\n```\n\ncpsrvd 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:\n\n```\npass=x\nsuccessful_internal_auth_with_timestamp=9999999999\nuser=root\ntfa_verified=1\nhasroot=1\n```\n\nEvery 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.\n\n## raw → cache and the do_token_denied gadget\n\ncPanel'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.\n\nOn 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.\n\nThe third request fixes that. From the PoC:\n\n```python\ndef stage3_propagate(s, ...):\n print(\"[3] firing do_token_denied to propagate raw -> cache...\")\n r = http(s, \"GET\", scheme, host, port, canonical, \"/scripts2/listaccts\",\n headers={\"Cookie\": f\"whostmgrsession={cookie_enc}\"})\n```\n\n`/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.\n\nThe 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.\n\nThe 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 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.\n\n## `successful_internal_auth_with_timestamp` was internal-only by convention\n\nThis is the [internal-only-by-convention](/patterns/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.\n\nThe shape repeats across the catalog. [`x-middleware-subrequest`](/posts/next-middleware-cve-2025-29927-recursion-guard-was-the-bypass) was a header Vercel's framework wrote to itself between internal `fetch()` calls; reading it from inbound network requests was never gated. [`__raw`](/posts/mikroorm-cve-2026-34220-raw-was-a-property-name) 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](/posts/ktransformers-cve-2026-26210-only-caller-was-on-localhost) 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`](/posts/github-rails-env-is-a-header-field) 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.\n\nThe 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.\n\n## The patch closes one writer\n\nThe 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.\n\nThe change in `Session.pm`'s `saveSession`:\n\n```diff\n- my $encoder = $ob && Cpanel::Session::Encoder->new( 'secret' => $ob );\n- local $session_ref->{'pass'} = $encoder->encode_data( $session_ref->{'pass'} )\n- if $encoder && length $session_ref->{'pass'};\n+ filter_sessiondata($session_ref);\n+ if ( length $session_ref->{'pass'} ) {\n+ if ( defined $ob && length $ob ) {\n+ my $encoder = Cpanel::Session::Encoder->new( 'secret' => $ob );\n+ $session_ref->{'pass'} = $encoder->encode_data( $session_ref->{'pass'} );\n+ }\n+ else {\n+ $session_ref->{'pass'} = 'no-ob:' . Cpanel::Session::Encoder->hex_encode_only( $session_ref->{'pass'} );\n+ }\n+ }\n```\n\n`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.\n\nCISA 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.\n\nThe 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.\n\nPoC: [Sachinart/CVE-2026-41940-cpanel-0day](https://github.com/Sachinart/CVE-2026-41940-cpanel-0day)","closing_line":"The patch closes one writer. The file format is still a bus.","hook_md":"cPanel's session state for an in-flight login lives in a file at `/var/cpanel/sessions/raw/:`. 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.\n\nThe Basic auth handler does not escape `\\r\\n` in the password value.","post_id":72,"slug":"cpanel-session-file-is-a-bus","title":"CVE-2026-41940: cPanel's Session File Is a Bus. The Basic Auth Password Wrote a Line to It.","type":"initial","unreadable_sentence":"The flag the internal-auth flow uses to mark a session as authenticated is a line in the file the failed login just wrote a line to."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCafYPXgAKCRDeZjl4jgkQ Jt2YAPsEmE+uJ4qCJvGrd+9CTdchGjjI07mzhKZi9via5auM6QD9GdFCY1gM9wEa EFTMxvqmTPYQ7FJWuapXDiNPH/ZKOw4= =33C8 -----END PGP SIGNATURE-----