#!/bin/bash
#Nifty: Fix all kinds of nasty WordPress backdoors on shared hosting

set -o pipefail

HOMEDIR="${1%/}"
if [ -z "$HOMEDIR" ]; then
  echo "Please provide a path to scan as the first positional argument."
fi

WP_SUSPICIOUS_USERS_CLAUSE='
  WHERE (
    user_login IN (
      "wpadmin", "wpclient", "wpcore", "jicato3848", "wordcamp", "wwwadmin", "wadminw", "itsme", "MUWY", "user",
      "JohnMiller83", "WPsupport", "WordPress", "wp_update_srv", "plesk_administrator", "wordpress_administrator",
      "bapaksaya", "wp-configuser", "wordpress_maintenance", "WPonlineSupport", "wpapitest", "admingusar", "adminfz",
      "wadmmines", "crander", "wpx", "admin@zzna.ru"
    ) AND user_email IN (
      "wp-security@hotmail.com", "jicato3848@2beiop.com", "wordcamp@wordpress.com", "wwwadmin@wordpress.com",
      "wadminw@wordpress.com", "123@abc.com", "wordpressuser@gmail.com", "support@wordpress.com",
      "support@wordpress.org", "JohnMiller83@wordpress.com", "demo@wordpress.com",
      "wordpress-update-service@wordpress.com", "pleskadmin@sadas.cc", "jlocasdasdasdsadasd@gmail.com",
      "polyanitsya2022@rambler.ru", "wp-configuser@config.com", "wordpressupdate@yandex.com", "utsav@wponlinesupport.com",
      "wpapitest@config.com", "adminguser@gmail.com", "adminfz@gmail.com", "email@email.me", "crander@stand.com",
      "jojojo1xx@gmail.com", "admin@zzna.ru"
    )
  )
  OR
  (
    user_login IN (
      "cgqqSecurity"
    )
  )
  OR
  (
    user_email IN (
      "BIBIL0DAY@proton.me"
    )
  )
  OR (user_login LIKE "wp_update-%" AND user_email = "")
'

read_wp_const() {
  local CONSTANT_NAME="$1"
  local WP_CONFIG="$2"
  grep -E 'define\(.+'"$CONSTANT_NAME" "$WP_CONFIG" | sed -E "s/.*define\(.+${CONSTANT_NAME}.+['\"]+([^'\"]+?)['\"].+$/\1/" | head -n1
}

read_wp_var() {
  local VARIABLE_NAME="$1"
  local WP_CONFIG="$2"
  grep -E '^[\t ]*\$'"$VARIABLE_NAME" "$WP_CONFIG" | sed -E "s/^[ \t]*\\"'$'"${VARIABLE_NAME}[ \t=]+['\"]([^'\"]+?)['\"].+$/\1/" | head -n1
}

query_wp() {
  local MYSQL_HOST="$1"
  local MYSQL_DATABASE="$2"
  local MYSQL_USER="$3"
  local MYSQL_PASS="$4"
  local QUERY="$5"
	echo -e "[mysql]\nuser=$MYSQL_USER\npassword=$MYSQL_PASS\n" | \
  mysql --defaults-file=/dev/stdin -NB \
        --connect-timeout=3 \
        -h "$MYSQL_HOST" "$MYSQL_DATABASE" \
        -e "$QUERY"
}

check_wp_users() {
	query_wp "$1" "$2" "$3" "$4" \
		'
      SELECT user_login, user_email
      FROM '"$5"'users
      '"$WP_SUSPICIOUS_USERS_CLAUSE"'
		'
}

delete_wp_users() {
	query_wp "$1" "$2" "$3" "$4" \
		'
		  DELETE FROM '"$5"'users
      '"$WP_SUSPICIOUS_USERS_CLAUSE"'
    '
}

check_wp_triggers() {
	query_wp "$1" "$2" "$3" "$4" \
		'SHOW TRIGGERS WHERE `Trigger` = "after_insert_comment"'
}

delete_wp_triggers() {
	query_wp "$1" "$2" "$3" "$4" \
		'DROP TRIGGER IF EXISTS after_insert_comment'
}

