CVE-2026-41940 – WHM & cPanel Authentication Bypass, Checking for Indicators of Compromise

  • Checking using the official cPanel script
  • Checking the access logs
  • How to check the last time WHM was upgraded

Checking using the official cPanel script

The instructions for using the official script are on the cPanel article.

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

Copy and paste the script into a shell file. Run the script.

Here is a copy of the script for convenience.

#!/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 0

There is a scan summary section that shows the findings.

Example output:

=================================================================
                       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)

Some of the results may be false positives, which means you may have to manually check and see if results are valid or not.

cPanel overhauled the detection script and made it more robust.

ℹ️ It appears that there can be a limit to the total number of sessions available on a system. You may want to double check how old the session files are (ls -lt /var/cpanel/sessions/raw) to ensure you catch all login attempts. It is theoretically possible that the system was compromised, but the session that did it is no longer available, meaning it would not show up in the script provided by cPanel. You should still be able to check the access_logs.

Checking the access logs

This may not be the most fool proof way to check for a compromise, but it may help identify if an IP address has been authenticated and accessed restricted endpoints.

Replace valid-ip-address1 and valid-ip-address2 with your IP addresses.

grep "listacct\|terminal\|json-api" /usr/local/cpanel/logs/access_log |  grep -v "403\|401" | grep -v "valid-ip-address1\|valid-ip-address2"

Basically, we are searching for any valid requests that contain listacct, terminal, or json-api.

How to check the last time WHM was upgraded

If your server is set to automatically update WHM, it may be beneficial to know when it last updated. You can check the update logs in /var/cpanel/updatelogs to check the time and version.

As a shortcut, you can use the following command to group all of the version changes from all of the update log files.

head /var/cpanel/updatelogs/update.*.log -n 20 | grep version | sort

Example output:

[2026-04-29 23:12:04 -0000]   Running version '11.134.0.19' of updatenow.
[2026-04-29 23:12:04 -0000]   Become an updatenow.static for version: 11.134.0.20
[2026-04-29 23:12:04 -0000]   Detected version '11.134.0.19' from version file.
[2026-04-29 23:12:04 -0000]   Switching to version 11.134.0.20 of updatenow to determine if we can reach that version without failure.
[2026-04-29 23:12:04 -0000]   Target version set to '11.134.0.20'
[2026-04-30 23:12:04 -0000]   Detected version '11.134.0.20' from version file.
[2026-04-30 23:12:04 -0000]   Running version '11.134.0.20' of updatenow.
[2026-04-30 23:12:04 -0000]   Target version set to '11.134.0.20'

Links:

https://labs.watchtowr.com/the-internet-is-falling-down-falling-down-falling-down-cpanel-whm-authentication-bypass-cve-2026-41940

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

https://hadrian.io/blog/cve-2026-41940-a-critical-authentication-bypass-in-cpanel

Using Auditd to monitor changes to Linux

Install and enable auditd with

sudo dnf install auditd
sudo systemctl enable auditd
sudo systemctl start auditd

Add a file or directory to monitor with

auditctl -w /etc/passwd -k password

-w is watch path
-k is a filter key we can use later to search through logs

Now we can search with ausearch

ausearch -k password

Using Preconfigured Rules

There are already some preconfigured rules in /usr/share/audit/sample-rules/

We can copy those to /etc/auditd/rules.d/ and use them.

cd /usr/share/audit/sample-rules/
cp 10-base-config.rules 30-stig.rules 31-privileged.rules 99-finalize.rules /etc/audit/rules.d/
augenrules --load

Note on the 31-privileged.rules file. You’ll need to run the commands in the file which will create a new file. Then we can copy that to “/etc/auditd/rules.d/”

