Chapter 7 — ClamAV & Malware Protection
The previous chapters secured the doors — locking down SSH, hardening the firewall, adding browser-enforced policies. This chapter deals with what happens if something slips through anyway. Malware on a web server most commonly arrives as a PHP web shell uploaded through a vulnerable application, as a compromised package, or injected into legitimate files during an attack. ClamAV is the Linux standard for scanning and catching these — and a few Apache configuration tricks can prevent uploaded files from being executed at all.
What this chapter covers: What ClamAV does and where it fits in your defences. The three ClamAV packages (clamscan, freshclam, clamd daemon). Keeping virus definitions current. Running on-demand scans with the most useful flags — and why to quarantine rather than auto-delete. Scheduled scans via cron and systemd timer. Protecting upload directories by denying PHP execution in Apache. On-access scanning for new uploads. The incident response playbook when a scan finds something. Complementary tools: rkhunter (rootkits), AIDE (file integrity). Resource considerations on a home server.
What ClamAV Does — and Its Limits
ClamAV's role in the security stack:
Layer 1 — Network: UFW (Chapter 3) blocks unwanted connections
Layer 2 — Auth: fail2ban (Ch.4) bans brute-force sources
SSH keys (Ch.5) eliminates password attacks
Layer 3 — Browser: Security headers (Ch.6) limits what browser runs
Layer 4 — Files: ClamAV (this chapter) detects malicious files
Apache upload rules prevents PHP execution
Layer 5 — Monitoring: logwatch, unattended-upgrades (Chapter 8)
ClamAV specifically watches for:
├── PHP web shells (uploaded .php files that give remote shell access)
├── Malicious JavaScript injected into HTML/PHP files
├── Trojan scripts hidden in archives or disguised as images
├── Known malware signatures in downloaded packages
└── Modifications to existing files (when combined with AIDE)
ClamAV is signature-based — it recognises malware it has seen before (or variants close enough to match a pattern). It won't catch genuinely novel malware until signatures are updated. For a web server, the most important threats it catches are well-known PHP web shells and common malicious scripts — exactly the kind of tool-based attacks that automated exploit kits deploy.
- It catches: known web shells, common trojans, malicious scripts, infected archives, most real-world automated attacks.
- It doesn't catch: zero-day exploits, custom malware written specifically for your server, compromised dependencies in your code (use
composer audit / npm audit for those).
- It's not real-time by default: scanning is on-demand or scheduled. On-access scanning (daemon mode) can make it reactive, but at a resource cost.
The Three ClamAV Components
clamav
Scanner
Contains clamscan — the command-line scanner. Loads signatures into memory each run (slower, but no daemon needed). Good for scheduled scans and one-off checks.
clamav-freshclam
Definition updater
The freshclam service downloads new virus signatures from ClamAV's mirrors. Runs as a daemon that checks for updates multiple times daily. Definitions are useless if they're stale.
clamav-daemon
Background daemon
Runs clamd — keeps signatures loaded in memory between scans. clamdscan talks to it and is much faster than clamscan for repeated scans. Also enables on-access scanning.
clamdscan
Daemon client
The client that sends scan jobs to clamd. Same flags as clamscan but faster because signatures are pre-loaded. Use this for scheduled scans if clamd is running.
Home server resource note: clamd loads the entire signature database into RAM — roughly 500 MB–1 GB. On a server with 2–4 GB of RAM also running Apache, MySQL, and cloudflared, that's a significant chunk. If RAM is tight, skip clamav-daemon and use clamscan directly for scheduled scans — it's slower but uses no persistent RAM.
Installing ClamAV
$ sudo apt install clamav clamav-daemon -y
$ sudo systemctl stop clamav-freshclam
$ sudo freshclam
ClamAV update process started at Sun Jun 15 12:00:00 2026
daily.cvd database is up-to-date (version: 27249, sigs: 2054253, ...)
main.cvd database is up-to-date (version: 62, sigs: 6647427, ...)
bytecode.cvd is up-to-date (version: 334, sigs: 91, ...)
$ sudo systemctl start clamav-freshclam
$ sudo systemctl enable clamav-freshclam
$ sudo systemctl start clamav-daemon
$ sudo systemctl enable clamav-daemon
$ sudo systemctl status clamav-freshclam clamav-daemon
● clamav-freshclam.service — ClamAV virus database updater
Active: active (running)
● clamav-daemon.service — Clam AntiVirus userspace daemon
Active: active (running)
$ clamscan --version
ClamAV 1.0.3/27249/Sun Jun 15 09:00:01 2026
Running Scans
Scanning the web root (most important scan to run regularly)
$ sudo clamscan -r -i /var/www/osztromok.com/public_html/
----------- SCAN SUMMARY -----------
Known viruses: 9580123
Engine version: 1.0.3
Scanned files: 847
Infected files: 0 ← nothing found
Time: 12.234 sec (0 m 12 s)
$ sudo clamscan -r -i /var/www/
$ sudo clamdscan -i /var/www/osztromok.com/public_html/
Key flags
-r / --recursive
Scan directories recursively — descends into subdirectories. Essential for scanning a web root. Without it, only files in the top directory are scanned.
-i / --infected
Only print infected file names. Without this, every scanned file is printed — noisy and hard to read in large directories. Always use this.
--move=DIR
Move infected files to a quarantine directory instead of leaving them in place. Preferred over --remove — you can review what was found before permanent deletion.
--remove
Use with caution — permanently deletes infected files immediately with no review. Appropriate for automated scans of upload temp directories, not for scanning your web root.
--exclude-dir=DIR
Skip a directory. Always exclude /proc, /sys, /dev when scanning the whole system — these are virtual filesystems, not real files.
--log=FILE
Write scan results to a log file. Essential for scheduled scans so you can review results later without watching the terminal.
--max-filesize=MB
Skip files larger than this size (default 25 MB). Large video files don't need virus scanning — adjust if your site serves large files.
-v / --verbose
Show every file being scanned (useful for debugging why a specific file isn't being scanned). Don't use in scheduled scans — generates huge log files.
Reading scan output — infected file found
/var/www/osztromok.com/public_html/uploads/image_20260615.php: Php.Webshell.Generic-2 FOUND
----------- SCAN SUMMARY -----------
Known viruses: 9580123
Engine version: 1.0.3
Scanned files: 1247
Infected files: 1
Time: 18.451 sec (0 m 18 s)
↑ Signature name "Php.Webshell.Generic-2" tells you:
Php = PHP malware
Webshell = remote command execution shell
Generic-2 = a known generic web shell variant
The path shows it was uploaded to the uploads directory — classic attack.
Do not delete without investigating first. When ClamAV finds something, the immediate question is not "how do I delete this?" — it's "how did it get there?" If you delete the file without finding the entry point, the attacker will just upload another. See the incident response playbook below.
Setting Up a Quarantine Directory
$ sudo mkdir -p /var/quarantine
$ sudo chmod 700 /var/quarantine
$ sudo clamscan -r -i --move=/var/quarantine /var/www/
/var/www/osztromok.com/public_html/uploads/shell.php: Php.Webshell.Generic-2 FOUND
/var/www/osztromok.com/public_html/uploads/shell.php: moved to '/var/quarantine/shell.php'
$ sudo ls -la /var/quarantine/
$ sudo file /var/quarantine/shell.php
$ sudo cat /var/quarantine/shell.php
$ sudo rm -rf /var/quarantine/*
Scheduled Scans
Option A — Cron job (simple)
$ sudo crontab -e
0 3 * * * clamscan -r -i --move=/var/quarantine --log=/var/log/clamav/daily-scan.log /var/www/ && echo "ClamAV scan complete — no threats" | mail -s "ClamAV Daily OK" emubantam@gmail.com || echo "ClamAV scan found threats — check /var/log/clamav/daily-scan.log" | mail -s "ClamAV ALERT" emubantam@gmail.com
0 3 * * * clamscan -r -i --move=/var/quarantine --log=/var/log/clamav/daily-scan.log /var/www/
0 4 * * 0 clamscan -r -i --move=/var/quarantine --exclude-dir=^/proc --exclude-dir=^/sys --exclude-dir=^/dev --exclude-dir=^/run --log=/var/log/clamav/weekly-scan.log /
Option B — Systemd timer (cleaner, with logging)
$ sudo nano /usr/local/bin/clamav-daily-scan.sh
#!/bin/bash
LOGFILE="/var/log/clamav/daily-scan-$(date +%Y-%m-%d).log"
QUARANTINE="/var/quarantine"
WEBROOT="/var/www"
echo "ClamAV scan started at $(date)" > "$LOGFILE"
clamscan -r -i --move="$QUARANTINE" --log="$LOGFILE" "$WEBROOT"
EXIT_CODE=$?
if [ $EXIT_CODE -eq 1 ]; then
echo "ALERT: Infected files found — check $LOGFILE and $QUARANTINE" | \
mail -s "ClamAV THREAT DETECTED on $(hostname)" emubantam@gmail.com
fi
exit $EXIT_CODE
$ sudo chmod +x /usr/local/bin/clamav-daily-scan.sh
$ sudo nano /etc/systemd/system/clamav-daily-scan.service
[Unit]
Description=ClamAV Daily Web Root Scan
After=clamav-daemon.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/clamav-daily-scan.sh
User=root
$ sudo nano /etc/systemd/system/clamav-daily-scan.timer
[Unit]
Description=Run ClamAV daily web root scan at 3 AM
[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=300
Persistent=true
[Install]
WantedBy=timers.target
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now clamav-daily-scan.timer
$ sudo systemctl list-timers clamav-daily-scan.timer
NEXT LEFT LAST PASSED UNIT ACTIVATES
Mon 2026-06-16 03:04:32 BST 14h left - - clamav-daily-scan.timer clamav-daily-scan.service
$ sudo systemctl start clamav-daily-scan.service
$ sudo journalctl -u clamav-daily-scan.service --no-pager
Protecting Upload Directories
ClamAV detects malicious files after they've been uploaded. Apache configuration can stop them from being executed even if they slip through. The two defences are complementary — use both.
The most dangerous upload attack is a PHP web shell: an attacker finds a file upload form (contact form, avatar upload, attachment) and uploads a file named shell.php (or shell.php.jpg to bypass naive extension checks). If Apache executes it, the attacker gains full shell access to your server under the web server's user.
<VirtualHost *:443>
<Directory /var/www/osztromok.com/public_html/uploads>
php_admin_flag engine off
<FilesMatch "\.php[0-9]?$">
Require all denied
</FilesMatch>
<FilesMatch "\.(phtml|phar|php3|php4|php5|phps|cgi|pl|py|rb|sh)$">
Require all denied
</FilesMatch>
AllowOverride None
</Directory>
</VirtualHost>
$ sudo apache2ctl configtest && sudo systemctl reload apache2
$ echo "<?php echo 'shell'; ?>" | sudo tee /var/www/osztromok.com/public_html/uploads/test.php
$ curl https://osztromok.com/uploads/test.php
403 Forbidden ← PHP execution blocked — correct
$ sudo rm /var/www/osztromok.com/public_html/uploads/test.php
AllowOverride None in the uploads directory is critical. Without it, an attacker can upload a .htaccess file that re-enables PHP execution with AddType application/x-httpd-php .jpg — turning every uploaded image into an executable PHP file. AllowOverride None prevents Apache from reading any .htaccess files in that directory.
On-Access Scanning for Upload Directories
On-access scanning tells clamd to automatically scan files when they appear or are modified in a specific directory — catching malicious uploads the moment they land, before any web request can trigger them.
$ sudo nano /etc/clamav/clamd.conf
OnAccessIncludePath /var/www/osztromok.com/public_html/uploads
OnAccessExcludeUname clamav
OnAccessPrevention yes
OnAccessMaxFileSize 20M
$ grep "^User" /etc/clamav/clamd.conf
User clamav
$ sudo nano /etc/clamav/clamd.conf
$ sudo systemctl restart clamav-daemon
$ sudo journalctl -u clamav-daemon -n 20 | grep -i "access"
clamd[1234]: On-access module loaded successfully.
clamd[1234]: Protecting directory: /var/www/osztromok.com/public_html/uploads
On-access scanning adds CPU overhead — every write to the watched directory triggers a scan. For a busy upload directory on a home server, this can be noticeable. If performance degrades, disable on-access and rely on scheduled scans instead. On-access also requires kernel support for fanotify, present in all modern Ubuntu/Debian kernels.
When a Scan Finds Something — Incident Response
Phase 1Contain — isolate the threat before investigating
Move the infected file to quarantine (if not done automatically by the scan): sudo mv /path/to/threat.php /var/quarantine/. Do not delete it yet — you need to examine it. If the site is actively serving malicious content, consider temporarily taking the vhost offline: sudo a2dissite osztromok.com.conf && sudo systemctl reload apache2.
Phase 2Investigate — how did it get there?
Check Apache access logs for requests to the infected file: sudo grep "threat.php" /var/log/apache2/access.log. Find when it was created: sudo stat /var/quarantine/threat.php — use the mtime (modification time). Check Apache logs around that time for POST requests (uploads): sudo grep "POST" /var/log/apache2/access.log | grep "Jun 15". Look at the file contents: sudo cat /var/quarantine/threat.php — this tells you what the attacker intended and sometimes reveals the upload method. Check for other suspicious files uploaded at the same time: sudo find /var/www/ -newer /var/quarantine/threat.php -type f | head -20.
Phase 3Close the entry point
Identify the upload mechanism the attacker used and close it. Common entry points: unpatched CMS plugin (run sudo -u www-data wp core update && wp plugin update --all), weak file type validation in custom upload code, world-writable directories, compromised credentials. Add PHP execution blocking to the upload directory (see the Apache config above) if not already in place. Add the uploading IP to fail2ban: sudo fail2ban-client set apache-auth banip ATTACKER-IP.
Phase 4Assess damage — was anything exfiltrated or modified?
Check if the web shell was ever executed (not just uploaded): sudo grep "threat.php" /var/log/apache2/access.log — a GET request to it means it was accessed. If it was executed, check for: new user accounts (cat /etc/passwd | grep -v nologin), new cron jobs (sudo crontab -l -u www-data), modified system files (see AIDE below), outbound connections (sudo ss -tnp | grep www-data). Run ClamAV again on the whole system: sudo clamscan -r -i /.
Phase 5Clean up and recover
Delete the quarantined file: sudo rm /var/quarantine/threat.php. Restore any modified legitimate files from git (if your web content is version-controlled) or from backup. Re-enable the site if it was taken offline: sudo a2ensite osztromok.com.conf && sudo systemctl reload apache2. Update all software: sudo apt upgrade && sudo apt autoremove. Run one final clean scan to confirm all threats are gone.
Complementary Tools
ClamAV catches known malware by signature. These tools cover different aspects of the threat landscape:
$ sudo apt install rkhunter -y
$ sudo rkhunter --update
$ sudo rkhunter --propupd
$ sudo rkhunter --check --skip-keypress
[ Rootkits ]
Checking for known rootkit files and directories [ None found ]
Checking for Anonoxymoron Rootkit [ Not found ]
[ Application checks ]
Checking Apache2 configuration... [ OK ]
System checks summary: 0 warnings
$ sudo apt install aide -y
$ sudo aideinit
$ sudo cp /var/lib/aide/aide.db.new /var/lib/aide/aide.db
$ sudo aide --check
AIDE found differences between database and filesystem!
Changed: /var/log/apache2/access.log
Troubleshooting
freshclam fails — "ERROR: /var/log/clamav/freshclam.log: Permission denied"
The freshclam service is already running and holds the log file, or the log file permissions are wrong. Solution: stop the service before running freshclam manually — sudo systemctl stop clamav-freshclam, then sudo freshclam, then sudo systemctl start clamav-freshclam. Never run sudo freshclam while the service is running — they'll conflict trying to write to the same log file.
clamd fails to start — "ERROR: Can't open/parse the config file /etc/clamav/clamd.conf"
Syntax error in clamd.conf. Check: sudo clamd --config-file=/etc/clamav/clamd.conf --check-config. Common causes: typo in a directive name, a value missing where one is expected, or an OnAccessIncludePath pointing to a non-existent directory. Also check ownership: ls -la /etc/clamav/clamd.conf — it should be owned by clamav:clamav.
clamscan is very slow — taking 10+ minutes for the web root
clamscan loads all signatures into memory on each run — that's ~150 MB of database reading before scanning starts. Switch to clamdscan if the daemon is running (signatures stay loaded in RAM, subsequent scans start immediately). Alternatively, accept the slowness and schedule scans during off-peak hours (3–4 AM). Also check: is /var/www on a network mount? Network filesystem scans are much slower than local disk.
ClamAV flagging a legitimate file as infected (false positive)
False positives happen — ClamAV's signatures are broad patterns that sometimes match benign files. Confirm it's a false positive: look at the file content and the signature name. Report it at https://www.clamav.net/reports/fp. Whitelist the specific file with --exclude=FILENAME or by adding a whitelist to the ClamAV database. Never blindly auto-delete files without a review step — this is why the --move flag (quarantine) is safer than --remove.
clamav-daemon not starting — "ERROR: SelfCheck: Database modification time has changed"
The signature database was updated by freshclam while clamd was running. clamd detected the change and logged an error, or may have stopped. This is normal — clamd should automatically reload the database. If it's stopped: sudo systemctl restart clamav-daemon. To prevent this: configure freshclam to signal clamd when updating (NotifyClamd /etc/clamav/clamd.conf in freshclam.conf).
Quick Reference — Chapter 7
| Command | Purpose |
| sudo clamscan -r -i /var/www/ | Scan web root recursively, show only infected files |
| sudo clamdscan -i /var/www/ | Same scan via daemon — much faster (daemon must be running) |
| sudo clamscan -r -i --move=/var/quarantine /var/www/ | Scan and quarantine infected files — preferred over --remove |
| sudo freshclam | Manually update virus definitions (stop clamav-freshclam service first) |
| clamscan --version | Check current signature database version and date |
| sudo systemctl status clamav-freshclam clamav-daemon | Check both ClamAV services are running |
| sudo rkhunter --check --skip-keypress | Scan for rootkits and backdoors |
| sudo aide --check | Compare current filesystem against the AIDE baseline |
| sudo lynis audit system | Full system security audit with scored report |
| sudo find /var/www/ -name "*.php" -newer /var/www/index.php | Find PHP files added after a reference date (incident investigation) |
| Tool | What it catches | When to run |
| ClamAV | Known malware, web shells, trojans in files | Daily scheduled scan of web root |
| rkhunter | Rootkits, hidden processes, backdoored binaries | Weekly, and after any suspicious activity |
| AIDE | Any file that has changed since the baseline | Daily or after software updates |
| lynis | Configuration weaknesses across the whole system | Monthly, and after major config changes |