check_wp_snippets() {
  query_wp "$1" "$2" "$3" "$4" \
    '
      SELECT option_id FROM '"$5"'options
      WHERE option_name = "wpcode_snippets"
      AND option_value LIKE "%String.fromCharCode(%"
    '

  query_wp "$1" "$2" "$3" "$4" \
    '
      SELECT ID FROM '"$5"'posts
      WHERE post_type = "wpcode"
      AND post_content LIKE "%String.fromCharCode(%"
    '
}

delete_wp_snippets() {
  query_wp "$1" "$2" "$3" "$4" \
    '
      DELETE FROM '"$5"'options
      WHERE option_name = "wpcode_snippets"
      AND option_value LIKE "%String.fromCharCode(%"
    '

  query_wp "$1" "$2" "$3" "$4" \
    '
      DELETE FROM '"$5"'posts
      WHERE post_type = "wpcode"
      AND post_content LIKE "%String.fromCharCode(%"
    '
}

check_config() {
  local CONFIG_PATH="$1"
  grep -E '@\s*(include|require)(_once|\s|\()?' "$CONFIG_PATH"
  return "$?"
}

check_crontab() {
  local CRONTAB_PATH="$1"
  grep -vqE "$CRONTAB_PATH" \
    -e "sleep [0-9]+; php '.+/wp-cron.php'\$" \
    -e "^#" \
    -e '^$'
  return "$?"
}

check_bashrc() {
  local BASHRC_PATH="$1"
  grep -qiE 'seed prng' "$BASHRC_PATH"
  return "$?"
}

is_empty() {
  ! grep -qvE '^$' "$1"
}

prompt() {
	while true; do
	  tput bold
		read -er -p "$* " resp
		tput sgr0
		case "$resp" in
			[Yy]*) return 0 ;;
			[Nn]*) return 1 ;;
			*) continue
		esac
	done
}

prompt_select() {
  local OPTIONS="$1"
  local PROMPT="$2"

  while true; do
    tput bold >&2
    read -er -p "$PROMPT " resp >&2
    tput sgr0 >&2
    case "${resp^^}" in
      ["${OPTIONS^^}"]) echo "$resp"; return ;;
      *) continue ;;
    esac
  done
}

HOMEDIR_OWNER_USERNAME="$(stat -c "%U" "$HOMEDIR")"