find /bin -type f -perm -04000 2>/dev/null | awk '{ printf "-a always,exit -F path=%s -F perm=x -F auid>=1000 -F auid!=unset -F key=privileged\n", $1 }' > priv.rules
#find /sbin -type f -perm -04000 2>/dev/null | awk '{ printf "-a always,exit -F path=%s -F perm=x -F auid>=1000 -F auid!=unset -F key=privileged\n", $1 }' >> priv.rules
#find /usr/bin -type f -perm -04000 2>/dev/null | awk '{ printf "-a always,exit -F path=%s -F perm=x -F auid>=1000 -F auid!=unset -F key=privileged\n", $1 }' >> priv.rules
#find /usr/sbin -type f -perm -04000 2>/dev/null | awk '{ printf "-a always,exit -F path=%s -F perm=x -F auid>=1000 -F auid!=unset -F key=privileged\n", $1 }' >> priv.rules
#filecap /bin 2>/dev/null | sed '1d' | awk '{ printf "-a always,exit -F path=%s -F perm=x -F auid>=1000 -F auid!=unset -F key=privileged\n", $2 }' >> priv.rules
#filecap /sbin 2>/dev/null | sed '1d' | awk '{ printf "-a always,exit -F path=%s -F perm=x -F auid>=1000 -F auid!=unset -F key=privileged\n", $2 }' >> priv.rules
#filecap /usr/bin 2>/dev/null | sed '1d' | awk '{ printf "-a always,exit -F path=%s -F perm=x -F auid>=1000 -F auid!=unset -F key=privileged\n", $2 }' >> priv.rules
#filecap /usr/sbin 2>/dev/null | sed '1d' | awk '{ printf "-a always,exit -F path=%s -F perm=x -F auid>=1000 -F auid!=unset -F key=privileged\n", $2 }' >> priv.rules

And Copy priv.rules to /etc/audit/rules.d/31-privileged.rules. Overwrite the file there if needed.

cp ./priv.rules /etc/audit/rules.d/31-privileged.rules

Load the rules.

augenrules --load

https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/security_hardening/auditing-the-system_security-hardening

Exim Troubleshooting Email Logs

The following is a great article explaining the main Exim log.

https://forums.cpanel.net/resources/reading-and-understanding-the-exim-main_log.383/

The following are some helpful tips from the post.

Search log by email address

You can search for specific addresses with the exigrep. Replace email@address with the email address of interest.

exigrep email@address /var/log/exim_mainlog

Message Direction

Looking at entries in the main log, some of the messages will have an indicator from the following table that tell us the status of the message and/or where it came from or went.

<=Indicates the arrival of a message to Exim for handling
=>Shows a normal message delivery
->Additional address for the same delivery, i.e. an Email forwarder.
>>cutthrough is a router precondition
This option requests delivery be attempted while the item is being received. It is usable in the RCPT ACL and valid only for single-recipient mails forwarded from one SMTP connection to another. If a recipient-verify callout connection is requested in the same ACL it is held open and used for the data, otherwise one is made after the ACL completes.
*>delivery suppressed by -N
**delivery failed; address bounced
==delivery deferred; temporary problem
<>For “<>” from the exim manual; Additionally, you will often find A bounce message is shown with the sender address “<>”, and if it is locally generated, this is followed by an item of the form
R=<message id>

Some other posts that may be helpful while troubleshooting mail deliveries.

View messages by ID

Bulk Delete Messages in Queue

LibreNMS – Could not ping 192.168.1.20 (192.168.1.20)

LibreNMS uses fping to check if devices are up or not. So if something is broken with fping, say a SELinux permission, you can receive the “Could not ping” error, while trying to add a new device.

LibreNMS unable to ping device

First we need to verify that fping is working. SSH into the LibreNMS server and try pinging an address.

fping 192.168.1.20

There was an issue with fping working if ipv6 was disabled. If fping is not working at all, check out this thread.

If you get an alive or unreachable message, then we know fping is working and can move on to the next stage of troubleshooting.

If you are using SELinux, then there is a good chance the problems has to do with that. You can try rerunning all the SELinux commands from the install guide. Note that it has a specific portion for fping.

