captaincore/lib/remote-scripts/detect-malformed-passwords
2026-04-28 11:48:47 -04:00

145 lines
5.7 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# Detect WordPress users with malformed or legacy password hashes
#
# Stock WordPress (6.9+) creates passwords as $wp$2y$10$... (bcrypt+phpass).
# Older installs may have $P$ phpass hashes (legitimate but stale — rehashed
# on next successful login). Anything else in user_pass is anomalous and
# indicates either a direct DB write (attacker plant) or a corrupted record:
#
# - Raw MD5 (32 hex chars) → almost never legitimate on modern WP
# - Empty user_pass → cannot log in via wp-login (suspicious for admin accounts)
# - Anything not starting with '$' → not a recognized PHP password hash format
#
# Output format: SEVERITY|ID|login|role|hash_type|user_registered
#
# Usage:
# captaincore ssh <site> --script=detect-malformed-passwords
# captaincore ssh @all --script=detect-malformed-passwords --quiet --label
#
# Flags:
# --quiet Only output if findings are present (for bulk scanning)
#
set -uo pipefail
# ── Argument parsing ─────────────────────────────────────────────
quiet=""
for _arg in "$@"; do
case "$_arg" in
--quiet) quiet=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"
PREFIX=$(wp db prefix $WP_FLAGS 2>/dev/null)
if [ -z "$PREFIX" ]; then
[ -z "$quiet" ] && echo "ERROR: could not read table prefix (DB unreachable?)"
exit 1
fi
# ── Detection query ──────────────────────────────────────────────
# Flags users whose user_pass falls into one of three anomalous categories:
# 1. Empty (cannot log in via standard wp-login flow)
# 2. Raw MD5 (32 hex chars; legacy WordPress format pre-2008)
# 3. Unknown format (does not match any recognized hash prefix)
# Excludes legitimate formats:
# - $wp$2y$... (WP 6.9+ bcrypt+phpass hybrid)
# - $P$ ... $H$ (phpass, legit but stale until next login)
# - $2a$ $2b$ $2y$ (bcrypt direct — used by some auth plugins)
# - $argon2* (passwords-evolved plugin)
read -r -d '' QUERY <<SQL || true
SELECT
u.ID,
u.user_login,
u.user_email,
u.user_registered,
CASE
WHEN u.user_pass = '' THEN 'EMPTY'
WHEN u.user_pass REGEXP '^[a-f0-9]{32}\$' THEN 'RAW_MD5'
WHEN u.user_pass REGEXP '^[a-f0-9]{40}\$' THEN 'RAW_SHA1'
WHEN u.user_pass REGEXP '^[a-f0-9]{64}\$' THEN 'RAW_SHA256'
ELSE 'UNKNOWN'
END AS hash_type,
CASE
WHEN um.meta_value LIKE '%administrator%' THEN 'administrator'
WHEN um.meta_value LIKE '%editor%' THEN 'editor'
WHEN um.meta_value LIKE '%author%' THEN 'author'
WHEN um.meta_value LIKE '%contributor%' THEN 'contributor'
WHEN um.meta_value LIKE '%subscriber%' THEN 'subscriber'
WHEN um.meta_value LIKE '%customer%' THEN 'customer'
WHEN um.meta_value IS NULL THEN 'none'
ELSE 'other'
END AS role
FROM \`${PREFIX}users\` u
LEFT JOIN \`${PREFIX}usermeta\` um ON um.user_id = u.ID
AND um.meta_key = '${PREFIX}capabilities'
WHERE u.user_pass = ''
OR u.user_pass REGEXP '^[a-f0-9]{32}\$'
OR u.user_pass REGEXP '^[a-f0-9]{40}\$'
OR u.user_pass REGEXP '^[a-f0-9]{64}\$'
OR (
u.user_pass NOT REGEXP '^\\\\\$wp\\\\\$'
AND u.user_pass NOT REGEXP '^\\\\\$P\\\\\$'
AND u.user_pass NOT REGEXP '^\\\\\$H\\\\\$'
AND u.user_pass NOT REGEXP '^\\\\\$2[aby]\\\\\$'
AND u.user_pass NOT REGEXP '^\\\\\$argon2'
AND u.user_pass != ''
AND u.user_pass NOT REGEXP '^[a-f0-9]{32,}\$'
)
ORDER BY u.ID
SQL
results=$(wp db query "$QUERY" --skip-column-names $WP_FLAGS 2>/dev/null)
if [ -z "$results" ]; then
[ -z "$quiet" ] && echo "No malformed password hashes detected."
exit 0
fi
# ── Output findings ──────────────────────────────────────────────
# Severity matrix:
# RAW_MD5 + administrator = CRITICAL (joinadrian.com pattern)
# RAW_MD5 + non-admin = HIGH
# EMPTY + administrator = HIGH (cannot log in normally — suspicious for admin)
# EMPTY + non-admin = MEDIUM (could be SSO/external-auth user)
# RAW_SHA1 / RAW_SHA256 + admin = CRITICAL
# RAW_SHA1 / RAW_SHA256 + non-admin = HIGH
# UNKNOWN + admin = CRITICAL (anomalous format on highest-priv account)
# UNKNOWN + non-admin = HIGH
echo "$results" | awk 'BEGIN { FS="\t"; OFS="|" } {
id=$1; login=$2; registered=$4; hash_type=$5; role=$6
if (hash_type == "EMPTY") {
severity = (role == "administrator") ? "HIGH" : "MEDIUM"
} else if (hash_type == "RAW_MD5" || hash_type == "RAW_SHA1" || hash_type == "RAW_SHA256" || hash_type == "UNKNOWN") {
severity = (role == "administrator") ? "CRITICAL" : "HIGH"
} else {
severity = "MEDIUM"
}
print severity, id, login, role, hash_type, registered
}'