A hacked WordPress, two attackers, and the hole no antivirus saw

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.

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 when I actually read the code, I hit a snag. 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 tell the whole story: a call to a plugin, then, seconds later, the first request to the file it just dropped.

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 in the first line is 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.

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. That is the lesson I keep from the episode.

Clean up, then make cleaning up pointless

Once the inventory is genuinely complete, cleanup becomes the boring part. Good.

I compared WordPress core file by file against the official archive of the same version, and replaced every difference. Exactly one was legitimate, the file that carries the install's language. The plugins I did not clean, I reinstalled from their official source: faster, safer, and nobody reviews thirty plugins by hand without missing one. The custom theme was irreplaceable. I read it line by line. It was untouched.

At that point the site is clean. And every bit 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 hardening itself 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 out. The built-in file editor, the one that lets you edit the site's code from a browser, is dead; the admin only opens over HTTPS; the old TLS 1.0 and 1.1 are turned away at the door. Files that talk too much no longer open either: debug logs, readme, composer.json, anything that smells like a .env. And a fail2ban jail bans on the first attempt against an exploitation pattern, no second chance.

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.

Two jobs remain that no constant settles: the secrets that may have leaked, and what you can fall back on when it goes wrong. Secrets first. 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.

The first guard watches core integrity. It asks wordpress.org about the version actually installed, rather than an archive frozen on my disk. That archive I had tried first, and it handed me a fine false alarm on the very first update: a frozen reference goes wrong on its own, without anyone doing anything wrong.

The big piece is the fingerprint: roughly 11,500 files compared against a reference, every PHP file, every .htaccess, the theme's scripts and stylesheets. The cache, too volatile to sit in it, gets its own separate check. Exactly the one that would have saved me from missing the 468-byte loader, had I had it that day.

Two dumb, effective checks round it off: 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. You go in, you stay in.

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 check I like best asks for the same page twice: once as a browser, once as Googlebot, then it compares the two 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.

Even the intrusion vector is no longer watched in the logs. We watch its trace: if the vulnerable plugin ever reappears in the plugin directory, the alert fires. And that is the principle everywhere. We stopped watching the hands that rattle the handle; we watch whether anything has moved inside the house: a file that changes, a value that shifts in the database, a door we thought was bricked up growing back.

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 a choice: a monitoring system you cannot read at a glance will never get debugged the day it goes wrong. And it will go wrong one day, usually at 4 a.m.

# /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 two Apache rules. The first cuts the PHP engine in the writable directories, with a directive no .htaccess can undo. The second forbids declaring a file handler there. Without them, a .jpg stuffed with PHP ran just fine: I checked before laying the rule down, and checked again after. The first time, the .jpg answered. The second, it did nothing but print its own harmless source.

# No PHP execution in the directories the web server can write to.
#
# Completes the first rule, which denies access to files whose EXTENSION is
# executable. This file closes the other half: an .htaccess could declare
# `AddType application/x-httpd-php .jpg` and get a harmless-looking file
# executed.
#
# php_admin_flag can NOT be overridden by an .htaccess, whatever AllowOverride
# says. That is what lets wp-content/cache keep AllowOverride All, which W3TC
# needs to declare its MIME types, while still denying execution there.

#
# No effect on files W3TC includes from its own code: the directive applies to
# the REQUEST path, not to a PHP include().
<DirectoryMatch "^/var/www/lesmontheme\.com/public/wp-content/(uploads|uploads-webpc|smush-webp|cache|w3tc-config|upgrade|upgrade-temp-backup)(/|$)">
    php_admin_flag engine off
</DirectoryMatch>
# The zones www-data writes to must not be able to declare a handler.
#
# AllowOverride All let an .htaccess there set AddType/AddHandler/SetHandler,
# hence make a file disguised as .jpg executable: the payload then escaped the
# monitoring's extension check.
#
# AuthConfig and Limit stay allowed, deliberately: several plugins (Contact
# Form 7, WP Migrate DB) drop an .htaccess there that DENIES access to their
# files. Cutting those would expose what they protect.
#
# wp-content/cache is not in the list: W3TC declares MIME types there to serve
# its compressed variants, and removing that would degrade the cache.
<DirectoryMatch "^/var/www/lesmontheme\.com/public/wp-content/(uploads|uploads-webpc|smush-webp|w3tc-config)(/|$)">
    AllowOverride AuthConfig Limit
