Latest Article Changes:
05/07/26 06:00PM CST: Updated CL6 tier instructions.
05/05/26 05:14PM CST: Added patched versions for 11.94 and 11.102
05/04/26 08:50AM CST: Updated the detection script further to remove additional false positives.
05/03/26 09:43PM CST: Clarified Patch Versions.
05/03/26 07:07PM CST: Updated Patch Versions for 11.110, 11.118, 11.126, 11.134, and 11.136
05/01/26 09:59PM CST: Added patched version for tier 11.124.
05/01/26 11:52AM CST: An updated version of the detection script has been added. This addresses scenarios where false positives were being detected.
05/01/26 08:05AM CST: Temporarily removed the current detection script while we confirm a new version
04/30/26 03:33PM CST: Updated restart instructions and added API command for 11.110 tier.
04/30/26 02:33PM CST: Added instructions for updating C6/CL6 servers on 110.0.50
04/29/26 02:46PM CST: Updated article's required actions and added detection script.
04/28/26 04:36PM CST: Updated article to include patched versions.
04/28/26 03:19PM CST: Updated article to reflect changes in mitigation steps
04/28/26 12:05PM CST: Initial article published.
Cause
An authentication bypass security issue has been identified in the cPanel software (including DNSOnly) affecting all versions after 11.40.
Resolution
We have pushed out a patch for the following cPanel & WHM versions:
- 11.86.0.41 and higher
- 11.94.0.28 and higher
- 11.102.0.39 and higher
- 11.110.0.97 and higher
- 11.118.0.63 and higher
- 11.124.0.35 and higher
- 11.126.0.54 and higher
- 11.130.0.19 and higher
- 11.132.0.29 and higher
- 11.134.0.20 and higher
- 11.136.0.5 and higher
We have pushed out a patch for the following WP Squared version:
- 136.1.7 and higher
For customers still on CentOS 6 or CloudLinux 6 using v110.0.50, we have also released v110.0.103 as a direct update. To upgrade to this version, run the following command to set the upgrade tier, and then follow the steps in the "Required Actions" below.
# sed -i "s/CPANEL=.*/CPANEL=cl6110/g" /etc/cpupdate.conf
Note: All further versions of cPanel are patched for this issue as well. Please see the latest changelogs for version information of each cPanel branch:
https://docs.cpanel.net/changelogs/
Required Actions
1. Update the server to one of the above-listed versions immediately via the cPanel update script:
# /scripts/upcp --force
2. Once the update has been completed, verify and confirm the cPanel build version being returned and perform a restart of the cPanel service (cpsrvd):
# /usr/local/cpanel/cpanel -V
# /scripts/restartsrv_cpsrvd --hard
3. Please note that if you have disabled cPanel updates or pinned your cPanel update configuration to a specific version, then these will not auto-update. Please identify and update these servers manually as a priority. If the server uses CentOS 7 or CloudLinux 7, you will need to set the version to 11.110.
# whmapi1 set_tier tier=11.110
Information on how to customize cPanel's Update Preferences from the command line can be found via the following support article:
How to customize cPanel's Update Preferences from the Command Line
4. In cases where you are not able to perform the above resolution, please apply one of the following mitigations:
- Block inbound traffic on ports 2083, 2087, 2095, and 2096 at the firewall, as well as disable Service Subdomains:
# whmapi1 set_tweaksetting key=proxysubdomains value=0 && /scripts/proxydomains remove && /scripts/rebuildhttpdconf && /scripts/restartsrv_httpd
-
Stop cpsrvd and cpdavd:
# whmapi1 configureservice service=cpsrvd enabled=0 monitored=0 && whmapi1 configureservice service=cpdavd enabled=0 monitored=0 && /scripts/restartsrv_cpsrvd --stop && /scripts/restartsrv_cpdavd --stop
We are currently working on finding paths to get a patch to versions not included above, especially for versions that have higher quantities of servers. In the meantime, it is highly recommended that you follow the instructions below until you can update to a supported version above that will continue to be updated as we release patches.
Warning: If your server is not running a supported version of cPanel that is eligible for this update, it is highly recommended that you work toward updating your server as soon as possible, as it may also be affected.
Detection Script
We are also providing the following detection script to look for indicators of compromise, and checks for sessions in the filesystem.
Save the following as ioc_checksessions_files.sh:
#!/bin/bash
# Scan for compromised cPanel/WHM session files.
#
# Each check function inspects a single session file and, if the IOC
# matches, calls report_finding with a severity. report_finding records
# the finding, prints a one-line header, and dumps the session for triage.
# A summary of all findings (grouped by severity) is printed at the end.
# Default paths
SESSIONS_DIR="/var/cpanel/sessions"
ACCESS_LOG="/usr/local/cpanel/logs/access_log"
# Flags
VERBOSE=0
PURGE=0
ASSUME_YES=0
# Parse flags
while [ $# -gt 0 ]; do
case "$1" in
--verbose)
VERBOSE=1
;;
--purge)
PURGE=1
;;
--yes|-y)
ASSUME_YES=1
;;
--sessions-dir)
SESSIONS_DIR="$2"; shift
;;
--access-log)
ACCESS_LOG="$2"; shift
;;
--help|-h)
echo "Usage: $0 [--verbose] [--purge [--yes]] [--sessions-dir DIR] [--access-log FILE]"
exit 0
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
shift
done
# Findings accumulator. Each entry: "SEVERITY|session_file|short_message"
FINDINGS=()
# Ordered list of unique session files that produced findings.
FINDING_SESSIONS=()
# Parallel array: token value associated with each entry in FINDING_SESSIONS
# (first non-empty token seen for that session).
FINDING_TOKENS=()
# Parallel array: highest severity reported for each session (by index)
FINDING_SEVERITIES=()
COUNT_CRITICAL=0
COUNT_WARNING=0
COUNT_INFO=0
COUNT_ATTEMPT=0
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Extract the value of a key=value line from a session file (first match).
# Use: get_field <file> <key>
get_field() {
local file="$1" key="$2"
grep "^${key}=" "$file" | head -1 | cut -d= -f2-
}
hr() {
echo " ----------------------------------------------------------------"
}
# Dump full contents of a session file plus related context (matching
# pre-auth file, access_log hits for the injected token, file metadata).
# Use: dump_session <session_file> [token_value]
dump_session() {
local session_file="$1"
local token_val="$2"
local session_name preauth_file
session_name=$(basename "$session_file")
preauth_file="$SESSIONS_DIR/preauth/$session_name"
hr
echo " SESSION DUMP: $session_file"
hr
echo " File metadata:"
ls -la "$session_file" 2>/dev/null | sed 's/^/ /'
echo
echo " Full session contents:"
sed 's/^/ /' "$session_file"
echo
if [ -f "$preauth_file" ]; then
echo " Matching pre-auth file: $preauth_file"
ls -la "$preauth_file" 2>/dev/null | sed 's/^/ /'
echo " Pre-auth contents:"
sed 's/^/ /' "$preauth_file"
echo
fi
if [ -n "$token_val" ] && [ -r "$ACCESS_LOG" ]; then
echo " Access log hits for token '$token_val':"
grep -aF -- "$token_val" "$ACCESS_LOG" | sed 's/^/ /' || echo " (none)"
echo
fi
hr
}
# Record a finding and print a brief header line. The full session dump is
# deferred to print_summary so that multiple findings for the same session
# are grouped together and the session is only dumped once. When the same
# session matches multiple IOCs at different severities, only the highest
# (CRITICAL > WARNING > ATTEMPT > INFO) is kept.
# Use: report_finding <SEVERITY> <session_file> <token_value> <message>
# SEVERITY is one of: CRITICAL, WARNING, ATTEMPT, INFO
report_finding() {
local severity="$1"
local session_file="$2"
local token_val="$3"
local message="$4"
# Severity ranking: CRITICAL=3, WARNING=2, ATTEMPT=1, INFO=0
local sev_rank=0
case "$severity" in
CRITICAL) sev_rank=3 ;;
WARNING) sev_rank=2 ;;
ATTEMPT) sev_rank=1 ;;
INFO) sev_rank=0 ;;
esac
local i found=0 prev_sev prev_rank
for i in "${!FINDING_SESSIONS[@]}"; do
if [ "${FINDING_SESSIONS[$i]}" = "$session_file" ]; then
found=1
prev_sev="${FINDING_SEVERITIES[$i]}"
case "$prev_sev" in
CRITICAL) prev_rank=3 ;;
WARNING) prev_rank=2 ;;
ATTEMPT) prev_rank=1 ;;
INFO) prev_rank=0 ;;
esac
if [ "$sev_rank" -le "$prev_rank" ]; then
# Existing finding is at least as severe; ignore.
return
fi
# Upgrade in place: replace severity, token, FINDINGS entry,
# and roll back the previous severity counter so the new one
# can be incremented below without double-counting.
FINDING_SEVERITIES[$i]="$severity"
[ -n "$token_val" ] && FINDING_TOKENS[$i]="$token_val"
local j
for j in "${!FINDINGS[@]}"; do
local entry="${FINDINGS[$j]}"
local entry_sev="${entry%%|*}"
local entry_file="${entry#*|}"; entry_file="${entry_file%%|*}"
if [ "$entry_file" = "$session_file" ] && [ "$entry_sev" = "$prev_sev" ]; then
FINDINGS[$j]="${severity}|${session_file}|${message}"
break
fi
done
case "$prev_sev" in
CRITICAL) COUNT_CRITICAL=$((COUNT_CRITICAL - 1)) ;;
WARNING) COUNT_WARNING=$((COUNT_WARNING - 1)) ;;
ATTEMPT) COUNT_ATTEMPT=$((COUNT_ATTEMPT - 1)) ;;
INFO) COUNT_INFO=$((COUNT_INFO - 1)) ;;
esac
break
fi
done
if [ "$found" -eq 0 ]; then
FINDING_SESSIONS+=("$session_file")
FINDING_TOKENS+=("$token_val")
FINDING_SEVERITIES+=("$severity")
FINDINGS+=("${severity}|${session_file}|${message}")
fi
case "$severity" in
CRITICAL) COUNT_CRITICAL=$((COUNT_CRITICAL + 1)) ;;
WARNING) COUNT_WARNING=$((COUNT_WARNING + 1)) ;;
ATTEMPT) COUNT_ATTEMPT=$((COUNT_ATTEMPT + 1)) ;;
INFO) COUNT_INFO=$((COUNT_INFO + 1)) ;;
esac
echo "[${severity}] ${message}: ${session_file}"
}
# ---------------------------------------------------------------------------
# IOC checks
# ---------------------------------------------------------------------------
# IOC 0: token_denied counter alongside cp_security_token, in a session
# whose origin is badpass or otherwise non-benign.
#
# - token_denied is incremented by do_token_denied() (cpsrvd.pl:3821)
# every time a request supplies the wrong cp_security_token. The
# session is killed on the third failure.
# - cp_security_token itself is set by newsession() unconditionally
# while security tokens are enabled (Cpanel/Server.pm:2290), so its
# presence is NOT by itself an IOC. The pair (token_denied,
# cp_security_token) tells us only that someone is actively trying
# tokens against this session.
#
# Auth markers (successful_*_auth_with_timestamp, hasroot=1,
# tfa_verified=1, or an access_log hit on the security token) cannot
# legitimately appear in a badpass session: the badpass call site
# (Cpanel/Server.pm:1244-1252) doesn't pass them, hasroot is not even
# in _SESSION_PARTS (Cpanel/Server.pm:2216-2247), and tfa_verified is
# forced to 0 unless the caller passes a truthy value (line 2295).
#
# Severity tiers:
# CRITICAL - badpass origin AND auth markers present (post-exploit)
# INFO - badpass origin, no auth markers, pass looks like a real
# encoded password (likely an unrelated failed login that
# happened to receive bad-token traffic)
# WARNING - origin is neither badpass nor a known-benign method
# (handle_form_login, create_user_session,
# handle_auth_transfer); the suspicious origin itself is
# the IOC
#
# Legitimate badpass sessions never carry a pass= line (the badpass
# call site at Cpanel/Server.pm:1244-1252 does not pass `pass` to
# newsession, and saveSession only writes pass= when length is
# non-zero - Cpanel/Session.pm:181). When we see one anyway we defer
# classification to IOC 5 (check_failed_exploit_attempt), which flags
# it as ATTEMPT.
check_token_denied_with_injected_token() {
local session_file="$1"
grep -q '^token_denied=' "$session_file" || return
grep -q '^cp_security_token=' "$session_file" || return
local token_val external_auth internal_auth hasroot tfa used
token_val=$(get_field "$session_file" cp_security_token)
external_auth=$(get_field "$session_file" successful_external_auth_with_timestamp)
internal_auth=$(get_field "$session_file" successful_internal_auth_with_timestamp)
hasroot=$(get_field "$session_file" hasroot)
tfa=$(get_field "$session_file" tfa_verified)
used=""
if [ -r "$ACCESS_LOG" ]; then
used=$(grep -aF -- "$token_val" "$ACCESS_LOG" | grep -m1 " 200 ")
fi
local has_auth_markers=0
if [ -n "$external_auth" ] || [ -n "$internal_auth" ] \
|| [ "$hasroot" = "1" ] || [ "$tfa" = "1" ] || [ -n "$used" ]; then
has_auth_markers=1
fi
if grep -q '^origin_as_string=.*method=badpass' "$session_file"; then
if [ "$has_auth_markers" -eq 1 ]; then
report_finding CRITICAL "$session_file" "$token_val" \
"Exploitation artifact - token_denied with injected cp_security_token (badpass origin, token used)"
else
# A pass= line on a badpass session is itself anomalous;
# defer to IOC 5 (ATTEMPT).
if grep -q '^pass=' "$session_file"; then
return
fi
report_finding INFO "$session_file" "$token_val" \
"Possible injected session (badpass origin, no usage observed)"
fi
elif grep -q '^origin_as_string=.*method=handle_form_login' "$session_file" || \
grep -q '^origin_as_string=.*method=create_user_session' "$session_file" || \
grep -q '^origin_as_string=.*method=handle_auth_transfer' "$session_file"; then
# Known-benign origins where token_denied + cp_security_token
# genuinely happens during normal use.
return
else
report_finding WARNING "$session_file" "$token_val" \
"Suspicious session with token_denied + cp_security_token (non-badpass origin)"
fi
}
# IOC 1: A session that still has its pre-auth marker file but already
# contains an auth-success timestamp (external or internal).
#
# write_session creates $SESSIONS_DIR/preauth/<session_name> when the
# session is written with needs_auth=1, and removes that marker once
# needs_auth is cleared on promotion (Cpanel/Session.pm:225-235). A
# legitimately authenticated session therefore never has both the
# preauth marker and an auth-success timestamp at the same time.
#
# Both successful_external_auth_with_timestamp and
# successful_internal_auth_with_timestamp are checked: the original
# poc.py payload injects the external variant; the watchtowr payload
# (poc/poc_watchtowr.py:35) injects the internal variant.
check_preauth_with_auth_attrs() {
local session_file="$1"
local session_name preauth_file
session_name=$(basename "$session_file")
preauth_file="$SESSIONS_DIR/preauth/$session_name"
[ -f "$preauth_file" ] || return
local marker
if grep -qE '^successful_external_auth_with_timestamp=' "$session_file"; then
marker="successful_external_auth_with_timestamp"
elif grep -qE '^successful_internal_auth_with_timestamp=' "$session_file"; then
marker="successful_internal_auth_with_timestamp"
else
return
fi
report_finding CRITICAL "$session_file" \
"$(get_field "$session_file" cp_security_token)" \
"Injected session - ${marker} present in pre-auth session"
}
# IOC 2: tfa_verified=1 outside of a legitimate origin method.
#
# tfa_verified=1 is set in only two places:
# - Cpanel/Security/Authn/TwoFactorAuth/Verify.pm:122, after a real
# TFA token validation succeeds.
# - Cpanel/Server.pm:2295, when a caller passes tfa_verified=1 to
# newsession().
# In both cases the legitimate origin method is one of handle_form_login,
# create_user_session, or handle_auth_transfer. tfa_verified=1 with any
# other origin (notably badpass) cannot occur in a benign flow.
check_tfa_with_bad_origin() {
local session_file="$1"
grep -qE '^tfa_verified=1$' "$session_file" || return
grep -q '^origin_as_string=.*method=handle_form_login' "$session_file" && return
grep -q '^origin_as_string=.*method=create_user_session' "$session_file" && return
grep -q '^origin_as_string=.*method=handle_auth_transfer' "$session_file" && return
report_finding WARNING "$session_file" \
"$(get_field "$session_file" cp_security_token)" \
"Session with tfa_verified=1 but suspicious origin"
}
# IOC 3: Session file contains a line that is not in `key=value` form.
#
# Three structural invariants together guarantee that every legitimate
# line matches ^[A-Za-z_][A-Za-z0-9_]*=:
#
# 1. write_session serializes via Cpanel::Config::FlushConfig::flushConfig
# with '=' as the separator (Cpanel/Session.pm:221), so the on-disk
# format is one key=value pair per line.
# 2. Keys come from a fixed whitelist (_SESSION_PARTS at
# Cpanel/Server.pm:2216-2247, applied at lines 2268-2270), so they
# always match the identifier shape above.
# 3. Cpanel::Session::filter_sessiondata strips \r\n from every value
# (Cpanel/Session.pm:315) and additionally strips \r\n=, from origin
# sub-values (line 312), so values can never re-introduce line
# breaks. The `pass` value is additionally encoded by saveSession
# (Cpanel/Session.pm:181-189) into either lowercase hex (with-secret
# via Cpanel::Session::Encoder->encode_data) or the literal prefix
# `no-ob:` followed by lowercase hex (no-secret via
# Cpanel::Session::Encoder->hex_encode_only), so it cannot
# reintroduce structural characters either.
#
# Any non-blank line that fails the regex is the footprint of an
# injection that bypassed these invariants - typically raw payload bytes
# that didn't form valid key=value pairs. Note: an injection whose
# smuggled lines DO match key=value (e.g. the watchtowr payload at
# poc/poc_watchtowr.py:35, which fabricates successful_internal_auth_
# with_timestamp/user/tfa_verified/hasroot lines) will not trip this
# check; it is caught by IOC-0 and IOC-4 instead.
check_malformed_session_line() {
local session_file="$1"
# Look for any non-blank line that doesn't start with key=...
grep -nE -v '^[A-Za-z_][A-Za-z0-9_]*=|^[[:space:]]*$' "$session_file" >/dev/null 2>&1 || return
report_finding CRITICAL "$session_file" \
"$(get_field "$session_file" cp_security_token)" \
"Malformed session line(s) detected (not key=value - newline injection footprint)"
}
# IOC 4: badpass origin combined with markers that no legitimate cpsrvd
# code path writes into a badpass session.
#
# The badpass call site (Cpanel/Server.pm:1244-1252) is:
#
# $randsession = $self->newsession(
# 'needs_auth' => 1,
# %security_token_options, # adds cp_security_token
# 'origin' => { 'method' => 'badpass' },
# );
#
# %security_token_options is why badpass sessions legitimately carry
# cp_security_token, but no auth-related options are ever supplied.
# newsession() filters %OPTS through the _SESSION_PARTS whitelist
# (Cpanel/Server.pm:2216-2247, applied at lines 2268-2270), so any key
# not in that whitelist cannot land in the session via newsession at
# all. Per marker:
#
# successful_external_auth_with_timestamp - whitelisted, but the
# badpass caller doesn't pass it
# successful_internal_auth_with_timestamp - same
# tfa_verified=1 - newsession unconditionally writes 0 unless the
# caller passed a truthy value (Cpanel/Server.pm:2295), and the
# badpass caller doesn't
# hasroot=1 - NOT in _SESSION_PARTS, so newsession cannot write it
# for ANY session. A repo-wide grep finds no caller of
# Cpanel::Session::Modify->set('hasroot', ...) either: hasroot is
# never written to a session by legitimate code. Its presence in
# any session file is conclusive evidence of newline injection
# (the watchtowr payload at poc/poc_watchtowr.py:35 smuggles
# hasroot=1 via \r\n in a user-controlled field).
check_badpass_with_auth_markers() {
local session_file="$1"
grep -q '^origin_as_string=.*method=badpass' "$session_file" || return
local markers=()
grep -q '^successful_external_auth_with_timestamp=' "$session_file" \
&& markers+=("successful_external_auth_with_timestamp")
grep -q '^successful_internal_auth_with_timestamp=' "$session_file" \
&& markers+=("successful_internal_auth_with_timestamp")
grep -qE '^hasroot=1$' "$session_file" && markers+=("hasroot=1")
grep -qE '^tfa_verified=1$' "$session_file" && markers+=("tfa_verified=1")
[ "${#markers[@]}" -gt 0 ] || return
local joined
joined=$(IFS=,; echo "${markers[*]}")
report_finding CRITICAL "$session_file" \
"$(get_field "$session_file" cp_security_token)" \
"badpass origin combined with authenticated markers ($joined) - impossible in benign flow"
}
# IOC 5: Failed exploit attempt - a badpass session that carries a
# pass= line, a token_denied counter, and no auth markers.
#
# A legitimate badpass session is created at Cpanel/Server.pm:1244-1252:
#
# $randsession = $self->newsession(
# 'needs_auth' => 1,
# %security_token_options,
# 'origin' => { 'method' => 'badpass' },
# );
#
# %security_token_options carries only cp_security_token,
# requested_token_at_next_login, and previous_session_user
# (Cpanel/Server.pm:1205-1226) - never `pass`. saveSession only
# writes a pass= line when length($session_ref->{pass}) is non-zero
# (Cpanel/Session.pm:181), so legitimate badpass sessions have no
# pass= line at all.
#
# An exploit that tampers with a user-controlled field on a
# badpass-bound request leaves a pass= line behind (saveSession
# encodes it as `<hex>` or `no-ob:<hex>` per Cpanel/Session.pm:181-189,
# but the format is irrelevant - its presence is the indicator). Combined
# with token_denied (someone was poking at cp_security_token) and the
# absence of auth markers (the injection didn't promote - otherwise
# IOC-0 or IOC-4 fires CRITICAL), this is the signature of a failed
# exploit attempt.
check_failed_exploit_attempt() {
local session_file="$1"
grep -q '^origin_as_string=.*method=badpass' "$session_file" || return
grep -q '^token_denied=' "$session_file" || return
# If auth markers are present, IOC-4 (CRITICAL) handles it.
grep -q '^successful_internal_auth_with_timestamp=' "$session_file" && return
grep -q '^successful_external_auth_with_timestamp=' "$session_file" && return
# Legitimate badpass sessions never carry pass=.
grep -q '^pass=' "$session_file" || return
report_finding ATTEMPT "$session_file" "$(get_field "$session_file" cp_security_token)" \
"Failed exploit attempt (badpass origin, token_denied, no auth markers, anomalous pass= line)"
}
# Inspect a *.lock file (Cpanel::SafeFile dotlock) and confirm it looks
# like a real lock before silently skipping it.
#
# Cpanel::Session uses Cpanel::SafeFile to write the session file to
# disk (serialization itself is handled in the session code). SafeFile
# creates a sibling dotlock at <session>.lock for the duration of every
# write and, on crash/abort, may leave it behind permanently. The lock contents
# are written by Cpanel::SafeFileLock::write_lock_contents as "$$\n$0\n"
# - first line is the PID, second line is the program name. These are
# not key=value pairs, so without a guard they trip
# check_malformed_session_line as a CRITICAL false positive.
#
# The CVE-2026-41940 exploit vector is the session file content, not the
# lock file, so a lock file that doesn't look right is not by itself an
# exploitation indicator. Emit a stderr notice for operator awareness and
# leave the SCAN SUMMARY counters alone.
check_lock_file() {
local lock_file="$1"
local first_line
first_line=$(grep -m1 -v '^[[:space:]]*$' "$lock_file" 2>/dev/null)
if [[ "$first_line" =~ ^[0-9]+$ ]]; then
return
fi
echo "[NOTICE] Skipping unexpected .lock contents: $lock_file" >&2
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
scan_sessions() {
local session_file
while IFS= read -r -d '' session_file; do
# SafeFile dotlocks come in two forms: <session>.lock (the
# final lock) and <session>.lock-<hex-and-hyphens> (the temp
# name SafeFile writes before atomic-renaming into place; it
# can also be left behind on crash). Skip both.
#
# Vim creates a .swp swap file alongside any file it opens,
# so an operator inspecting a session in vim leaves one
# behind. The format is binary and not a session.
case "$session_file" in
*.lock | *.lock-*)
check_lock_file "$session_file"
continue
;;
*.swp)
continue
;;
esac
check_token_denied_with_injected_token "$session_file"
check_preauth_with_auth_attrs "$session_file"
check_tfa_with_bad_origin "$session_file"
check_malformed_session_line "$session_file"
check_badpass_with_auth_markers "$session_file"
check_failed_exploit_attempt "$session_file"
done < <(find "$SESSIONS_DIR/raw" -type f -print0 2>/dev/null)
}
print_summary() {
local total=$((COUNT_CRITICAL + COUNT_WARNING + COUNT_INFO + COUNT_ATTEMPT))
echo
echo "================================================================="
echo " SCAN SUMMARY"
echo "================================================================="
echo " CRITICAL findings: $COUNT_CRITICAL"
echo " WARNING findings: $COUNT_WARNING"
echo " ATTEMPT findings: $COUNT_ATTEMPT"
echo " INFO findings: $COUNT_INFO"
echo " Total : $total"
echo "-----------------------------------------------------------------"
if [ "$total" -eq 0 ]; then
echo "[+] No indicators of compromise found."
return
fi
# --purge has destructive blast radius (live session files for every
# logged-in user). Require either --yes for non-interactive use, or
# an explicit "yes" at an attached TTY.
if [ "$PURGE" -eq 1 ] && [ "$ASSUME_YES" -ne 1 ]; then
if [ ! -t 0 ]; then
echo "[ERROR] --purge requires --yes when stdin is not a TTY (cron, pipes, etc)" >&2
echo " Re-run with --yes to confirm deletion." >&2
exit 64
fi
echo
echo "About to delete ${#FINDING_SESSIONS[@]} session file(s) plus matching preauth markers."
local confirm=""
read -r -p "Type 'yes' to confirm: " confirm
if [ "$confirm" != "yes" ]; then
echo "[+] Aborted; no files deleted."
PURGE=0
fi
fi
# For each unique session, print only the highest-severity finding, then dump/purge as needed.
local i session token severity message found=0
for i in "${!FINDING_SESSIONS[@]}"; do
session="${FINDING_SESSIONS[$i]}"
token="${FINDING_TOKENS[$i]}"
severity="${FINDING_SEVERITIES[$i]}"
found=0
# Find the first matching finding for this session and severity.
# Use `read` with three names so the last variable (entry_msg)
# absorbs any remaining `|` characters - the previous `${var##*|}`
# form took only the suffix after the LAST `|`, which would
# silently truncate any future message that contained one.
for entry in "${FINDINGS[@]}"; do
local entry_sev entry_file entry_msg
IFS='|' read -r entry_sev entry_file entry_msg <<< "$entry"
if [ "$entry_file" = "$session" ] && [ "$entry_sev" = "$severity" ]; then
message="$entry_msg"
found=1
break
fi
done
echo
echo "================================================================="
echo " SESSION: $session"
echo "================================================================="
echo " Findings:"
if [ "$found" -eq 1 ]; then
printf " [%-8s] %s\n" "$severity" "$message"
else
printf " [%-8s] %s\n" "$severity" "(no message found)"
fi
echo
if [ "$VERBOSE" -eq 1 ]; then
dump_session "$session" "$token"
fi
if [ "$PURGE" -eq 1 ]; then
echo " [ACTION] Deleting session file: $session"
rm -f -- "$session"
local preauth_marker="$SESSIONS_DIR/preauth/$(basename "$session")"
if [ -e "$preauth_marker" ]; then
echo " [ACTION] Deleting preauth marker: $preauth_marker"
rm -f -- "$preauth_marker"
fi
fi
done
if [ "$COUNT_CRITICAL" -gt 0 ] || [ "$COUNT_WARNING" -gt 0 ]; then
echo
echo "[!] INDICATORS OF COMPROMISE DETECTED - IMMEDIATE ACTION REQUIRED"
echo " 1. Purge all affected sessions"
echo " 2. Force password reset for root and all WHM users"
echo " 3. Audit /var/log/wtmp and WHM access logs for unauthorized access"
echo " 4. Check for persistence mechanisms (cron, SSH keys, backdoors)"
fi
}
if [ ! -d "$SESSIONS_DIR/raw" ]; then
echo "[ERROR] Sessions directory not found: $SESSIONS_DIR/raw" >&2
echo " Pass --sessions-dir DIR to point at a different location" >&2
echo " (the default is /var/cpanel/sessions)." >&2
exit 64
fi
echo "[*] Scanning session files for injection indicators..."
scan_sessions
print_summary
# Exit codes (for cron / monitoring):
# 2 - at least one CRITICAL or WARNING finding (compromise indicators)
# 1 - only ATTEMPT or INFO findings (probing, no confirmed compromise)
# 0 - clean scan
if [ "$COUNT_CRITICAL" -gt 0 ] || [ "$COUNT_WARNING" -gt 0 ]; then
exit 2
elif [ "$COUNT_ATTEMPT" -gt 0 ] || [ "$COUNT_INFO" -gt 0 ]; then
exit 1
fi
exit 0Run this as the following:
# /bin/bash ./ioc_checksessions_files.sh
This will generate a summary similar to the following example:
=================================================================
SCAN SUMMARY
=================================================================
CRITICAL findings: 1
WARNING findings: 0
ATTEMPT findings: 1
INFO findings: 0
Total : 2
-----------------------------------------------------------------
=================================================================
SESSION: /var/cpanel/sessions/raw/:cusK9ghEd6MPo4eW
=================================================================
Findings:
[ATTEMPT ] Failed exploit attempt (badpass origin, token_denied, no auth markers, anomalous pass= line)
=================================================================
SESSION: /var/cpanel/sessions/raw/:TMnjH0tBK6jP2V3I
=================================================================
Findings:
[CRITICAL] Exploitation artifact - token_denied with injected cp_security_token (badpass origin, token used)
[!] INDICATORS OF COMPROMISE DETECTED - IMMEDIATE ACTION REQUIRED
1. Purge all affected sessions
2. Force password reset for root and all WHM users
3. Audit /var/log/wtmp and WHM access logs for unauthorized access
4. Check for persistence mechanisms (cron, SSH keys, backdoors)If the server is confirmed to be root-compromised, you will need to migrate the cPanel accounts to a known clean server:
How to move all cPanel accounts from one server to another
Or alternatively, reinstall the operating system on the server and restore the accounts from backups.