{"uuid": "575e27a9-bd38-4df2-ba34-7b9446fd4daf", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2026-41940", "type": "seen", "source": "https://gist.github.com/mrjhnsn/5bceea4afa2ce5815a44fd7a5f7732d7", "content": "#!/bin/bash\n# Scan for compromised cPanel/WHM session files.\n#\n# Each check function inspects a single session file and, if the IOC\n# matches, calls report_finding with a severity. report_finding records\n# the finding, prints a one-line header, and dumps the session for triage.\n# A summary of all findings (grouped by severity) is printed at the end.\n\n\n# Default paths\nSESSIONS_DIR=\"/var/cpanel/sessions\"\nACCESS_LOG=\"/usr/local/cpanel/logs/access_log\"\n\n# Flags\nVERBOSE=0\nPURGE=0\nASSUME_YES=0\n\n# Parse flags\nwhile [ $# -gt 0 ]; do\n    case \"$1\" in\n        --verbose)\n            VERBOSE=1\n            ;;\n        --purge)\n            PURGE=1\n            ;;\n        --yes|-y)\n            ASSUME_YES=1\n            ;;\n        --sessions-dir)\n            SESSIONS_DIR=\"$2\"; shift\n            ;;\n        --access-log)\n            ACCESS_LOG=\"$2\"; shift\n            ;;\n        --help|-h)\n            echo \"Usage: $0 [--verbose] [--purge [--yes]] [--sessions-dir DIR] [--access-log FILE]\"\n            exit 0\n            ;;\n        *)\n            echo \"Unknown argument: $1\" &gt;&amp;2\n            exit 1\n            ;;\n    esac\n    shift\ndone\n\n# Findings accumulator. Each entry: \"SEVERITY|session_file|short_message\"\nFINDINGS=()\n# Ordered list of unique session files that produced findings.\nFINDING_SESSIONS=()\n# Parallel array: token value associated with each entry in FINDING_SESSIONS\n# (first non-empty token seen for that session).\nFINDING_TOKENS=()\n# Parallel array: highest severity reported for each session (by index)\nFINDING_SEVERITIES=()\nCOUNT_CRITICAL=0\nCOUNT_WARNING=0\nCOUNT_INFO=0\nCOUNT_ATTEMPT=0\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\n\n# Extract the value of a key=value line from a session file (first match).\n# Use: get_field  \nget_field() {\n    local file=\"$1\" key=\"$2\"\n    grep \"^${key}=\" \"$file\" | head -1 | cut -d= -f2-\n}\n\nhr() {\n    echo \"    ----------------------------------------------------------------\"\n}\n\n# Dump full contents of a session file plus related context (matching\n# pre-auth file, access_log hits for the injected token, file metadata).\n# Use: dump_session  [token_value]\ndump_session() {\n    local session_file=\"$1\"\n    local token_val=\"$2\"\n    local session_name preauth_file\n    session_name=$(basename \"$session_file\")\n    preauth_file=\"$SESSIONS_DIR/preauth/$session_name\"\n\n    hr\n    echo \"    SESSION DUMP: $session_file\"\n    hr\n    echo \"    File metadata:\"\n    ls -la \"$session_file\" 2&gt;/dev/null | sed 's/^/      /'\n    echo\n    echo \"    Full session contents:\"\n    sed 's/^/      /' \"$session_file\"\n    echo\n\n    if [ -f \"$preauth_file\" ]; then\n        echo \"    Matching pre-auth file: $preauth_file\"\n        ls -la \"$preauth_file\" 2&gt;/dev/null | sed 's/^/      /'\n        echo \"    Pre-auth contents:\"\n        sed 's/^/      /' \"$preauth_file\"\n        echo\n    fi\n\n    if [ -n \"$token_val\" ] &amp;&amp; [ -r \"$ACCESS_LOG\" ]; then\n        echo \"    Access log hits for token '$token_val':\"\n        grep -aF -- \"$token_val\" \"$ACCESS_LOG\" | sed 's/^/      /' || echo \"      (none)\"\n        echo\n    fi\n    hr\n}\n\n# Record a finding and print a brief header line. The full session dump is\n# deferred to print_summary so that multiple findings for the same session\n# are grouped together and the session is only dumped once. When the same\n# session matches multiple IOCs at different severities, only the highest\n# (CRITICAL &gt; WARNING &gt; ATTEMPT &gt; INFO) is kept.\n# Use: report_finding    \n# SEVERITY is one of: CRITICAL, WARNING, ATTEMPT, INFO\nreport_finding() {\n    local severity=\"$1\"\n    local session_file=\"$2\"\n    local token_val=\"$3\"\n    local message=\"$4\"\n\n    # Severity ranking: CRITICAL=3, WARNING=2, ATTEMPT=1, INFO=0\n    local sev_rank=0\n    case \"$severity\" in\n        CRITICAL) sev_rank=3 ;;\n        WARNING)  sev_rank=2 ;;\n        ATTEMPT)  sev_rank=1 ;;\n        INFO)     sev_rank=0 ;;\n    esac\n\n    local i found=0 prev_sev prev_rank\n    for i in \"${!FINDING_SESSIONS[@]}\"; do\n        if [ \"${FINDING_SESSIONS[$i]}\" = \"$session_file\" ]; then\n            found=1\n            prev_sev=\"${FINDING_SEVERITIES[$i]}\"\n            case \"$prev_sev\" in\n                CRITICAL) prev_rank=3 ;;\n                WARNING)  prev_rank=2 ;;\n                ATTEMPT)  prev_rank=1 ;;\n                INFO)     prev_rank=0 ;;\n            esac\n            if [ \"$sev_rank\" -le \"$prev_rank\" ]; then\n                # Existing finding is at least as severe; ignore.\n                return\n            fi\n            # Upgrade in place: replace severity, token, FINDINGS entry,\n            # and roll back the previous severity counter so the new one\n            # can be incremented below without double-counting.\n            FINDING_SEVERITIES[$i]=\"$severity\"\n            [ -n \"$token_val\" ] &amp;&amp; FINDING_TOKENS[$i]=\"$token_val\"\n            local j\n            for j in \"${!FINDINGS[@]}\"; do\n                local entry=\"${FINDINGS[$j]}\"\n                local entry_sev=\"${entry%%|*}\"\n                local entry_file=\"${entry#*|}\"; entry_file=\"${entry_file%%|*}\"\n                if [ \"$entry_file\" = \"$session_file\" ] &amp;&amp; [ \"$entry_sev\" = \"$prev_sev\" ]; then\n                    FINDINGS[$j]=\"${severity}|${session_file}|${message}\"\n                    break\n                fi\n            done\n            case \"$prev_sev\" in\n                CRITICAL) COUNT_CRITICAL=$((COUNT_CRITICAL - 1)) ;;\n                WARNING)  COUNT_WARNING=$((COUNT_WARNING - 1))   ;;\n                ATTEMPT)  COUNT_ATTEMPT=$((COUNT_ATTEMPT - 1))   ;;\n                INFO)     COUNT_INFO=$((COUNT_INFO - 1))         ;;\n            esac\n            break\n        fi\n    done\n\n    if [ \"$found\" -eq 0 ]; then\n        FINDING_SESSIONS+=(\"$session_file\")\n        FINDING_TOKENS+=(\"$token_val\")\n        FINDING_SEVERITIES+=(\"$severity\")\n        FINDINGS+=(\"${severity}|${session_file}|${message}\")\n    fi\n\n    case \"$severity\" in\n        CRITICAL) COUNT_CRITICAL=$((COUNT_CRITICAL + 1)) ;;\n        WARNING)  COUNT_WARNING=$((COUNT_WARNING + 1))   ;;\n        ATTEMPT)  COUNT_ATTEMPT=$((COUNT_ATTEMPT + 1))   ;;\n        INFO)     COUNT_INFO=$((COUNT_INFO + 1))         ;;\n    esac\n\n    echo \"[${severity}] ${message}: ${session_file}\"\n}\n\n# ---------------------------------------------------------------------------\n# IOC checks\n# ---------------------------------------------------------------------------\n\n# IOC 0: token_denied counter alongside cp_security_token, in a session\n# whose origin is badpass or otherwise non-benign.\n#\n# - token_denied is incremented by do_token_denied() (cpsrvd.pl:3821)\n#   every time a request supplies the wrong cp_security_token. The\n#   session is killed on the third failure.\n# - cp_security_token itself is set by newsession() unconditionally\n#   while security tokens are enabled (Cpanel/Server.pm:2290), so its\n#   presence is NOT by itself an IOC. The pair (token_denied,\n#   cp_security_token) tells us only that someone is actively trying\n#   tokens against this session.\n#\n# Auth markers (successful_*_auth_with_timestamp, hasroot=1,\n# tfa_verified=1, or an access_log hit on the security token) cannot\n# legitimately appear in a badpass session: the badpass call site\n# (Cpanel/Server.pm:1244-1252) doesn't pass them, hasroot is not even\n# in _SESSION_PARTS (Cpanel/Server.pm:2216-2247), and tfa_verified is\n# forced to 0 unless the caller passes a truthy value (line 2295).\n#\n# Severity tiers:\n#   CRITICAL - badpass origin AND auth markers present (post-exploit)\n#   INFO     - badpass origin, no auth markers, pass looks like a real\n#              encoded password (likely an unrelated failed login that\n#              happened to receive bad-token traffic)\n#   WARNING  - origin is neither badpass nor a known-benign method\n#              (handle_form_login, create_user_session,\n#              handle_auth_transfer); the suspicious origin itself is\n#              the IOC\n#\n# Legitimate badpass sessions never carry a pass= line (the badpass\n# call site at Cpanel/Server.pm:1244-1252 does not pass `pass` to\n# newsession, and saveSession only writes pass= when length is\n# non-zero - Cpanel/Session.pm:181). When we see one anyway we defer\n# classification to IOC 5 (check_failed_exploit_attempt), which flags\n# it as ATTEMPT.\ncheck_token_denied_with_injected_token() {\n    local session_file=\"$1\"\n\n    grep -q '^token_denied='      \"$session_file\" || return\n    grep -q '^cp_security_token=' \"$session_file\" || return\n\n    local token_val external_auth internal_auth hasroot tfa used\n    token_val=$(get_field      \"$session_file\" cp_security_token)\n    external_auth=$(get_field  \"$session_file\" successful_external_auth_with_timestamp)\n    internal_auth=$(get_field  \"$session_file\" successful_internal_auth_with_timestamp)\n    hasroot=$(get_field        \"$session_file\" hasroot)\n    tfa=$(get_field            \"$session_file\" tfa_verified)\n    used=\"\"\n    if [ -r \"$ACCESS_LOG\" ]; then\n        used=$(grep -aF -- \"$token_val\" \"$ACCESS_LOG\" | grep -m1 \" 200 \")\n    fi\n\n    local has_auth_markers=0\n    if [ -n \"$external_auth\" ] || [ -n \"$internal_auth\" ] \\\n       || [ \"$hasroot\" = \"1\" ] || [ \"$tfa\" = \"1\" ] || [ -n \"$used\" ]; then\n        has_auth_markers=1\n    fi\n\n    if grep -q '^origin_as_string=.*method=badpass' \"$session_file\"; then\n        if [ \"$has_auth_markers\" -eq 1 ]; then\n            report_finding CRITICAL \"$session_file\" \"$token_val\" \\\n                \"Exploitation artifact - token_denied with injected cp_security_token (badpass origin, token used)\"\n        else\n            # A pass= line on a badpass session is itself anomalous;\n            # defer to IOC 5 (ATTEMPT).\n            if grep -q '^pass=' \"$session_file\"; then\n                return\n            fi\n            report_finding INFO \"$session_file\" \"$token_val\" \\\n                \"Possible injected session (badpass origin, no usage observed)\"\n        fi\n    elif grep -q '^origin_as_string=.*method=handle_form_login' \"$session_file\" || \\\n         grep -q '^origin_as_string=.*method=create_user_session' \"$session_file\" || \\\n         grep -q '^origin_as_string=.*method=handle_auth_transfer' \"$session_file\"; then\n        # Known-benign origins where token_denied + cp_security_token\n        # genuinely happens during normal use.\n        return\n    else\n        report_finding WARNING \"$session_file\" \"$token_val\" \\\n            \"Suspicious session with token_denied + cp_security_token (non-badpass origin)\"\n    fi\n}\n\n# IOC 1: A session that still has its pre-auth marker file but already\n# contains an auth-success timestamp (external or internal).\n#\n# write_session creates $SESSIONS_DIR/preauth/ when the\n# session is written with needs_auth=1, and removes that marker once\n# needs_auth is cleared on promotion (Cpanel/Session.pm:225-235). A\n# legitimately authenticated session therefore never has both the\n# preauth marker and an auth-success timestamp at the same time.\n#\n# Both successful_external_auth_with_timestamp and\n# successful_internal_auth_with_timestamp are checked: the original\n# poc.py payload injects the external variant; the watchtowr payload\n# (poc/poc_watchtowr.py:35) injects the internal variant.\ncheck_preauth_with_auth_attrs() {\n    local session_file=\"$1\"\n    local session_name preauth_file\n    session_name=$(basename \"$session_file\")\n    preauth_file=\"$SESSIONS_DIR/preauth/$session_name\"\n\n    [ -f \"$preauth_file\" ] || return\n\n    local marker\n    if grep -qE '^successful_external_auth_with_timestamp=' \"$session_file\"; then\n        marker=\"successful_external_auth_with_timestamp\"\n    elif grep -qE '^successful_internal_auth_with_timestamp=' \"$session_file\"; then\n        marker=\"successful_internal_auth_with_timestamp\"\n    else\n        return\n    fi\n\n    report_finding CRITICAL \"$session_file\" \\\n        \"$(get_field \"$session_file\" cp_security_token)\" \\\n        \"Injected session - ${marker} present in pre-auth session\"\n}\n\n# IOC 2: tfa_verified=1 outside of a legitimate origin method.\n#\n# tfa_verified=1 is set in only two places:\n#   - Cpanel/Security/Authn/TwoFactorAuth/Verify.pm:122, after a real\n#     TFA token validation succeeds.\n#   - Cpanel/Server.pm:2295, when a caller passes tfa_verified=1 to\n#     newsession().\n# In both cases the legitimate origin method is one of handle_form_login,\n# create_user_session, or handle_auth_transfer. tfa_verified=1 with any\n# other origin (notably badpass) cannot occur in a benign flow.\ncheck_tfa_with_bad_origin() {\n    local session_file=\"$1\"\n\n    grep -qE '^tfa_verified=1$' \"$session_file\" || return\n    grep -q '^origin_as_string=.*method=handle_form_login'    \"$session_file\" &amp;&amp; return\n    grep -q '^origin_as_string=.*method=create_user_session'  \"$session_file\" &amp;&amp; return\n    grep -q '^origin_as_string=.*method=handle_auth_transfer' \"$session_file\" &amp;&amp; return\n\n    report_finding WARNING \"$session_file\" \\\n        \"$(get_field \"$session_file\" cp_security_token)\" \\\n        \"Session with tfa_verified=1 but suspicious origin\"\n}\n\n# IOC 3: Session file contains a line that is not in `key=value` form.\n#\n# Three structural invariants together guarantee that every legitimate\n# line matches ^[A-Za-z_][A-Za-z0-9_]*=:\n#\n#   1. write_session serializes via Cpanel::Config::FlushConfig::flushConfig\n#      with '=' as the separator (Cpanel/Session.pm:221), so the on-disk\n#      format is one key=value pair per line.\n#   2. Keys come from a fixed whitelist (_SESSION_PARTS at\n#      Cpanel/Server.pm:2216-2247, applied at lines 2268-2270), so they\n#      always match the identifier shape above.\n#   3. Cpanel::Session::filter_sessiondata strips \\r\\n from every value\n#      (Cpanel/Session.pm:315) and additionally strips \\r\\n=, from origin\n#      sub-values (line 312), so values can never re-introduce line\n#      breaks. The `pass` value is additionally encoded by saveSession\n#      (Cpanel/Session.pm:181-189) into either lowercase hex (with-secret\n#      via Cpanel::Session::Encoder-&gt;encode_data) or the literal prefix\n#      `no-ob:` followed by lowercase hex (no-secret via\n#      Cpanel::Session::Encoder-&gt;hex_encode_only), so it cannot\n#      reintroduce structural characters either.\n#\n# Any non-blank line that fails the regex is the footprint of an\n# injection that bypassed these invariants - typically raw payload bytes\n# that didn't form valid key=value pairs. Note: an injection whose\n# smuggled lines DO match key=value (e.g. the watchtowr payload at\n# poc/poc_watchtowr.py:35, which fabricates successful_internal_auth_\n# with_timestamp/user/tfa_verified/hasroot lines) will not trip this\n# check; it is caught by IOC-0 and IOC-4 instead.\ncheck_malformed_session_line() {\n    local session_file=\"$1\"\n\n    # Look for any non-blank line that doesn't start with key=...\n    grep -nE -v '^[A-Za-z_][A-Za-z0-9_]*=|^[[:space:]]*$' \"$session_file\" &gt;/dev/null 2&gt;&amp;1 || return\n\n    report_finding CRITICAL \"$session_file\" \\\n        \"$(get_field \"$session_file\" cp_security_token)\" \\\n        \"Malformed session line(s) detected (not key=value - newline injection footprint)\"\n}\n\n# IOC 4: badpass origin combined with markers that no legitimate cpsrvd\n# code path writes into a badpass session.\n#\n# The badpass call site (Cpanel/Server.pm:1244-1252) is:\n#\n#   $randsession = $self-&gt;newsession(\n#       'needs_auth' =&gt; 1,\n#       %security_token_options,            # adds cp_security_token\n#       'origin' =&gt; { 'method' =&gt; 'badpass' },\n#   );\n#\n# %security_token_options is why badpass sessions legitimately carry\n# cp_security_token, but no auth-related options are ever supplied.\n# newsession() filters %OPTS through the _SESSION_PARTS whitelist\n# (Cpanel/Server.pm:2216-2247, applied at lines 2268-2270), so any key\n# not in that whitelist cannot land in the session via newsession at\n# all. Per marker:\n#\n#   successful_external_auth_with_timestamp - whitelisted, but the\n#       badpass caller doesn't pass it\n#   successful_internal_auth_with_timestamp - same\n#   tfa_verified=1 - newsession unconditionally writes 0 unless the\n#       caller passed a truthy value (Cpanel/Server.pm:2295), and the\n#       badpass caller doesn't\n#   hasroot=1 - NOT in _SESSION_PARTS, so newsession cannot write it\n#       for ANY session. A repo-wide grep finds no caller of\n#       Cpanel::Session::Modify-&gt;set('hasroot', ...) either: hasroot is\n#       never written to a session by legitimate code. Its presence in\n#       any session file is conclusive evidence of newline injection\n#       (the watchtowr payload at poc/poc_watchtowr.py:35 smuggles\n#       hasroot=1 via \\r\\n in a user-controlled field).\ncheck_badpass_with_auth_markers() {\n    local session_file=\"$1\"\n\n    grep -q '^origin_as_string=.*method=badpass' \"$session_file\" || return\n\n    local markers=()\n    grep -q '^successful_external_auth_with_timestamp=' \"$session_file\" \\\n        &amp;&amp; markers+=(\"successful_external_auth_with_timestamp\")\n    grep -q '^successful_internal_auth_with_timestamp=' \"$session_file\" \\\n        &amp;&amp; markers+=(\"successful_internal_auth_with_timestamp\")\n    grep -qE '^hasroot=1$'      \"$session_file\" &amp;&amp; markers+=(\"hasroot=1\")\n    grep -qE '^tfa_verified=1$' \"$session_file\" &amp;&amp; markers+=(\"tfa_verified=1\")\n\n    [ \"${#markers[@]}\" -gt 0 ] || return\n\n    local joined\n    joined=$(IFS=,; echo \"${markers[*]}\")\n    report_finding CRITICAL \"$session_file\" \\\n        \"$(get_field \"$session_file\" cp_security_token)\" \\\n        \"badpass origin combined with authenticated markers ($joined) - impossible in benign flow\"\n}\n\n# IOC 5: Failed exploit attempt - a badpass session that carries a\n# pass= line, a token_denied counter, and no auth markers.\n#\n# A legitimate badpass session is created at Cpanel/Server.pm:1244-1252:\n#\n#   $randsession = $self-&gt;newsession(\n#       'needs_auth' =&gt; 1,\n#       %security_token_options,\n#       'origin' =&gt; { 'method' =&gt; 'badpass' },\n#   );\n#\n# %security_token_options carries only cp_security_token,\n# requested_token_at_next_login, and previous_session_user\n# (Cpanel/Server.pm:1205-1226) - never `pass`. saveSession only\n# writes a pass= line when length($session_ref-&gt;{pass}) is non-zero\n# (Cpanel/Session.pm:181), so legitimate badpass sessions have no\n# pass= line at all.\n#\n# An exploit that tampers with a user-controlled field on a\n# badpass-bound request leaves a pass= line behind (saveSession\n# encodes it as `` or `no-ob:` per Cpanel/Session.pm:181-189,\n# but the format is irrelevant - its presence is the indicator). Combined\n# with token_denied (someone was poking at cp_security_token) and the\n# absence of auth markers (the injection didn't promote - otherwise\n# IOC-0 or IOC-4 fires CRITICAL), this is the signature of a failed\n# exploit attempt.\ncheck_failed_exploit_attempt() {\n    local session_file=\"$1\"\n\n    grep -q '^origin_as_string=.*method=badpass' \"$session_file\" || return\n    grep -q '^token_denied=' \"$session_file\" || return\n\n    # If auth markers are present, IOC-4 (CRITICAL) handles it.\n    grep -q '^successful_internal_auth_with_timestamp=' \"$session_file\" &amp;&amp; return\n    grep -q '^successful_external_auth_with_timestamp=' \"$session_file\" &amp;&amp; return\n\n    # Legitimate badpass sessions never carry pass=.\n    grep -q '^pass=' \"$session_file\" || return\n\n    report_finding ATTEMPT \"$session_file\" \"$(get_field \"$session_file\" cp_security_token)\" \\\n        \"Failed exploit attempt (badpass origin, token_denied, no auth markers, anomalous pass= line)\"\n}\n\n# Inspect a *.lock file (Cpanel::SafeFile dotlock) and confirm it looks\n# like a real lock before silently skipping it.\n#\n# Cpanel::Session uses Cpanel::SafeFile to write the session file to\n# disk (serialization itself is handled in the session code). SafeFile\n# creates a sibling dotlock at .lock for the duration of every\n# write and, on crash/abort, may leave it behind permanently. The lock contents\n# are written by Cpanel::SafeFileLock::write_lock_contents as \"$$\\n$0\\n\"\n# - first line is the PID, second line is the program name. These are\n# not key=value pairs, so without a guard they trip\n# check_malformed_session_line as a CRITICAL false positive.\n#\n# The CVE-2026-41940 exploit vector is the session file content, not the\n# lock file, so a lock file that doesn't look right is not by itself an\n# exploitation indicator. Emit a stderr notice for operator awareness and\n# leave the SCAN SUMMARY counters alone.\ncheck_lock_file() {\n    local lock_file=\"$1\"\n    local first_line\n    first_line=$(grep -m1 -v '^[[:space:]]*$' \"$lock_file\" 2&gt;/dev/null)\n    if [[ \"$first_line\" =~ ^[0-9]+$ ]]; then\n        return\n    fi\n    echo \"[NOTICE] Skipping unexpected .lock contents: $lock_file\" &gt;&amp;2\n}\n\n# ---------------------------------------------------------------------------\n# Main\n# ---------------------------------------------------------------------------\n\nscan_sessions() {\n    local session_file\n    while IFS= read -r -d '' session_file; do\n        # SafeFile dotlocks come in two forms: .lock (the\n        # final lock) and .lock- (the temp\n        # name SafeFile writes before atomic-renaming into place; it\n        # can also be left behind on crash). Skip both.\n        #\n        # Vim creates a .swp swap file alongside any file it opens,\n        # so an operator inspecting a session in vim leaves one\n        # behind. The format is binary and not a session.\n        case \"$session_file\" in\n            *.lock | *.lock-*)\n                check_lock_file \"$session_file\"\n                continue\n                ;;\n            *.swp)\n                continue\n                ;;\n        esac\n        check_token_denied_with_injected_token \"$session_file\"\n        check_preauth_with_auth_attrs          \"$session_file\"\n        check_tfa_with_bad_origin              \"$session_file\"\n        check_malformed_session_line           \"$session_file\"\n        check_badpass_with_auth_markers        \"$session_file\"\n        check_failed_exploit_attempt           \"$session_file\"\n    done &lt; &lt;(find \"$SESSIONS_DIR/raw\" -type f -print0 2&gt;/dev/null)\n}\n\n\nprint_summary() {\n    local total=$((COUNT_CRITICAL + COUNT_WARNING + COUNT_INFO + COUNT_ATTEMPT))\n\n    echo\n    echo \"=================================================================\"\n    echo \"                       SCAN SUMMARY\"\n    echo \"=================================================================\"\n    echo \"  CRITICAL findings: $COUNT_CRITICAL\"\n    echo \"  WARNING  findings: $COUNT_WARNING\"\n    echo \"  ATTEMPT  findings: $COUNT_ATTEMPT\"\n    echo \"  INFO     findings: $COUNT_INFO\"\n    echo \"  Total            : $total\"\n    echo \"-----------------------------------------------------------------\"\n\n    if [ \"$total\" -eq 0 ]; then\n        echo \"[+] No indicators of compromise found.\"\n        return\n    fi\n\n    # --purge has destructive blast radius (live session files for every\n    # logged-in user). Require either --yes for non-interactive use, or\n    # an explicit \"yes\" at an attached TTY.\n    if [ \"$PURGE\" -eq 1 ] &amp;&amp; [ \"$ASSUME_YES\" -ne 1 ]; then\n        if [ ! -t 0 ]; then\n            echo \"[ERROR] --purge requires --yes when stdin is not a TTY (cron, pipes, etc)\" &gt;&amp;2\n            echo \"        Re-run with --yes to confirm deletion.\" &gt;&amp;2\n            exit 64\n        fi\n        echo\n        echo \"About to delete ${#FINDING_SESSIONS[@]} session file(s) plus matching preauth markers.\"\n        local confirm=\"\"\n        read -r -p \"Type 'yes' to confirm: \" confirm\n        if [ \"$confirm\" != \"yes\" ]; then\n            echo \"[+] Aborted; no files deleted.\"\n            PURGE=0\n        fi\n    fi\n\n\n    # For each unique session, print only the highest-severity finding, then dump/purge as needed.\n    local i session token severity message found=0\n    for i in \"${!FINDING_SESSIONS[@]}\"; do\n        session=\"${FINDING_SESSIONS[$i]}\"\n        token=\"${FINDING_TOKENS[$i]}\"\n        severity=\"${FINDING_SEVERITIES[$i]}\"\n        found=0\n        # Find the first matching finding for this session and severity.\n        # Use `read` with three names so the last variable (entry_msg)\n        # absorbs any remaining `|` characters - the previous `${var##*|}`\n        # form took only the suffix after the LAST `|`, which would\n        # silently truncate any future message that contained one.\n        for entry in \"${FINDINGS[@]}\"; do\n            local entry_sev entry_file entry_msg\n            IFS='|' read -r entry_sev entry_file entry_msg &lt;&lt;&lt; \"$entry\"\n            if [ \"$entry_file\" = \"$session\" ] &amp;&amp; [ \"$entry_sev\" = \"$severity\" ]; then\n                message=\"$entry_msg\"\n                found=1\n                break\n            fi\n        done\n        echo\n        echo \"=================================================================\"\n        echo \"  SESSION: $session\"\n        echo \"=================================================================\"\n        echo \"  Findings:\"\n        if [ \"$found\" -eq 1 ]; then\n            printf \"    [%-8s] %s\\n\" \"$severity\" \"$message\"\n        else\n            printf \"    [%-8s] %s\\n\" \"$severity\" \"(no message found)\"\n        fi\n        echo\n        if [ \"$VERBOSE\" -eq 1 ]; then\n            dump_session \"$session\" \"$token\"\n        fi\n        if [ \"$PURGE\" -eq 1 ]; then\n            echo \"    [ACTION] Deleting session file: $session\"\n            rm -f -- \"$session\"\n            local preauth_marker=\"$SESSIONS_DIR/preauth/$(basename \"$session\")\"\n            if [ -e \"$preauth_marker\" ]; then\n                echo \"    [ACTION] Deleting preauth marker: $preauth_marker\"\n                rm -f -- \"$preauth_marker\"\n            fi\n        fi\n    done\n\n    if [ \"$COUNT_CRITICAL\" -gt 0 ] || [ \"$COUNT_WARNING\" -gt 0 ]; then\n        echo\n        echo \"[!] INDICATORS OF COMPROMISE DETECTED - IMMEDIATE ACTION REQUIRED\"\n        echo \"    1. Purge all affected sessions\"\n        echo \"    2. Force password reset for root and all WHM users\"\n        echo \"    3. Audit /var/log/wtmp and WHM access logs for unauthorized access\"\n        echo \"    4. Check for persistence mechanisms (cron, SSH keys, backdoors)\"\n    fi\n}\n\nif [ ! -d \"$SESSIONS_DIR/raw\" ]; then\n    echo \"[ERROR] Sessions directory not found: $SESSIONS_DIR/raw\" &gt;&amp;2\n    echo \"        Pass --sessions-dir DIR to point at a different location\" &gt;&amp;2\n    echo \"        (the default is /var/cpanel/sessions).\" &gt;&amp;2\n    exit 64\nfi\n\necho \"[*] Scanning session files for injection indicators...\"\nscan_sessions\nprint_summary\n\n# Exit codes (for cron / monitoring):\n#   2 - at least one CRITICAL or WARNING finding (compromise indicators)\n#   1 - only ATTEMPT or INFO findings (probing, no confirmed compromise)\n#   0 - clean scan\nif [ \"$COUNT_CRITICAL\" -gt 0 ] || [ \"$COUNT_WARNING\" -gt 0 ]; then\n    exit 2\nelif [ \"$COUNT_ATTEMPT\" -gt 0 ] || [ \"$COUNT_INFO\" -gt 0 ]; then\n    exit 1\nfi\nexit 0", "creation_timestamp": "2026-05-05T22:15:49.000000Z"}