/
home2
/
reumatologia
/
Upload File
HOME
#!/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