mapfile -t WP_CONFIGS < <(find "$HOMEDIR" -maxdepth 5 -type f -name wp-config.php)
for WP_CONFIG in "${WP_CONFIGS[@]}"; do
  [ -z "$WP_CONFIG" ] && continue
  OWNER_USERNAME="$(stat -c "%U" "$WP_CONFIG")"
  if [ "$OWNER_USERNAME" = "root" ];  then
    echo "  ERROR: The owner of $WP_CONFIG is root. Please chown the files such that they belong to the correct user and try again."
    continue
  elif [ "$OWNER_USERNAME" = "UNKNOWN" ]; then
    echo "  ERROR: Owner of $WP_CONFIG is '$OWNER_USERNAME' but that user doesn't seem to exist. You may need to chown the files and retry."
    continue
  fi

  # Add the owner to the list of crontabs to check
  CRONTAB_USERS+=("$OWNER_USERNAME")

  WP_DIRECTORY="$(dirname "$WP_CONFIG")"
  WP_VERSION="$(read_wp_var "wp_version" "$WP_DIRECTORY/wp-includes/version.php")"
  WP_LOCALE="$(read_wp_var "wp_local_package" "$WP_DIRECTORY/wp-includes/version.php")"
  [ -z "$WP_LOCALE" ] && WP_LOCALE="en_US"
  DB_HOST="$(read_wp_const "DB_HOST" "$WP_CONFIG" | sed -E 's/:3306$//')"
  DB_NAME="$(read_wp_const "DB_NAME" "$WP_CONFIG")"
  DB_USER="$(read_wp_const "DB_USER" "$WP_CONFIG")"
  DB_PASS="$(read_wp_const "DB_PASSWORD" "$WP_CONFIG")"
  DB_PREFIX="$(read_wp_var "table_prefix" "$WP_CONFIG")"

  echo "$WP_CONFIG"
  echo "  Testing MySQL credentials"
  if query_wp "$DB_HOST" "$DB_NAME" "$DB_USER" "$DB_PASS" "SELECT NULL" 2>&1 > /dev/null | sed 's/^/    /'; then
    echo "  Looking for suspicious users"
    SUS_USERS="$(check_wp_users "$DB_HOST" "$DB_NAME" "$DB_USER" "$DB_PASS" "$DB_PREFIX")"

    if [ -n "$SUS_USERS" ]; then
      echo "$SUS_USERS" | sed 's/^/    /'

      echo
      if prompt "    Delete the above users? >"; then
        delete_wp_users "$DB_HOST" "$DB_NAME" "$DB_USER" "$DB_PASS" "$DB_PREFIX"
      fi
      echo
    fi

    echo "  Looking for suspicious triggers"
    NUM_SUS_TRIGGERS="$(check_wp_triggers "$DB_HOST" "$DB_NAME" "$DB_USER" "$DB_PASS" "$DB_PREFIX" | wc -l)"
    if [ "$NUM_SUS_TRIGGERS" -ne "0" ]; then
      echo "    Found $NUM_SUS_TRIGGERS suspicious triggers"
      if prompt "    Delete the triggers? >"; then
        delete_wp_triggers "$DB_HOST" "$DB_NAME" "$DB_USER" "$DB_PASS" "$DB_PREFIX"
      fi
      echo
    fi

    echo "  Looking for suspicious snippets"
    NUM_SUS_SNIPPETS="$(check_wp_snippets "$DB_HOST" "$DB_NAME" "$DB_USER" "$DB_PASS" "$DB_PREFIX" | wc -l)"
    if [ "$NUM_SUS_SNIPPETS" -ne "0" ]; then
      echo "    Found $NUM_SUS_SNIPPETS suspicious code snippets"
      if prompt "    Delete the snippets? >"; then
        delete_wp_snippets "$DB_HOST" "$DB_NAME" "$DB_USER" "$DB_PASS" "$DB_PREFIX"
      fi
      echo
    fi
  else
    echo "  MySQL connection failed, skipping user and trigger cleanup"
  fi

  echo "  Verifying checksum of core WordPress files"
  /usr/bin/su - "$OWNER_USERNAME" -c 'set -o pipefail; /usr/local/bin/wp core verify-checksums --path="'"$(dirname "$WP_CONFIG")"'" |& sed "s/^/    /"'
  WP_CHECKSUM_EXIT="$?"
  echo

  echo "  Checking configuration file for silent includes"
  WP_CONFIG_INCLUDES="$(check_config "$WP_CONFIG")"
  if [ -n "$WP_CONFIG_INCLUDES" ]; then
    sed 's/^/    /' <<< "$WP_CONFIG_INCLUDES"
    echo

    if prompt "  Edit wp-config.php? >"; then
      ${EDITOR:-vi} "$WP_CONFIG"
    fi
    echo
  fi

  if [ "$WP_CHECKSUM_EXIT" -ne "0" ] && prompt "  Reinstall WordPress $WP_VERSION ($WP_LOCALE) core files? >"; then
    echo "    Remounting CageFS for $OWNER_USERNAME"
    cagefsctl --remount "$OWNER_USERNAME"
    echo "    Deleting $WP_DIRECTORY/index.php"
    rm -rf "$WP_DIRECTORY/index.php" |& sed 's/^/      /'
    echo "    Deleting $WP_DIRECTORY/wp-admin/"
    rm -rf "$WP_DIRECTORY/wp-admin" |& sed 's/^/      /'
    echo "    Deleting $WP_DIRECTORY/wp-includes/"
    rm -rf "$WP_DIRECTORY/wp-includes" |& sed 's/^/      /'

    echo "    Making directory user-writable"
    chmod u+rwX "$WP_DIRECTORY"

    while :; do
      /usr/bin/su - "$OWNER_USERNAME" -c 'set -o pipefail; /usr/local/bin/wp core download --skip-content --force --version="'"$WP_VERSION"'" --locale="'"$WP_LOCALE"'" --path="'"$WP_DIRECTORY"'" |& sed "s/^/    /"'
      WP_REINSTALL_EXIT="$?"
      echo
      if [ "$WP_REINSTALL_EXIT" -eq 0 ] || ! prompt $"\n""  Reinstall failed, try again? >"; then
        break
      fi
    done
  fi
done

echo "No more WordPress installations found"
echo

# Add the owner of the scan path to the list of crontabs to check
if [ "$HOMEDIR_OWNER_USERNAME" != "root" ] && [ "$HOMEDIR_OWNER_USERNAME" != "UNKNOWN" ]; then
  CRONTAB_USERS+=("$HOMEDIR_OWNER_USERNAME")
