#!/usr/bin/env bash
#
# auditor-web-malware.sh
# Auditor de solo lectura para buscar indicios de compromiso en sitios PHP.
# Detecta Joomla, WordPress o funciona en modo generico.
# Inspirado en la guia de LinuxParty sobre Helix Ultimate y SP Page Builder.
#
# No elimina, mueve ni modifica archivos o registros de la base de datos.
set -uo pipefail
export LC_ALL=C
VERSION="1.3.1"
DAYS=30
SINCE=""
ROOT=""
LOG_DIR=""
TOKEN=""
OUTPUT=""
MAX_RESULTS=200
SKIP_DB=0
QUIET=0
COLOR_MODE="auto"
WEB_USERS="apache,www-data,nginx"
CMS_MODE="auto"
CMS="generic"
ALERT_HITS=0
REVIEW_HITS=0
ERRORS=0
MYSQL_CNF=""
SUSPECTS_OUTPUT=""
REVIEW_SCRIPT=""
RUN_STAMP=$(date '+%Y%m%d-%H%M%S')
usage() {
cat <<'EOF'
Uso:
sudo ./auditor-web-malware.sh --root /ruta/al/sitio [opciones]
Opciones:
-r, --root DIR Directorio raiz del sitio web (obligatorio).
-d, --days N PHP modificados en los ultimos N dias (30).
--since FECHA Buscar ficheros posteriores a FECHA; ej. 2026-07-15.
-l, --logs DIR Directorio de logs Apache/Nginx. Se intenta detectar.
-t, --token TEXTO Token, nombre de webshell o texto literal para los logs.
-o, --output FILE Informe de salida. Por defecto, en el directorio actual.
-m, --max N Maximo de lineas mostradas por prueba (200).
--web-users LIST Usuarios web separados por coma (apache,www-data,nginx).
--cms TIPO auto, joomla, wordpress o generic (auto).
--skip-db No analizar la base de datos del CMS.
-q, --quiet No mostrar progreso; escribir solamente el informe.
--color MODO Colores: auto, always o never (auto).
--no-color Equivale a --color never.
-h, --help Mostrar esta ayuda.
--version Mostrar la version.
Codigos de salida:
0 Auditoria terminada sin indicios de alta prioridad.
1 Se encontraron indicios de alta prioridad que deben revisarse.
2 Error de uso o no se pudo completar una parte esencial.
Ejemplos:
sudo ./auditor-web-malware.sh -r /var/www/vhosts/dominio/httpdocs
sudo ./auditor-web-malware.sh -r /var/www/vhosts/dominio/httpdocs \
--since 2026-07-15 --logs /var/www/vhosts/system/dominio/logs
sudo CMS_DB_PASSWORD='clave' ./auditor-web-malware.sh -r /srv/wordpress
La variable CMS_DB_PASSWORD, si existe, sustituye la clave leida de
configuration.php o wp-config.php. La clave nunca se imprime en el informe.
El auditor genera junto al informe:
- Una lista terminada en .sospechosos.txt.
- Un script autonomo revisar-SITIO-FECHA.sh para examinar con bat o less
los ficheros sospechosos de esa ejecucion concreta.
EOF
}
die() {
printf 'ERROR: %s\n' "$*" >&2
exit 2
}
is_uint() {
[[ "$1" =~ ^[0-9]+$ ]]
}
while (($#)); do
case "$1" in
-r|--root)
(($# >= 2)) || die "Falta el valor de $1"
ROOT=$2; shift 2 ;;
-d|--days)
(($# >= 2)) || die "Falta el valor de $1"
DAYS=$2; shift 2 ;;
--since)
(($# >= 2)) || die "Falta el valor de $1"
SINCE=$2; shift 2 ;;
-l|--logs)
(($# >= 2)) || die "Falta el valor de $1"
LOG_DIR=$2; shift 2 ;;
-t|--token)
(($# >= 2)) || die "Falta el valor de $1"
TOKEN=$2; shift 2 ;;
-o|--output)
(($# >= 2)) || die "Falta el valor de $1"
OUTPUT=$2; shift 2 ;;
-m|--max)
(($# >= 2)) || die "Falta el valor de $1"
MAX_RESULTS=$2; shift 2 ;;
--web-users)
(($# >= 2)) || die "Falta el valor de $1"
WEB_USERS=$2; shift 2 ;;
--cms)
(($# >= 2)) || die "Falta el valor de $1"
CMS_MODE=${2,,}; shift 2 ;;
--skip-db) SKIP_DB=1; shift ;;
-q|--quiet) QUIET=1; shift ;;
--color)
(($# >= 2)) || die "Falta el valor de $1"
COLOR_MODE=${2,,}; shift 2 ;;
--no-color) COLOR_MODE="never"; shift ;;
-h|--help) usage; exit 0 ;;
--version) printf '%s\n' "$VERSION"; exit 0 ;;
--) shift; break ;;
-*) die "Opcion desconocida: $1" ;;
*)
[[ -z "$ROOT" ]] || die "Argumento inesperado: $1"
ROOT=$1; shift ;;
esac
done
[[ -n "$ROOT" ]] || die "Debes indicar --root /ruta/al/sitio"
[[ -d "$ROOT" ]] || die "No existe el directorio: $ROOT"
is_uint "$DAYS" || die "--days debe ser un numero entero"
is_uint "$MAX_RESULTS" && ((MAX_RESULTS > 0)) || die "--max debe ser mayor que cero"
ROOT=$(readlink -f -- "$ROOT") || die "No se pudo resolver la ruta raiz"
[[ "$ROOT" != "/" ]] || die "No se permite auditar / como raiz. Indica el sitio concreto"
[[ "$CMS_MODE" =~ ^(auto|joomla|wordpress|generic)$ ]] || die "--cms debe ser auto, joomla, wordpress o generic"
[[ "$COLOR_MODE" =~ ^(auto|always|never)$ ]] || die "--color debe ser auto, always o never"
if [[ -n "$SINCE" ]] && ! date -d "$SINCE" '+%F %T' >/dev/null 2>&1; then
die "Fecha no valida para --since: $SINCE"
fi
if [[ -z "$OUTPUT" ]]; then
OUTPUT="$PWD/auditoria-web-$(basename "$ROOT")-$RUN_STAMP.log"
fi
OUTPUT_DIR=$(dirname -- "$OUTPUT")
[[ -d "$OUTPUT_DIR" ]] || die "No existe el directorio del informe: $OUTPUT_DIR"
umask 077
: >"$OUTPUT" || die "No se puede escribir el informe: $OUTPUT"
OUTPUT=$(readlink -f -- "$OUTPUT")
if [[ "$OUTPUT" == *.log ]]; then
SUSPECTS_OUTPUT="${OUTPUT%.log}.sospechosos.txt"
else
SUSPECTS_OUTPUT="${OUTPUT}.sospechosos.txt"
fi
REVIEW_SCRIPT="$OUTPUT_DIR/revisar-$(basename "$ROOT")-$RUN_STAMP.sh"
: >"$SUSPECTS_OUTPUT" || die "No se puede escribir la lista de sospechosos: $SUSPECTS_OUTPUT"
WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/auditor-web.XXXXXXXX") || die "No se pudo crear el temporal"
chmod 700 "$WORKDIR"
cleanup() {
if [[ -n "$MYSQL_CNF" && -f "$MYSQL_CNF" ]]; then
: >"$MYSQL_CNF"
fi
rm -rf -- "$WORKDIR"
}
trap cleanup EXIT
# Las señales deben finalizar realmente la auditoria. El trap EXIT se encarga
# después de borrar el temporal y destruir el fichero efimero de MySQL.
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
# Los colores se aplican solo a la consola. El informe siempre queda en texto
# plano para poder buscar, comparar, enviar o procesar sus resultados.
C_RESET=""
C_BOLD=""
C_DIM=""
C_RED=""
C_YELLOW=""
C_GREEN=""
C_BLUE=""
C_CYAN=""
C_MAGENTA=""
C_WHITE=""
init_colors() {
local enable=0
if [[ "$COLOR_MODE" == "always" ]]; then
enable=1
elif [[ "$COLOR_MODE" == "auto" && -t 1 && -z "${NO_COLOR:-}" ]]; then
enable=1
fi
if ((enable)); then
C_RESET=$'\033[0m'
C_BOLD=$'\033[1m'
C_DIM=$'\033[2m'
C_RED=$'\033[1;31m'
C_YELLOW=$'\033[1;33m'
C_GREEN=$'\033[1;32m'
C_BLUE=$'\033[1;34m'
C_CYAN=$'\033[1;36m'
C_MAGENTA=$'\033[1;35m'
C_WHITE=$'\033[0;37m'
fi
}
init_colors
console_line() {
local color=$1
shift
((QUIET)) || printf '%b%s%b\n' "$color" "$*" "$C_RESET"
}
say() {
printf '%s\n' "$*" >>"$OUTPUT"
((QUIET)) || printf '%s\n' "$*"
}
say_color() {
local color=$1
shift
printf '%s\n' "$*" >>"$OUTPUT"
console_line "$color" "$*"
}
progress() {
console_line "$C_CYAN" "-> $*"
}
section() {
local rule="================================================================================"
say ""
say_color "$C_CYAN" "$rule"
say_color "${C_BOLD}${C_CYAN}" "$1"
say_color "$C_CYAN" "$rule"
}
note_error() {
ERRORS=$((ERRORS + 1))
say_color "$C_RED" "[ERROR] $*"
}
priority_color() {
case "$1" in
ALERTA) printf '%s' "$C_RED" ;;
REVISAR) printf '%s' "$C_YELLOW" ;;
INFO) printf '%s' "$C_BLUE" ;;
*) printf '%s' "$C_RESET" ;;
esac
}
# Localiza dentro de una coincidencia el nombre de un fichero real bajo ROOT.
# Sirve tanto para salidas grep (fichero:linea:contenido) como para listados find.
result_file_from_line() {
local line=$1 candidate
[[ "$line" == *"$ROOT"* ]] || return 1
candidate="$ROOT${line#*"$ROOT"}"
while [[ "$candidate" != "$ROOT" && ! -f "$candidate" ]]; do
if [[ "$candidate" == *:* ]]; then
candidate=${candidate%:*}
elif [[ "$candidate" == *" "* ]]; then
candidate=${candidate% *}
elif [[ "$candidate" == *" -> "* ]]; then
candidate=${candidate%% -> *}
else
return 1
fi
done
[[ -f "$candidate" ]] || return 1
printf '%s' "$candidate"
}
# Presenta el contexto en blanco y resalta solo la ruta del fichero.
console_result_line() {
local line=$1 result_file before after
if result_file=$(result_file_from_line "$line"); then
before=${line%%"$result_file"*}
after=${line#*"$result_file"}
printf '%b%s%b%b%s%b%s%b\n' \
"$C_WHITE" "$before" "$C_RESET" \
"$C_MAGENTA" "$result_file" "$C_RESET" \
"$after" "$C_RESET"
else
printf '%b%s%b\n' "$C_WHITE" "$line" "$C_RESET"
fi
}
# Añade a la lista de revision los ficheros de ALERTA o REVISAR, sin duplicados.
collect_suspect_files() {
local file=$1 line result_file
while IFS= read -r line; do
if result_file=$(result_file_from_line "$line"); then
printf '%s\n' "$result_file" >>"$SUSPECTS_OUTPUT"
fi
done <"$file"
sort -u -o "$SUSPECTS_OUTPUT" "$SUSPECTS_OUTPUT"
}
# Crea un revisor autocontenido para los resultados de esta ejecucion.
# Las rutas se insertan escapadas como elementos de un array Bash: nunca se
# evaluan como codigo y los ficheros se abren exclusivamente en modo lectura.
generate_review_script() {
local item
{
cat <<'REVIEW_HEADER'
#!/usr/bin/env bash
#
# Revisor autonomo generado por auditor-web.sh.
# Abre como texto los ficheros sospechosos de una auditoria concreta.
# No ejecuta, modifica ni elimina ninguno de ellos.
set -uo pipefail
export LC_ALL=C
AUDIT_ROOT=
AUDIT_DATE=
AUDIT_REPORT=
FILES=(
REVIEW_HEADER
while IFS= read -r item || [[ -n "$item" ]]; do
[[ -n "$item" ]] || continue
printf ' %q\n' "$item"
done <"$SUSPECTS_OUTPUT"
cat <<'REVIEW_BODY'
)
die() {
printf 'ERROR: %s\n' "$*" >&2
exit 2
}
choose_viewer() {
if command -v bat >/dev/null 2>&1; then
VIEWER="bat"
elif command -v batcat >/dev/null 2>&1; then
VIEWER="batcat"
elif command -v less >/dev/null 2>&1; then
VIEWER="less"
else
die "No se encontro bat, batcat ni less"
fi
}
show_list() {
local i state
printf '\nRevision: %s\n' "$AUDIT_DATE"
printf 'Raiz auditada: %s\n' "$AUDIT_ROOT"
printf 'Informe: %s\n' "$AUDIT_REPORT"
printf 'Ficheros sospechosos: %d\n\n' "${#FILES[@]}"
for i in "${!FILES[@]}"; do
[[ -f "${FILES[$i]}" ]] && state="" || state=" [YA NO EXISTE]"
printf ' %3d) %s%s\n' "$((i + 1))" "${FILES[$i]}" "$state"
done
printf '\n'
}
safe_file() {
local file=$1 resolved
resolved=$(readlink -f -- "$file" 2>/dev/null || true)
[[ -n "$resolved" && -f "$resolved" ]] || return 1
[[ "$resolved" == "$AUDIT_ROOT/"* ]] || return 1
printf '%s' "$resolved"
}
open_file() {
local requested=$1 file
if ! file=$(safe_file "$requested"); then
printf 'No se abre: no existe, no es regular o esta fuera de la raiz auditada.\n' >&2
return
fi
printf '\nRevisando: %s\n' "$file"
printf 'Tamaño: %s bytes | Modificado: %s\n\n' \
"$(stat -c '%s' -- "$file" 2>/dev/null || printf '?')" \
"$(stat -c '%y' -- "$file" 2>/dev/null || printf '?')"
if [[ "$VIEWER" == "bat" || "$VIEWER" == "batcat" ]]; then
"$VIEWER" --paging=always --color=always \
--style=header,numbers,changes -- "$file"
else
LESS='-R -N -S' less -- "$file"
fi
}
choose_viewer
if ((${#FILES[@]} == 0)); then
printf 'Esta auditoria no produjo ficheros sospechosos para revisar.\n'
exit 0
fi
show_list
index=0
while ((index < ${#FILES[@]})); do
printf '[%d/%d] %s\n' "$((index + 1))" "${#FILES[@]}" "${FILES[$index]}"
read -r -p "INTRO/n=abrir, s=saltar, a NUM=abrir, l=lista, q=salir: " action || break
case "$action" in
""|n|N)
open_file "${FILES[$index]}"
index=$((index + 1))
;;
s|S) index=$((index + 1)) ;;
l|L) show_list ;;
q|Q) break ;;
a\ [0-9]*|A\ [0-9]*)
number=${action#* }
if ((number >= 1 && number <= ${#FILES[@]})); then
open_file "${FILES[$((number - 1))]}"
else
printf 'Numero fuera de rango.\n'
fi
;;
*) printf 'Opcion no reconocida.\n' ;;
esac
done
printf 'Revision terminada. No se ha modificado ningun fichero.\n'
REVIEW_BODY
} >"$REVIEW_SCRIPT" || {
note_error "No se pudo generar el script de revision: $REVIEW_SCRIPT"
return 1
}
# Sustituye exclusivamente las tres asignaciones reservadas de cabecera.
sed -i \
-e "s|^AUDIT_ROOT=$|AUDIT_ROOT=$(printf '%q' "$ROOT")|" \
-e "s|^AUDIT_DATE=$|AUDIT_DATE=$(printf '%q' "$(date --iso-8601=seconds)")|" \
-e "s|^AUDIT_REPORT=$|AUDIT_REPORT=$(printf '%q' "$OUTPUT")|" \
"$REVIEW_SCRIPT"
chmod 700 "$REVIEW_SCRIPT" || {
note_error "No se pudo marcar como ejecutable: $REVIEW_SCRIPT"
return 1
}
return 0
}
# Muestra un fichero de resultados, limita su salida y actualiza los contadores.
# Prioridad: ALERTA (alta), REVISAR (media), INFO (informativa).
publish_results() {
local title=$1 priority=$2 file=$3 collect=${4:-1} total color line
total=$(wc -l <"$file" 2>/dev/null || printf '0')
total=${total//[[:space:]]/}
[[ -n "$total" ]] || total=0
color=$(priority_color "$priority")
say ""
say_color "$color" "[$priority] $title"
say_color "$color" "Coincidencias: $total"
if ((total > 0)); then
sed -n "1,${MAX_RESULTS}p" "$file" >>"$OUTPUT"
if ((total > MAX_RESULTS)); then
printf '%s\n' "... se omiten $((total - MAX_RESULTS)) resultados (usa --max para ampliar)." >>"$OUTPUT"
fi
if ((QUIET == 0)); then
while IFS= read -r line; do
console_result_line "$line"
done < <(sed -n "1,${MAX_RESULTS}p" "$file")
if ((total > MAX_RESULTS)); then
console_line "$C_MAGENTA" "... se omiten $((total - MAX_RESULTS)) resultados (usa --max para ampliar)."
fi
fi
case "$priority" in
ALERTA)
ALERT_HITS=$((ALERT_HITS + total))
((collect)) && collect_suspect_files "$file"
;;
REVISAR)
REVIEW_HITS=$((REVIEW_HITS + total))
((collect)) && collect_suspect_files "$file"
;;
esac
else
say_color "$C_GREEN" "Sin coincidencias."
fi
}
# Recorta lineas enormes/ofuscadas para que el informe siga siendo manejable.
trim_lines() {
awk '{ if (length($0) > 1200) print substr($0,1,1200) " ...[linea recortada]"; else print }'
}
grep_code() {
local outfile=$1 regex=$2
grep -RInE --binary-files=without-match \
--exclude-dir=.git --exclude-dir=.svn \
--include='*.php' --include='*.phtml' --include='*.php[0-9]' \
--include='*.pht' --include='*.phar' --include='*.inc' \
--include='*.module' --include='*.js' \
-- "$regex" "$ROOT" 2>/dev/null | trim_lines >"$outfile" || true
}
run_find_recent() {
local outfile=$1
if [[ -n "$SINCE" ]]; then
find "$ROOT" -xdev -type f \
\( -iname '*.php' -o -iname '*.phtml' -o -iname '*.php[0-9]' -o -iname '*.inc' \) \
-newermt "$SINCE" -printf '%TY-%Tm-%Td %TH:%TM %M %u:%g %s bytes %p\n' \
2>/dev/null | sort -r >"$outfile"
else
find "$ROOT" -xdev -type f \
\( -iname '*.php' -o -iname '*.phtml' -o -iname '*.php[0-9]' -o -iname '*.inc' \) \
-mtime "-$DAYS" -printf '%TY-%Tm-%Td %TH:%TM %M %u:%g %s bytes %p\n' \
2>/dev/null | sort -r >"$outfile"
fi
}
progress "Preparando auditoria de $ROOT"
if [[ "$CMS_MODE" == "auto" ]]; then
if [[ -f "$ROOT/configuration.php" && -d "$ROOT/administrator" ]]; then
CMS="joomla"
elif [[ -f "$ROOT/wp-config.php" && -d "$ROOT/wp-includes" ]]; then
CMS="wordpress"
else
CMS="generic"
fi
else
CMS=$CMS_MODE
fi
say_color "${C_BOLD}${C_CYAN}" "AUDITORIA DE SEGURIDAD WEB - auditor-web-malware.sh v$VERSION"
say "Inicio: $(date --iso-8601=seconds)"
say "Servidor: $(hostname -f 2>/dev/null || hostname)"
say "Usuario: $(id -un) (uid=$(id -u))"
say "Raiz web: $ROOT"
say "CMS detectado: $CMS"
say "Informe: $OUTPUT"
say "Periodo: ${SINCE:+desde $SINCE}${SINCE:-ultimos $DAYS dias}"
say_color "$C_GREEN" "Modo: SOLO LECTURA; ninguna coincidencia se elimina automaticamente"
if [[ "$CMS" == "joomla" && ! -f "$ROOT/configuration.php" ]]; then
say_color "$C_YELLOW" "ADVERTENCIA: no se encuentra configuration.php; comprueba la raiz indicada."
elif [[ "$CMS" == "wordpress" && ! -f "$ROOT/wp-config.php" ]]; then
say_color "$C_YELLOW" "ADVERTENCIA: no se encuentra wp-config.php; comprueba la raiz indicada."
elif [[ "$CMS" == "generic" ]]; then
say_color "$C_BLUE" "INFO: CMS no reconocido; se aplicaran todas las pruebas genericas de ficheros."
fi
section "1. ARCHIVOS RECIENTES Y NOMBRES SOSPECHOSOS"
F="$WORKDIR/recent"
run_find_recent "$F"
publish_results "PHP y extensiones ejecutables modificados recientemente" "REVISAR" "$F"
F="$WORKDIR/suspicious_names"
find "$ROOT" -xdev -type f \( \
-iname 'tx_*' -o -iname '*.php.json' -o -iname '*.phtml' -o \
-iname '*.php[0-9]' -o -iname '*.pht' -o -iname '*.phar' -o -iname '.*.php' -o -iname '*.jpg.php' -o \
-iname '*.png.php' -o -iname '*.gif.php' -o -iname '*.ico.php' -o \
-iname '*.txt.php' -o -iname '*.bak.php' -o -iname '*.old.php' -o \
-iname '*php*.jpg' -o -iname '*php*.png' \
\) -printf '%TY-%Tm-%Td %TH:%TM %M %u:%g %s bytes %p\n' \
2>/dev/null | sort -r >"$F"
publish_results "Extensiones dobles, PHP oculto, tx_* y nombres de alta sospecha" "ALERTA" "$F"
F="$WORKDIR/generic_names"
find "$ROOT" -xdev -type f \( \
-iname 'cache.php' -o -iname 'about.php' -o -iname 'license.php' -o \
-iname 'style.php' -o -iname 'index_old.php' -o -iname 'config.old.php' \
\) -printf '%TY-%Tm-%Td %TH:%TM %M %u:%g %s bytes %p\n' \
2>/dev/null | sort -r >"$F"
publish_results "Nombres genericos usados a veces por webshells (pueden ser legitimos)" "REVISAR" "$F"
section "2. PHP EN DIRECTORIOS DONDE NORMALMENTE NO DEBERIA EJECUTARSE"
F="$WORKDIR/php_upload_dirs"
: >"$F"
RISKY_HIGH_DIRS=(images media uploads wp-content/uploads)
for d in "${RISKY_HIGH_DIRS[@]}"; do
[[ -d "$ROOT/$d" ]] || continue
find "$ROOT/$d" -xdev -type f \( \
-iname '*.php' -o -iname '*.phtml' -o -iname '*.php[0-9]' -o -iname '*.pht' -o -iname '*.phar' -o -iname '*.inc' \
\) -printf '%TY-%Tm-%Td %TH:%TM %M %u:%g %s bytes %p\n' 2>/dev/null >>"$F"
done
sort -r -o "$F" "$F"
publish_results "Scripts PHP dentro de uploads, images o media" "ALERTA" "$F"
F="$WORKDIR/php_cache_dirs"
: >"$F"
RISKY_REVIEW_DIRS=(tmp cache logs wp-content/cache wp-content/upgrade)
for d in "${RISKY_REVIEW_DIRS[@]}"; do
[[ -d "$ROOT/$d" ]] || continue
find "$ROOT/$d" -xdev -type f \( \
-iname '*.php' -o -iname '*.phtml' -o -iname '*.php[0-9]' -o -iname '*.pht' -o -iname '*.phar' -o -iname '*.inc' \
\) -printf '%TY-%Tm-%Td %TH:%TM %M %u:%g %s bytes %p\n' 2>/dev/null >>"$F"
done
sort -r -o "$F" "$F"
publish_results "Scripts PHP dentro de tmp, cache, logs o upgrade" "REVISAR" "$F"
section "3. FIRMAS DE CODIGO PELIGROSO U OFUSCADO"
F="$WORKDIR/high_signal"
HIGH_SIGNAL_REGEX='eval[[:space:]]*\([[:space:]]*(base64_decode|gzinflate|gzuncompress|str_rot13)|assert[[:space:]]*\([[:space:]]*\$_(GET|POST|REQUEST|COOKIE)|((system|exec|shell_exec|passthru|popen)[[:space:]]*\([[:space:]]*\$_(GET|POST|REQUEST|COOKIE))|call_user_func(_array)?[[:space:]]*\([[:space:]]*\$_(GET|POST|REQUEST|COOKIE)|base64_decode[[:space:]]*\([[:space:]]*\$_(GET|POST|REQUEST|COOKIE)'
grep_code "$F" "$HIGH_SIGNAL_REGEX"
publish_results "Combinaciones de alta senal tipicas de webshell" "ALERTA" "$F"
F="$WORKDIR/command_exec"
grep_code "$F" '(system|exec|shell_exec|passthru|proc_open|popen|pcntl_exec)[[:space:]]*\('
publish_results "Funciones capaces de ejecutar comandos" "REVISAR" "$F"
F="$WORKDIR/obfuscation"
grep_code "$F" '(eval|assert|base64_decode|gzinflate|gzuncompress|str_rot13|rawurldecode|urldecode|create_function)[[:space:]]*\(|[A-Za-z0-9+/]{500,}={0,2}'
publish_results "Ofuscacion, evaluacion dinamica o cadenas Base64 muy largas" "REVISAR" "$F"
F="$WORKDIR/uploads"
grep_code "$F" 'move_uploaded_file[[:space:]]*\(|is_uploaded_file[[:space:]]*\('
publish_results "Codigo capaz de recibir/subir ficheros" "REVISAR" "$F"
F="$WORKDIR/direct_db"
grep_code "$F" 'mysqli_connect|new[[:space:]]+mysqli|PDO[[:space:]]*\('
publish_results "Conexiones directas a bases de datos" "REVISAR" "$F"
F="$WORKDIR/js_payloads"
grep_code "$F" '<script|eval[[:space:]]*\(|atob[[:space:]]*\(|fromCharCode[[:space:]]*\(|document\.write[[:space:]]*\('
publish_results "JavaScript inyectado u ofuscado en PHP/JS" "REVISAR" "$F"
F="$WORKDIR/remote_io"
grep_code "$F" '(curl_exec|fsockopen|stream_socket_client)[[:space:]]*\(|(file_get_contents|fopen)[[:space:]]*\([[:space:]]*"https?://|file_put_contents[[:space:]]*\('
publish_results "Descarga remota, sockets o escritura de ficheros" "REVISAR" "$F"
F="$WORKDIR/very_long_lines"
: >"$F"
while IFS= read -r -d '' codefile; do
awk 'length($0)>10000 {printf "%s:%d: longitud=%d\n", FILENAME, NR, length($0)}' "$codefile" 2>/dev/null || true
done < <(find "$ROOT" -xdev -type f \( -iname '*.php' -o -iname '*.phtml' -o -iname '*.php[0-9]' -o -iname '*.pht' -o -iname '*.phar' -o -iname '*.inc' -o -iname '*.js' \) -print0 2>/dev/null) >"$F"
publish_results "Lineas de codigo mayores de 10.000 caracteres (posible ofuscacion)" "REVISAR" "$F"
section "4. PERMISOS, PROPIETARIOS Y ENLACES SIMBOLICOS"
F="$WORKDIR/exact_777"
find "$ROOT" -xdev \( -type f -o -type d \) -perm 0777 \
-printf '%M %u:%g %p\n' 2>/dev/null >"$F"
publish_results "Archivos o directorios con permisos exactamente 777" "ALERTA" "$F"
F="$WORKDIR/world_writable"
find "$ROOT" -xdev \( -type f -o -type d \) -perm -0002 \
-printf '%M %u:%g %p\n' 2>/dev/null >"$F"
publish_results "Elementos escribibles por cualquier usuario (world-writable)" "REVISAR" "$F"
F="$WORKDIR/web_owner"
: >"$F"
IFS=',' read -r -a WEB_USER_ARRAY <<<"$WEB_USERS"
for webuser in "${WEB_USER_ARRAY[@]}"; do
webuser=${webuser//[[:space:]]/}
[[ -n "$webuser" ]] || continue
if id "$webuser" >/dev/null 2>&1; then
find "$ROOT" -xdev -user "$webuser" -printf "$webuser %M %u:%g %p\n" \
2>/dev/null >>"$F"
fi
done
publish_results "Elementos propiedad de usuarios del servidor web" "REVISAR" "$F"
F="$WORKDIR/external_links"
: >"$F"
while IFS= read -r -d '' link; do
target=$(readlink -f -- "$link" 2>/dev/null || true)
if [[ -z "$target" ]]; then
printf 'ROTO %s -> %s\n' "$link" "$(readlink -- "$link" 2>/dev/null)" >>"$F"
elif [[ "$target" != "$ROOT" && "$target" != "$ROOT/"* ]]; then
printf 'EXTERNO %s -> %s\n' "$link" "$target" >>"$F"
fi
done < <(find "$ROOT" -xdev -type l -print0 2>/dev/null)
publish_results "Enlaces simbolicos rotos o que salen de la raiz web" "REVISAR" "$F"
section "5. .HTACCESS, .USER.INI Y CONFIGURACION PHP"
F="$WORKDIR/config_injections"
: >"$F"
while IFS= read -r -d '' cfg; do
grep -HniE --binary-files=without-match \
'(auto_prepend_file|auto_append_file|AddHandler.*(php|phtml)|SetHandler.*(php|proxy)|base64|eval[[:space:]]*\()' \
"$cfg" 2>/dev/null || true
done < <(find "$ROOT" -xdev -type f \( -name '.htaccess' -o -name '.user.ini' -o -name 'php.ini' \) -print0 2>/dev/null) \
| trim_lines >"$F"
publish_results "Reglas capaces de ejecutar PHP o cargar codigo antes del CMS" "ALERTA" "$F"
F="$WORKDIR/config_redirects"
: >"$F"
while IFS= read -r -d '' cfg; do
grep -HniE --binary-files=without-match \
'(RewriteRule.*https?://|RewriteCond.*HTTP_USER_AGENT|php_value|php_flag)' \
"$cfg" 2>/dev/null || true
done < <(find "$ROOT" -xdev -type f \( -name '.htaccess' -o -name '.user.ini' -o -name 'php.ini' \) -print0 2>/dev/null) \
| trim_lines >"$F"
publish_results "Redirecciones o reglas condicionales que deben verificarse" "REVISAR" "$F"
F="$WORKDIR/all_htaccess"
find "$ROOT" -xdev -type f \( -name '.htaccess' -o -name '.user.ini' -o -name 'php.ini' \) \
-printf '%TY-%Tm-%Td %TH:%TM %M %u:%g %s bytes %p\n' 2>/dev/null | sort -r >"$F"
publish_results "Inventario de .htaccess, .user.ini y php.ini para revision manual" "INFO" "$F"
section "6. LOGS DE APACHE/NGINX"
LOG_DIRS=()
if [[ -n "$LOG_DIR" ]]; then
[[ -d "$LOG_DIR" ]] && LOG_DIRS+=("$(readlink -f -- "$LOG_DIR")") || note_error "No existe --logs $LOG_DIR"
else
parent=$(dirname -- "$ROOT")
domain=$(basename -- "$parent")
[[ -d "$parent/logs" ]] && LOG_DIRS+=("$parent/logs")
[[ -d "/var/www/vhosts/system/$domain/logs" ]] && LOG_DIRS+=("/var/www/vhosts/system/$domain/logs")
[[ -d "$ROOT/logs" ]] && LOG_DIRS+=("$ROOT/logs")
fi
F="$WORKDIR/access_logs"
: >"$F"
LOG_REGEX='tx_|php\.json|\.phtml|cmd=|shell|webshell|base64|com_sppagebuilder|sppagebuilder|helixultimate|option=com_ajax|wp-content/uploads/[^? ]+\.php|wp-vcd|eval-stdin|wp-file-manager'
for logdir in "${LOG_DIRS[@]}"; do
while IFS= read -r -d '' logfile; do
zgrep -HniE -- "$LOG_REGEX" "$logfile" 2>/dev/null || true
if [[ -n "$TOKEN" ]]; then
zgrep -HniF -- "$TOKEN" "$logfile" 2>/dev/null || true
fi
done < <(find "$logdir" -maxdepth 2 -type f \( -iname 'access*' -o -iname '*access*log*' -o -iname 'proxy_access*' \) -print0 2>/dev/null)
done | trim_lines >"$F"
if ((${#LOG_DIRS[@]} == 0)); then
say_color "$C_YELLOW" "No se detecto un directorio de logs. Repite con --logs /ruta/a/logs."
fi
# Estas coincidencias pertenecen a los logs, no a los ficheros solicitados en
# cada URL. Se muestran y contabilizan, pero no deben recorrerse línea a línea
# ni añadirse a la lista de archivos PHP que abrirá el revisor autónomo.
publish_results "Peticiones relacionadas con webshells, payloads, Helix o SP Page Builder" "REVISAR" "$F" 0
section "7. TAREAS PROGRAMADAS Y PERSISTENCIA"
F="$WORKDIR/cron_suspicious"
: >"$F"
for crondir in /etc/crontab /etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly /var/spool/cron /var/spool/cron/crontabs; do
[[ -e "$crondir" ]] || continue
if [[ -f "$crondir" ]]; then
grep -HniE '(curl|wget|php|python|perl|base64|/tmp/|/dev/shm|nc[[:space:]]|bash[[:space:]]+-c)' "$crondir" 2>/dev/null || true
elif [[ -d "$crondir" ]]; then
grep -RInE --binary-files=without-match \
'(curl|wget|php|python|perl|base64|/tmp/|/dev/shm|nc[[:space:]]|bash[[:space:]]+-c)' \
"$crondir" 2>/dev/null || true
fi
done | trim_lines >"$F"
publish_results "Cron con descargas, interpretes o ejecucion desde temporales" "REVISAR" "$F"
F="$WORKDIR/user_cron"
crontab -l >"$F" 2>/dev/null || : >"$F"
publish_results "Crontab del usuario que ejecuta la auditoria ($(id -un))" "INFO" "$F"
# Extrae una variable o un define() literal de un fichero PHP mediante tokens.
# El fichero potencialmente comprometido se lee como texto: NO se ejecuta.
php_config_value() {
local config=$1 kind=$2 property=$3
[[ -n "${PHP_BIN:-}" ]] || return 1
"$PHP_BIN" -r '
$code = @file_get_contents($argv[1]);
if ($code === false) exit(1);
$tokens = token_get_all($code);
$kind = $argv[2]; $wanted = $argv[3];
$n = count($tokens);
$decode = function($token) {
$s=$token; $q=$s[0]; $s=substr($s,1,-1);
if ($q === "\"") return stripcslashes($s);
return str_replace(["\\\\", "\\".chr(39)], ["\\", chr(39)], $s);
};
for ($i=0; $i<$n; $i++) {
if ($kind === "variable") {
if (!is_array($tokens[$i]) || $tokens[$i][0] !== T_VARIABLE || $tokens[$i][1] !== "$".$wanted) continue;
for ($j=$i+1; $j<$n && $j<$i+12; $j++) {
if (is_array($tokens[$j]) && $tokens[$j][0] === T_CONSTANT_ENCAPSED_STRING) {
echo $decode($tokens[$j][1]); exit(0);
}
}
} elseif ($kind === "define") {
if (!is_array($tokens[$i]) || $tokens[$i][0] !== T_STRING || strtolower($tokens[$i][1]) !== "define") continue;
$strings=[];
for ($j=$i+1; $j<$n && $j<$i+20; $j++) {
if (is_array($tokens[$j]) && $tokens[$j][0] === T_CONSTANT_ENCAPSED_STRING) $strings[]=$decode($tokens[$j][1]);
if ($tokens[$j] === ";") break;
}
if (count($strings)>=2 && $strings[0] === $wanted) { echo $strings[1]; exit(0); }
}
}
exit(1);
' "$config" "$kind" "$property" 2>/dev/null
}
section "8. VERSIONES LOCALES DEL CMS, EXTENSIONES, PLUGINS Y TEMAS"
F="$WORKDIR/versions"
: >"$F"
PHP_BIN=$(command -v php 2>/dev/null || true)
if [[ -z "$PHP_BIN" ]]; then
for candidate in /opt/plesk/php/*/bin/php /usr/local/bin/php /usr/bin/php; do
[[ -x "$candidate" ]] && PHP_BIN=$candidate
done
fi
if [[ "$CMS" == "joomla" ]]; then
for manifest in \
"$ROOT/administrator/manifests/files/joomla.xml" \
"$ROOT/plugins/system/helixultimate/helixultimate.xml" \
"$ROOT/administrator/components/com_sppagebuilder/sppagebuilder.xml" \
"$ROOT/components/com_sppagebuilder/sppagebuilder.xml"; do
[[ -f "$manifest" ]] || continue
version=$(sed -n 's:.*<version[^>]*>[[:space:]]*\([^<]*\)[[:space:]]*</version>.*:\1:p' "$manifest" | head -n 1)
printf '%s version=%s\n' "$manifest" "${version:-no detectada}" >>"$F"
done
while IFS= read -r -d '' manifest; do
version=$(sed -n 's:.*<version[^>]*>[[:space:]]*\([^<]*\)[[:space:]]*</version>.*:\1:p' "$manifest" | head -n 1)
printf '%s version=%s\n' "$manifest" "${version:-no detectada}" >>"$F"
done < <(find "$ROOT/templates" -mindepth 2 -maxdepth 2 -type f -name 'templateDetails.xml' -print0 2>/dev/null)
elif [[ "$CMS" == "wordpress" ]]; then
if [[ -f "$ROOT/wp-includes/version.php" ]]; then
version=$(php_config_value "$ROOT/wp-includes/version.php" variable wp_version || true)
if [[ -z "$version" ]]; then
version=$(sed -nE "s/^[[:space:]]*\\\$wp_version[[:space:]]*=[[:space:]]*[\"']([^\"']+)[\"'].*/\\1/p" "$ROOT/wp-includes/version.php" | head -n 1)
fi
printf 'WordPress core version=%s\n' "${version:-no detectada}" >>"$F"
fi
while IFS= read -r -d '' plugin_file; do
plugin_name=$(sed -nE 's/^[[:space:]]*\*?[[:space:]]*Plugin Name:[[:space:]]*(.*)$/\1/ip' "$plugin_file" | head -n 1)
plugin_version=$(sed -nE 's/^[[:space:]]*\*?[[:space:]]*Version:[[:space:]]*(.*)$/\1/ip' "$plugin_file" | head -n 1)
[[ -n "$plugin_name" ]] && printf 'Plugin: %s version=%s fichero=%s\n' "$plugin_name" "${plugin_version:-no detectada}" "$plugin_file" >>"$F"
done < <(grep -RIlZ --include='*.php' -m1 'Plugin Name:' "$ROOT/wp-content/plugins" 2>/dev/null || true)
while IFS= read -r -d '' style_file; do
theme_name=$(sed -nE 's/^[[:space:]]*Theme Name:[[:space:]]*(.*)$/\1/ip' "$style_file" | head -n 1)
theme_version=$(sed -nE 's/^[[:space:]]*Version:[[:space:]]*(.*)$/\1/ip' "$style_file" | head -n 1)
[[ -n "$theme_name" ]] && printf 'Tema: %s version=%s fichero=%s\n' "$theme_name" "${theme_version:-no detectada}" "$style_file" >>"$F"
done < <(find "$ROOT/wp-content/themes" -mindepth 2 -maxdepth 2 -type f -name style.css -print0 2>/dev/null)
fi
sort -u -o "$F" "$F"
publish_results "Versiones detectadas; comparar con las corregidas del fabricante" "INFO" "$F"
mysql_escape_option() {
local value=$1
value=${value//\\/\\\\}
value=${value//\"/\\\"}
printf '%s' "$value"
}
db_query_to_file() {
local outfile=$1 sql=$2
if ! "$MYSQL_BIN" --defaults-extra-file="$MYSQL_CNF" --batch --raw --skip-column-names \
--default-character-set=utf8mb4 "$DB_NAME" -e "$sql" >"$outfile" 2>"$outfile.err"; then
note_error "Consulta SQL fallida: $(tr '\n' ' ' <"$outfile.err" | cut -c1-300)"
: >"$outfile"
return 1
fi
}
section "9. BASE DE DATOS DEL CMS"
CONFIG=""
[[ "$CMS" == "joomla" ]] && CONFIG="$ROOT/configuration.php"
[[ "$CMS" == "wordpress" ]] && CONFIG="$ROOT/wp-config.php"
if ((SKIP_DB)); then
say "Analisis SQL omitido por --skip-db."
elif [[ "$CMS" == "generic" ]]; then
say "Analisis SQL omitido: CMS generico, sin esquema de tablas conocido."
elif [[ -z "$CONFIG" || ! -f "$CONFIG" ]]; then
say "Analisis SQL omitido: falta el fichero de configuracion del CMS."
elif [[ -z "$PHP_BIN" ]]; then
say "Analisis SQL omitido: PHP CLI no esta instalado ni se encontro en /opt/plesk/php."
else
MYSQL_BIN=$(command -v mariadb 2>/dev/null || command -v mysql 2>/dev/null || true)
if [[ -z "$MYSQL_BIN" ]]; then
say "Analisis SQL omitido: no se encontro el cliente mariadb/mysql."
else
if [[ "$CMS" == "joomla" ]]; then
DB_TYPE=$(php_config_value "$CONFIG" variable dbtype || true)
DB_HOST=${CMS_DB_HOST:-$(php_config_value "$CONFIG" variable host || true)}
DB_USER=${CMS_DB_USER:-$(php_config_value "$CONFIG" variable user || true)}
DB_PASS=${CMS_DB_PASSWORD:-${JOOMLA_DB_PASSWORD:-$(php_config_value "$CONFIG" variable password || true)}}
DB_NAME=${CMS_DB_NAME:-$(php_config_value "$CONFIG" variable db || true)}
DB_PREFIX=${CMS_DB_PREFIX:-$(php_config_value "$CONFIG" variable dbprefix || true)}
else
DB_TYPE="mysqli"
DB_HOST=${CMS_DB_HOST:-$(php_config_value "$CONFIG" define DB_HOST || true)}
DB_USER=${CMS_DB_USER:-$(php_config_value "$CONFIG" define DB_USER || true)}
DB_PASS=${CMS_DB_PASSWORD:-$(php_config_value "$CONFIG" define DB_PASSWORD || true)}
DB_NAME=${CMS_DB_NAME:-$(php_config_value "$CONFIG" define DB_NAME || true)}
DB_PREFIX=${CMS_DB_PREFIX:-$(php_config_value "$CONFIG" variable table_prefix || true)}
fi
if [[ -n "$DB_TYPE" && "$DB_TYPE" != "mysqli" && "$DB_TYPE" != "mysql" ]]; then
say "Analisis SQL omitido: el tipo de base de datos es $DB_TYPE, no MySQL/MariaDB."
elif [[ ! "$DB_PREFIX" =~ ^[A-Za-z0-9_]+$ || -z "$DB_NAME" || -z "$DB_USER" ]]; then
say "Analisis SQL omitido: no se pudieron leer credenciales/prefijo literales sin ejecutar la configuracion."
say "Puedes indicar CMS_DB_HOST, CMS_DB_USER, CMS_DB_PASSWORD, CMS_DB_NAME y CMS_DB_PREFIX."
else
MYSQL_CNF="$WORKDIR/mysql-client.cnf"
umask 077
DB_PORT=""
DB_SOCKET=""
if [[ "$DB_HOST" =~ ^([^:]+):([0-9]+)$ ]]; then
DB_HOST=${BASH_REMATCH[1]}
DB_PORT=${BASH_REMATCH[2]}
elif [[ "$DB_HOST" =~ ^([^:]+):(/.+)$ ]]; then
DB_HOST=${BASH_REMATCH[1]}
DB_SOCKET=${BASH_REMATCH[2]}
fi
{
printf '[client]\n'
printf 'user="%s"\n' "$(mysql_escape_option "$DB_USER")"
printf 'password="%s"\n' "$(mysql_escape_option "$DB_PASS")"
printf 'host="%s"\n' "$(mysql_escape_option "${DB_HOST:-localhost}")"
[[ -n "$DB_PORT" ]] && printf 'port=%s\n' "$DB_PORT"
[[ -n "$DB_SOCKET" ]] && printf 'socket="%s"\n' "$(mysql_escape_option "$DB_SOCKET")"
} >"$MYSQL_CNF"
chmod 600 "$MYSQL_CNF"
F="$WORKDIR/db_test"
if ! db_query_to_file "$F" 'SELECT 1 AS conexion;'; then
say "No se pudo conectar a la base de datos; el resto de SQL se omite."
else
say_color "$C_GREEN" "Conexion SQL de solo lectura establecida con la base '$DB_NAME' y prefijo '$DB_PREFIX'."
if [[ "$CMS" == "joomla" ]]; then
F="$WORKDIR/db_users"
db_query_to_file "$F" "SELECT u.id,u.name,u.username,u.email,u.registerDate,COALESCE(GROUP_CONCAT(g.title ORDER BY g.title SEPARATOR ','),'sin grupo') AS grupos FROM ${DB_PREFIX}users u LEFT JOIN ${DB_PREFIX}user_usergroup_map m ON m.user_id=u.id LEFT JOIN ${DB_PREFIX}usergroups g ON g.id=m.group_id GROUP BY u.id,u.name,u.username,u.email,u.registerDate ORDER BY u.id DESC;" || true
publish_results "Usuarios Joomla, del mas reciente al mas antiguo" "INFO" "$F"
F="$WORKDIR/db_admins"
db_query_to_file "$F" "SELECT u.id,u.name,u.username,u.email,u.registerDate,g.id AS group_id,g.title FROM ${DB_PREFIX}users u JOIN ${DB_PREFIX}user_usergroup_map m ON m.user_id=u.id JOIN ${DB_PREFIX}usergroups g ON g.id=m.group_id WHERE g.id=8 OR LOWER(g.title) IN ('super users','superusuarios','super utilisateurs') ORDER BY u.id DESC;" || true
publish_results "Super Users de Joomla (verificar uno por uno)" "INFO" "$F"
F="$WORKDIR/db_menu_bad"
db_query_to_file "$F" "SELECT id,title,LENGTH(params) AS longitud,LEFT(REPLACE(REPLACE(params,CHAR(10),' '),CHAR(13),' '),1000) AS muestra FROM ${DB_PREFIX}menu WHERE params REGEXP '<script|eval[[:space:]]*\\\\(|atob[[:space:]]*\\\\(|fromCharCode[[:space:]]*\\\\(|document\\\\.write[[:space:]]*\\\\(' ORDER BY LENGTH(params) DESC;" || true
publish_results "Parametros de menu Joomla con scripts u ofuscacion" "ALERTA" "$F"
F="$WORKDIR/db_menu_review"
db_query_to_file "$F" "SELECT id,title,LENGTH(params) AS longitud,LEFT(params,1000) AS muestra FROM ${DB_PREFIX}menu WHERE params LIKE '%http://%' OR params LIKE '%https://%' OR LENGTH(params)>10000 ORDER BY LENGTH(params) DESC LIMIT 100;" || true
publish_results "Parametros de menu con URL o tamano anormal" "REVISAR" "$F"
F="$WORKDIR/db_templates_bad"
db_query_to_file "$F" "SELECT id,title,template,LENGTH(params) AS longitud,LEFT(REPLACE(REPLACE(params,CHAR(10),' '),CHAR(13),' '),1000) AS muestra FROM ${DB_PREFIX}template_styles WHERE params REGEXP '<script|eval[[:space:]]*\\\\(|atob[[:space:]]*\\\\(|fromCharCode[[:space:]]*\\\\(|document\\\\.write[[:space:]]*\\\\(' OR LENGTH(params)>10000 ORDER BY LENGTH(params) DESC;" || true
publish_results "Estilos de plantilla con payload o parametros anormalmente grandes" "ALERTA" "$F"
F="$WORKDIR/db_modules_bad"
db_query_to_file "$F" "SELECT id,title,module,LENGTH(params) AS params_len,LENGTH(content) AS content_len,LEFT(REPLACE(REPLACE(CONCAT(params,' ',content),CHAR(10),' '),CHAR(13),' '),1000) AS muestra FROM ${DB_PREFIX}modules WHERE CONCAT(params,' ',content) REGEXP '<script|eval[[:space:]]*\\\\(|atob[[:space:]]*\\\\(|fromCharCode[[:space:]]*\\\\(|document\\\\.write[[:space:]]*\\\\(' OR LENGTH(params)>10000 OR LENGTH(content)>50000 ORDER BY GREATEST(LENGTH(params),LENGTH(content)) DESC;" || true
publish_results "Modulos con scripts, ofuscacion o contenido muy grande" "ALERTA" "$F"
else
F="$WORKDIR/db_users"
db_query_to_file "$F" "SELECT ID,user_login,user_email,user_registered,display_name FROM ${DB_PREFIX}users ORDER BY ID DESC;" || true
publish_results "Usuarios WordPress, del mas reciente al mas antiguo" "INFO" "$F"
F="$WORKDIR/db_admins"
db_query_to_file "$F" "SELECT u.ID,u.user_login,u.user_email,u.user_registered,um.meta_key,um.meta_value AS capacidades FROM ${DB_PREFIX}users u JOIN ${DB_PREFIX}usermeta um ON um.user_id=u.ID WHERE um.meta_key LIKE '${DB_PREFIX}%capabilities' AND um.meta_value LIKE '%administrator%' ORDER BY u.ID DESC;" || true
publish_results "Administradores WordPress (verificar uno por uno)" "INFO" "$F"
F="$WORKDIR/db_wp_options_bad"
db_query_to_file "$F" "SELECT option_id,option_name,LENGTH(option_value) AS longitud,LEFT(REPLACE(REPLACE(option_value,CHAR(10),' '),CHAR(13),' '),1000) AS muestra FROM ${DB_PREFIX}options WHERE option_value REGEXP '<script|eval[[:space:]]*\\\\(|atob[[:space:]]*\\\\(|fromCharCode[[:space:]]*\\\\(|document\\\\.write[[:space:]]*\\\\(|base64_decode[[:space:]]*\\\\(' ORDER BY LENGTH(option_value) DESC;" || true
publish_results "wp_options con scripts, evaluacion u ofuscacion" "ALERTA" "$F"
F="$WORKDIR/db_wp_options_large"
db_query_to_file "$F" "SELECT option_id,option_name,autoload,LENGTH(option_value) AS longitud,LEFT(REPLACE(REPLACE(option_value,CHAR(10),' '),CHAR(13),' '),500) AS muestra FROM ${DB_PREFIX}options WHERE LENGTH(option_value)>10000 ORDER BY LENGTH(option_value) DESC LIMIT 100;" || true
publish_results "Opciones WordPress grandes que merecen revision" "REVISAR" "$F"
F="$WORKDIR/db_wp_key_options"
db_query_to_file "$F" "SELECT option_name,LEFT(REPLACE(REPLACE(option_value,CHAR(10),' '),CHAR(13),' '),1500) AS valor FROM ${DB_PREFIX}options WHERE option_name IN ('siteurl','home','admin_email','default_role','active_plugins');" || true
publish_results "URL, correo administrador, rol por defecto y plugins activos" "INFO" "$F"
F="$WORKDIR/db_wp_posts_bad"
db_query_to_file "$F" "SELECT ID,post_type,post_status,post_title,LENGTH(post_content) AS longitud,LEFT(REPLACE(REPLACE(post_content,CHAR(10),' '),CHAR(13),' '),1000) AS muestra FROM ${DB_PREFIX}posts WHERE post_content REGEXP 'eval[[:space:]]*\\\\(|atob[[:space:]]*\\\\(|fromCharCode[[:space:]]*\\\\(|document\\\\.write[[:space:]]*\\\\(' ORDER BY ID DESC LIMIT 200;" || true
publish_results "Entradas/paginas WordPress con JavaScript ofuscado" "REVISAR" "$F"
fi
fi
fi
fi
fi
section "10. PASOS MANUALES IMPRESCINDIBLES"
say_color "$C_YELLOW" "[ ] Confirmar cada administrador/Super User y eliminar solo los no reconocidos."
say_color "$C_YELLOW" "[ ] Comparar los archivos sospechosos con una copia limpia de la misma version."
say_color "$C_YELLOW" "[ ] Revisar las lineas de access_log alrededor del primer acceso sospechoso."
if [[ "$CMS" == "joomla" ]]; then
say_color "$C_YELLOW" "[ ] Actualizar Joomla, Helix Ultimate/Helix3, SP Page Builder y extensiones."
elif [[ "$CMS" == "wordpress" ]]; then
say_color "$C_YELLOW" "[ ] Actualizar WordPress, plugins y temas; eliminar los que no se utilicen."
else
say_color "$C_YELLOW" "[ ] Actualizar el CMS/framework y todas sus extensiones o dependencias."
fi
say_color "$C_YELLOW" "[ ] Despues de limpiar: cambiar CMS, MySQL, FTP/SFTP, SSH, panel y API keys."
say_color "$C_YELLOW" "[ ] Revocar sesiones y comprobar crons, usuarios del sistema y claves SSH."
say_color "$C_YELLOW" "[ ] Conservar una copia forense antes de borrar nada si puede haber investigacion."
section "RESUMEN"
generate_review_script
say "Fin: $(date --iso-8601=seconds)"
if ((ALERT_HITS > 0)); then
say_color "$C_RED" "Coincidencias ALERTA: $ALERT_HITS"
else
say_color "$C_GREEN" "Coincidencias ALERTA: $ALERT_HITS"
fi
if ((REVIEW_HITS > 0)); then
say_color "$C_YELLOW" "Coincidencias para REVISAR: $REVIEW_HITS"
else
say_color "$C_GREEN" "Coincidencias para REVISAR: $REVIEW_HITS"
fi
if ((ERRORS > 0)); then
say_color "$C_RED" "Errores parciales: $ERRORS"
else
say_color "$C_GREEN" "Errores parciales: $ERRORS"
fi
say_color "$C_CYAN" "Informe completo: $OUTPUT"
say_color "$C_CYAN" "Lista de ficheros sospechosos: $SUSPECTS_OUTPUT"
if [[ -x "$REVIEW_SCRIPT" ]]; then
say_color "$C_GREEN" "Script de revision generado: $REVIEW_SCRIPT"
say_color "$C_GREEN" "Ejecutar revision: $REVIEW_SCRIPT"
fi
say ""
say_color "$C_MAGENTA" "IMPORTANTE: una coincidencia no demuestra por si sola que exista malware."
say_color "$C_MAGENTA" "La prioridad aumenta cuando coinciden fecha reciente, ubicacion impropia,"
say_color "$C_MAGENTA" "nombre anomalo, ofuscacion y una peticion correlacionada en access_log."
if ((ERRORS > 0)) && ((ALERT_HITS == 0)); then
exit 2
elif ((ALERT_HITS > 0)); then
exit 1
fi
exit 0