mirror of
https://ghproxy.net/https://github.com/CaptainCore/captaincore.git
synced 2026-08-05 14:21:06 +08:00
418 lines
17 KiB
Bash
Executable file
418 lines
17 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
|
|
#
|
|
# Detect SEO spam injection in WordPress content
|
|
#
|
|
# Scans for the off-screen-cloaking pattern that SEO link-injection
|
|
# malware uses:
|
|
#
|
|
# <div style="position:absolute;left:-9999px;width:500px;">
|
|
# <a href="https://spamcasino.example/">keyword</a>
|
|
# </div>
|
|
#
|
|
# The element is shifted thousands of pixels off-screen so human visitors
|
|
# never see it, but Google's crawler indexes the link as an outbound
|
|
# endorsement from your domain. The attacker monetizes by selling SEO
|
|
# link juice to spam clients.
|
|
#
|
|
# This kind of injection lives in the database (post_content, widget
|
|
# options) as plain HTML with no PHP execution or obfuscation, so file-
|
|
# integrity scans, WP core checksums, plugin checksums, and signature-
|
|
# based malware scanners all walk past it. Only a content-pattern scan
|
|
# or Googlebot-perspective render check surfaces it.
|
|
#
|
|
# Detection strategies (all run by default):
|
|
#
|
|
# 1. wp_posts.post_content — finds <div>/<p> with position:absolute and
|
|
# negative-left in any publish/draft/private post or page.
|
|
#
|
|
# 2. wp_options.option_value — sidebar/footer widget content
|
|
# (widget_text, widget_custom_html, etc.) is a common spam location
|
|
# that lives outside post content.
|
|
#
|
|
# 3. Frontend curl as Googlebot via localhost (Host-header routed,
|
|
# cache-bypass) — confirms what's actually served to crawlers and
|
|
# catches any server-side cloaking that doesn't appear in the DB.
|
|
#
|
|
# Output format (pipe-delimited):
|
|
# SEVERITY|SOURCE|ID|TYPE|STATUS|TITLE|HITS|SAMPLE
|
|
#
|
|
# SOURCE = POST / OPTION / FRONTEND
|
|
# HITS = number of cloaked elements found
|
|
# SAMPLE = the external host found inside the cloak block
|
|
#
|
|
# To eliminate false positives from anti-spam honeypot fields (which use
|
|
# the same off-screen positioning trick to hide bot-decoy inputs), a row
|
|
# is only emitted when SAMPLE is a real external hostname — not the site
|
|
# itself, not a Kinsta staging sibling, and not CSS/text gibberish that
|
|
# the SAMPLE extraction picked up because no href existed near the cloak.
|
|
# In short: position:absolute + left:-9999px alone is not enough; there
|
|
# must also be an <a href> to an external domain inside the block.
|
|
#
|
|
# Usage:
|
|
# captaincore ssh <site> --script=detect-seo-spam
|
|
# captaincore ssh @all --script=detect-seo-spam --quiet
|
|
#
|
|
# Flags:
|
|
# --quiet Only emit output when findings exist
|
|
# --skip-frontend Skip the live curl step (DB scan only)
|
|
#
|
|
|
|
set -uo pipefail
|
|
|
|
# ── Argument parsing ─────────────────────────────────────────────
|
|
quiet=""
|
|
skip_frontend=""
|
|
for _arg in "$@"; do
|
|
case "$_arg" in
|
|
--quiet) quiet=true ;;
|
|
--skip-frontend) skip_frontend=true ;;
|
|
--*) ;;
|
|
*) WP_ROOT="$_arg" ;;
|
|
esac
|
|
done
|
|
|
|
WP_ROOT="${WP_ROOT:-.}"
|
|
cd "$WP_ROOT" 2>/dev/null || { echo "ERROR: Cannot access $WP_ROOT"; exit 1; }
|
|
|
|
if [ ! -f "wp-config.php" ] && [ -f "public/wp-config.php" ]; then
|
|
cd public
|
|
elif [ ! -f "wp-config.php" ] && [ -f "public_html/wp-config.php" ]; then
|
|
cd public_html
|
|
fi
|
|
|
|
if [ ! -f "wp-config.php" ]; then
|
|
[ -z "$quiet" ] && echo "WordPress not found."
|
|
exit 0
|
|
fi
|
|
|
|
if ! command -v wp &>/dev/null; then
|
|
echo "ERROR: WP-CLI not available"
|
|
exit 1
|
|
fi
|
|
|
|
WP_FLAGS="--skip-themes --skip-plugins --skip-packages"
|
|
|
|
if ! wp db prefix $WP_FLAGS &>/dev/null; then
|
|
[ -z "$quiet" ] && echo "ERROR: WordPress DB unreachable"
|
|
exit 1
|
|
fi
|
|
|
|
PREFIX=$(wp db prefix $WP_FLAGS 2>/dev/null)
|
|
# Strip protocol and trailing path. -E (extended regex) for cross-platform
|
|
# compatibility — BSD sed (macOS) doesn't honor \? in basic regex.
|
|
SITE_HOST=$(wp option get home $WP_FLAGS 2>/dev/null | sed -E 's|^https?://||;s|/.*$||')
|
|
|
|
strip_wpcli_status() {
|
|
grep -v -E '^(Success|Error|Warning):|^[[:space:]]*$' || true
|
|
}
|
|
|
|
# Validate that a SAMPLE looks like a real external hostname. Rejects:
|
|
# - empty / "-" → no href was found inside the cloak (honeypot field)
|
|
# - CSS or text → contains space/colon/semicolon/<>/parens
|
|
# - bare label → no dot (single token, not an FQDN)
|
|
# - self-references → matches site's own host or kinsta.cloud sibling
|
|
# (the same site's staging or production environment)
|
|
#
|
|
# Returns 0 (valid spam target) / 1 (false positive — skip).
|
|
is_valid_external_host() {
|
|
local sample="$1"
|
|
local self_host="${2:-}"
|
|
|
|
[ -z "$sample" ] || [ "$sample" = "-" ] && return 1
|
|
|
|
# Hostname charset only: letters, digits, dots, hyphens
|
|
case "$sample" in
|
|
*[!a-zA-Z0-9.-]*) return 1 ;;
|
|
esac
|
|
|
|
# Must be an FQDN (at least one dot)
|
|
case "$sample" in
|
|
*.*) ;;
|
|
*) return 1 ;;
|
|
esac
|
|
|
|
if [ -n "$self_host" ]; then
|
|
local self_norm="${self_host#www.}"
|
|
local sample_norm="${sample#www.}"
|
|
[ "$sample_norm" = "$self_norm" ] && return 1
|
|
|
|
# Same site's staging / production / kinsta.cloud sibling
|
|
case "$sample_norm" in
|
|
stg-*"$self_norm"|staging-*"$self_norm"|*-staging.kinsta.cloud|*.kinsta.cloud)
|
|
# Production->staging or staging->production cross-link.
|
|
# Only reject when self_host is also a kinsta.cloud or the
|
|
# bare slug appears in both — otherwise a real spam link
|
|
# ending in .kinsta.cloud would be hidden.
|
|
case "$self_norm" in
|
|
*.kinsta.cloud) return 1 ;;
|
|
esac
|
|
# Strip kinsta.cloud + staging prefix and compare slug
|
|
local sample_slug="${sample_norm%.kinsta.cloud}"
|
|
sample_slug="${sample_slug#stg-}"
|
|
sample_slug="${sample_slug#staging-}"
|
|
sample_slug="${sample_slug%-staging}"
|
|
local self_slug="${self_norm%.com}"
|
|
self_slug="${self_slug%.org}"
|
|
self_slug="${self_slug%.net}"
|
|
self_slug="${self_slug%.co}"
|
|
case "$sample_slug" in
|
|
"$self_slug"|"$self_slug"-*) return 1 ;;
|
|
esac
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
found_any=""
|
|
seen_post_ids=" "
|
|
|
|
# Pattern matching shared between scans:
|
|
#
|
|
# <div|p ... style="...position:absolute...left:-DIGITS...">...</div|p>
|
|
#
|
|
# 3+ digits on the negative-left filters out the rare legitimate -50px-
|
|
# style overlap design while catching every off-screen cloak observed in
|
|
# the wild (typical values are -5000 to -84000).
|
|
#
|
|
# Some cloaking variants use a class instead of inline style:
|
|
# <div class="loading-frame-wrapper">...</div>
|
|
# combined with a CSS rule defined elsewhere. We match that as a fallback.
|
|
|
|
CLOAK_REGEX='<(div|p)[[:space:]][^>]*style="[^"]*position[[:space:]]*:[[:space:]]*absolute[^"]*left[[:space:]]*:[[:space:]]*-[0-9]{3,}'
|
|
CLOAK_CLASS_REGEX='<div[[:space:]][^>]*class="[^"]*loading-frame-wrapper'
|
|
|
|
# Spam-domain href pattern for the visible-spam scan (section 1b).
|
|
#
|
|
# Matches <a href="http(s)://...spam-keyword..."> anywhere in content.
|
|
# Keywords are drug names, payday-loan terms, and counterfeit-luxury
|
|
# patterns observed across multiple injection campaigns. Single hit could
|
|
# be a legitimate medical/news reference, so the visible-spam scan
|
|
# requires 2+ matches per post before flagging.
|
|
#
|
|
# This catches the unobfuscated variant where attackers append garbled
|
|
# <p> blocks containing 5-10 anchor tags to spam domains, with no
|
|
# cloaking style — the variant that sailed past CLOAK_REGEX on
|
|
# snlym.com posts 356 and 359.
|
|
|
|
SPAM_HREF_REGEX='<a[[:space:]]+[^>]*href="https?://[^"]*(cialis|viagra|payday|louisvuitton|kamagra|sildenafil|tadalafil|dapoxetine|nolvadex|propecia|tetracycline|isotretinoin|clomid|doxycycline|prednisone|wellbutrin|abilify|levitra|maxalt|elocon|rxpills|rxtabs|edtabs|pharmacyrx|onlinepharmacy|rxonline|rxbest|cheaprx|fastrx|norxbest|noprescription|nopres)[^"]*"'
|
|
|
|
# ── 1. wp_posts.post_content scan ───────────────────────────────
|
|
|
|
POST_QUERY="
|
|
SELECT ID, post_type, post_status, post_title
|
|
FROM ${PREFIX}posts
|
|
WHERE post_status IN ('publish', 'draft', 'private')
|
|
AND (post_content REGEXP '${CLOAK_REGEX}'
|
|
OR post_content REGEXP '${CLOAK_CLASS_REGEX}')
|
|
ORDER BY post_modified_gmt DESC
|
|
"
|
|
|
|
results=$(wp db query "$POST_QUERY" --skip-column-names $WP_FLAGS 2>/dev/null | strip_wpcli_status)
|
|
|
|
if [ -n "$results" ]; then
|
|
while IFS=$'\t' read -r id ptype pstatus ptitle; do
|
|
[ -z "${id// }" ] && continue
|
|
|
|
# Sample external host (best-effort first href= near cloak)
|
|
sample=$(wp db query "
|
|
SELECT SUBSTRING_INDEX(
|
|
SUBSTRING_INDEX(
|
|
SUBSTRING(post_content, LOCATE('position', post_content)),
|
|
'href=\"', -1),
|
|
'\"', 1)
|
|
FROM ${PREFIX}posts WHERE ID = $id
|
|
" --skip-column-names $WP_FLAGS 2>/dev/null | strip_wpcli_status | head -1 | head -c 80 | sed -E 's|^https?://||;s|/.*$||')
|
|
|
|
# Skip false positives: honeypot fields, CSS gibberish, self-links
|
|
is_valid_external_host "$sample" "$SITE_HOST" || continue
|
|
|
|
found_any=true
|
|
|
|
# Hit count: number of "position" tokens (each cloak has one)
|
|
hits=$(wp db query "
|
|
SELECT (LENGTH(post_content) - LENGTH(REPLACE(post_content, 'position', '')))/8
|
|
FROM ${PREFIX}posts WHERE ID = $id
|
|
" --skip-column-names $WP_FLAGS 2>/dev/null | strip_wpcli_status | head -1)
|
|
|
|
case "$pstatus" in
|
|
publish) sev=CRITICAL ;;
|
|
*) sev=HIGH ;;
|
|
esac
|
|
|
|
seen_post_ids="${seen_post_ids}${id} "
|
|
|
|
printf "%s|POST|%s|%s|%s|%s|%s|%s\n" \
|
|
"$sev" "$id" "$ptype" "$pstatus" "$ptitle" "${hits:-?}" "$sample"
|
|
done <<< "$results"
|
|
fi
|
|
|
|
# ── 1b. Visible spam-link paragraph scan ────────────────────────
|
|
#
|
|
# Catches the unobfuscated injection variant: posts with 2+ <a href>
|
|
# anchors pointing at known pharmacy / payday / luxury / drug-name
|
|
# domains, with no off-screen cloaking style. The attacker just
|
|
# appends garbled English seeded with affiliate links to the bottom
|
|
# of a post — visible to humans, indexed by search engines.
|
|
#
|
|
# Threshold: 2+ matching anchors per post. A single hit could be a
|
|
# legitimate medical/news reference; 2+ is unambiguous spam.
|
|
#
|
|
# Posts already flagged by section 1 are skipped to avoid duplicate
|
|
# rows for the cloaked-AND-visible case (e.g. snlym.com post 346,
|
|
# which had both a cloaked div and 3 trailing visible <p> blocks).
|
|
|
|
VISIBLE_QUERY="
|
|
SELECT ID, post_type, post_status, post_title
|
|
FROM ${PREFIX}posts
|
|
WHERE post_status IN ('publish', 'draft', 'private')
|
|
AND post_content REGEXP '${SPAM_HREF_REGEX}'
|
|
ORDER BY post_modified_gmt DESC
|
|
"
|
|
|
|
vis_results=$(wp db query "$VISIBLE_QUERY" --skip-column-names $WP_FLAGS 2>/dev/null | strip_wpcli_status)
|
|
|
|
if [ -n "$vis_results" ]; then
|
|
while IFS=$'\t' read -r id ptype pstatus ptitle; do
|
|
[ -z "${id// }" ] && continue
|
|
|
|
# Already emitted by section 1? Skip.
|
|
case "$seen_post_ids" in
|
|
*" $id "*) continue ;;
|
|
esac
|
|
|
|
# Count matching anchors. Fetch post_content and grep for the
|
|
# spam-href pattern. Threshold is 2+ to avoid flagging single
|
|
# legitimate medical/news references.
|
|
body=$(wp db query "
|
|
SELECT post_content
|
|
FROM ${PREFIX}posts WHERE ID = $id
|
|
" --skip-column-names $WP_FLAGS 2>/dev/null | strip_wpcli_status)
|
|
|
|
anchor_hits=$(echo "$body" | grep -ioE '<a[[:space:]]+[^>]*href="https?://[^"]*(cialis|viagra|payday|louisvuitton|kamagra|sildenafil|tadalafil|dapoxetine|nolvadex|propecia|tetracycline|isotretinoin|clomid|doxycycline|prednisone|wellbutrin|abilify|levitra|maxalt|elocon|rxpills|rxtabs|edtabs|pharmacyrx|onlinepharmacy|rxonline|rxbest|cheaprx|fastrx|norxbest|noprescription|nopres)[^"]*"' | wc -l | tr -d ' ')
|
|
|
|
[ "${anchor_hits:-0}" -lt 2 ] && continue
|
|
|
|
# Sample: first spam href hostname found
|
|
sample=$(echo "$body" \
|
|
| grep -ioE '<a[[:space:]]+[^>]*href="https?://[^"]*(cialis|viagra|payday|louisvuitton|kamagra|sildenafil|tadalafil|dapoxetine|nolvadex|propecia|tetracycline|isotretinoin|clomid|doxycycline|prednisone|wellbutrin|abilify|levitra|maxalt|elocon|rxpills|rxtabs|edtabs|pharmacyrx|onlinepharmacy|rxonline|rxbest|cheaprx|fastrx|norxbest|noprescription|nopres)[^"]*"' \
|
|
| head -1 \
|
|
| grep -ioE 'href="[^"]*"' \
|
|
| sed -E 's|href="||;s|".*$||;s|^https?://||;s|/.*$||' \
|
|
| head -c 80)
|
|
|
|
# Sanity: extracted hostname looks like a real external domain
|
|
is_valid_external_host "$sample" "$SITE_HOST" || continue
|
|
|
|
found_any=true
|
|
seen_post_ids="${seen_post_ids}${id} "
|
|
|
|
case "$pstatus" in
|
|
publish) sev=CRITICAL ;;
|
|
*) sev=HIGH ;;
|
|
esac
|
|
|
|
printf "%s|POST|%s|%s|%s|%s|%s|%s\n" \
|
|
"$sev" "$id" "$ptype" "$pstatus" "$ptitle" "$anchor_hits" "$sample"
|
|
done <<< "$vis_results"
|
|
fi
|
|
|
|
# ── 2. wp_options.option_value scan (widgets, theme mods, etc.) ──
|
|
#
|
|
# Widget content (widget_text, widget_custom_html) is a common spam
|
|
# location independent of post_content. These options store serialized
|
|
# arrays where each widget instance has a "text" or "content" field
|
|
# containing raw HTML — directly editable by anyone with admin access.
|
|
#
|
|
# We scan ALL options whose value matches the cloaking pattern. The
|
|
# autoload column doesn't matter — both autoloaded and on-demand options
|
|
# can hold spam.
|
|
|
|
OPTION_QUERY="
|
|
SELECT option_id, option_name
|
|
FROM ${PREFIX}options
|
|
WHERE option_value REGEXP '${CLOAK_REGEX}'
|
|
OR option_value REGEXP '${CLOAK_CLASS_REGEX}'
|
|
"
|
|
|
|
opt_results=$(wp db query "$OPTION_QUERY" --skip-column-names $WP_FLAGS 2>/dev/null | strip_wpcli_status)
|
|
|
|
if [ -n "$opt_results" ]; then
|
|
while IFS=$'\t' read -r opt_id opt_name; do
|
|
[ -z "${opt_id// }" ] && continue
|
|
|
|
sample=$(wp db query "
|
|
SELECT SUBSTRING_INDEX(
|
|
SUBSTRING_INDEX(
|
|
SUBSTRING(option_value, LOCATE('position', option_value)),
|
|
'href=\"', -1),
|
|
'\"', 1)
|
|
FROM ${PREFIX}options WHERE option_id = $opt_id
|
|
" --skip-column-names $WP_FLAGS 2>/dev/null | strip_wpcli_status | head -1 | head -c 80 | sed -E 's|^https?://||;s|/.*$||')
|
|
|
|
# Skip false positives: honeypot fields, CSS gibberish, self-links
|
|
is_valid_external_host "$sample" "$SITE_HOST" || continue
|
|
|
|
found_any=true
|
|
|
|
hits=$(wp db query "
|
|
SELECT (LENGTH(option_value) - LENGTH(REPLACE(option_value, 'position', '')))/8
|
|
FROM ${PREFIX}options WHERE option_id = $opt_id
|
|
" --skip-column-names $WP_FLAGS 2>/dev/null | strip_wpcli_status | head -1)
|
|
|
|
printf "CRITICAL|OPTION|%s|option|live|%s|%s|%s\n" \
|
|
"$opt_id" "$opt_name" "${hits:-?}" "$sample"
|
|
done <<< "$opt_results"
|
|
fi
|
|
|
|
# ── 3. Frontend curl as Googlebot ───────────────────────────────
|
|
#
|
|
# Cache-bypass headers + cachebuster query parameter ensure we don't
|
|
# see stale cached HTML. This catches server-side UA cloaking and
|
|
# corroborates DB findings (or surfaces spam in unscanned tables).
|
|
#
|
|
|
|
if [ -z "$skip_frontend" ] && command -v curl &>/dev/null && [ -n "$SITE_HOST" ]; then
|
|
UA_BOT="Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
|
|
CACHEBUST=$(date +%s)
|
|
|
|
body=$(curl -ksL --max-time 20 \
|
|
-A "$UA_BOT" \
|
|
-H "Host: $SITE_HOST" \
|
|
-H "Cache-Control: no-cache" \
|
|
-H "Pragma: no-cache" \
|
|
"https://localhost/?nocache=${CACHEBUST}" 2>/dev/null)
|
|
|
|
if [ -n "$body" ]; then
|
|
cloak_hits=$(echo "$body" | grep -ioE 'position[[:space:]]*:[[:space:]]*absolute[^>]*left[[:space:]]*:[[:space:]]*-[0-9]{3,}' | wc -l | tr -d ' ')
|
|
class_hits=$(echo "$body" | grep -ioE 'class="[^"]*loading-frame-wrapper' | wc -l | tr -d ' ')
|
|
total_hits=$((cloak_hits + class_hits))
|
|
|
|
if [ "$total_hits" -gt 0 ]; then
|
|
# Find first href= inside any cloak block
|
|
sample=$(echo "$body" \
|
|
| grep -ioE '(<(div|p)[^>]*style="[^"]*position[^"]*absolute[^"]*left[^"]*-[0-9]+[^"]*"[^>]*>|<div[^>]*class="[^"]*loading-frame-wrapper[^"]*"[^>]*>)[^<]*<a[^>]*href="[^"]*"' \
|
|
| grep -ioE 'href="[^"]*"' \
|
|
| head -1 \
|
|
| sed -E 's|href="||;s|".*$||;s|^https?://||;s|/.*$||')
|
|
|
|
# Skip false positives: honeypot fields with no <a href> in the cloak
|
|
if is_valid_external_host "$sample" "$SITE_HOST"; then
|
|
found_any=true
|
|
printf "CRITICAL|FRONTEND|home|--|live|%s|%s|%s\n" \
|
|
"$SITE_HOST" "$total_hits" "$sample"
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# ── 4. Result ────────────────────────────────────────────────────
|
|
|
|
if [ -z "$found_any" ]; then
|
|
[ -z "$quiet" ] && echo "No SEO spam detected."
|
|
exit 0
|
|
fi
|
|
|
|
exit 0
|