</DirectoryMatch>

Then the updates. This script only exists because DISALLOW_FILE_MODS stops WordPress from patching itself. It lifts the lock, works, puts it back, verifies, reports. Two details earn their keep. The trap on line 53, without which a crash halfway through would leave the site wide open until the next Tuesday. And the guard on line 30: if the database dump fails, nothing moves, the whole run aborts. A safety net you believe is there and is not is worse than no net at all, because you jump thinking you are covered.

#!/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

# Without this check, a broken wp-cli left $dispo and $coeur empty: the script
# concluded "nothing to update" and the site silently stopped being patched,
# week after week. That is exactly what led to the original incident.
if ! command -v wp >/dev/null; then
    if printf '%s' "wp-cli est introuvable sur $(hostname).

Aucune mise a jour n'a pu etre tentee, et ce silence aurait pu durer des mois.
Reinstaller wp-cli puis relancer /root/maj.sh." \
        | mail -s "[MAJ] wp-cli introuvable, aucune mise a jour appliquee" -a "From: $FROM" "$DEST"; then
        echo "$(date -u '+%F %T') wp-cli introuvable, abandon" >> "$LOG"
    else
        echo "$(date -u '+%F %T') wp-cli introuvable, abandon, ET MAIL NON ENVOYE" >> "$LOG"
    fi
    exit 1
fi

# Update lists are cached in the database, and www-data reads wp-config.php so
# it holds the MySQL credentials: it can write whatever archive URL it likes,
# which root would then install. We force a real lookup.
wp option delete _site_transient_update_plugins --allow-root >/dev/null 2>&1
wp option delete _site_transient_update_core --allow-root >/dev/null 2>&1
wp option delete _site_transient_update_themes --allow-root >/dev/null 2>&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 :
# net you believe is there and is not is worth less than no net at all,
# because you take the risk believing you are covered.
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"
    if 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"; then
        echo "$(date -u '+%F %T') sauvegarde impossible, mise a jour annulee" >> "$LOG"
    else
        echo "$(date -u '+%F %T') sauvegarde impossible, annulee, ET MAIL NON ENVOYE" >> "$LOG"
    fi
    exit 1
fi
find /root/backups -name 'db-avant-maj-*.sql.gz' -mtime +30 -delete 2>/dev/null

# The trap is armed BEFORE the marker is dropped: in between, an interruption
# would leave an orphan marker, and the monitoring would stay silent for 15
# minutes over a real outage.
remettre_le_verrou() {
    sed -i "s/define( 'DISALLOW_FILE_MODS', false );/define( 'DISALLOW_FILE_MODS', true );/" "$SITE/wp-config.php"
    rm -f "$STATE/maj-en-cours"
}
trap remettre_le_verrou EXIT INT TERM

# Marker read by the monitoring: during this window, a site that does not
# answer is normal. The marker is dated and expires on its own in 15 minutes.
mkdir -p "$STATE"; touch "$STATE/maj-en-cours"

# upgrade/ and upgrade-temp-backup/ must belong to root BEFORE the WordPress
# upgrader works in them: otherwise www-data drops a symlink there, which the
# upgrader follows as root while emptying the directory, wiping the target.
# We MOVE then recreate: an rm -rf can fail silently if www-data recreates
# entries in a loop, and install -d then only fixes the top directory.
for d in upgrade upgrade-temp-backup; do
    if [ -e "${SITE:?}/wp-content/$d" ]; then
        # Outside the web root: the rm -rf below can fail if www-data writes
        # in a loop, and a tree it owns under wp-content would be one more
        # executable directory, invisible to check 3.
        mv "${SITE:?}/wp-content/$d" "/root/upgrade-ancien-$(date -u +%Y%m%dT%H%M%S)-$d" 2>/dev/null
    fi
    install -d -o root -g www-data -m 750 "$SITE/wp-content/$d"
    if [ -n "$(find "$SITE/wp-content/$d" -mindepth 1 -print -quit 2>/dev/null)" ]; then
        echo "$(date -u '+%F %T') reprise de $d impossible, mise a jour annulee" >> "$LOG"
        printf '%s' "Impossible de reprendre wp-content/$d a www-data avant la mise a jour.