https://docs.librenms.org/Installation/Install-LibreNMS/#selinux

If it is still not working, we can take a look at the issue with the audit2why command and feed in the audit log.

audit2why < /var/log/audit/audit.log

Here is some example output.

[root@librenms ~]#
type=AVC msg=audit(1676192040.183:404404): avc:  denied  { bind } for  pid=128555 comm="fping" lport=1 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:system_r:httpd_t:s0 tclass=rawip_socket permissive=0

        Was caused by:
                Missing type enforcement (TE) allow rule.

[root@librenms ~]#

Another, perhaps more effective way to check the log is to follow it using the “tail -f” command.

tail -f /var/log/audit/audit.log | grep denied

And then in the web browser, try adding a new device. If SELinux is blocking it, it should throw a denied entry.

Example output

type=AVC msg=audit(1676192040.183:404404): avc:  denied  { bind } for  pid=128555 comm="fping" lport=1 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:system_r:httpd_t:s0 tclass=rawip_socket permissive=0

Now we have verified that the issue is SELinux permissions related. We can create a module to allow it.

audit2allow -a -M fping_http < /var/log/audit/audit.log

And apply the module with

semodule -i fping_http.pp

You may need to do this a couple times. Check the audit log again to see if anything new shows up. Notice the slight difference in this error compared to the above error.

# tail -f /var/log/audit/audit.log | grep denied
type=AVC msg=audit(1676192613.121:404409): avc: denied { node_bind } for pid=153257 comm="fping" scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:node_t:s0 tclass=rawip_socket permissive=0

We’ll create a new module for this and apply it

audit2allow -a -M node_http < /var/log/audit/audit.log
semodule -i node_http.pp

Not sure that is the best way to fix the problem. But it appears that SELinux is keeping Apache “httpd” from running fping which is why we need to create and load the modules.

Enable Logging for firewalld

Enabling logging on firewall rules can be beneficial for tracking why a certain rule is not behaving as you intended.

Enabling logging is relatively straight forward.

  • Enable Firewall Logging
  • Check Logs
  • Disable Firewall Logging (Optional)

Enable Firewall Logging

Quickest way to enable logging is to run

sudo firewall-cmd --set-log-denied=all

This changes the options in the /etc/firewalld/firewalld.conf config file. Options include all, unicast, broadcast, multicast, and off

Enable Log option for firewalld

The command also reloads the firewall so manually restarting the firewall is necessary.

Checking Logs

You can use dmesg to view the failed attempts or you can follow the messages log and filter to just show the rejects

sudo tail -f /var/log/messages | grep -i REJECT

You can now try to access the server or run a test to trigger a log event. In my case I tried initiating a SSH connection.

Oct  1 16:32:10 localhost kernel: FINAL_REJECT: IN=eno1 OUT= MAC=f8:ab:98:12:fe:11:a1:ec:a6:00:67:3e:97:00 SRC=192.168.1.1 DST=192.168.88.2 LEN=60 TOS=0x08 PREC=0x40 TTL=59 ID=43080 DF PROTO=TCP SPT=38192 DPT=22 WINDOW=52240 RES=0x00 SYN URGP=0

Interesting bits are bolded. Our destination port it 22 “ssh” and our source address is 192.168.1.1. If I want this IP to access the server, I’ll need to add the 192.168.1.1 IP range in the allowed IP ranges.

Disable Logging (Optional)

After you have finished troubleshooting your problem, you may want to turn the logging feature off so you don’t fill up the logs with failed entries.

You can turn it off with

sudo firewall-cmd --set-log-denied=off

We can verify that logging is off by running

sudo firewall-cmd --get-log-denied 

If the firewall logging option is off it will return “off”

The following site has some more information and alternative ways

https://www.cyberciti.biz/faq/enable-firewalld-logging-for-denied-packets-on-linux/