fi

# Remove duplicate crontab users, we don't need to check them more than once each
mapfile -t CRONTAB_USERS < <(printf "%s\n" "${CRONTAB_USERS[@]}" | sort -u)
for CRONTAB_USER in "${CRONTAB_USERS[@]}"; do
  for CRONTAB in "/var/spool/cron/$CRONTAB_USER" "$HOMEDIR/.cagefs/var/spool/cron/$CRONTAB_USER"; do
    echo "Checking crontab $CRONTAB"
    if [ -f "$CRONTAB" ]; then
      if is_empty "$CRONTAB"; then
        echo "  Deleted empty crontab"
        rm "$CRONTAB"
        continue
      fi

      if check_crontab "$CRONTAB"; then
        sed 's/^/  /' "$CRONTAB"
        echo
        case "$(prompt_select "dek" "  (K)eep, (e)dit or (d)elete?")" in
          [kK]) echo "  Keeping crontab" ;;
          [eE]) ${EDITOR:-vi} "$CRONTAB" ;;
          [dD])
            echo "  Deleting crontab"
            rm "$CRONTAB"
            ;;
        esac
      else
        echo "  Crontab only contains whitelisted cronjobs"
      fi
    else
      echo "  No crontab present in $CRONTAB"
    fi
    echo
  done

  echo "Checking .bashrc for $CRONTAB_USER"
  BASHRC="$HOMEDIR/.bashrc"
  if [ -f "$BASHRC" ]; then
    if is_empty "$BASHRC"; then
      echo "  Deleted empty .bashrc"
      rm "$BASHRC"
    fi

    if check_bashrc "$BASHRC"; then
      sed 's/^/  /' "$BASHRC"
      echo
      case "$(prompt_select "dek" "  (K)eep, (e)dit or (d)elete?")" in
        [kK]) echo "  Keeping $BASHRC"; echo ;;
        [eE]) ${EDITOR:-vi} "$BASHRC" ;;
        [dD])
          echo "  Deleting $BASHRC"
          rm "$BASHRC"
          echo
          ;;
      esac
    else
      echo "  No matches found in .bashrc"
      echo
    fi
  else
    echo "  No rcfile present in $BASHRC"
    echo
  fi
done

if prompt "Perform a Yara scan? >"; then
  YARA="$(yara -r /scripts/yara/surftown.yar "$HOMEDIR")"

  if [ -n "$YARA" ]; then
    echo -e "$YARA" |& sed 's/^/  /'

    if prompt "Delete all matches? >"; then
      cut -f2 -d' ' <<< "$YARA" | sort -u | xargs rm
    fi
  else
    echo "  No matches found"
  fi
fi

echo

declare -a HIDDEN_PHP_FILES
if prompt "Scan for hidden PHP files? >"; then
  while IFS= read -r -d '' -u 9 FILEPATH; do
    FILENAME="$(basename -- "$FILEPATH")"
    EXTENSION="${FILENAME##*.}"
    MIMETYPE="$(file -i -- "$FILEPATH" | cut -d':' -f2 | cut -d';' -f1 | tr -d ' ')"

    if [ "$MIMETYPE" = "text/x-php" ] && [ "$EXTENSION" != "php" ] && [ "$EXTENSION" != "html" ] && [ "$FILENAME" != "CodeSniffer.conf.dist" ]; then
      echo "  $FILEPATH: $MIMETYPE"
      HIDDEN_PHP_FILES+=("$FILEPATH")
    fi
  done 9< <(find "$HOMEDIR" -type f -print0)

  if [ "${#HIDDEN_PHP_FILES[@]}" -gt 0 ] && prompt "Delete all matches? >"; then
    for FILEPATH in "${HIDDEN_PHP_FILES[@]}"; do
      rm -f -- "$FILEPATH"
    done
  fi
fi

echo

if [ "$HOMEDIR_OWNER_USERNAME" != "root" ] && [ "$HOMEDIR_OWNER_USERNAME" != "UNKNOWN" ]; then
  echo "Remounting CageFS for $HOMEDIR_OWNER_USERNAME"
  cagefsctl --remount "$HOMEDIR_OWNER_USERNAME"
fi