Quelque chose y ecrit en continu. Mise a jour annulee : la faire dans ces
conditions exposerait a un effacement en root. A regarder a la main." \
            | mail -s "[MAJ] reprise de $d impossible, mise a jour annulee" -a "From: $FROM" "$DEST"
        exit 1
    fi
done
rm -rf /root/upgrade-ancien-* 2>/dev/null
reste=$(find /root -maxdepth 1 -name 'upgrade-ancien-*' -print -quit 2>/dev/null)
[ -n "$reste" ] && RAPPORT="$RAPPORT
ATTENTION : d'anciens repertoires upgrade n'ont pas pu etre supprimes ($reste).
Quelque chose y ecrit en continu, a regarder a la main.
"

# The transients are purged again HERE, right before installing: done earlier,
# it left www-data several minutes (it reads wp-config.php, hence the MySQL
# credentials) to rewrite the archive URL that root would install.
wp option delete _site_transient_update_plugins --allow-root >/dev/null 2>&1
wp option delete _site_transient_update_core --allow-root >/dev/null 2>&1

# DISALLOW_FILE_MODS also blocks wp-cli, so we lift it for the operation.
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. The results GATE what follows: we do not freeze a baseline on
# a state we could not verify.
coeur_ok=0; ext_ok=0
v_coeur=$(wp core verify-checksums --allow-root --skip-plugins --skip-themes 2>&1)
echo "$v_coeur" | grep -q "^Success:" && coeur_ok=1
v_ext=$(wp plugin verify-checksums --all --allow-root 2>&1)
echo "$v_ext" | grep -q "^Success:" && ext_ok=1
RAPPORT="$RAPPORT
## Verification after the update
$(echo "$v_coeur" | tail -3)
$(echo "$v_ext" | 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
"

# The monitoring baseline is rebuilt ONLY on a verified state. Otherwise it
# would absorb whatever the update left behind, and the monitoring would never
# see it again.
if [ "$coeur_ok" = "1" ] && [ "$ext_ok" = "1" ] && [ "$code" = "200" ]; then
    /root/surveille.sh --rebaseline >/dev/null 2>&1
    RAPPORT="$RAPPORT
Empreinte de surveillance reconstruite (coeur et extensions conformes, site en 200).
"
    sujet="[MAJ] example.com"
else
    RAPPORT="$RAPPORT
ATTENTION : empreinte de surveillance NON reconstruite.
  coeur conforme : $([ "$coeur_ok" = 1 ] && echo oui || echo NON)
  extensions conformes : $([ "$ext_ok" = 1 ] && echo oui || echo NON)
  page d'accueil : HTTP $code
La surveillance va donc signaler les fichiers mis a jour comme des ecarts, ce qui
est voulu : il faut regarder avant de valider. Une fois l'etat verifie a la main,
lancer /root/surveille.sh --rebaseline.
Restauration possible depuis /root/golden/ et les dumps /root/backups/.
"
    sujet="[MAJ] ATTENTION : verification en echec"
fi

if printf '%s' "$RAPPORT" | mail -s "$sujet" -a "From: $FROM" "$DEST"; then
    echo "$(date -u '+%F %T') mises a jour appliquees, HTTP $code, rebaseline=$([ "$coeur_ok$ext_ok$code" = "11200" ] && echo oui || echo non)" >> "$LOG"
else
    echo "$(date -u '+%F %T') mises a jour appliquees mais COMPTE RENDU NON ENVOYE" >> "$LOG"
fi

And the monitoring itself. Ten checks, only one of which reads the logs, and that one never sends mail. Email is reserved for what left a trace: code that changed, an account that appeared, a page that lies to the robots.

#!/bin/bash
# Watches example.com. Sends mail ONLY when something is wrong.
# Run from cron. Prints nothing when all is well: silence is success.

RACINE=/var/www/example.com
SITE="$RACINE/public"
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
"; }

# Directories the web server may write to. Everything else belongs to root.
# upgrade/ and upgrade-temp-backup/ are NO LONGER here: they belong to root,
# otherwise www-data can drop a symlink there that the WordPress upgrader
# follows as root during the weekly update, wiping the link's target.
ZONES="uploads cache uploads-webpc smush-webp w3tc-config"

# Subdirectories where W3TC legitimately writes generated .php, non-stop.
# Anchored to the real path: a */cache/* pattern would let through a
# wp-content/upgrade/cache/shell.php, and the attacker picks the names.
GENERES="$SITE/wp-content/cache/db $SITE/wp-content/cache/object
$SITE/wp-content/cache/page_enhanced $SITE/wp-content/cache/minify
$SITE/wp-content/cache/fragment $SITE/wp-content/cache/stats
$SITE/wp-content/cache/tmp $RACINE/node_modules $RACINE/.git"

# One pass at a time. Without this lock, a `wp` stuck on a degraded network
# lets the next pass start, and two instances write the same state files.
# --rebaseline is exempt: it is called by the update script, which would
# otherwise run for nothing.
if [ "${1:-}" != "--rebaseline" ]; then
    exec 9>"$STATE/.verrou"
    if ! flock -n 9; then
        # Exiting silently here passes "I checked nothing" off as "all is
        # well". A stuck pass must show, and past an hour it must be said by
        # mail: the monitoring is dead without saying so.
        debut=$(cat "$STATE/.verrou-debut" 2>/dev/null || echo 0)
        age=$(( $(date +%s) - debut ))
        echo "$(date -u '+%F %T') passage saute : verrou tenu depuis ${age}s" >> /var/log/surveillance.log
        if [ "$debut" -gt 0 ] && [ "$age" -gt 3600 ]; then
            printf 'La surveillance de %s est bloquee depuis %s secondes.\nAucun controle ne tourne plus. Voir /var/log/surveillance.log\n' \
                "$HOST" "$age" | mail -s "[ALERTE] surveillance bloquee" -a "From: $FROM" "$DEST"
        fi
        exit 0
    fi
    date +%s > "$STATE/.verrou-debut"
fi

# Builds find's exclusion expression from $GENERES.
exclusions() {
    local d
    for d in $GENERES; do printf '%s\n' "-not" "-path" "$d/*"; done
}

# The fingerprint: everything that is code, minus the generated streams.
# empreinte <output-file>: returns non-zero if hashing was cut short.
# Without that status, a timeout returned a truncated list looking successful,
# and --rebaseline froze it as the baseline: everything past the cut-off point
# was then compared against nothing at all, without a word.
empreinte() {
    local ex=() st
    mapfile -t ex < <(exclusions)
    # -type f is vital: without it, find also picks up a named pipe, and
    # sha256sum blocks on it forever. Combined with the lock above, that
    # stopped the monitoring for good, in silence. Verified in a sandbox.
    # LC_ALL=C: `comm` needs both sides sorted the same way, and a rebaseline
    # run by hand from a non-C locale does not sort like the cron does.
    find "$RACINE" -type f \( -name "*.php" -o -name ".htaccess" \
        -o -path "*/themes/montheme/*.js" \
        -o -path "*/themes/montheme/*.css" \) \
        "${ex[@]}" -print0 2>/dev/null \
        | timeout 300 xargs -0 sha256sum 2>/dev/null > "$1"
    st=${PIPESTATUS[1]}
    LC_ALL=C sort -o "$1" "$1"
    return "$st"
}

# The database values that never change on their own.
sentinelle_db() {
    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';
"
}

# --- 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
    # --skip-plugins/--skip-themes: without them, root runs the site's plugin
    # code on every pass. timeout: an unreachable wordpress.org must not leave
    # the script hanging until the next pass.
    chk=$(cd "$SITE" && timeout 90 wp core verify-checksums --allow-root --skip-plugins --skip-themes 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
    # A check that vanishes silently is worse than a missing one:
    # you believe you are watched when you no longer are.
    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"
DBREF="$STATE/db-sentinelle.txt"
if [ "${1:-}" = "--rebaseline" ]; then
    neuve=$(mktemp)
    if ! empreinte "$neuve"; then
        rm -f "$neuve"
        echo "Hachage interrompu (delai depasse) : reference NON remplacee"
        exit 1
    fi
    # A baseline that suddenly shrinks is not a baseline, it is an accident.
    # We refuse rather than freeze a diminished inventory.
    ancien=$(wc -l < "$BASE" 2>/dev/null || echo 0)
    if [ "$ancien" -gt 0 ] && [ "$(wc -l < "$neuve")" -lt $(( ancien * 8 / 10 )) ]; then
        echo "Empreinte amputee de plus de 20% ($(wc -l < "$neuve") contre $ancien) : reference NON remplacee"
        rm -f "$neuve"
        exit 1
    fi
    mv "$neuve" "$BASE"
    # The database sentinel is rebuilt HERE too. The script used to exit before
    # reaching it: the "run --rebaseline" printed in the alert mail therefore
    # did nothing, and the same alert came back every day.
    db=$(sentinelle_db)
    if [ -n "$db" ]; then
        printf '%s\n' "$db" > "$DBREF"
        echo "Reference reconstruite : $(wc -l < "$BASE") fichiers, sentinelle base a jour"
    else
        echo "Reference fichiers reconstruite, mais la base n'a pas repondu : sentinelle NON mise a jour"
        exit 1
    fi
    exit 0
fi
if [ -f "$BASE" ]; then
    current=$(mktemp)
    if ! empreinte "$current"; then
        add "Empreinte incomplete" "Le hachage des fichiers a depasse le delai imparti.
Aucune comparaison fiable n'a pu etre faite ce passage : la surveillance des
fichiers est aveugle tant que ce n'est pas regle."
    fi
    drift=$(comm -13 "$BASE" "$current" | awk '{print $2}' | head -30)
    # `gone` is computed on PATHS only. On full lines (hash + path), a merely
    # modified file came out as both "new" and "gone": the alert announced the
    # loss of a file that had in fact just been infected.

    gone=$(comm -23 <(awk '{print $2}' "$BASE" | LC_ALL=C sort) <(awk '{print $2}' "$current" | LC_ALL=C sort) | head -10)
    rm -f "$current"
    [ -n "$drift" ] && add "Fichiers nouveaux ou modifies depuis la reference" "$drift"
    [ -n "$gone" ] && add "Fichiers disparus depuis la reference" "$gone"
else
    add "Baseline fingerprint missing" "Lancer : /root/surveille.sh --rebaseline"
fi

# --- 3. Executable dropped into a writable zone ------------------------------
# One check for every zone www-data can write to. The old version only looked
# at uploads/ and cache/, and exempted subdirectories by pattern:
# upgrade/node_modules/x.php or cache/minify/x.php slipped through. Here the
# only exemptions are anchored and named.
zones=""
for z in $ZONES; do [ -d "$SITE/wp-content/$z" ] && zones="$zones $SITE/wp-content/$z"; done
if [ -n "$zones" ]; then
    # shellcheck disable=SC2086
    intrus=$(find $zones -type f \( -iname "*.php" -o -iname "*.php[0-9]" -o -iname "*.phtml" \
                -o -iname "*.pht" -o -iname "*.phps" -o -iname "*.cgi" -o -iname "*.pl" \
                -o -iname "*.py" -o -iname "*.sh" \) 2>/dev/null \
        | while read -r f; do
              # W3TC writes md5-named serialised caches, in these folders only
              case "$f" in
                "$SITE/wp-content/cache/db/"*|"$SITE/wp-content/cache/object/"*|"$SITE/wp-content/cache/fragment/"*|"$SITE/wp-content/cache/stats/"*)
                    # An md5 name proves nothing, the attacker picks it too.
                    # A real W3TC cache starts with its own inclusion guard.
                    head -c 16 "$f" 2>/dev/null | grep -q '<?php exit' && continue ;;
                "$SITE/wp-content/w3tc-config/master.php") continue ;;
              esac
              # A guard index.php holds no primitive. Exempting on size alone
              # let a 23-byte webshell through.
              case "$f" in
                */index.php)
                    # shellcheck disable=SC2016
                    grep -qEi '\$_(GET|POST|REQUEST|COOKIE)|eval|assert|base64_decode|gzinflate|str_rot13|system|exec|passthru|shell_exec|popen|proc_open|create_function|file_put_contents|move_uploaded_file|preg_replace|include|require' "$f" 2>/dev/null \
                        || continue ;;
              esac
              printf '%s\n' "$f"
          done | head -20)
    [ -n "$intrus" ] && add "Executable depose dans une zone inscriptible" "$intrus"

    # The checks above are limited to regular files, otherwise a named pipe
    # freezes sha256sum. Anything that is neither file nor directory has no
    # business here, and exists precisely to block whatever comes reading.
    # shellcheck disable=SC2086
    bizarres=$(find $zones ! -type f ! -type d 2>/dev/null | head -10)
    [ -n "$bizarres" ] && add "Fichier de type inattendu dans une zone inscriptible" "$bizarres
