The site is down. Blank page, HTTP 200, no PHP error in the logs. Nothing to grab onto.
Googlebot, meanwhile, gets full pages. Links to counterfeit shops, indexed for three days under the client's name. The site is dead for humans and very much alive for crawlers. That is the worst case: nobody sees the content dirtying the domain, and the domain gets dirty anyway.
The antivirus has just finished its sweep. 373 files scanned, zero detections.
It took three days to untangle: where they came in, what they left behind, and what had to change so none of them could do it again. The client, the domain and the addresses are anonymised. The rest is exact, including the times I chased the wrong lead. Those are usually the useful parts.
The blank page was not the problem
The first file I open is index.php. It has indeed been swapped for a trojaned version. Perfect suspect, case closed.
Except that reading the code properly, the trojan never stops for a normal visitor. It serves the expected page and only deviates for search crawlers. Cloaking: one content for Google, another for you. That file had no reason to produce a blank page.
Wrong lead. The real cause was dumber, and more brutal: the wp-content directory had been renamed. Plugins, theme, media, everything WordPress looks for in there had become unreachable. The core booted, found no theme, rendered nothing. A whole site killed by an mv.
Which leaves the question of why an attacker would sabotage the site he is milking. It makes no sense. A dead site earns nothing, and it brings the administrator running. That is exactly what you avoid when you sell links.
The logs answered. There were two of them, and they hated each other.
The second group in had dropped an .htaccess allowing only its own backdoors. The earlier group's were locked out. That group came back a few hours later and took two 403s on its own tools, on a server it considered its own. Its answer was to re-drop its backdoor, then rename wp-content. Three seconds separate the two actions in the log.
So the sabotage was not aimed at the client. It was a message to a competitor. The site was only the ground they fought on.
Eighteen seconds
Which left the question that brawl had hidden: how did they get in?
To find out, you do not look for odd files. You look for what happened right before the first odd file appeared. An attacker deletes his files easily enough. The request that created them, far less so.
So I timestamped every suspicious file, kept the oldest, and walked the access log back to that minute. Two lines are enough.
04:04:35 POST /?wpmudev-hub=<token> 200
04:04:53 GET /<backdoor>.php 200
Eighteen seconds between the call to the plugin and the first request to the webshell. Nobody types a random URL and lands on a file that did not exist twenty seconds earlier.
That wpmudev-hub parameter belongs to the WPMU DEV Dashboard, a multi-site management plugin. Versions up to 5.0.0 carry an authentication bypass, tracked as CVE-2026-15459. It only opens in one case: when the service API key was never filled in. The plugin then accepts an anonymous call, and lets you install another plugin. Installing a plugin means running code. A hole does not need to be subtler than that.
Two details turned the hypothesis into a diagnosis. In the database, the wpmudev_apikey option was empty: the vulnerable state, exactly. And the nonce the plugin had stored matched the suffix of the attack URLs. This was no longer a plausible story. It was the trace of the exploitation, written by the plugin itself.
Nine successful exploits, nine different addresses, the last one three days after the first. Nobody had bothered to close the door behind them.
Intrusion timeline: anonymous call to the plugin, webshell eighteen seconds later, self-repair kit, cloaking, nine exploits over three days, then sabotage between rival groups. 04:04:35 The plugin accepts an anonymous call CVE-2026-15459, API key never filled in + 18 s The dropped webshell already answers the server now runs their code then Six files disguised as images and scripts the kit that puts the backdoor back then index.php swapped: spam for the crawlers the human visitor sees nothing 3 days Nine exploits, nine addresses the hole stays open the whole time finally Retaliation between groups: the site falls wp-content renamed, blank page
Three days between the door left open and the outage that finally revealed it.
What the antivirus missed, and what I missed
Finding the door is one thing. Finding everything they left behind is another. And there, my tools failed me one after the other.
ClamAV first: 0 detections across 373 files. That is not a flaw in the product. It is the ceiling of signature matching, against code written to look like nothing.
Next reflex, grep base64_decode. Nothing either. Sensitive function names are assembled character by character at runtime, and command URLs are encoded. There is no string to find: the file does not contain the words that would give it away, it builds them.
Then the modification times. They had been backdated to blend into the original install. A file dropped the previous week proudly showed the same date as the rest of WordPress core. Only the inode change time holds, the ctime, because PHP cannot rewrite it. That is what dated the intrusion to the second. Without it I would still be looking.
The neatest piece was the self-repair kit. Six files disguised as images and scripts, with names built to survive a quick review: server-sied-renderr.min.js, icon_twistedd.png. You read fast, you see a minified script and an icon, you move on. Each actually held a byte-for-byte copy of the trojaned index.php and its .htaccess. Fake wp-login.php files put them back. Delete the backdoor without deleting the kit and you watch it return.
And I missed a file on the first pass.
To sweep recently modified files, I had excluded wp-content/cache and its 78,000 legitimate files. An exclusion of convenience, made so the command would return something readable. A 468-byte remote loader had survived in there, with its own .htaccess allowing it. I found it on the second pass, the one I ran with no exclusions at all, assuming the first had failed.
That is the lesson I keep from the whole episode. An exclusion in a sweep is a hiding place you hand over. A directory too big to search does not need an exemption, it needs its own check.
Clean up, then make cleaning up pointless
Once the inventory is genuinely complete, cleanup becomes the boring part. Good.
WordPress core was compared file by file against the official archive of the same version, and every difference replaced. Exactly one was legitimate: the file carrying the install's language variant. Plugins were not cleaned but reinstalled from their official source. That is faster and safer, because nobody can review thirty plugins by hand. The custom theme could not be replaced. It was checked line by line. It was untouched.
At that point the site is clean. It is also exactly as vulnerable as before.
That is what most remediations forget: cleaning repairs the past, it writes nothing about the future. The part that counts starts here.
The web server can no longer write a single line of code. The whole tree belongs to root, the web server group is read-only, directories are 750 and files 640. A few directories have to stay writable, of course, since WordPress drops media and cache there. For those, PHP execution is denied at the Apache level:
<DirectoryMatch "…/wp-content/(uploads|cache|upgrade)(/|$)">
<FilesMatch "\.(?i:php|phtml|php[0-9]|pht|cgi|pl|py|sh)$">
Require all denied
</FilesMatch>
</DirectoryMatch>
On one side the places you can write to, on the other the places where PHP runs. The intersection is empty, and that is the whole point.
Two disjoint sets: directories the web server can write to on one side, directories where PHP runs on the other. No directory belongs to both. Web server can write PHP runs uploads/ cache/ upgrade/ WordPress core plugins theme ∅ intersection
Nowhere to drop code and run it.
Replay the 04:04:35 attack against this setup. The vulnerable plugin still accepts the anonymous call. It still tries to write its file. It fails. The hole is intact, the exploitation is not.
The config that closes what was left
Banning writes breaks one thing along the way, and not a small one: WordPress can no longer update itself. The DISALLOW_FILE_MODS constant makes that block explicit, and removes plugin installation from the admin UI in the same move.
That is deliberate, but it creates a new risk. A site that stops receiving patches becomes a different problem, and frankly a worse one than the one just fixed.
The compensation is a weekly job run by root. It flips the constant, dumps the database, updates, restores permissions, verifies core checksums, tests that the site still answers, rebuilds the monitoring baseline, and emails a report. The block holds against an intruder, and patches still land. It is the only arrangement I found that sacrifices neither.
The rest of the config comes down to three questions.
Who is still logged in? Salts were regenerated. Every open session drops, the attacker's included.
Who can still trigger code? WordPress's internal cron was disabled in favour of a system job. The internal one depends on traffic: it fires from outside, on a plain visit. That is a door that opens on request.
What is still visible from the street? The login URL moved. The file editor is off, admin forced over HTTPS, TLS 1.0 and 1.1 refused. Files that talk too much are denied: debug logs, readme, composer.json, anything shaped like a .env. And a fail2ban jail bans on the first attempt against an exploitation pattern.
No Content Security Policy, though, and I stand by that. The site loads a tag manager, two ad networks, a map and an external booking widget. A CSP written blind breaks the site silently. A CSP permissive enough to allow everything protects nothing while ticking the box in the audit. Better no CSP than a fake one. It is a project, not a config line.
On secrets, the rule was simple: anything readable during the compromise is lost. Admin passwords, database credentials, SSH keys regenerated. One key unused for a year disappeared along the way. Third-party API keys were inventoried with their scope, to be rotated next.
Then I looked at the backups. They existed: one a week at the host, kept on a 28-day rolling window, plus one full server snapshot a year.
On paper that is reassuring. In this incident, much less so. The intrusion dates from 7 August and was only found on the 10th. With a weekly backup, the most recent one stood every chance of already containing the backdoor: restoring meant reinstalling the problem. And 28 days of retention leaves four restore points, when a compromise can sit quiet for months before showing itself.
A backup is only worth what you know about its contents. That is why a reference state was frozen separately, once the site was clean: site archive, database dump, plugin list with versions, and a file of 11,784 SHA-256 hashes that stands as provable evidence of a clean state at a given date. It is not one more backup, it is the only restore point known to be clean.
Along the way, four years of custom code that had never been versioned went into a Git repository, with an archive kept off the server. Until then, the only copy of the theme was the one running in production. On the machine that had just been breached.
Knowing before the attacker does
A hardened site with no monitoring is a site you do not know has fallen again. Hardening lowers the probability, it does not zero it. Monitoring is what closes the gap.
So a job runs every thirty minutes, and only sends mail when there is something to say. Silence is success.
First check, core integrity. It asks wordpress.org about the version actually installed. I first compared against an archive frozen on disk, which produced a fine false alarm on the very first update: a frozen reference goes wrong on its own, without anyone doing anything wrong.
Then comes the fingerprint. Roughly 11,500 files compared against a reference: every PHP file, every .htaccess, and the theme's scripts and stylesheets. The cache, too volatile to sit in a fingerprint, gets its own check. Exactly the one that would have saved me from missing the 468-byte loader.
Two more checks, dumb and effective: no executables in the media directory, and not one file in the must-use plugin directory. That last one is the handiest hiding place in WordPress, because nothing dropped there can be disabled from the admin UI.
In the database, a sentinel watches fourteen sensitive values: site address, admin email, registration flag, default role, active theme, active plugins, and the full list of accounts, their roles and their application passwords. Creating a quiet admin account is any intruder's first move for persistence. It now raises an alert within thirty minutes.
The last check requests the same page twice. Once as a browser, once as Googlebot, then compares response sizes. That is the cloaking signature. The very one serving spam while the site sat empty.
There was still the trap that kills every monitoring setup, and it is not technical: fatigue. The first version alerted on any exploitation pattern in the logs, which came to 856 lines a day. Almost all of them generic scanners raking the whole internet and taking 404s. A daily 856-line report, you read it twice, then you file it unopened.
I first sorted those patterns into two families, keeping only the ones aimed at this site. The noise dropped to almost nothing. Almost: twice a day a bot replayed the original hole, and the homepage answered it 200, since the plugin no longer exists. Two daily alerts for an attack that had become impossible.
So we cut harder. Mail now only goes out when something succeeded: a file appeared, a file changed, a database value moved, the site went down. Attempts are still logged on the server, available for an investigation, but they no longer wake anyone up.
Two columns: on the left what is only logged, the attempts; on the right what sends mail, the real consequences. What stays in the log an exploit URL tried a scan of /.env, /xmlrpc.php a 404 on an old backdoor no mail What sends mail a PHP file added or changed a sensitive database value moved the vulnerable plugin reappearing the site going down or cloaking mail straight away Alert on the outcome, never on the intent.
An attempt teaches nothing, they arrive by the thousand. A file that moves does.
And the intrusion vector is no longer watched in the logs. What is watched is the condition that makes it possible: if the vulnerable plugin ever shows up again in the plugin directory, the alert fires. We no longer watch for someone trying the door. We watch that the door is still bricked up.
An alert that fires every day for nothing stops being read. And that is the day the real one slips through.
The whole stack, ready to copy
Everything above fits into three scheduled jobs and two scripts. Here they are in full, anonymised: paths, domain, database and addresses are replaced with example values, the rest is the code that actually runs.
The cron first. Nothing exotic, and that is the point: a monitoring system you cannot read at a glance will never get debugged on the day it gets something wrong.
# /etc/cron.d/surveillance
# Monitoring: every 30 minutes
*/30 * * * * root /root/surveille.sh >/dev/null 2>&1
# Updates: Tuesday 04:17, off-peak
17 4 * * 2 root /root/maj.sh >/dev/null 2>&1
# Antivirus: Sunday 03:17
17 3 * * 0 root /root/scan-antivirus.sh >/dev/null 2>&1
Then the updates. This script exists only because DISALLOW_FILE_MODS stops WordPress from updating itself. It lifts the lock, works, puts it back, verifies, and reports. Two details matter. The trap on line 53, without which a crash halfway through would leave the site writable until the following week. And the guard on line 30: if the database dump fails, nothing gets updated. A safety net you believe is there and is not is worse than no net at all.
#!/bin/bash
# WordPress updates driven by the system, not by WordPress.
# DISALLOW_FILE_MODS stops WordPress from updating itself. That is
# deliberate: www-data must not write to core. This script does it
# instead, as root, with checks before and after.
SITE=/var/www/example.com/public
STATE=/var/lib/site-watch
DEST=alerte@example.com
FROM=surveillance@example.com
LOG=/var/log/maj.log
cd "$SITE" || exit 1
dispo=$(wp plugin list --update=available --field=name --allow-root 2>/dev/null)
coeur=$(wp core check-update --minor --field=version --allow-root 2>/dev/null | grep -E "^[0-9]+\." | head -1)
if [ -z "$dispo" ] && [ -z "$coeur" ]; then
echo "$(date -u '+%F %T') rien a mettre a jour" >> "$LOG"
exit 0
fi
RAPPORT="Mises a jour appliquees sur example.com le $(date -u '+%F a %H:%M UTC').
"
# Dump the database before touching anything. Sans elle on ne met rien a jour :
# un filet de securite qu'on croit tendu et qui ne l'est pas vaut moins que pas
# de filet du tout, parce qu'on prend le risque en pensant etre couvert.
mkdir -p /root/backups
DUMP="/root/backups/db-avant-maj-$(date -u +%Y%m%dT%H%M%S).sql.gz"
set -o pipefail
mysqldump --single-transaction wordpress_prod 2>/dev/null | gzip > "$DUMP"
etat_dump=$?
set +o pipefail
if [ "$etat_dump" -ne 0 ] || [ "$(stat -c %s "$DUMP" 2>/dev/null || echo 0)" -lt 100000 ]; then
rm -f "$DUMP"
printf '%s' "Mise a jour ANNULEE sur example.com le $(date -u '+%F a %H:%M UTC').
La sauvegarde de la base a echoue, ou le fichier produit est trop petit pour
etre credible. Rien n'a ete mis a jour : on ne touche pas au site sans point
de retour. Verifier MySQL et l'espace disque, puis relancer /root/maj.sh." \
| mail -s "[MAJ] ECHEC sauvegarde, mise a jour annulee" -a "From: $FROM" "$DEST"
echo "$(date -u '+%F %T') sauvegarde impossible, mise a jour annulee" >> "$LOG"
exit 1
fi
find /root/backups -name 'db-avant-maj-*.sql.gz' -mtime +30 -delete 2>/dev/null
# DISALLOW_FILE_MODS also blocks wp-cli, so we lift it for the operation.
# The trap puts the lock back whatever happens. Without it, a crashed wp or
# a reboot mid-run would leave the site writable until the next Tuesday,
# with nothing to signal it.
remettre_le_verrou() {
sed -i "s/define( 'DISALLOW_FILE_MODS', false );/define( 'DISALLOW_FILE_MODS', true );/" "$SITE/wp-config.php"
}
trap remettre_le_verrou EXIT INT TERM
sed -i "s/define( 'DISALLOW_FILE_MODS', true );/define( 'DISALLOW_FILE_MODS', false );/" wp-config.php
if [ -n "$dispo" ]; then
RAPPORT="$RAPPORT
## Plugins
$(wp plugin update --all --allow-root 2>&1 | tail -20)
"
fi
if [ -n "$coeur" ]; then
RAPPORT="$RAPPORT
## WordPress core (current branch)
$(wp core update --minor --allow-root 2>&1 | tail -6)
$(wp core update-db --allow-root 2>&1 | tail -2)
"
fi
remettre_le_verrou
# Files written by root must stay readable by www-data
chown -R root:www-data "$SITE/wp-admin" "$SITE/wp-includes" "$SITE/wp-content/plugins" 2>/dev/null
find "$SITE/wp-admin" "$SITE/wp-includes" "$SITE/wp-content/plugins" -type d -exec chmod 750 {} + 2>/dev/null
find "$SITE/wp-admin" "$SITE/wp-includes" "$SITE/wp-content/plugins" -type f -exec chmod 640 {} + 2>/dev/null
# Verification
RAPPORT="$RAPPORT
## Verification after the update
$(wp core verify-checksums --allow-root 2>&1 | tail -3)
$(wp plugin verify-checksums --all --allow-root 2>&1 | tail -2)
"
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 40 https://www.example.com/)
RAPPORT="$RAPPORT
Page d'accueil apres mise a jour : HTTP $code
"
[ "$code" != "200" ] && RAPPORT="$RAPPORT
ATTENTION : le site ne repond pas normalement. Restauration possible depuis /root/golden/ et les dumps /root/backups/.
"
# The monitoring baseline must follow, or it will alert for nothing
/root/surveille.sh --rebaseline >/dev/null 2>&1
RAPPORT="$RAPPORT
Empreinte de surveillance reconstruite.
"
printf '%s' "$RAPPORT" | mail -s "[MAJ] example.com" -a "From: $FROM" "$DEST"
echo "$(date -u '+%F %T') mises a jour appliquees, HTTP $code" >> "$LOG"
And the monitoring. Ten checks, only one of which reads the logs, and that one never sends mail. Email is reserved for what succeeded: a file that appeared, a file that changed, a database value that moved.
#!/bin/bash
# Watches example.com. Sends mail ONLY when something is wrong.
# Run from cron. Prints nothing when all is well: silence is success.
SITE=/var/www/example.com/public
REF=/root/reference/wordpress
STATE=/var/lib/site-watch
DEST=alerte@example.com
FROM=surveillance@example.com
HOST=$(hostname)
mkdir -p "$STATE"
chmod 700 "$STATE"
ALERTS=""
add() { ALERTS="${ALERTS}
## $1
$2
"; }
# --- 1. WordPress core integrity ---------------------------------------------
# Ask wordpress.org about the version ACTUALLY installed: a frozen reference
# archive goes wrong on the very first core update.
if command -v wp >/dev/null; then
chk=$(cd "$SITE" && wp core verify-checksums --allow-root 2>&1)
if echo "$chk" | grep -q "^Success:"; then
: # coeur conforme
elif echo "$chk" | grep -qiE "couldn't fetch|failed to|could not resolve|error establishing"; then
: # wordpress.org injoignable : on ne crie pas au loup
else
add "WordPress core integrity" "$(echo "$chk" | grep -vE '^(Success|Warning: Could not)' | head -30)"
fi
else
# Un controle qui disparait en silence est pire qu'un controle absent :
# on croit etre surveille alors qu'on ne l'est plus.
add "Controle du coeur impossible : wp-cli introuvable" "Reinstaller wp-cli, sinon l'integrite du coeur n'est plus verifiee."
fi
# --- 2. Any PHP file missing from the baseline fingerprint -------------------
# Baseline taken after cleanup. After a legitimate WordPress update,
# rebuild it with: /root/surveille.sh --rebaseline
BASE="$STATE/baseline-php.sha256"
if [ "${1:-}" = "--rebaseline" ]; then
find /var/www/example.com \( -name "*.php" -o -name ".htaccess" -o -path "*/themes/montheme/*.js" -o -path "*/themes/montheme/*.css" \) -not -path "*/cache/*" -not -path "*/node_modules/*" -not -path "*/.git/*" -print0 2>/dev/null \
| xargs -0 sha256sum 2>/dev/null | sort > "$BASE"
echo "Reference reconstruite : $(wc -l < "$BASE") fichiers PHP"
exit 0
fi
if [ -f "$BASE" ]; then
current=$(mktemp)
find /var/www/example.com \( -name "*.php" -o -name ".htaccess" -o -path "*/themes/montheme/*.js" -o -path "*/themes/montheme/*.css" \) -not -path "*/cache/*" -not -path "*/node_modules/*" -not -path "*/.git/*" -print0 2>/dev/null \
| xargs -0 sha256sum 2>/dev/null | sort > "$current"
drift=$(comm -13 "$BASE" "$current" | awk '{print $2}' | head -30)
gone=$(comm -23 "$BASE" "$current" | awk '{print $2}' | head -10)
rm -f "$current"
[ -n "$drift" ] && add "PHP files added or modified since the baseline" "$drift"
[ -n "$gone" ] && add "PHP files gone since the baseline" "$gone"
else
add "Baseline fingerprint missing" "Lancer : /root/surveille.sh --rebaseline"
fi
# --- 3. Executables in uploads (never normal) --------------------------------
badup=$(find "$SITE/wp-content/uploads" -type f \( -iname "*.php*" -o -iname "*.phtml" -o -iname "*.cgi" -o -iname "*.pl" -o -iname "*.py" \) 2>/dev/null | grep -v "index.php$" | head -20)
[ -n "$badup" ] && add "Executable files in uploads/" "$badup"
# --- 3b. Unexpected PHP inside the cache -------------------------------------
# The baseline skips the cache (thousands of volatile files), which makes it
# an ideal hiding place: a backdoor survived the cleanup in there. W3TC only
# writes to the subdirectories listed below.
cachephp=$(find "$SITE/wp-content/cache" -name "*.php" 2>/dev/null \
| grep -avE "/cache/(db|page_enhanced|minify|object|fragment|stats|tmp)/" | head -20)
[ -n "$cachephp" ] && add "Unexpected PHP file in the cache" "$cachephp"
# --- 4. mu-plugins must stay empty -------------------------------------------
mu=$(find "$SITE/wp-content/mu-plugins" -type f 2>/dev/null | head -10)
[ -n "$mu" ] && add "Files found in mu-plugins (auto-loaded, cannot be disabled)" "$mu"
# --- 4b. The plugin used as the entry vector must stay gone ------------------
# We no longer watch for the `wpmudev-hub` call in the logs: with the plugin
# uninstalled the parameter does nothing and the homepage answers 200 to every
# passing bot, i.e. two pointless alerts a day. We now watch the condition
# that would make the attack possible, not the attempt.
wpmu=$(find "$SITE/wp-content/plugins" -maxdepth 1 \( -iname "*wpmudev*" -o -iname "*wpmu-dev*" \) 2>/dev/null | head -3)
[ -n "$wpmu" ] && add "The WPMU DEV plugin is back (the intrusion vector)" "$wpmu
Verifier la version : les 5.0.0 et anterieures portent CVE-2026-15459."
# --- 5. Exploitation attempts: logged, NEVER emailed -------------------------
# An attempt is not an event, it is internet background noise: bots try every
# known URL on every site, all the time. Mail is reserved for what SUCCEEDED,
# meaning a new file, a modified file or a changed database value (checks 1,
# 2, 3, 3b, 4, 4b and 10). The trail stays in /var/log/tentatives.log for
# any later investigation.
# Only log what was never logged: otherwise the same events come back on
# every run for as long as they sit inside the window.
# TRUSTED: admin addresses, excluded so our own tests do not raise alerts.
TRUSTED="203.0.113.10 203.0.113.10 203.0.113.10"
SEEN="$STATE/last-exploit-epoch"
last_epoch=$(cat "$SEEN" 2>/dev/null || echo 0)
max_epoch=$last_epoch
# Two families of patterns, two different rules.
# CIBLE (targeted): what aims at this site, the entry vector and the webshells
# dropped back then. Logged even on failure (403, 404, 503): touching those
# means knowing the site's history. Two exceptions, where the request never
# reached WordPress: the http->https 301, which comes straight back over https
# and would be counted twice, and the 421 SNI mismatch.
# BALAYAGE (sweeps): scanners raking the whole internet, dozens a day, all of
# them 404s. Logged only if the server answered something other than a refusal.
# A daily alert about nothing stops being read, and that is the day the real
# one slips through.
# Deliberately absent: PHP-CGI argument injection (auto_prepend_file,
# allow_url_include). PHP runs as mod_php, so the homepage answers 200 to every
# attempt while none of them execute, verified with a marker that never came
# back. If such an injection ever landed, it would leave a file, and check 2
# is the one that would see it.
CIBLE="\?action=[A-Za-z0-9]{15,}|shell1\.php|shell2\.php|shell3"
BALAYAGE="eval-stdin|/bin/sh|/\.env|/\.git/|phpinfo|wp-config\.php[.~]|/shell|alfa-?rex|/xmlrpc\.php"
# Fichiers de travail dans $STATE (root, 700) et non dans /tmp : root y ecrit,
# et /tmp est inscriptible par www-data. Le noyau bloque deja le detournement
# par lien symbolique, mais un script de securite ne se repose pas sur un sysctl.
ACCES="$STATE/acces-du-jour.txt"
YDAY=$(date -u -d "yesterday" "+%d/%b/%Y")
TODAY=$(date -u "+%d/%b/%Y")
expl=""
while IFS= read -r line; do
[ -z "$line" ] && continue
ip=${line%% *}
case " $TRUSTED " in *" $ip "*) continue ;; esac
ts=$(printf '%s' "$line" | grep -oE '[0-9]{2}/[A-Za-z]{3}/[0-9]{4}:[0-9:]{8}')
[ -z "$ts" ] && continue
ep=$(date -u -d "$(printf '%s' "$ts" | tr ':' ' ' | awk '{print $1" "$2":"$3":"$4}' | sed 's|/| |g')" +%s 2>/dev/null || echo 0)
[ "$ep" -le "$last_epoch" ] && continue
[ "$ep" -gt "$max_epoch" ] && max_epoch=$ep
expl="${expl}${line}
"
done <<EOF
$(
grep -ahE "$YDAY|$TODAY" /var/log/apache2/*access*.log 2>/dev/null \
| sed -E 's/^[a-z0-9.-]*lesmontheme\.com:[0-9]+ //' > "$ACCES"
{
grep -aiE "$CIBLE" "$ACCES" | awk '$9 !~ /^(301|302|421)$/'
grep -aiE "$BALAYAGE" "$ACCES" | awk '$9 !~ /^(301|302|400|401|403|404|408|421|429|503)$/'
} | awk '{printf "%-16s %s %s %s %s\n", $1, $4, $6, $7, $9}' | sed 's/\[//' | sort -u | head -40
rm -f "$ACCES"
)
EOF
echo "$max_epoch" > "$SEEN"
if [ -n "$expl" ]; then
{
echo "===== $(date -u '+%F %T UTC') ====="
printf '%s' "$expl"
} >> /var/log/tentatives.log
# Nobody prunes this file, so bound it here or it grows forever.
if [ "$(stat -c %s /var/log/tentatives.log 2>/dev/null || echo 0)" -gt 5000000 ]; then
tail -n 5000 /var/log/tentatives.log > /var/log/tentatives.log.tmp \
&& mv /var/log/tentatives.log.tmp /var/log/tentatives.log
fi
fi
# --- 6. Is the site answering? -----------------------------------------------
# Second try before crying wolf: an Apache reload or a passing network
# hiccup must not raise an alert.
verifier_site() {
: > "$STATE/derniere-page.html"
curl -s -o "$STATE/derniere-page.html" -w "%{http_code}" --max-time 25 \
-A "Mozilla/5.0 (surveillance)" https://www.example.com/ 2>/dev/null
}
code=$(verifier_site)
if [ "$code" != "200" ] && [ "$code" != "503" ]; then
sleep 20
code=$(verifier_site)
fi
size=$(stat -c %s "$STATE/derniere-page.html" 2>/dev/null || echo 0)
if [ "$code" != "200" ] && [ "$code" != "503" ]; then
add "The site is not answering normally" "code HTTP = $code, taille = $size octets"
elif [ "$code" = "200" ] && [ "$size" -lt 5000 ]; then
add "The site answers 200 but the page is nearly empty" "taille = $size octets (une page vide en 200 est exactement le symptome de l'incident d'aout 2026)"
fi
# --- 7. Cloaking: a bot and a human must get the same page -------------------
if [ "$code" = "200" ]; then
sbot=$(curl -s -o /dev/null -w "%{size_download}" --max-time 25 \
-A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
https://www.example.com/ 2>/dev/null)
if [ -n "$sbot" ] && [ "$sbot" -gt 0 ] 2>/dev/null; then
diff=$(( sbot > size ? sbot - size : size - sbot ))
limit=$(( size / 5 ))
[ "$diff" -gt "$limit" ] && add "Content differs by visitor (possible cloaking)" \
"navigateur = $size octets, Googlebot = $sbot octets"
fi
fi
# --- 8. Services and disk ----------------------------------------------------
for svc in apache2 mysql; do
systemctl is-active --quiet "$svc" || add "Service stopped" "$svc"
done
use=$(df --output=pcent / | tail -1 | tr -dc '0-9')
[ "${use:-0}" -gt 85 ] && add "Disk space" "partition / occupee a ${use} %"
# --- 9. Result of the weekly ClamAV scan (read here, not run here) -----------
# The scan itself lives in /root/scan-antivirus.sh (weekly cron): too slow to
# run on every pass.
if [ -f "$STATE/clamscan-last.txt" ]; then
hits=$(grep -a "FOUND" "$STATE/clamscan-last.txt" 2>/dev/null | head -20)
[ -n "$hits" ] && add "ClamAV detections (last weekly scan)" "$hits"
fi
# --- 10. Database sentinel ---------------------------------------------------
# An attacker with database access can act without touching a single file:
# that is how the fake plugin got activated. So we watch the values that
# matter, not the whole database.
DBREF="$STATE/db-sentinelle.txt"
dbnow=$(mysql -N -r wordpress_prod 2>/dev/null -e "
SELECT CONCAT('option:', option_name, '=', LEFT(option_value, 400))
FROM wp_options
WHERE option_name IN ('siteurl','home','admin_email','users_can_register',
'default_role','template','stylesheet','active_plugins')
ORDER BY option_name;
SELECT CONCAT('user:', u.ID, ':', u.user_login, ':', u.user_email, ':', IFNULL(m.meta_value,''))
FROM wp_users u
LEFT JOIN wp_usermeta m ON m.user_id = u.ID AND m.meta_key = 'wp_capabilities'
ORDER BY u.ID;
SELECT CONCAT('apppass:', COUNT(*)) FROM wp_usermeta WHERE meta_key = '_application_passwords';
SELECT CONCAT('mu-plugin-option:', COUNT(*)) FROM wp_options WHERE option_value REGEXP 'eval\\\\(|base64_decode|gzinflate';
")
if [ -n "$dbnow" ]; then
if [ -f "$DBREF" ]; then
dbdiff=$(diff "$DBREF" <(printf '%s\n' "$dbnow") 2>/dev/null | grep -E '^[<>]' | head -20)
[ -n "$dbdiff" ] && add "Database: a sensitive value changed" "$dbdiff
(< = valeur de reference, > = valeur actuelle. Si le changement est legitime :
/root/surveille.sh --rebaseline)"
else
printf '%s\n' "$dbnow" > "$DBREF"
fi
elif [ -f "$DBREF" ]; then
# La sentinelle a deja fonctionne : si elle ne rend plus rien, c'est la
# sentinelle qui est tombee, pas la base qui s'est videe.
add "Sentinelle base de donnees muette" "La requete de controle ne rend plus rien.
Verifier que MySQL repond et que les acces de root sont valides, sinon les
comptes et les options ne sont plus surveilles du tout."
fi
# --- Sending -----------------------------------------------------------------
if [ -n "$ALERTS" ]; then
# Dedup: do not report an identical anomaly again within 12 h, or a
# persistent one floods the mailbox.
sig=$(printf '%s' "$ALERTS" | sha256sum | cut -d' ' -f1)
last="$STATE/last-alert-$sig"
if [ -f "$last" ] && [ $(( $(date +%s) - $(stat -c %Y "$last") )) -lt 43200 ]; then
echo "$(date -u '+%F %T') anomalie identique deja signalee, mail supprime" >> /var/log/surveillance.log
exit 0
fi
find "$STATE" -name 'last-alert-*' -mtime +2 -delete 2>/dev/null
touch "$last"
# Always keep a local trace: the alert survives a failed send.
{
echo "===== $(date -u '+%F %T UTC') ====="
echo "$ALERTS"
} >> /var/log/alertes.log
{
echo "Anomalies detectees sur $HOST ($(date -u '+%Y-%m-%d %H:%M UTC'))."
echo "Site : https://www.example.com/"
echo "Serveur : 203.0.113.10"
echo "$ALERTS"
echo
echo "--"
echo "Surveillance automatique installee apres l'incident du 10 aout 2026."
echo "Script : /root/surveille.sh — journal : /var/log/surveillance.log"
echo "Ce mail ne part que si un fichier ou une valeur en base a change."
echo "Les tentatives d'URL sont journalisees sans alerte : /var/log/tentatives.log"
} | mail -s "[ALERTE] example.com" -a "From: $FROM" "$DEST"
echo "$(date -u '+%F %T') ALERTE envoyee" >> /var/log/surveillance.log
else
echo "$(date -u '+%F %T') OK" >> /var/log/surveillance.log
fi
One piece is not in this picture and cannot be: the backup cadence on the host side. One a week over 28 days is four restore points, none of them guaranteed clean. No script fixes that.
What actually made a difference
I ran this remediation with Claude as a pair, and asked it to score the site's security before and after: 70, then 92. Let me say it straight away, that number means nothing. It adds up measures that do not carry the same weight, and it comes from the very party that did the work. It is good for one thing, and that is why I keep it: checking we are moving the right way.
What deserves keeping is the breakdown. The antivirus saw nothing. The grep on dangerous functions saw nothing. Modification times actively lied. The only checks that produced knowledge were the ones comparing the install against an outside reference: the official core hashes, and the ctime the attacker cannot rewrite. Everything else cost me time.
And the one change that would have stopped the intrusion is neither an antivirus, nor a fingerprint, nor a detection rule. It is a line of permissions. Recognising a specific attack gets bypassed by changing the attack. Making a whole class of attacks impossible does not.
Detection is how you know. Architecture is what protects.
Top comments (0)