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)"
}

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

scan_sessions() {
    local session_file
    while IFS= read -r -d '' session_file; do
        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

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 should be accurate now.

ℹ️ 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

Email Account Sending out spam while Suspended – cPanel

When you suspend an account in cPanel, the users password, in “/home/ACCOUNT/etc/DOMAIN.com/shadow”, gets two exclamation marks prepended to the password hash. This means that the hash of a password from a user trying to login will not match what is in the shadow file effectively blocking the login.

Screenshot showing cPanel email account suspended.

However, there is an option that allows an admin to sign into all email accounts for a domain. The option is in WHM and called “Mail authentication via domain owner password”. It is under “Tweak Settings -> Mail”.

Screenshot showing Mail authentication via domain owner password setting.

If this option is enabled, then the admin, or an attacker that has compromised the admin password, can continue to login and send mail as a user.

A lot of email spam attacks appear to be automated. So if the admin password was weak and some hacker is using it to send spam, there is a good chance they don’t know it is the admin password. They just want to send out emails. Update the admin password, check and make sure the system is secure and monitor.

Differences between RTO, RPO, MTBF, and MTFF

Here is a quick overview of the differences between, RTO, RPO, MTBF, and MTFF.

NameMeaning
RTO (Recovery Time Objective)Time it takes to recover from a disruption, system failure, data loss etc.
RPO (Recovery Point Objective)How much data can you afford to loose? If RPO is 24 hours, then backups need to be performed daily.
MTBF (Mean Time Between Failures)Time between failures. Use for repairable systems
MTTF (Mean Time to Failure)Time before system fails. Use for nor repairable systems.

http://techtarget.com/whatis/definition/recovery-point-objective-RPO

http://rubrik.com/insights/rto-rpo-whats-the-difference

http://en.m.wikipedia.org/wiki/Mean_time_between_failures

Securely Delete Files on Linux

We can use srm to securely delete files on Linux.

Install srm with

sudo apt install secure-delete

We can now securely delete files by running

srm filetodelete.txt

# srm --help
srm v3.1 (c) 1997-2003 by van Hauser / THC <vh@thc.org>

Syntax: srm [-dflrvz] file1 file2 etc.

Options:
-d ignore the two dot special files "." and "..".
-f fast (and insecure mode): no /dev/urandom, no synchronize mode.
-l lessens the security (use twice for total insecure mode).
-r recursive mode, deletes all subdirectories.
-v is verbose mode.
-z last wipe writes zeros instead of random data.

srm does a secure overwrite/rename/delete of the target file(s).
Default is secure mode (38 writes).
You can find updates at http://www.thc.org

Other links for securely erasing drives.
https://www.tomshardware.com/how-to/secure-erase-ssd-or-hard-drive

A Quick Overview of SAML

SAML stands for Security Assertion Markup Language. It allows for Single Sign On or SSO to a service.

There are three entities or roles involved when using SAML to sign into a service.

  1. Principal or Subject: a.k.a. you, or the person or service logging in.
  2. Service Provider (SP): This is the service you are accessing. It could be email, a website, etc.
  3. Identity Provider (IdP): This is the entity response for authenticating the Principal.

As an example, let’s say you want to log into a new website utilizing your email SSO credentials. You click the SSO login button, you are redirected to the IdP to login. Once authenticated, your device will receive a token which is then passed back to the Service Provider and allows you access to the new website.

This is a very simplified version of what happens when you login using SAML. It may be helpful to know that the Service Provider and the Identify Provider will have needed to be configured to work together before the user attempts to log in.

https://auth0.com/blog/how-saml-authentication-works

https://infosec.mozilla.org/guidelines/iam/saml.html

Hacking Hashed SSH known_hosts file

On Ubuntu, by default, the hosts in .ssh/known_hosts are hashed. This can theoretically help with security. If an attacker compromises a host, they will not be able to tell the IP addresses of other hosts in the known_hosts file.

https://security.stackexchange.com/questions/56268/ssh-benefits-of-using-hashed-known-hosts

Anatomy of the hashed known_hosts

Here is an example of a hashed entry in the known_hosts file.

|1|ma8KL2XrNYkNnknf68N4IuZ+c+I=|PmR+n2i0/epUGZZh2S+LB6OaowQ= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEjqG8/el8c669FxcvEw5mMfDRTDxsjgLiz44dCTtchs

There are three main parts.

The first part ma8KL2XrNYkNnknf68N4IuZ+c+I= is the salt to use.

PmR+n2i0/epUGZZh2S+LB6OaowQ= This is our hashed IP address/hostname

ssh-ed25519 is the key type

AAAAC3NzaC1lZDI1NTE5AAAAIEjqG8/el8c669FxcvEw5mMfDRTDxsjgLiz44dCTtchs Is the public SSH key of the remote host.

SSH-KEYSCAN

We can use ssh-keyscan to check the keys of hosts. The -t ssh-ed25519 option only shows ed25519 keys. Remove or change to show all key types e.g. RSA/DSA

For example:

┌──(kali㉿localhost)-[~]
└─$ ssh-keyscan -t ssh-ed25519 localhost
# localhost:22 SSH-2.0-OpenSSH_9.6p1 Debian-3
localhost ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEjqG8/el8c669FxcvEw5mMfDRTDxsjgLiz44dCTtchs

We can compare the SSH public key with the one in our known_hosts file to verify we have the correct host.

As a side note, we can also use the -H option to show us a hashed version. The salt changes each time it is run, so it is not useful for comparing the hashed IP address.

Example:

┌──(kali㉿localhost)-[~]
└─$ ssh-keyscan -H -t ssh-ed25519 localhost
# localhost:22 SSH-2.0-OpenSSH_9.6p1 Debian-3
|1|j2j9iv/GkPfnG9Yv4WzJsy/L1pc=|wethKgsGBH0Mi+rFW3zSNSWiGso= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEjqG8/el8c669FxcvEw5mMfDRTDxsjgLiz44dCTtchs

Hacking known_hosts hashes

There are a few different techniques that can be used to identify known hosts IP addresses even if they are hashed.

https://stackoverflow.com/questions/427979/how-do-you-extract-ip-addresses-from-files-using-a-regex-in-a-linux-shell

Search through bash history

history | egrep '([0-9]{1,3}\.){3}[0-9]{1,3}'

Example output

┌──(kali㉿localhost)-[~]
└─$ history | egrep '([0-9]{1,3}\.){3}[0-9]{1,3}' | head -n2
1 ssh kali@127.0.0.1

Check if SSH Public Key is on Shodan

Since the SSH public key is um, well, public, we can search for it on Shodan to see if it’s a known public server. https://www.shodan.io

Copy the public ssh key from the known_hosts file. It is the last portion of the line i.e.
AAAAC3NzaC1lZDI1NTE5AAAAIEjqG8/el8c669FxcvEw5mMfDRTDxsjgLiz44dCTtchs

|1|ma8KL2XrNYkNnknf68N4IuZ+c+I=|PmR+n2i0/epUGZZh2S+LB6OaowQ= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEjqG8/el8c669FxcvEw5mMfDRTDxsjgLiz44dCTtchs

Brute force

Since the address space for IPv4 is fairly small, and the private IP address space even smaller, brute forcing all the addresses is perfectly feasible.

Here is a quick example on how you would hash an IP address. Commands are taken from the above Stack Exchange link.

Take the salt and put it into a variable

key=`echo j2j9iv/GkPfnG9Yv4WzJsy/L1pc= | base64 -d | xxd -p`

Next we can run the following command to hash the result. The IP (127.0.0.1) is where we would want to enumerate the IP address.

echo -n "127.0.0.1" | openssl sha1 -mac HMAC -macopt hexkey:$key|awk '{print $2}' | xxd -r -p | base64

The output is PmR+n2i0/epUGZZh2S+LB6OaowQ= which is the correct hash.

Automating should be fairly simple.

A note on SSH ports. If the host is using a non standard ssh port, you will need to update the above command with the port, but the address needs to be wrapped in square brackets []

echo -n "[127.0.0.1]:2222" | openssl sha1 -mac HMAC -macopt hexkey:$key|awk '{print $2}' | xxd -r -p | base64

Use ssh-keyscan

A final way we can discover known-hosts, is by using ssh-keyscan. The man page says the following

ssh-keyscan is a utility for gathering the public SSH host keys of a number of hosts. It was designed to aid in building and verifying ssh_known_hosts files

ssh-keyscan uses non-blocking socket I/O to contact as many hosts as possible in parallel, so it is very efficient. The keys from a domain of 1,000 hosts can be collected in tens of seconds, even when some of those hosts are down or do not run sshd(8). For scanning, one does not need login access to the machines that are being scanned, nor does the scanning process involve any encryption.

Hosts to be scanned may be specified by hostname, address or by CIDR network range (e.g. 192.168.16/28). If a network range is specified, then all addresses in that range will be scanned.

This makes it super convenient to do a network scan using ssh-keyscan and then compare the public ssh keys with those in the known_hosts file.

Example:

ssh-keyscan 192.168.0.0/16

To scan all private IP ranges (RFC1912), we just run the scan with all three IP ranges

ssh-keyscan 192.168.0.0/16
ssh-keyscan 172.12.0.0/12
ssh-keyscan 10.0.0.0/8

Check for backdoored version of xz (CVE-2024-3094) (Ansible/Bash)

Info on the xc backdoor

https://www.openwall.com/lists/oss-security/2024/03/29/4

https://tukaani.org/xz-backdoor/

https://www.tenable.com/blog/frequently-asked-questions-cve-2024-3094-supply-chain-backdoor-in-xz-utils

Kostas on Twitter posted a helpful one-liner to check the xz version without running the actual command.

https://twitter.com/kostastsale/status/1773890846250926445

Versions 5.6.0 and 5.6.1 are backdoored.

Bash one liner

The following Bash commands were taken and modified from the above Twitter link

Here is a one liner that will check the version of xz binaries and return if they are safe or vulnerable. You’ll need to run this in a Bash shell. May have issues in sh.

for xz_p in $(type -a xz | awk '{print $NF}' ); do  if ( strings "$xz_p" | grep "xz (XZ Utils)" | grep '5.6.0\|5.6.1' ); then echo $xz_p Vulnerable; else echo $xz_p Safe ; fi ; done 

Ansible Playbooks

Here are two different Ansible Playbooks to check if the xz package(s) are backdoored.

This one uses the above Bash commands to check the xz binaries.

---
- name: Check if XZ tools are compromised
# https://twitter.com/kostastsale/status/1773890846250926445
  hosts: all

  tasks: 
    - name: Run Bash command
      shell : 
        for xz_p in $(type -a xz | awk '{print $NF}' ); do 
          if ( strings "$xz_p" | grep "xz (XZ Utils)" | grep '5.6.0\|5.6.1' ); 
            then echo $xz_p Vulnerable!; 
          else 
            echo $xz_p Safe ; 
          fi ; 
        done
      args: 
        executable: /bin/bash
      register: result

    - name: Show output
      ansible.builtin.debug:
        msg: "{{ result.stdout_lines }}"

The following playbook uses the package manager to check the xz version. On RHEL/Fedora this is the xc package. On Debian/Ubuntu, it is part of the liblzma5 package.

---
- name: Check if XZ tools are compromised
  hosts: all

  tasks:
    - name: Collect package info
      ansible.builtin.package_facts:
        manager: auto

    - name: Check if liblzma5 is vulnerable (Ubuntu/Debian)
      ansible.builtin.debug:
        msg: "Installed version of liblzma5/xz: {{ ansible_facts.packages['liblzma5'] | map(attribute='version') | join(', ') }} Vulnerable!"
      when: ('liblzma5' in ansible_facts.packages) and (ansible_facts.packages['liblzma5'][0].version.split('-')[0] is version('5.6.0', '==') or ansible_facts.packages['liblzma5'][0].version.split('-')[0] is version('5.6.1', '=='))

    - name: Check if xz is vulnerable (RHEL/Fedora/Rocky/Alma)
      ansible.builtin.debug:
        msg: "Installed version of xz: {{ ansible_facts.packages['xz'] | map(attribute='version') | join(', ') }} is vulnerable"
      when: ('xz' in ansible_facts.packages) and (ansible_facts.packages['xz'][0].version is version('5.6.0', '==') or ansible_facts.packages['xz'][0].version is version('5.6.1', '=='))

Harden SSH for AlmaLinux 9 (RHEL, Fedora)

These steps are taken from the following link. They have other guides for hardening Ubuntu, Debian etc.

https://www.sshaudit.com/hardening_guides.html#rocky9

You will need to become the root user, use either su – or sudo -i

First we need to regenerate the RSA and ED25519 keys

rm /etc/ssh/ssh_host_*
ssh-keygen -t rsa -b 4096 -f /etc/ssh/ssh_host_rsa_key -N ""
ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_rsa_key -N ""

Next, remove the small Diffie-Hellman moduli. The moduli file contains prime numbers and generators. Removing the smaller numbers should help increase security as it makes attempting to factor the private keys harder.

awk '$5 >= 3071' /etc/ssh/moduli > /etc/ssh/moduli.safe
mv /etc/ssh/moduli.safe /etc/ssh/moduli

We can now specify which key exchange, ciphers, and algorithms to use.

Add the following to “/etc/crypto-policies/back-ends/opensshserver.config”

# Restrict key exchange, cipher, and MAC algorithms, as per sshaudit.com
# hardening guide.
KexAlgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,gss-curve25519-sha256-,diffie-hellman-group16-sha512,gss-group16-sha512-,diffie-hellman-group18-sha512,diffie-hellman-group-exchange-sha256

Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr

MACs hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,umac-128-etm@openssh.com

HostKeyAlgorithms sk-ssh-ed25519-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,sk-ssh-ed25519@openssh.com,ssh-ed25519,rsa-sha2-512,rsa-sha2-256

RequiredRSASize 3072

CASignatureAlgorithms sk-ssh-ed25519@openssh.com,ssh-ed25519,rsa-sha2-512,rsa-sha2-256

GSSAPIKexAlgorithms gss-curve25519-sha256-,gss-group16-sha512-

HostbasedAcceptedAlgorithms sk-ssh-ed25519-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,sk-ssh-ed25519@openssh.com,ssh-ed25519,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-512,rsa-sha2-256-cert-v01@openssh.com,rsa-sha2-256

PubkeyAcceptedAlgorithms sk-ssh-ed25519-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,sk-ssh-ed25519@openssh.com,ssh-ed25519,rsa-sha2-512,rsa-sha2-256

Finally, restart the ssh server

systemctl restart sshd

Other helpful links

https://www.ssh.com/academy

https://www.redhat.com/en/blog/primes-parameters-and-moduli

https://security.stackexchange.com/questions/79043/is-it-considered-worth-it-to-replace-opensshs-moduli-file

How to Fix OpenVAS “ERROR: The default PostgreSQL version (14) is not 16 that is required libgvmd”

Currently OpenVAS needs PostgreSQL 16 on port 5432. If you have multiple versions of PostgreSQL, the lowest version will typically run on port 5432, and then they’ll increment from there. For example, if you have PostgreSQL 14, 15, and 16, 14 will run on port 5432, 15 on 5433, and 16 on 5434.

The quick fix is to edit the PostgreSQL config files, change the port numbers, restart PostgreSQL, and rerun gvm-setup.

vi /etc/postgresql/16/main/postgresql.conf

Change port number from 5434 to 5432

You will need to remove/disable/change the port for PostgreSQL 14

sudo apt remove postgresql-14
sudo systemctl restart postgresql@16-main.service

We can verify that PostgreSQL is running with netstat.

netstat -tulpn

We can see that the ports 5432 (PostgreSQL 16) and 5433 (PostgreSQL 15) are both running.

Rerun gvm-setup

sudo gvm-setup