(tube nomme, socket ou peripherique : sert a bloquer les outils qui les lisent)"

    # No keyword check on .htaccess files: try it, and you flag the hardening
    # directives along with the opening ones, plus the ones W3TC writes itself
    # in minify/ and page_enhanced/. The stable .htaccess files of the writable
    # zones are in the fingerprint, so watched by their content; and an
    # .htaccess re-enabling PHP is useless without a file to run, which the
    # check above catches. PHP execution is also cut at the vhost level.
fi

# --- 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"
# Work files live in $STATE (root, 700), not /tmp: root writes them, and /tmp
# is writable by www-data. The kernel already blocks the symlink hijack, but a
# security script does not rest on a sysctl someone can flip.
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"
    {
        # The status is read AFTER the request's closing quote, not at field 9:
        # the attacker picks the request line, hence the field count, and could
        # land an excluded code in $9 to disappear from the log.
        # The status is the first field AFTER the closing quote of the request.
        # Apache escapes inner quotes as \": we strip them before splitting,
        # otherwise the attacker plants a fake status inside their own request
        # line to get themselves filtered out of the log.
        # shellcheck disable=SC2016
        statut='{ s=$0; gsub(/\\"/, "", s); n=split(s, q, "\""); if (n>=3) { split(q[3], c, " "); st=c[1] } else st="" }'
        grep -aiE "$CIBLE" "$ACCES" | awk "$statut"' st !~ /^(301|302|421)$/ { print }'
        grep -aiE "$BALAYAGE" "$ACCES" | awk "$statut"' st !~ /^(301|302|400|401|403|404|408|421|429|503)$/ { print }'
    } | 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" ]; then
    sleep 20
    code=$(verifier_site)
