
A WordPress uploads folder is supposed to hold your holiday photos
and the occasional PDF. It is emphatically not supposed to hold a PHP
web shell — but “supposed to” is doing a lot of heavy lifting there. The
uploads directory is the single most common place a compromised WordPress ends
up executing attacker code, because it’s writable, web-reachable, and everyone
forgets about it. So I gave mine a nose: a virus scanner that sniffs the upload
folder every night and emails me the moment it smells something. 👃
And because a smoke detector you’ve never tested is just ceiling jewellery, I
also made it go off on purpose. More on that at the end.
🎯 The Design (deliberately boring)
- 🌙 On-demand, nightly. Not a real-time on-access daemon — that’s heavy for a modest VPS and I’m basically the only person who ever uploads anything. A once-a-night sweep is the right trade-off: worst case, malware sits for a few hours before the morning email, not weeks.
- 🔇 Silent unless it finds something. Same philosophy as everything else on this box (rkhunter, Monit): a clean scan sends nothing. A mail from the scanner therefore always means “look at this now,” never “everything’s fine, as usual.”
- 🔎 Detection only. It reports, it does not delete or quarantine. On a personal blog I’d rather eyeball a finding than have an automated janitor silently eat a false-positive file.
- 🧩 Ansible-managed (role
blog_clamav), so it’s reproducible and version-controlled, not a snowflake script I’ll forget how I built.
📦 How It’s Installed & Configured
On AlmaLinux 9, the whole thing is ClamAV plus its signature updater:
dnf install clamav clamav-updateThe packaged freshclam.conf ships with a placeholder line that
stops it from ever running — the classic first-time gotcha. Remove it,
pull the signature database once, and enable auto-updates:
# kill the "Example" line or freshclam refuses to start
sed -i '/^Example/d' /etc/freshclam.conf
freshclam # first full signature download
systemctl enable --now clamav-freshclam # keep signatures fresh automaticallyThe actual scan is a small script at
/usr/local/sbin/wordpress-clamav-scan.sh. It scans only the uploads
directory, plays nice with the rest of the box, skips absurdly large files
(malware isn’t hiding in a 300 MB video), and — the important part
— only emails on a hit:
#!/bin/bash
SCAN_LOG="/var/log/clamav/wordpress-scan.log"
UPLOADS_DIR="/srv/wordpress-www/wp-content/uploads"
nice -n 19 ionice -c3 clamscan --recursive --infected --no-summary \
--max-filesize=200M --max-scansize=200M \
"$UPLOADS_DIR" > "$SCAN_LOG" 2>&1
RC=$?
# clamscan exit code 1 = something was found -> mail it, otherwise stay silent
if [ "$RC" -eq 1 ]; then
mail -s "[$(hostname -f)] ClamAV: infected file(s) found in WordPress uploads" \
me@example.com < "$SCAN_LOG"
fi
exit 0A cron entry runs it every night at 02:45. Mail goes out through the same
msmtp relay the rest of my alerts use (rkhunter, Monit), so there’s
one mail path to keep working, not three.
45 2 * * * /usr/local/sbin/wordpress-clamav-scan.sh🧪 Making It Bark: the EICAR Test
Here’s the fun bit. You do not download real malware to test an
antivirus — you use EICAR, an industry-standard 68-byte
test string that every scanner on earth is contractually obliged to flag, and
which is completely harmless (it’s not executable malware, it’s a printable
ASCII string designed purely to trip detection). Drop it in the scan path, run
the real script, watch the whole chain fire, then delete it:
# drop the harmless EICAR test file into uploads
printf 'X5O!P%%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n' \
> /srv/wordpress-www/wp-content/uploads/eicar-test.txt
# run the actual nightly script by hand
/usr/local/sbin/wordpress-clamav-scan.sh
# clean up immediately
rm -f /srv/wordpress-www/wp-content/uploads/eicar-test.txtClamAV spotted it instantly — Eicar-Signature FOUND —
the script hit exit code 1, fired the mail, and moments later this landed in my
inbox:
From: homelab@apt-upgrade.me
Subject: [www.apt-upgrade.me] ClamAV: infected file(s) found in WordPress uploads
/srv/wordpress-www/wp-content/uploads/eicar-test.txt: Eicar-Signature FOUNDAnd to be thorough I checked the mail relay’s own log, which confirmed the
handoff rather than just assuming it:
host=smtp.strato.de tls=on auth=on ... recipients=raphael.muench@proton.me
smtpstatus=250 ... 'OK queued' exitcode=EX_OKDetection ✔️, the real script’s mail path ✔️, delivery to my actual inbox ✔️.
That’s the difference between “I configured a scanner” and “I have a scanner
that works” — the second one you’ve watched catch something. 🎯 (Test file
deleted right after, uploads clean again. This was a drill, not a breach.)
⚠️ Honest Limitations
- It’s a nightly scan, not real-time. A malicious upload is caught at the next 02:45 run, not the instant it lands.
- It scans uploads, the highest-risk folder — not the whole disk. Different tool for the OS layer (that’s rkhunter’s job here).
- Detection, not removal. The email is the whole product; cleanup is a human decision.
None of that bothers me for a personal blog where I’m the main uploader. The
point isn’t a bulletproof EDR — it’s that if the uploads folder ever grows
a PHP file it shouldn’t have, I hear about it by breakfast instead of finding
out from someone else. That, plus rkhunter watching the OS and Monit watching
the services, is three cheap, independent tripwires that all end in the same
place: my inbox. 🛡️
As always, the whole thing — the ClamAV role, the scan script, the cron
entry, the mail config — is public and version-controlled in my Ansible
repo:
github.com/aptupgrademe/www_k3s.
Borrow it, break it, tell me what I missed. 🧑💻