Troubleshooting Backup Errors on WHM / cPanel

Below are some helpful locations of files, logs etc for troubleshooting backup errors on WHM

WHM backup logs
Change date to the correct date. Should be one log per day or I guess every time a backup runs.

/usr/local/cpanel/logs/cpbackup/{date}.log

View WHM backup config

cat /var/cpanel/backups/config

View WHM remote destination config(s)
Replace *** with the appropriate name.

cat /var/cpanel/backups/***.backup_destination

Rysnc.pm file

May need to modify this file to increase time out limits if you are having issues with time out errors for backups.

/usr/local/cpanel/Cpanel/Transport/Files/Rsync.pm

This link has some more info https://forums.cpanel.net/threads/what-commands-does-cpanel-run-over-ssh-to-do-rsync-backups.671777/

Check Access Logs for website

Typically on a cPanel host your access logs are kept in

/usr/local/apache/domlogs/username/incredigeek.com

Where username is your cPanel username and incredigeek.com is your website.

To view the logs you can use tail -f to follow the log.

tail -f /usr/local/apache/domlogs/username/incredigeek.com

You can also use grep to search the logs.

grep "text to search" /usr/local/apache/domlogs/username/incredigeek.com

cPanel Logs

How to view the logs

There are multiple ways to view log files, here are some common ways.

tail the log, shows the 10 most recent log entries.

tail /var/log/messages

tail the log and keep monitoring it for new entries.

tail -f /var/log/messages

Find specific info in log file

cat /var/log/messages | grep texttosearch

cPanel Log Paths

Main log

/var/log/messages

Access logs

/usr/local/cpanel/logs/access_log

Access logs for a specific domain

/home/user/access-logs/domainname.com

Account Transfers/miscellaneous logs

/var/cpanel/logs

Auditing Log (Account creation and deletions)

/var/cpanel/accounting.log

Backup Logs

/usr/local/cpanel/logs/cpbackup

CPHULKD Log

/usr/local/cpanel/logs/cphulkd.log

DNSAdmin, DNS Clustering

/usr/local/cpanel/logs/dnsadmin_log

Task Queue Processing Daemon

/usr/local/cpanel/logs/queueprocd.log

DBMapping

/usr/local/cpanel/logs/setupdbmap_log

Easy Apache Build logs

/usr/local/cpanel/logs/easy/apache/

Error logs

/usr/local/cpanel/logs/error_log
/var/log/cpanel

License log

/usr/local/cpanel/logs/license_log

local database modifications

/usr/local/cpanel/logs/build_locale_database_log

Login errors CPSRVD

/usr/local/cpanel/logs/login_log

Bandwidth History

/var/cpanel/bandwidth/{USERNAME}

Service Status Logs

/var/log/chkservd.log

Tailwatch log

/usr/local/cpanel/logs/tailwatch_log

Update Analysis Reporting

/usr/local/cpanel/logs/updated_analysis/{TIMESTAMP}.log

Update log UPCP

/var/cpanel/updatelogs/updated.{TIMESTAMP}.log

cPanel Email Logs

 

Horde log

/var/cpanel/horde/log/

RoundCube

/var/cpanel/roundcube/log/

Squirrel Mail

/var/cpanel/squirrelmail/

Panic log

/usr/local/cpanel/logs/panic_log

Delivery and receipt log

/var/log/exim_mainlog

Incoming mail queue

/var/spool/exim/input/

Log of messages rejected based on ACLS or other policies

/var/log/exim_rejectlog

Unexpected/Fatal error log

/var/log/exim_paniclog

IMAP, POP login attempts, transactions, fatal errors and spam scoring

/var/log/maillog

Mailman

/usr/local/cpanel/3rdparty/mailmain/logs

MySQL

MySQL error log

/var/lib/mysql/{SERVER_NAME}.err

MySQL slow query log (if enabled in my.cnf)

/var/log/slowqueries