-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathessential-plugin-scan.sh
More file actions
508 lines (438 loc) · 19.5 KB
/
essential-plugin-scan.sh
File metadata and controls
508 lines (438 loc) · 19.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
#!/bin/bash
# Purpose: Scans all WordPress sites on a Plesk server for the Essential Plugin supply-chain attack (April 2026)
# Platform: Linux (Plesk)
# Features:
# - Detects all 31 affected plugin slugs from the Essential Plugin portfolio
# - Checks for the wpos-analytics/ backdoor module within each plugin directory
# - Scans PHP files for known backdoor code signatures (fetch_ver_info, version_info_clean, etc.)
# - Detects the wp-comments-posts.php malware dropper file
# - Flags wp-config.php infection (unusual file size or C2 domain references)
# - Broad C2 domain scan across all PHP files outside the plugins directory
# - Outputs a colour-coded report with per-site status and remediation steps
# - Generates a plain-text report file suitable for emailing to clients
# - Self-update capability with automatic or manual updates
# Usage: ./essential-plugin-scan.sh [--update|--self-update]
# Environment Variables:
# WP_VHOSTS_DIR - Plesk vhosts root directory (default: /var/www/vhosts)
# REPORT_FILE - Output path for the plain-text email report
# (default: /tmp/essential-plugin-scan-<hostname>-<timestamp>.txt)
# AUTO_UPDATE - Set to "true" to enable automatic updates (default: false)
# UPDATE_CHECK_INTERVAL - Hours between update checks (default: 24)
# GITHUB_BRANCH - GitHub branch to update from (default: main)
# Reference: https://anchor.host/someone-bought-30-wordpress-plugins-and-planted-a-backdoor-in-all-of-them/
set -euo pipefail
###############################################################################
# SELF-UPDATE FUNCTIONS
###############################################################################
# Self-update configuration
GITHUB_REPO="architecpoint/plesk-scripts"
GITHUB_BRANCH="${GITHUB_BRANCH:-main}"
SCRIPT_PATH="$(realpath "${BASH_SOURCE[0]}")"
SCRIPT_RELATIVE_PATH="essential-plugin-malware-scan/essential-plugin-scan.sh"
UPDATE_CHECK_FILE="/tmp/.essential_plugin_scan_update_check"
# Function to log update messages
log_update() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [UPDATE] $1"
}
# Function to check if update check is needed based on interval
should_check_for_update() {
local check_interval_hours="${UPDATE_CHECK_INTERVAL:-24}"
local check_interval_seconds=$((check_interval_hours * 3600))
if [ ! -f "${UPDATE_CHECK_FILE}" ]; then
return 0
fi
local last_check
last_check=$(stat -c %Y "${UPDATE_CHECK_FILE}" 2>/dev/null || echo 0)
local current_time
current_time=$(date +%s)
local time_diff=$((current_time - last_check))
if [ "${time_diff}" -ge "${check_interval_seconds}" ]; then
return 0
fi
return 1
}
# Function to update the check timestamp
update_check_timestamp() {
touch "${UPDATE_CHECK_FILE}" 2>/dev/null || true
}
# Function to perform self-update
self_update() {
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
log_update "WARNING: Neither curl nor wget found. Cannot check for updates."
return 1
fi
local github_url="https://raw.githubusercontent.com/${GITHUB_REPO}/${GITHUB_BRANCH}/${SCRIPT_RELATIVE_PATH}"
local temp_file="${SCRIPT_PATH}.update.$$"
local backup_file="${SCRIPT_PATH}.backup"
log_update "Checking for updates from GitHub..."
log_update "Source: ${github_url}"
# Download the latest version
if command -v curl >/dev/null 2>&1; then
if ! curl -sSfL "${github_url}" -o "${temp_file}"; then
log_update "ERROR: Failed to download update from GitHub"
rm -f "${temp_file}"
return 1
fi
elif command -v wget >/dev/null 2>&1; then
if ! wget -q "${github_url}" -O "${temp_file}"; then
log_update "ERROR: Failed to download update from GitHub"
rm -f "${temp_file}"
return 1
fi
fi
# Verify the downloaded file
if [ ! -s "${temp_file}" ]; then
log_update "ERROR: Downloaded file is empty"
rm -f "${temp_file}"
return 1
fi
if ! head -n 1 "${temp_file}" | grep -q "^#!/bin/bash"; then
log_update "ERROR: Downloaded file does not appear to be a valid bash script"
rm -f "${temp_file}"
return 1
fi
# Compare file contents
if cmp -s "${SCRIPT_PATH}" "${temp_file}"; then
log_update "Already running the latest version. No update needed."
rm -f "${temp_file}"
update_check_timestamp
return 0
fi
log_update "New version available. Installing update..."
# Create backup
if ! cp -f "${SCRIPT_PATH}" "${backup_file}"; then
log_update "ERROR: Failed to create backup"
rm -f "${temp_file}"
return 1
fi
# Make executable
chmod +x "${temp_file}"
# Atomically replace
if ! mv -f "${temp_file}" "${SCRIPT_PATH}"; then
log_update "ERROR: Failed to install update"
mv -f "${backup_file}" "${SCRIPT_PATH}"
return 1
fi
log_update "Successfully updated to the latest version!"
log_update "Backup saved to: ${backup_file}"
update_check_timestamp
# Re-execute with updated version
log_update "Restarting with updated version..."
exec "${SCRIPT_PATH}" "$@"
}
# Check for manual update flag
for arg in "$@"; do
if [ "${arg}" = "--update" ] || [ "${arg}" = "--self-update" ]; then
log_update "Manual update requested..."
self_update "$@"
exit $?
fi
done
# Auto-update if enabled
if [ "${AUTO_UPDATE:-false}" = "true" ] && should_check_for_update; then
log_update "Auto-update enabled. Checking for updates..."
self_update "$@" || {
log_update "WARNING: Auto-update failed. Continuing with current version..."
}
fi
###############################################################################
# MAIN SCRIPT CONFIGURATION
###############################################################################
# Directory containing Plesk virtual hosts
WP_VHOSTS_DIR="${WP_VHOSTS_DIR:-/var/www/vhosts}"
# Plain-text report file written alongside terminal output — ready to email to a client
REPORT_FILE="${REPORT_FILE:-/tmp/essential-plugin-scan-$(hostname -s)-$(date '+%Y%m%d-%H%M%S').txt}"
# Open file descriptor 3 for the report file
exec 3>"${REPORT_FILE}"
# All 31 plugin slugs permanently closed by WordPress.org on April 7, 2026.
# The entire "Essential Plugin" portfolio was acquired via Flippa in early 2025
# and backdoored in version 2.6.7 (August 8, 2025), then weaponised April 5-6, 2026.
# Source: https://anchor.host/someone-bought-30-wordpress-plugins-and-planted-a-backdoor-in-all-of-them/
AFFECTED_PLUGINS=(
"accordion-and-accordion-slider"
"album-and-image-gallery-plus-lightbox"
"audio-player-with-playlist-ultimate"
"blog-designer-for-post-and-widget"
"countdown-timer-ultimate"
"featured-post-creative"
"footer-mega-grid-columns"
"hero-banner-ultimate"
"html5-videogallery-plus-player"
"meta-slider-and-carousel-with-lightbox"
"popup-anything-on-click"
"portfolio-and-projects"
"post-category-image-with-grid-and-slider"
"post-grid-and-filter-ultimate"
"preloader-for-website"
"product-categories-designs-for-woocommerce"
"sp-faq"
"sliderspack-all-in-one-image-sliders"
"sp-news-and-widget"
"styles-for-wp-pagenavi-addon"
"ticker-ultimate"
"timeline-and-history-slider"
"woo-product-slider-and-carousel-with-category"
"wp-blog-and-widgets"
"wp-featured-content-and-slider"
"wp-logo-showcase-responsive-slider-slider"
"wp-responsive-recent-post-slider"
"wp-slick-slider-and-image-carousel"
"wp-team-showcase-and-slider"
"wp-testimonial-with-widget"
"wp-trending-post-slider-and-widget"
)
# Colour codes
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# Global summary counters
SITES_SCANNED=0
SITES_WITH_PLUGINS=0
SITES_WITH_BACKDOOR=0
SITES_COMPROMISED=0
###############################################################################
# FUNCTIONS
###############################################################################
# Output helpers — write to terminal (with colours) and to fd 3 / report file (plain text)
rpt() { echo "$*"; echo "$*" >&3; }
section() { echo -e "${BOLD}$1${NC}"; echo "$1" >&3; }
status_danger(){ echo -e " ${RED}${BOLD}$1${NC}"; echo " $1" >&3; }
status_warn() { echo -e " ${YELLOW}${BOLD}$1${NC}"; echo " $1" >&3; }
status_ok() { echo -e " ${GREEN}$1${NC}"; echo " $1" >&3; }
log_info() { echo -e "${CYAN}[INFO]${NC} $1"; echo "[INFO] $1" >&3; }
log_ok() { echo -e "${GREEN}[OK]${NC} $1"; echo "[OK] $1" >&3; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; echo "[WARN] $1" >&3; }
log_danger() { echo -e "${RED}[DANGER]${NC} $1"; echo "[DANGER] $1" >&3; }
# Check wp-config.php for signs of active infection.
# Returns 0 = clean, 1 = signs of infection detected.
check_wp_config_infection() {
local wp_config="$1"
local result=0
local file_size
file_size=$(wc -c < "${wp_config}" 2>/dev/null || echo "0")
# A clean wp-config.php is typically 2–5 KB.
# The injected payload adds ~6.2 KB (3,345 → 9,540 bytes in the confirmed case).
if [ "${file_size}" -gt 8000 ]; then
log_danger " wp-config.php is unusually large: ${file_size} bytes (expected ~3000; malware adds ~6200)"
result=1
fi
# C2 domain used by the attacker's analytics infrastructure
if grep -q "essentialplugin\.com" "${wp_config}" 2>/dev/null; then
log_danger " wp-config.php references attacker C2 domain: analytics.essentialplugin.com"
result=1
fi
# Ethereum smart contract used to resolve the C2 domain (making traditional takedowns ineffective)
if grep -qiE "eth_call|ethereum|smart.?contract" "${wp_config}" 2>/dev/null; then
log_danger " wp-config.php contains Ethereum/blockchain C2 resolution code"
result=1
fi
# Known wpos-analytics loader markers that appear in the injected block
if grep -qE "wpos_analytics_anl|wpos-analytics" "${wp_config}" 2>/dev/null; then
log_danger " wp-config.php contains wpos-analytics malware markers"
result=1
fi
return "${result}"
}
# Scan a directory for known backdoor code signatures in PHP files.
# Prints any findings; always returns 0 so callers are not affected by set -e.
scan_signatures_in_dir() {
local dir="$1"
local -a signatures=(
"fetch_ver_info"
"version_info_clean"
"wpos_analytics_anl"
"Plugin Wpos Analytics Data Starts"
"analytics.essentialplugin.com"
)
for sig in "${signatures[@]}"; do
local matches
matches=$(grep -rl "${sig}" "${dir}" --include="*.php" 2>/dev/null || true)
if [ -n "${matches}" ]; then
log_danger " Backdoor signature '${sig}' found:"
while IFS= read -r f; do
echo " -> ${f}"; echo " -> ${f}" >&3
done <<< "${matches}"
fi
done
}
# Scan a single WordPress installation rooted at the given wp-config.php path.
scan_wordpress_site() {
local wp_config="$1"
local site_dir
site_dir="$(dirname "${wp_config}")"
local plugins_dir="${site_dir}/wp-content/plugins"
local site_has_plugin=0
local site_has_backdoor=0
local site_is_compromised=0
SITES_SCANNED=$((SITES_SCANNED + 1))
rpt ""
section "=== ${site_dir} ==="
# --- 1. Check for affected plugin slugs ---
local found_count=0
if [ -d "${plugins_dir}" ]; then
for slug in "${AFFECTED_PLUGINS[@]}"; do
local plugin_path="${plugins_dir}/${slug}"
if [ -d "${plugin_path}" ]; then
site_has_plugin=1
found_count=$((found_count + 1))
log_warn " Affected plugin found: ${slug}"
# Check for the wpos-analytics backdoor module directory
if [ -d "${plugin_path}/wpos-analytics" ]; then
log_danger " -> wpos-analytics/ backdoor module is PRESENT"
site_has_backdoor=1
scan_signatures_in_dir "${plugin_path}/wpos-analytics"
# Confirm the specific PHP deserialization backdoor file
if [ -f "${plugin_path}/wpos-analytics/class-anylc-admin.php" ]; then
log_danger " -> class-anylc-admin.php (PHP deserialization backdoor) confirmed"
fi
else
log_ok " -> wpos-analytics/ module not found (plugin may already be patched or cleaned)"
fi
fi
done
fi
if [ "${found_count}" -eq 0 ]; then
log_ok " No affected plugins detected"
fi
# --- 2. Check for the malware dropper file ---
# Named 'wp-comments-posts.php' (extra 's') to mimic core 'wp-comments-post.php'
if [ -f "${site_dir}/wp-comments-posts.php" ]; then
log_danger " Malware dropper FOUND: wp-comments-posts.php (note the extra 's')"
site_is_compromised=1
fi
# --- 3. Check wp-config.php for active infection ---
check_wp_config_infection "${wp_config}" || site_is_compromised=1
# --- 4. Broad C2 domain scan across all site PHP files (outside plugins dir) ---
local c2_hits
c2_hits=$(grep -rl "essentialplugin\.com" "${site_dir}" --include="*.php" 2>/dev/null \
| grep -v "/wp-content/plugins/" || true)
if [ -n "${c2_hits}" ]; then
log_danger " C2 domain reference found outside the plugins directory:"
while IFS= read -r f; do echo " -> ${f}"; echo " -> ${f}" >&3; done <<< "${c2_hits}"
site_is_compromised=1
fi
# --- Update global counters ---
if [ "${site_has_plugin}" -eq 1 ]; then
SITES_WITH_PLUGINS=$((SITES_WITH_PLUGINS + 1))
fi
if [ "${site_has_backdoor}" -eq 1 ]; then
SITES_WITH_BACKDOOR=$((SITES_WITH_BACKDOOR + 1))
fi
if [ "${site_is_compromised}" -eq 1 ]; then
SITES_COMPROMISED=$((SITES_COMPROMISED + 1))
fi
# --- Per-site status verdict ---
if [ "${site_is_compromised}" -eq 1 ]; then
status_danger "STATUS: ACTIVELY COMPROMISED"
rpt " Remediation:"
rpt " 1. Restore wp-config.php from a clean backup (pre-April 6, 2026)"
rpt " 2. Delete wp-comments-posts.php if present"
rpt " 3. Remove or replace all affected plugins"
rpt " 4. Rotate all database passwords and WordPress secret keys"
rpt " 5. Audit all PHP files for additional injected payloads"
rpt " 6. Check Google Search Console for hidden SEO spam / cloaked redirects"
elif [ "${site_has_backdoor}" -eq 1 ]; then
status_warn "STATUS: BACKDOOR PRESENT (may not yet have been activated)"
rpt " Remediation: Remove wpos-analytics/ from each affected plugin, or install patched versions."
rpt " Patched plugins: https://anchor.host/someone-bought-30-wordpress-plugins-and-planted-a-backdoor-in-all-of-them/"
elif [ "${site_has_plugin}" -eq 1 ]; then
status_warn "STATUS: AFFECTED PLUGIN DETECTED (appears already cleaned/patched)"
rpt " Remediation: Confirm plugin is on version 2.6.9.1+ and consider full removal."
else
status_ok "STATUS: CLEAN"
fi
return 0
}
###############################################################################
# MAIN
###############################################################################
SCAN_STARTED="$(date '+%Y-%m-%d %H:%M:%S %Z')"
SERVER_HOST="$(hostname)"
rpt ""
section "================================================================"
section " Essential Plugin Supply-Chain Attack Scanner"
section " Threat: WordPress plugins weaponized via Flippa acquisition"
section " Attack activated: April 5-6, 2026 (backdoor planted Aug 2025)"
section " IOC domain: analytics.essentialplugin.com"
section " Server: ${SERVER_HOST}"
section " Scan started: ${SCAN_STARTED}"
section "================================================================"
rpt ""
if [ ! -d "${WP_VHOSTS_DIR}" ]; then
rpt "ERROR: Vhosts directory '${WP_VHOSTS_DIR}' not found."
rpt " Set the WP_VHOSTS_DIR environment variable to override."
exec 3>&-
exit 1
fi
rpt "Scanning: ${WP_VHOSTS_DIR}"
rpt "Checking for ${#AFFECTED_PLUGINS[@]} known affected plugin slugs..."
# Find all WordPress installations, excluding system and cache directories
while IFS= read -r wp_config; do
scan_wordpress_site "${wp_config}" || log_warn "Error scanning ${wp_config} — continuing..."
done < <(
find "${WP_VHOSTS_DIR}" -name "wp-config.php" \
-not -path "*/wp-admin/*" \
-not -path "*/wp-includes/*" \
-not -path "*/.cache/*" \
-not -path "*/cache/*" \
-not -path "*/backup*/*" \
2>/dev/null | sort
)
###############################################################################
# SUMMARY REPORT
###############################################################################
rpt ""
section "================================================================"
section " SCAN COMPLETE — SUMMARY"
section "================================================================"
rpt " WordPress sites scanned : ${SITES_SCANNED}"
rpt " Sites with affected plugin(s) : ${SITES_WITH_PLUGINS}"
rpt " Sites with backdoor module : ${SITES_WITH_BACKDOOR}"
rpt " Sites actively compromised : ${SITES_COMPROMISED}"
rpt ""
if [ "${SITES_COMPROMISED}" -gt 0 ]; then
status_danger "CRITICAL: ${SITES_COMPROMISED} site(s) actively compromised — immediate action required!"
rpt ""
rpt " Full remediation steps:"
rpt " 1. Restore wp-config.php from a clean backup predating April 6, 2026"
rpt " 2. Delete wp-comments-posts.php (the malware dropper) if present"
rpt " 3. Remove or replace all Essential Plugin portfolio plugins"
rpt " 4. Rotate all database passwords and WordPress secret keys"
rpt " 5. Scan all PHP files for additional injected payloads"
rpt " 6. Review Google Search Console for hidden SEO spam / cloaked redirects"
rpt ""
elif [ "${SITES_WITH_BACKDOOR}" -gt 0 ]; then
status_warn "WARNING: ${SITES_WITH_BACKDOOR} site(s) have the backdoor module present."
rpt ""
rpt " Remediation options (for each affected plugin):"
rpt " Option A — Install a community-patched version (wpos-analytics stripped):"
rpt " wp plugin install https://plugins.captaincore.io/<plugin>-patched.zip --force"
rpt ""
rpt " Option B — Manual removal:"
rpt " 1. Delete wp-content/plugins/<plugin>/wpos-analytics/"
rpt " 2. Remove the loader block from the main plugin PHP file"
rpt " (search for 'Plugin Wpos Analytics Data Starts' or 'wpos_analytics_anl')"
rpt ""
rpt " Option C — Remove the plugin entirely if it is no longer needed"
rpt ""
elif [ "${SITES_WITH_PLUGINS}" -gt 0 ]; then
status_warn "INFO: ${SITES_WITH_PLUGINS} site(s) had affected plugins (appear already patched)."
rpt " Verify each is on version 2.6.9.1+ and consider full removal."
rpt ""
fi
if [ "${SITES_SCANNED}" -eq 0 ]; then
rpt " No WordPress installations found under ${WP_VHOSTS_DIR}"
elif [ "${SITES_WITH_PLUGINS}" -eq 0 ]; then
status_ok "All ${SITES_SCANNED} site(s) are CLEAN — no affected plugins detected."
rpt ""
fi
rpt " Reference: https://anchor.host/someone-bought-30-wordpress-plugins-and-planted-a-backdoor-in-all-of-them/"
rpt ""
# Write report footer and close the report file
echo "Report generated: $(date '+%Y-%m-%d %H:%M:%S %Z') | Server: ${SERVER_HOST}" >&3
exec 3>&-
echo "Report saved to: ${REPORT_FILE}"
echo "(You can email this file directly to your client)"