ClamAV en conteneur : un clamd centralisé, durci et économe en RAM
Sur un serveur qui fait à la fois de la messagerie (amavis + sendmail/postfix) et du proxy (squid), on se retrouve vite avec plusieurs
clamdqui chargent chacun la même base de signatures en mémoire. Chaque instance résidente garde toute la base en RAM — environ 1 Gio par démon. Cet article décrit, étape par étape et avec les preuves de test, comment remplacer ces instances par un seulclamdconteneurisé, non-root, fortement durci, qui partage les signatures de l’hôte et dont la RAM est plafonnée par systemd.Tout ce qui suit a été déployé et re-vérifié sur un hôte Rocky Linux 10, Podman 5.8, ClamAV 1.5. Les commandes sont reproductibles. Les chiffres cités sont des mesures réelles, pas des estimations.
Sommaire
- Le problème : autant de
clamdque de clients - L’architecture cible
- Partager la base de signatures de l’hôte (sans duplication)
- Le conteneur durci, ligne par ligne
- Exposer
clamdsans le mettre en danger - Donner à
clamdaccès au répertoire de scan d’amavis (sans root) - Repointer les services — et deux pièges non évidents
- Preuves de test (chaîne EICAR uniquement)
- Limites et compromis
- Rollback
- Checklist de revue sécurité
1. Le problème : autant de clamd que de clients
ClamAV se décline en deux usages : le scanner ponctuel clamscan (recharge toute la base à chaque appel, lent) et le démon clamd (garde la base résidente, scanne en quelques millisecondes). Les intégrations sérieuses utilisent clamd.
Le souci : la base ClamAV pèse aujourd’hui plus de 3,6 millions de signatures ; une fois chargée et indexée par clamd, elle occupe ~1 Gio de RAM (961 Mio mesurés sur l’hôte de référence). Or les distributions encouragent une instance par client — typiquement, sur un serveur de messagerie durci :
clamd@amavisdpour amavis (filtrage du courrier),clamd@scanpourclamav-milter,- et parfois un troisième pour le proxy.
Soit deux à trois copies de la même base en RAM, chacune mise à jour séparément par son propre freshclam. Sur la machine de référence, la consolidation a libéré plusieurs Gio de RAM et supprimé des téléchargements de signatures redondants.
L’objectif est donc : un seul clamd, partagé, isolé dans un conteneur, et plus sûr que les démons hôte qu’il remplace.
2. L’architecture cible
┌──────────────────────── HÔTE ────────────────────────┐
│ │
│ freshclam (hôte) ──► /var/lib/clamav (RW, 1 source)│
│ │ │
│ │ bind-mount RO │
│ ▼ │
│ ┌─────────── Conteneur Podman (Quadlet) ─────────┐ │
│ │ clamd (User clamav, NON-root, CapEff=0) │ │
│ │ DatabaseDirectory=/var/lib/clamav-host (RO) │ │
│ │ MemoryMax=3G / NoNewPrivileges │ │
│ │ ├─ TCPSocket 127.0.0.1:3310 ──────────────┐ │ │
│ │ └─ LocalSocket /tmp/clamd.sock │ │ │
│ └───────────────────│──────────────────────────│─┘ │
│ socket exposée via│ mount /tmp │ │
│ ▼ │ │
│ /run/clamd-container/clamd.sock │ │
│ (660, groupe clamav-clients) │ │
│ │ autres │ │
│ ┌────────┴────────┐ conteneurs ◄─────┘ │
│ ▼ ▼ (réseau interne) │
│ amavis squid │
│ (membre (membre │
│ clamav-clients) clamav-clients) │
└───────────────────────────────────────────────────────┘
Quatre principes de conception :
| Principe | Mise en œuvre |
|---|---|
| Une seule base, une seule mise à jour | freshclam de l’hôte ; base montée en lecture seule dans le conteneur. |
| Moindre privilège | clamd non-root, capabilities réduites à zéro à l’exécution, NoNewPrivileges. |
| Aucune exposition réseau | Loopback 127.0.0.1 et/ou socket unix restreinte par groupe. Jamais 0.0.0.0. |
| RAM bornée | Plafond cgroup systemd (MemoryMax). |
3. Partager la base de signatures de l’hôte (sans duplication)
La règle d’or : une seule source de vérité pour les signatures. C’est freshclam de l’hôte qui écrit dans /var/lib/clamav. Le conteneur ne fait aucun freshclam : il lit la base de l’hôte, montée en lecture seule.
# Dans le Quadlet (voir §4) :
Volume=/var/lib/clamav:/var/lib/clamav-host:ro
Environment=CLAMAV_NO_FRESHCLAMD=true
Et dans clamd.conf :
DatabaseDirectory /var/lib/clamav-host
clamd détecte les nouvelles signatures tout seul à chaque cycle SelfCheck (~10 min par défaut) et recharge la base à chaud. La fraîcheur côté clients est donc au pire SelfCheck après le freshclam de l’hôte.
⚠️ Le piège du
chownau démarrageL’image officielle
clamav/clamavfait unchownde/var/lib/clamavà l’amorçage (pour donner la base à son utilisateurclamav). C’est incompatible avec un montage en lecture seule à cet emplacement : le conteneur planterait au démarrage.La solution est de ne pas monter la base de l’hôte sur
/var/lib/clamav, mais sur un autre chemin (/var/lib/clamav-host) et de pointerDatabaseDirectorydessus. On laisse/var/lib/clamavêtre un volume Podman ordinaire et inscriptible, que l’entrypoint peutchownsans gêne — il n’y a juste aucune signature dedans.
Pré-requis indispensable, à ne pas oublier : activer freshclam sur l’hôte. Sans lui, la base se fige et le conteneur sert des signatures périmées en silence.
systemctl enable --now clamav-freshclam
sigtool --info /var/lib/clamav/daily.cld | grep -E 'Version|Signatures'
# Version: 28037
# Signatures: 355472
4. Le conteneur durci, ligne par ligne
On gère le conteneur en Quadlet : un simple fichier .container que systemd transforme en service. Pas de podman run à rallonge, pas de script de démarrage — tout est déclaratif et survit aux mises à jour.
/etc/containers/systemd/clamd.container :
[Unit]
Description=ClamAV clamd centralisé (conteneur durci)
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=clamd
# Image ÉPINGLÉE au digest (chaîne d'approvisionnement reproductible, pas de tag mouvant)
Image=docker.io/clamav/clamav@sha256:60e70e270d5703992f7c4f2c5c998a4709abf94153e80af8c461aa3c02513934
# Écoute LOCALE uniquement : clamd n'a aucune authentification.
PublishPort=127.0.0.1:3310:3310
# Volume DB par défaut (writable) : l'entrypoint y fait son chown ; pas de signatures dedans.
Volume=clamav-db:/var/lib/clamav:Z
# Base de signatures de l'HÔTE, en LECTURE SEULE.
Volume=/var/lib/clamav:/var/lib/clamav-host:ro
# Répertoire temporaire d'amavis (scan par chemin), LECTURE SEULE.
Volume=/var/spool/amavisd:/var/spool/amavisd:ro
# clamd.conf personnalisée : User clamav (NON-root) + DatabaseDirectory hôte.
Volume=/etc/clamav-container/clamd.conf:/etc/clamav/clamd.conf:ro
# Répertoire hôte monté sur /tmp : la socket /tmp/clamd.sock y apparaît côté hôte.
Volume=/run/clamd-container:/tmp:Z
Environment=CLAMAV_NO_MILTERD=true
Environment=CLAMAV_NO_FRESHCLAMD=true
# --- Durcissement ---
NoNewPrivileges=true
DropCapability=all
AddCapability=chown dac_override fowner setgid setuid
[Service]
Restart=always
TimeoutStartSec=600
# --- RAM bornée (tout l'intérêt de la manœuvre) ---
MemoryHigh=2G
MemoryMax=3G
[Install]
WantedBy=multi-user.target default.target
Et la clamd.conf montée en lecture seule (/etc/clamav-container/clamd.conf) :
LogFile /var/log/clamav/clamd.log
LogTime yes
LocalSocket /tmp/clamd.sock
LocalSocketMode 660
TCPSocket 3310
User clamav
DatabaseDirectory /var/lib/clamav-host
StreamMaxLength 100M
MaxThreads 8
Décortiquons les choix de sécurité.
clamd tourne en utilisateur non-root
User clamav dans clamd.conf. C’est le point capital. Un conteneur dont le process tourne en root affaiblit l’isolation : la moindre faille d’évasion donne un root hôte (avec podman rootful). Ici le processus de scan final tourne en clamav.
L’entrypoint de l’image, lui, démarre brièvement en root pour préparer la base puis redescend en clamav. C’est pourquoi on ne peut pas tout interdire d’emblée.
Capabilities réduites à l’os — puis à zéro
DropCapability=all retire toutes les capabilities Linux, puis on n’en rend que cinq, le strict nécessaire à l’entrypoint root pour préparer les fichiers et changer d’utilisateur :
chown dac_override fowner setgid setuid
Ce qui compte, c’est l’état du processus clamd final : il tourne en clamav et n’a aucune capability effective. Vérification :
CLAMPID=$(podman exec clamd pgrep -x clamd)
podman exec clamd sh -c "grep -E 'Cap(Eff|Bnd)' /proc/$CLAMPID/status"
# CapEff: 0000000000000000 <- aucune capability active
# CapBnd: 00000000000000cb <- bounding set = exactement les 5 autorisées
0xcb correspond bit à bit à chown (0), dac_override (1), fowner (3), setgid (6), setuid (7) — ni plus, ni moins.
NoNewPrivileges et image épinglée
NoNewPrivileges=true empêche tout regain de privilège via setuid après lancement. L’image est épinglée au digest SHA-256, pas à un tag mouvant comme stable ou latest : on sait exactement quel binaire tourne, et une image compromise en amont ne se substitue pas en silence à la nôtre. (Pensez à re-vérifier et mettre à jour le digest lors des montées de version.)
RAM bornée
systemctl show clamd.service -p MemoryCurrent -p MemoryPeak --value \
| awk 'NR==1{printf "courant : %.0f MiB\n",$1/1048576} NR==2{printf "pic : %.0f MiB\n",$1/1048576}'
# courant : 961 MiB
# pic : 1000 MiB (au chargement de la base ; plafonds MemoryHigh=2G, MemoryMax=3G)
MemoryHigh applique une pression douce (throttling) ; MemoryMax est le plafond dur (OOM-kill au-delà). Gardez de la marge : ici clamd tient en ~961 Mio et culmine à ~1 Gio en chargeant la base. Ne descendez pas MemoryMax sous ~1,5 Gio : avec une base qui grossit, un plafond trop bas ferait tuer clamd au chargement. Le but n’est pas d’affamer clamd, mais d’empêcher une dérive (gros flux, fuite) de manger toute la mémoire de l’hôte.
5. Exposer clamd sans le mettre en danger
clamd n’a aucune authentification. Quiconque peut ouvrir sa socket peut lui faire scanner n’importe quel fichier, voire le saturer. Donc : jamais d’écoute réseau.
ss -ltn | grep 3310
# LISTEN 127.0.0.1:3310 <- loopback uniquement, jamais 0.0.0.0
Le loopback TCP convient pour les autres conteneurs (via un réseau Podman interne dédié). Mais pour les services de l’hôte, on peut faire mieux. Car 127.0.0.1:3310 est joignable par tout processus local, y compris un compte applicatif compromis sans rapport avec l’antivirus.
La socket unix restreinte par groupe
Une socket unix en mode 0660, possédée par un groupe dédié, n’est ouvrable que par les membres de ce groupe. C’est du contrôle d’accès POSIX standard, vérifiable, et bien plus fin qu’un port loopback ouvert à tous.
On crée un groupe dédié clamav-clients et on y ajoute les seuls services autorisés :
groupadd -r -g 961 clamav-clients
gpasswd -a amavis clamav-clients
gpasswd -a squid clamav-clients
Pourquoi un groupe dédié plutôt qu’un groupe existant ? Réutiliser
amavis,squidouclamavserait moins sûr : soit le groupe n’est pas partagé par tous les consommateurs, soit on sur-attribue des droits (par ex. donner le groupeclamavà squid lui ouvre aussi des fichiers de signatures). Un groupe dont l’unique raison d’être est « parler à la socket clamd » est l’expression exacte du moindre privilège.
Faire sortir la socket du conteneur, proprement
L’entrypoint de l’image surveille un chemin précis : /tmp/clamd.sock. On ne le déplace donc pas ; on monte un répertoire de l’hôte sur /tmp du conteneur. La socket créée par clamd apparaît alors côté hôte dans ce répertoire.
Le répertoire est créé par tmpfiles.d (donc recréé au boot, robuste) avec deux finesses :
/etc/tmpfiles.d/clamd-container.conf :
d /run/clamd-container 2750 100 clamav-clients -
- Propriétaire uid 100 = le
clamavdu conteneur. En podman rootful sans remap d’UID, l’uid 100 du conteneur est l’uid 100 de l’hôte : c’est lui qui écrit la socket. - Bit setgid (
2…) : tout fichier créé dans ce répertoire hérite du groupeclamav-clients. La socket appartient donc automatiquement au bon groupe — pas besoin d’unchgrpen post-démarrage, pas de course à la création.
Combiné à LocalSocketMode 660 dans clamd.conf, on obtient exactement :
stat -c '%A %G' /run/clamd-container/clamd.sock
# srw-rw---- clamav-clients
Mode 660, groupe clamav-clients. Un non-membre est refusé par le noyau :
# En tant que membre (amavis) : la socket répond
runuser -u amavis -- \
python3 -c 'import socket;socket.socket(socket.AF_UNIX).connect("/run/clamd-container/clamd.sock")'
# (ok, aucune erreur)
# En tant que non-membre (nobody) : refusé
runuser -u nobody -- \
python3 -c 'import socket;socket.socket(socket.AF_UNIX).connect("/run/clamd-container/clamd.sock")'
# PermissionError: [Errno 13] Permission denied
6. Donner à clamd accès au répertoire de scan d’amavis (sans root)
amavis scanne par chemin : il dépose le message dans son répertoire temporaire et envoie à clamd une commande CONTSCAN /chemin/vers/le/fichier. Pour que ça marche, clamd doit pouvoir lire ces fichiers — qui appartiennent à amavis:amavis en 0640.
La tentation serait de faire tourner clamd en root, ou de l’ajouter au groupe amavis. Les deux élargissent trop les droits. La bonne réponse est une ACL POSIX ciblée : on accorde à l’uid 100 (le clamav du conteneur) un accès lecture seule au répertoire temporaire d’amavis, avec une ACL par défaut pour que les fichiers créés ensuite en héritent.
TEMPBASE=/var/spool/amavisd
setfacl -R -m u:100:rX "$TEMPBASE" # existant
setfacl -R -d -m u:100:rX "$TEMPBASE" # par défaut (nouveaux fichiers)
getfacl -p "$TEMPBASE" | grep -E 'user:100|default:user:100'
# user:100:r-x
# default:user:100:r-x
Et le montage côté conteneur est en lecture seule (:ro) : clamd lit, ne modifie rien.
Caveat à connaître : si le répertoire temporaire est entièrement recréé (réinstallation, purge), l’ACL est à ré-appliquer. Pour la robustesse, on peut la matérialiser dans un
tmpfiles.dou un hook de déploiement.
7. Repointer les services — et deux pièges non évidents
amavis
Dans amavisd.conf, on remplace la cible du scanner par la socket de groupe :
['ClamAV-clamd',
\&ask_daemon, ["CONTSCAN {}\n", "/run/clamd-container/clamd.sock"],
qr/\bOK$/m, qr/\bFOUND$/m,
qr/^.*?: (?!Infected Archive)(.*) FOUND$/m ],
⚠️ Piège n°1 : amavisd jette ses groupes supplémentaires
En ajoutant
amavisau groupeclamav-clientspuis en redémarrant amavisd, on s’attend à ce qu’il puisse ouvrir la socket. Il échoue pourtant en « Permission denied » et bascule sur leclamscanCLI de secours — un scan qui prend ~25 secondes au lieu de quelques dizaines de millisecondes.La cause : en abandonnant ses privilèges root, amavisd réinitialise ses groupes supplémentaires et ne conserve que
$daemon_group. Le groupeclamav-clientsest donc perdu, malgré l’appartenance déclarée dans/etc/group.La correction consiste à déclarer explicitement la liste des groupes appliqués par amavisd :
$daemon_group = 'amavis'; @daemon_groups = qw(amavis clamav-clients); # <- indispensableOn vérifie sur le démon en marche :
for p in $(pgrep -f amavisd); do grep ^Groups: /proc/$p/status; done | sort -u # Groups: 961 984 984 <- 961 = clamav-clients présent
Les démons hôte… et le piège du Wants=
On désactive les clamd de l’hôte devenus inutiles. Mais disable ne suffit pas :
⚠️ Piège n°2 :
amavisd.serviceressuscite leclamdhôteL’unit systemd fournie par la distribution contient :
# /usr/lib/systemd/system/amavisd.service Wants=clamd@amavisd.serviceConséquence : chaque redémarrage d’amavis relance le
clamdhôte que l’on croyait éteint — qui recharge 1,3 Gio de base en RAM. Tout le bénéfice de la consolidation part en fumée, en silence.La parade fiable est de masquer (et pas seulement désactiver) les instances hôte. Un
Wants=vers une unit masquée échoue sans bloquer le démarrage d’amavis :systemctl mask clamd@amavisd.service clamd@scan.service systemctl stop clamd@amavisd.service clamd@scan.serviceOn confirme qu’il ne reste qu’un seul
clamd(celui du conteneur) :ps -eo user,args | grep '[c]lamd --foreground' # 100 clamd --foreground <- uid 100 = le clamav du conteneur, et lui seul
Le clamscan CLI reste configuré en scanner de secours : si le conteneur est indisponible, amavis bloque quand même les infections (plus lentement), avec la base de l’hôte à jour. La sécurité ne dépend pas de la disponibilité du conteneur.
8. Preuves de test (chaîne EICAR uniquement)
On ne teste jamais avec un vrai malware. La chaîne EICAR est un fichier de test inoffensif, reconnu par tous les antivirus précisément pour ce genre de validation.
Test fonctionnel de bout en bout
On injecte un courriel contenant l’EICAR dans amavis (port 10024) et on lit le verdict :
amavis[…] Blocked INFECTED (Eicar-Signature) {Quarantined},
<t@example.com> -> <d@example.com>, … 136 ms
136 ms : c’est la signature d’un scan par le démon via la socket. Si l’on voyait ~25 000 ms, ce serait le clamscan de secours — le signe que la socket n’est pas joignable (cf. piège n°1).
Scan direct par chemin (CONTSCAN)
# Depuis un membre du groupe, sur un EICAR déposé dans le temp d'amavis :
member: /var/spool/amavisd/tmp/…/eicar.txt: Eicar-Test-Signature FOUND | 21.5 ms
Récapitulatif des contrôles
| Contrôle | Commande | Résultat attendu | Mesuré |
|---|---|---|---|
| Écoute loopback seule | ss -ltn | grep 3310 | 127.0.0.1:3310 | ✅ |
clamd non-root | …/proc/$PID/status Uid | 100 (clamav) | ✅ |
| Aucune capability active | CapEff | 0000…0000 | ✅ |
| Bounding set minimal | CapBnd | …00cb (5 caps) | ✅ |
| Socket de groupe | stat socket | srw-rw---- clamav-clients | ✅ |
| Non-membre refusé | connexion nobody | Permission denied | ✅ |
| Base à jour | sigtool --info | version courante | ✅ 28037 |
| RAM sous plafond | MemoryCurrent / MemoryPeak | < MemoryMax | ✅ 961 / 1000 MiB |
Un seul clamd | ps … clamd | conteneur uniquement | ✅ |
9. Limites et compromis
L’honnêteté technique fait partie du sérieux d’une démarche sécurité. Ce montage a des contreparties qu’il faut assumer :
- Podman rootful. L’égalité d’UID hôte/conteneur (uid 100 =
clamavdes deux côtés) qui rend l’ACL et la socket si simples repose sur du rootful. En rootless, les UID sont remappés (subuid) : l’ACL devrait viser l’UID remappé, et la socket sortir via un autre mécanisme. C’est faisable mais plus complexe ; nous l’avons écarté ici pour la clarté. - SELinux en mode permissive sur l’hôte de démonstration. Les montages portent le bon contexte (
:Z), mais nous recommandonsenforcingen production. À valider : politique d’accès declamdau temp d’amavis sous enforcing. - Persistance de l’ACL. Si le répertoire temp d’amavis est recréé de zéro, l’ACL est à ré-appliquer (cf. §6).
clamdlit le courrier en transit. Par construction, le moteur accède aux fichiers temporaires d’amavis (en lecture seule). C’est inhérent au scan par chemin ; on le borne par l’ACL et le montage:ro.- Fraîcheur des signatures. Les clients voient les nouvelles signatures au pire un cycle
SelfCheck(~10 min) après lefreshclamde l’hôte. RéglezSelfCheckselon votre tolérance. - Dépendance au démon. Le conteneur est un point de défaillance pour la vitesse de scan — mais pas pour la couverture : le
clamscande secours prend le relais (plus lentement) avec la base de l’hôte.
10. Rollback
Revenir à l’état antérieur est immédiat :
# 1) Réactiver les clamd hôte
systemctl unmask clamd@amavisd.service clamd@scan.service
systemctl enable --now clamd@amavisd.service clamd@scan.service
# 2) Remettre amavis sur l'ancienne cible (entrée conservée en commentaire)
# puis : systemctl restart amavisd
# 3) Arrêter le conteneur
systemctl stop clamd.service
Les fichiers de configuration d’origine ayant été sauvegardés (*.bak) et les anciennes entrées conservées en commentaire, l’opération est sans perte.
11. Checklist de revue sécurité
-
clamdnon-root dans le conteneur (User clamav, Uid 100). - Aucune écoute sur
0.0.0.0— loopback127.0.0.1et socket unix uniquement. - Socket unix
0660+ groupe dédiéclamav-clients(pas0666), non-membre refusé par le noyau. - Image épinglée au digest, pas de tag mouvant.
- Capabilities au strict minimum (5 dans le bounding set,
CapEff=0) ;NoNewPrivileges. - Base de signatures montée en lecture seule :
clamdne peut pas altérer les signatures. - Aucun secret dans cet article ni dans les configs publiées (hostnames/IP réels anonymisés).
- Mises à jour couvertes : source unique (
freshclamhôte), fraîcheur ≤SelfCheck. - Limites/compromis explicités (rootful, SELinux permissive, persistance ACL, lecture du temp mail).
- Rollback documenté.
- Commandes reproductibles et copiables.
Conclusion
En remplaçant plusieurs clamd par un seul moteur conteneurisé, non-root, sans capability effective, exposé par une socket restreinte par groupe et alimenté par la base de l’hôte, on gagne sur les deux tableaux : plusieurs gigaoctets de RAM récupérés et une surface d’attaque réduite par rapport aux démons hôte d’origine. Les deux pièges les moins évidents — amavisd qui jette ses groupes supplémentaires, et l’unit systemd qui ressuscite le clamd hôte — sont précisément le genre de détails qui séparent un montage « qui a l’air de marcher » d’un montage vérifié.
Tous les fichiers de configuration testés (Quadlet, clamd.conf, extrait amavisd.conf, tmpfiles.d, script de préparation hôte) sont fournis prêts à copier.
À propos de Convergent
Chez Convergent, nous concevons et durcissons des infrastructures Linux où la sécurité est mesurable, pas décorative. Si vous voulez auditer votre chaîne antivirus, consolider vos ressources ou durcir vos conteneurs, parlons-en.