fi
size=$(stat -c %s "$STATE/derniere-page.html" 2>/dev/null || echo 0)
# The 503 used to be treated as healthy, a leftover from the maintenance
# window: a site down for good therefore never triggered anything.
# The only legitimate exception now is the update window, which drops a dated
# marker; past 15 minutes it is stale and the alert fires again.
maj_en_cours=0
if [ -f "$STATE/maj-en-cours" ] \
   && [ $(( $(date +%s) - $(stat -c %Y "$STATE/maj-en-cours" 2>/dev/null || echo 0) )) -lt 900 ]; then
    maj_en_cours=1
fi
if [ "$code" != "200" ] && [ "$maj_en_cours" = "1" ]; then
    :   # mise a jour en cours, on laisse passer
elif [ "$code" != "200" ]; 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.
dbnow=$(sentinelle_db)

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
        # We do NOT write the baseline here. The file fingerprint does not
        # either: a baseline set with no human eye, on a state that may already
        # be compromised, makes the monitoring an accomplice. We alert until a
        # human looks and runs --rebaseline.
        add "Sentinelle base de donnees absente" "Aucune reference n'existe pour les comptes et les options.
Verifier a la main que les comptes administrateurs et les extensions actives sont
ceux attendus, PUIS lancer : /root/surveille.sh --rebaseline"
    fi
else
    # Mute whatever happens, baseline or not: if it returns nothing from the
    # first pass, it never initialises and never complains.
    add "Sentinelle base de donnees muette" "La requete de controle ne rend 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"
    # Without this test, the log wrote "alert sent" even when the MTA was
    # down: you believed you had been warned when you had not.
    } | if mail -s "[ALERTE] example.com" -a "From: $FROM" "$DEST"; then
        echo "$(date -u '+%F %T') ALERTE envoyee" >> /var/log/surveillance.log
    else
        echo "$(date -u '+%F %T') ALERTE NON ENVOYEE (echec du mail) - voir /var/log/alertes.log" >> /var/log/surveillance.log
    fi
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

The real lesson is not in the score. I do have one, mind you, worked out with Claude, which paired with me from start to finish: 70 before, 92 after. It only serves me as a compass, to check I am heading the right way. A number that adds up measures of unequal weight, handed down by the very party that did the work, proves nothing. What counts is elsewhere, in the breakdown.

The antivirus saw nothing. The grep on dangerous functions saw nothing. Modification times lied cold. 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.

Comments (0)