Compromission Zimbra — Cryptomineur XMRig « javab »
Type : Incident de sécurité — compromission serveur de messagerie Produit : Zimbra Collaboration Suite 8.8.15 GA (build 4372) sur Ubuntu 18.04 Sévérité : Critique — exfiltration…
Sommaire
- Sommaire
- 1. Résumé
- 2. Symptômes
- Commande de première vérification
- 3. Chronologie de l'attaque
- Enseignements
- 4. Indicateurs de compromission (IOC)
- Fichiers
- Infrastructure de l'attaquant
- Webshells (noms observés)
- Signatures de détection
- 5. Procédure de diagnostic
- Étape 1 — Geler sans tuer
- Étape 2 — Capturer les artefacts
- Étape 3 — Généalogie et réseau
- Étape 4 — Lire les scripts
- Étape 5 — Chercher les webshells
- Étape 6 — Reconstituer l'activité
- Étape 7 — Vérifier si root est compromis
- 6. Procédure d'éradication
- 6.1 — Supprimer la persistance d'abord
- 6.2 — Tuer les processus
- 6.3 — Purger les fichiers
- 6.4 — Interdire l'exécution depuis les tmpfs
- 6.5 — Mettre les webshells en quarantaine
- 6.6 — Restaurer la crontab Zimbra
- 6.7 — Vérification à J+2 h
- 7. Blocage réseau
- 7.1 — IOC
- 7.2 — Pools de minage
- 7.3 — Restriction du webmail et de la console d'administration
- 8. Rotation des secrets
- 8.1 — Clés PreAuth (priorité absolue)
- 8.2 — Clé de jetons d'authentification
- 8.3 — Mots de passe
- 8.4 — Secrets réversibles (souvent oubliés)
- 8.5 — Persistance dans les boîtes
- 9. Durcissement sans mise à jour possible
- 9.1 — Vulnérabilités exploitables par le port 25
- 9.2 — Filtrage applicatif nginx
- 9.3 — Surveillance du webroot
- 9.4 — Alternatives à la migration
- 10. Pièges à éviter — retour d'expérience
- ❌ `chattr +i` sur le webroot
- ❌ `chown root:zimbra` sur le webroot
- ❌ Filtrage egress par UID sans les IP locales
- ❌ `noexec` sur `/tmp`
- ❌ `apt remove --purge zimbra-snmp`
- ❌ Tuer les processus sans liste blanche
- ⚠️ `zmmysqlstatus` ne répond pas « running »
- 11. Dépannage : Zimbra ne redémarre plus
- Table de diagnostic
- Ordre de démarrage correct
- Vérifier la connectivité interne
- MySQL ne démarre pas
- Restaurer les fichiers de configuration modifiés
- Annexe A — Conventions de nommage
- Annexe B — Script d'automatisation
- Code source complet
- Références
Type : Incident de sécurité — compromission serveur de messagerie Produit : Zimbra Collaboration Suite 8.8.15 GA (build 4372) sur Ubuntu 18.04 Sévérité : Critique — exfiltration de données confirmée Dernière révision : août 2026
Sommaire#
- Résumé
- Symptômes
- Chronologie de l'attaque
- Indicateurs de compromission (IOC)
- Procédure de diagnostic
- Procédure d'éradication
- Blocage réseau
- Rotation des secrets
- Durcissement sans mise à jour possible
- Pièges à éviter
- Dépannage : Zimbra ne redémarre plus
- Annexe A — Conventions de nommage
- Annexe B — Script d'automatisation
1. Résumé#
Un serveur Zimbra 8.8.15 exposé sur Internet a été compromis via une vulnérabilité d'exécution de code à distance. L'attaquant a maintenu un accès interactif pendant 11 jours avant que l'infection ne devienne visible, en déposant successivement huit portes dérobées JSP dans le webroot Jetty.
Le symptôme visible — une saturation CPU à 100 % causée par un mineur de Monero — n'était que la dernière étape, la plus bruyante. L'objectif réel était atteint dès le premier jour : dump complet de l'annuaire LDAP (comptes, empreintes de mots de passe, secrets de configuration) et génération de clés PreAuth permettant de se connecter à n'importe quelle boîte sans mot de passe.
Point clé à retenir Une charge CPU anormale sur un serveur de messagerie n'est jamais le problème. C'est le voyant qui signale qu'un problème bien plus grave est déjà survenu.
2. Symptômes#
| Symptôme | Détail observé |
|---|---|
| Charge CPU | 8 cœurs à 100 %, load average > 17 |
| Processus | /dev/shm/javab à 450 % CPU, 2,3 Go de RSS |
| Processus annexes | /bin/sh /dev/shm/.rguard et /dev/shm/idle en plusieurs exemplaires |
| Propriétaire | UID zimbra (999) |
| Parent | PPID 1 — processus réattachés à init |
| Uptime | 747 jours — aucun redémarrage, donc aucun correctif noyau |
Commande de première vérification#
ps auxfww | grep -E '/dev/shm|/tmp/\.|javab' | grep -v grep
ls -la /dev/shm/
uptime3. Chronologie de l'attaque#
| Date | Événement |
|---|---|
| J-11, 06:40 | Dépôt du premier webshell 430a8f33.jsp dans /public/ |
| J-11, 06:41 | zmprov -l gaa — énumération de tous les comptes |
| J-11, 06:41 | Lecture puis régénération des clés PreAuth des 7 domaines |
| J-11, 06:42 | zmslapcat — dump complet du LDAP |
| J-11, 06:42 | Archive .tar.gz déposée dans le webroot, téléchargée, puis effacée |
| J-11, 06:42 | zmlocalconfig -e snmp_notify=no — l'attaquant referme sa propre porte |
| J-6 à J-1 | Dépôt de 7 webshells supplémentaires, IP sources variées |
| J-1, 20:04 | Dépôt de info.jsp (lecteur de localconfig.xml) |
| J-1, 21:26 | Lancement du mineur XMRig — première manifestation visible |
Enseignements#
- Le mineur arrive en dernier. Onze jours d'accès silencieux ont précédé. L'attaquant monétise l'accès après avoir extrait ce qui a de la valeur.
- L'attaquant referme le vecteur d'entrée pour garder l'accès exclusif et éviter d'être délogé par un concurrent. Une vulnérabilité corrigée ne signifie donc pas un serveur sain.
- Les IP sources changent au fil des jours : accès partagé ou revendu entre plusieurs opérateurs.
4. Indicateurs de compromission (IOC)#
Fichiers#
| Chemin | Rôle | SHA-256 |
|---|---|---|
/dev/shm/javab | XMRig 6.26.0 repackagé | 4d1a59ee3632459f3795ecf7e85921cb0d86f8befc9c99608978810f9517ad3e |
/dev/shm/.rguard | Tue les mineurs concurrents (boucle 0,1 s) | 248a6797e9f33ac0a2e048e1e380d186d3003cca575d27771d4310fe2cb0b185 |
/dev/shm/idle | Watchdog : relance javab s'il meurt | 9dfdc9563ded053a0e95e80e1744793f3154cf035e1e356ee9465d856b4f465e |
/dev/shm/.khp | Installeur de persistance, re-téléchargement | 2d96f6a038eacd89d55fadce05956aca784abebb7cccdd417ecd5050198882cd |
/dev/shm/.khp_ts | Horodatage du throttle (7200 s) | — |
Infrastructure de l'attaquant#
Commande et contrôle
141.94.96.195:8080Serveurs de re-téléchargement du payload (/ksmd)
45.74.7.226 /m51src/public/cache/stores/redis/lang/
45.74.7.234 /assets/img/uploads/
87.229.64.49 /static/js/vendor/
185.119.90.206 /webchat/images/filetypes/
kworker.eth.limo /kworker/api/ (domaine ENS — non saisissable)
kworker.eth.link /kworker/api/ (domaine ENS — non saisissable)Les URL sont stockées en hexadécimal dans
idleet décodées à l'exécution viapython3,xxd,perlouprintfselon ce qui est disponible.
Opérateurs des webshells
89.117.0.154 (dump LDAP, opérateur initial)
66.245.218.125 38.54.108.101 213.111.178.179
45.32.35.241 8.209.238.39 193.149.176.114
172.86.88.209 153.75.94.48 144.172.104.250
107.189.23.121Scan de masse sur /service/admin/soap/
213.176.26.0/24Pools de minage
pool.supportxmr.com:80,443,8080,9000
randomx.xmrig.com:443Portefeuille Monero
87G3m8dwSx5ie2CECUCnJSWBA51WcDjCjVEWGGAkFrwmRXrhwxurG8zQMVoip9aWXbHDEhHsyp2nXYuEVpBNyE8CB7Sjm6RWebshells (noms observés)#
Les noms alternent entre plausibles et aléatoires, dans deux répertoires :
<WEBROOT>/public/430a8f33.jsp <WEBROOT>/js/fpw8me7s.jsp
<WEBROOT>/public/zimmbox.jsp <WEBROOT>/js/rxrcavk7.jsp
<WEBROOT>/public/config.jsp <WEBROOT>/js/5i0xCJci.jsp
<WEBROOT>/public/rr.jsp
<WEBROOT>/public/info.jspSyntaxe d'appel — commande en base64 dans le paramètre c, clé statique dans k :
GET /public/430a8f33.jsp?k=<clé_hex_16>&c=<commande_base64>Signatures de détection#
Processus
javab | .rguard | .khp | ksmd | ksmdr | krun | xmrig | kinsing
kdevtmpfs | kswaped | kworkerds | ssl-compet | sslmon | mnmonitor
cpuhide | gt_sys_wd | gt_sys_health | universal-miner | esocket4Comportements caractéristiques
- Processus dont
/proc/<pid>/exepointe vers/dev/shm/,/tmp/,/var/tmp/ - Processus dont l'exécutable est marqué
(deleted)et qui ne tourne pas en root - Processus non-root déguisé en thread noyau (
[kworker],[kswapd]) - Ligne de crontab
* * * * * /dev/shm/.khp - Suppression récursive des répertoires
.claude(anti-forensics)
5. Procédure de diagnostic#
⚠️ Ne pas redémarrer le serveur.
/dev/shmest un système de fichiers en RAM : tous les artefacts disparaissent au reboot.⚠️ Ne pas tuer les processus immédiatement. Les watchdogs les relancent en moins d'une seconde, et les preuves sont perdues.
Étape 1 — Geler sans tuer#
SIGSTOP arrête la consommation CPU instantanément et fige l'ensemble pour analyse.
mkdir -p /root/ir && cd /root/ir
for P in $(pgrep -f '/dev/shm'); do kill -STOP $P; done
ps aux | grep '[/]dev/shm' | tee frozen.txtÉtape 2 — Capturer les artefacts#
cp -a /dev/shm/. /root/ir/shm-copy/ 2>/dev/null
ls -laR /dev/shm | tee shm-listing.txt
sha256sum /dev/shm/* /dev/shm/.* 2>/dev/null | tee hashes.txtÉtape 3 — Généalogie et réseau#
Le champ PPid indique le vecteur d'entrée. Un parent JVM Jetty désigne un webshell.
for P in $(pgrep -f '/dev/shm'); do
echo "=== PID $P ==="
ls -l /proc/$P/exe /proc/$P/cwd
tr '\0' ' ' < /proc/$P/cmdline; echo
grep -E 'PPid|Uid' /proc/$P/status
done | tee lineage.txt
ss -tunp | grep -vE '127\.0\.0\.1|::1' | tee netconn.txtÉtape 4 — Lire les scripts#
.rguard, idle et .khp sont des scripts shell en clair. Ils contiennent
le C2, la persistance et la liste des concurrents à éliminer.
cat /dev/shm/.rguard
cat /dev/shm/idle
cat /dev/shm/.khp
strings /dev/shm/javab | grep -EiA1 'pool|stratum|xmr|wallet|http'Étape 5 — Chercher les webshells#
find /opt/zimbra/jetty_base/webapps /opt/zimbra/mailboxd/webapps \
\( -name '*.jsp' -o -name '*.jspx' \) -mtime -60 \
-printf '%TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | sort -rÉtape 6 — Reconstituer l'activité#
Décoder les commandes exécutées via les webshells :
grep -h '<NOM_WEBSHELL>\.jsp' /opt/zimbra/log/access_log* \
| grep -oP 'c=\K[^&\s"]+' \
| while read -r c; do
printf '%s\n' "$(python3 -c "import urllib.parse,base64,sys;print(base64.b64decode(urllib.parse.unquote(sys.argv[1])).decode(errors='replace'))" "$c")"
doneÉtape 7 — Vérifier si root est compromis#
Le malware, s'il obtient root, pose l'attribut immuable sur les répertoires
temporaires et renomme chattr en t.
ls -la /usr/bin/chattr /usr/bin/t /bin/javab 2>&1
lsattr -d /tmp /var/tmp /dev/shm
find /etc/systemd /usr/lib/systemd -name 'javab.service'
ls -la /etc/init.d/javab 2>/dev/null| Observation | Interprétation |
|---|---|
chattr présent, pas de /usr/bin/t | Branche root non exécutée |
chattr absent, /usr/bin/t présent | Root compromis — reconstruction obligatoire |
lsattr affiche i minuscule | Immuable posé par le malware |
lsattr affiche I majuscule | Index htree ext4 — normal, pas un indicateur |
6. Procédure d'éradication#
L'ordre est déterminant. Tuer les processus avant de supprimer la persistance garantit une réinfection à la prochaine exécution de la tâche planifiée.
6.1 — Supprimer la persistance d'abord#
# Crontabs de tous les utilisateurs
for u in $(cut -f1 -d: /etc/passwd); do
crontab -u "$u" -l 2>/dev/null | grep -qE '/dev/shm|javab|\.khp|\.rguard|ksmd' && {
echo "Purge de $u"
crontab -u "$u" -l > /root/ir/crontab-$u.bak
crontab -u "$u" -l | grep -vE '/dev/shm|javab|\.khp|\.rguard|ksmd' | crontab -u "$u" -
}
done
# Crons système, unités systemd, init.d
grep -rlE '/dev/shm|javab|\.khp' /etc/cron.d/ /etc/crontab 2>/dev/null
rm -f /etc/systemd/system/javab.service /etc/init.d/javab
systemctl daemon-reload6.2 — Tuer les processus#
for i in 1 2 3; do
pkill -9 -f '/dev/shm'
sleep 2
done
ps aux | grep '[/]dev/shm' # doit être vide6.3 — Purger les fichiers#
chattr -R -ia /dev/shm /tmp /var/tmp 2>/dev/null
rm -f /dev/shm/javab /dev/shm/idle /dev/shm/.khp /dev/shm/.khp_ts /dev/shm/.rguard6.4 — Interdire l'exécution depuis les tmpfs#
mount -o remount,noexec,nosuid,nodev /dev/shm
grep -q '/dev/shm' /etc/fstab || \
echo 'tmpfs /dev/shm tmpfs defaults,noexec,nosuid,nodev 0 0' >> /etc/fstab⚠️ Ne pas appliquer
noexecà/tmp: la JVM de Zimbra y extrait des bibliothèques natives. Le service ne démarrerait plus.
6.5 — Mettre les webshells en quarantaine#
Conserver les fichiers avant suppression : ils constituent une preuve.
mkdir -p /root/ir/webshells
cd /opt/zimbra/jetty_base/webapps/zimbra
for f in public/<NOM>.jsp js/<NOM>.jsp; do
[ -f "$f" ] && cp -a "$f" /root/ir/webshells/ \
&& sha256sum "$f" >> /root/ir/webshells/hashes.txt \
&& rm -f "$f"
done6.6 — Restaurer la crontab Zimbra#
Le malware réécrit la crontab de l'utilisateur zimbra et détruit les tâches
légitimes (rotation des logs, apprentissage antispam, rapports).
cd /opt/zimbra/conf/crontabs # /opt/zimbra/conf sur certaines versions
cat crontab crontab.ldap crontab.store crontab.logger crontab.mta > /tmp/crontab.zimbra
chown zimbra:zimbra /tmp/crontab.zimbra
su - zimbra -c 'crontab /tmp/crontab.zimbra'
su - zimbra -c 'crontab -l | grep -c zimbra' # attendu : ~30 lignes
rm -f /tmp/crontab.zimbra6.7 — Vérification à J+2 h#
La persistance observée relançait une grappe complète toutes les deux heures. Deux cycles sans réapparition valident l'éradication.
watch -n 60 "date; ps aux | grep '[/]dev/shm'; ls -la /dev/shm/"7. Blocage réseau#
7.1 — IOC#
apt install -y ipset
ipset create miner_ioc hash:net -exist
for IP in 141.94.96.195 45.74.7.226 45.74.7.234 87.229.64.49 185.119.90.206 \
89.117.0.154 66.245.218.125 213.111.178.179 38.54.108.101 \
45.32.35.241 8.209.238.39 193.149.176.114 172.86.88.209 \
153.75.94.48 144.172.104.250 107.189.23.121; do
ipset add miner_ioc "$IP" -exist
done
ipset add miner_ioc 213.176.26.0/24 -exist
iptables -I INPUT -m set --match-set miner_ioc src -j DROP
iptables -I OUTPUT -m set --match-set miner_ioc dst -j DROP7.2 — Pools de minage#
for P in 3333 3334 4444 5555 6666 7777 9000 14433 14444 45560 45700; do
iptables -I OUTPUT -p tcp --dport $P -j DROP
done
cat >> /etc/hosts <<'EOF'
0.0.0.0 pool.supportxmr.com
0.0.0.0 randomx.xmrig.com
0.0.0.0 kworker.eth.limo
0.0.0.0 kworker.eth.link
EOF7.3 — Restriction du webmail et de la console d'administration#
La mesure la plus efficace de tout ce document. Toute la chaîne d'attaque transite par HTTPS : un webshell inatteignable est inoffensif.
for P in 443 7071 80 22; do
iptables -I INPUT -p tcp --dport $P -j DROP
iptables -I INPUT -p tcp --dport $P -s <IP_ADMIN_1> -j ACCEPT
iptables -I INPUT -p tcp --dport $P -s <IP_ADMIN_2> -j ACCEPT
iptables -I INPUT -p tcp --dport $P -s 127.0.0.1 -j ACCEPT
done
iptables-save > /etc/iptables.rules.irÉquivalent MikroTik (préférable : le filtrage tient même si le serveur est compromis) :
/ip firewall address-list
add list=admin_ok address=<IP_ADMIN_1> comment="Poste admin"
add list=admin_ok address=<IP_ADMIN_2> comment="Bureau"
/ip firewall filter
add chain=forward dst-address=<IP_SERVEUR> protocol=tcp dst-port=25 \
action=accept comment="SMTP entrant public"
add chain=forward dst-address=<IP_SERVEUR> protocol=tcp \
dst-port=80,443,7071,22 src-address-list=admin_ok action=accept
add chain=forward dst-address=<IP_SERVEUR> protocol=tcp \
dst-port=80,443,7071,22 action=dropCe que cette mesure neutralise#
| Vecteur | Statut |
|---|---|
CVE-2022-37042 / 27925 (mboximport) | Inatteignable |
| Webshells déjà déposés | Inertes |
Clés PreAuth volées (/service/preauth) | Inatteignable |
| Jetons d'administration forgés | Inatteignable |
| Console d'administration (7071) | Inatteignable |
| Scan de masse | Stoppé |
Ce qu'elle ne neutralise pas#
Le port 25 doit rester ouvert. Trois vulnérabilités s'exploitent par simple envoi d'un message — voir section 9.
Points de vigilance#
- Certificat Let's Encrypt : fermer le port 80 casse la validation HTTP-01.
Basculer sur DNS-01 avant :
acme.sh --issue --dns dns_<provider> -d <FQDN> - Ports clients (143, 993, 465, 587) : à fermer seulement si aucun utilisateur nomade. L'attaquant détenant les mots de passe, ces ports restent une voie d'accès.
- Anti-lockout : toujours prévoir un retour arrière automatique, ou un accès console hors bande, avant d'appliquer une restriction incluant SSH.
8. Rotation des secrets#
L'attaquant a disposé de l'UID zimbra : tout ce que cet utilisateur peut lire
est compromis.
8.1 — Clés PreAuth (priorité absolue)#
Une clé PreAuth permet d'ouvrir n'importe quelle boîte sans mot de passe. Tant qu'elle n'est pas régénérée, toutes les autres mesures sont contournables.
for D in <DOMAINE_1> <DOMAINE_2> <DOMAINE_N>; do
su - zimbra -c "zmprov generateDomainPreAuthKey $D"
done⚠️ Casse toute intégration SSO utilisant le PreAuth : reporter la nouvelle clé.
8.2 — Clé de jetons d'authentification#
Permet de forger des sessions valides, y compris administrateur.
su - zimbra -c 'zmprov mcf zimbraAuthTokenKey ""'
su - zimbra -c 'zmmailboxdctl restart'8.3 — Mots de passe#
Le dump LDAP contient les empreintes userPassword (SSHA512 salées). Non
réversibles, mais cassables hors ligne sans limite de tentatives.
su - zimbra -c 'zmprov -l gaa' > /root/ir/comptes.txt
while read A; do
su - zimbra -c "zmprov ma $A zimbraPasswordMustChange TRUE"
done < /root/ir/comptes.txt8.4 — Secrets réversibles (souvent oubliés)#
Certains attributs sont chiffrés, pas hachés, avec une clé présente dans
localconfig.xml — que le webshell info.jsp lisait précisément.
| Attribut | Contenu |
|---|---|
zimbraDataSourcePassword | Identifiants des comptes externes des utilisateurs |
zimbraGalLdapBindPassword | Compte de service annuaire |
zimbraDomainAuthLdapSearchBindPassword | Compte de service authentification |
while read A; do
su - zimbra -c "zmprov ga $A zimbraDataSourceName" 2>/dev/null \
| grep -i DataSourceName | sed "s|^|$A : |"
done < /root/ir/comptes.txt | tee /root/ir/comptes-externes.txtCes identifiants appartiennent à des services hors du périmètre. Chaque utilisateur concerné doit changer son mot de passe chez le fournisseur d'origine.
8.5 — Persistance dans les boîtes#
Redirections et filtres survivent à une réinstallation — ils sont dans les données.
while read A; do
F=$(su - zimbra -c "zmprov ga $A zimbraPrefMailForwardingAddress" 2>/dev/null | grep -i forwarding)
[ -n "$F" ] && echo "REDIRECTION $A -> $F"
su - zimbra -c "zmprov ga $A zimbraIsAdminAccount zimbraIsDelegatedAdminAccount" 2>/dev/null \
| grep -qi TRUE && echo "ADMIN $A"
S=$(su - zimbra -c "zmprov ga $A zimbraMailSieveScript" 2>/dev/null | grep -iE 'redirect|discard')
[ -n "$S" ] && echo "SIEVE $A : $S"
done < /root/ir/comptes.txt | tee /root/ir/mailbox-persistence.txt9. Durcissement sans mise à jour possible#
Lorsque ni l'OS ni Zimbra ne peuvent être mis à jour, le principe est de supprimer les conditions d'exploitation plutôt que de corriger le code.
9.1 — Vulnérabilités exploitables par le port 25#
Ces trois-là ne passent pas par HTTP : le filtrage IP ne les arrête pas.
| CVE | Mécanisme | Neutralisation |
|---|---|---|
| CVE-2026-73570 | SNMP via SMTP → commandes shell | zmlocalconfig -e snmp_notify=no |
| CVE-2024-45519 | postjournal analyse le champ CC | zmlocalconfig -e postjournal_enabled=false |
| CVE-2022-41352 | Pièce jointe → amavis/cpio → écriture webroot | apt install pax |
apt install -y pax
su - zimbra -c 'zmlocalconfig -e snmp_notify=no'
su - zimbra -c 'zmlocalconfig -e postjournal_enabled=false'
su - zimbra -c 'zmlocalconfig -s | grep -E "postjournal|snmp_notify"'
su - zimbra -c 'zmcontrol restart'
paxremplacecpiopour l'extraction des archives par amavis, ce qui neutralise la traversée de chemin. Ne pas désinstaller le paquetzimbra-snmp: il est référencé parzmcontrolet sa suppression casse le démarrage.
9.2 — Filtrage applicatif nginx#
Bloque les exploits avant qu'ils n'atteignent la JVM.
Fichier : /opt/zimbra/conf/nginx/templates/nginx.conf.web.https.default.template
(sauvegarder avant modification), à insérer après la ligne server { :
# === Durcissement — Zimbra 8.8.15 non patchable ===
location ~* ^/service/extension/(mboximport|backup|clientUploader) { deny all; }
location ~* \.(jsp|jspx)$ { deny all; }
location ~* ^/service/preauth { allow 127.0.0.1; deny all; }
location ~* ^/service/admin/soap { allow <IP_ADMIN_1>; allow 127.0.0.1; deny all; }
location ~* ^/zimbraAdmin { allow <IP_ADMIN_1>; allow 127.0.0.1; deny all; }
client_max_body_size 30m;su - zimbra -c 'zmproxyconfgen && zmproxyctl restart'
curl -sk -o /dev/null -w 'jsp=%{http_code}\n' https://127.0.0.1/public/x.jsp
curl -sk -o /dev/null -w 'mbox=%{http_code}\n' https://127.0.0.1/service/extension/mboximport
# les deux doivent renvoyer 403Retirer la ligne
preauthsi un SSO l'utilise.
9.3 — Surveillance du webroot#
apt install -y inotify-tools
cat > /usr/local/bin/webroot-watch.sh <<'EOF'
#!/bin/bash
inotifywait -m -r -e create,moved_to --format '%w%f' \
/opt/zimbra/jetty_base/webapps /dev/shm /tmp 2>/dev/null | while read F; do
logger -t WEBROOT-ALERT "Nouveau fichier: $F"
echo "$(date) $F" >> /var/log/webroot-alert.log
done
EOF
chmod +x /usr/local/bin/webroot-watch.sh
cat > /etc/systemd/system/webroot-watch.service <<'EOF'
[Unit]
Description=Surveillance webroot Zimbra
[Service]
ExecStart=/usr/local/bin/webroot-watch.sh
Restart=always
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload && systemctl enable --now webroot-watch9.4 — Alternatives à la migration#
| Option | Effet | Effort |
|---|---|---|
| Ubuntu Pro | Correctifs de sécurité 18.04 jusqu'en 2028. Gratuit ≤ 5 machines. | pro attach <token> |
| Zimbra 8.8.15 P46 | Ferme mboximport, cpio, postjournal, privesc nginx. Pas de changement de version majeure ni d'OS. | ~1 h d'interruption |
| Migration 10.1.20+ / Carbonio | Solution pérenne | VM neuve + export/import |
Le passage en P46 offre le meilleur rapport effort/résultat : il ferme d'un coup la majorité des vecteurs sans toucher à l'OS.
10. Pièges à éviter — retour d'expérience#
Ces mesures paraissent défendables mais cassent Zimbra en production. Elles ont toutes été appliquées puis annulées lors de cet incident.
❌ chattr +i sur le webroot#
chattr +i /opt/zimbra/jetty_base/webapps/zimbra/public # NE PAS FAIREConséquence : Starting zimbra webapp...Failed. — Jetty ne peut plus déployer
les webapps. Bloque également toute mise à jour Zimbra.
❌ chown root:zimbra sur le webroot#
chown -R root:zimbra /opt/zimbra/jetty_base/webapps # NE PAS FAIREConséquence : Zimbra exige zimbra:zimbra. Les trois webapps échouent au
démarrage. Correction : chown -R zimbra:zimbra puis zmfixperms -e.
❌ Filtrage egress par UID sans les IP locales#
iptables -A ZIMBRA_OUT -d 127.0.0.0/8 -j RETURN
iptables -A ZIMBRA_OUT -p tcp -m multiport --dports 25,53,80,443 -j RETURN
iptables -A ZIMBRA_OUT -j DROP # NE PAS FAIRE tel quelConséquence : FATAL: failed to initialize LDAP client. Zimbra se connecte à
lui-même via son IP publique, pas par la boucle locale : ldap 389, lmtp 7025,
memcached 11211, mysql 7306, clamd 3310, saslauthd 7073, amavis 10024-10032.
Si un filtrage egress est souhaité, autoriser explicitement toutes les IP locales :
for a in $(ip -4 -o addr show scope global | awk '{print $4}'); do
iptables -A ZIMBRA_OUT -d "$a" -j RETURN
done❌ noexec sur /tmp#
Conséquence : la JVM ne peut plus extraire ses bibliothèques natives
(jffi*.tmp). Appliquer noexec à /dev/shm uniquement.
❌ apt remove --purge zimbra-snmp#
Conséquence : le paquet est référencé par zmcontrol, son absence peut faire
échouer le démarrage global. Désactiver snmp_notify suffit.
❌ Tuer les processus sans liste blanche#
Un script de nettoyage qui cible « tout processus dont l'exe est supprimé » tue
mysqld, slapd ou java après une mise à jour de paquet, et provoque une
reprise sur incident InnoDB. Toujours exclure les binaires sous /opt/zimbra/.
⚠️ zmmysqlstatus ne répond pas « running »#
La commande renvoie mysqld is alive. Un test sur la chaîne « running »
produit un faux négatif permanent.
11. Dépannage : Zimbra ne redémarre plus#
Table de diagnostic#
| Symptôme | Cause probable | Correction |
|---|---|---|
failed to initialize LDAP client | Filtrage egress bloquant le port 389 | Retirer la chaîne ZIMBRA_OUT |
zimbra webapp ... Failed | chattr +i ou chown root:zimbra | chattr -R -i, chown -R zimbra:zimbra, zmfixperms -e |
mysql.server is not running | InnoDB en reprise, socket périmé, disque plein | Voir ci-dessous |
Unable to determine enabled services from ldap | Conséquence de mailboxd arrêté | Réparer mailboxd d'abord |
Tout Stopped sauf ldap/mta/proxy | MySQL en panne | MySQL est le prérequis de tout |
Ordre de démarrage correct#
su - zimbra -c 'ldap status' || su - zimbra -c 'ldap start'
su - zimbra -c 'mysql.server start'
sleep 20
su - zimbra -c 'zmmysqlstatus' # attendu : "mysqld is alive"
su - zimbra -c 'zmconfigdctl start'
su - zimbra -c 'zmcontrol start'
su - zimbra -c 'zmcontrol status'Vérifier la connectivité interne#
for hp in 127.0.0.1:389 <FQDN>:389 127.0.0.1:7025 127.0.0.1:11211 127.0.0.1:7306; do
h="${hp%:*}"; p="${hp##*:}"
timeout 3 bash -c "exec 3<>/dev/tcp/$h/$p" 2>/dev/null \
&& echo "OK $hp" || echo "ECHEC $hp"
doneMySQL ne démarre pas#
tail -60 /opt/zimbra/log/mysql_error.log
df -h /opt ; df -i /opt
rm -f /opt/zimbra/db/mysql.sock /opt/zimbra/db/mysql.pid
su - zimbra -c 'mysql.server start'| Message dans le log | Action |
|---|---|
InnoDB: Starting crash recovery | Attendre 5 à 15 min, ne rien toucher |
No space left on device | Purger /opt/zimbra/data/mailboxd/imap-*-session-cache_* |
Can't start server: Bind on unix socket | Supprimer socket et PID périmés |
Permission denied | /opt/zimbra/libexec/zmfixperms -e |
Restaurer les fichiers de configuration modifiés#
python3 -c "import xml.dom.minidom; xml.dom.minidom.parse('/opt/zimbra/jetty_base/etc/webdefault.xml')" \
&& echo "XML valide" || echo "XML CASSE — restaurer la sauvegarde"
cp -a /opt/zimbra/jetty_base/etc/webdefault.xml.bak \
/opt/zimbra/jetty_base/etc/webdefault.xml
su - zimbra -c 'zmmailboxdctl restart'Annexe A — Conventions de nommage#
Les valeurs sensibles de cette documentation sont remplacées par des marqueurs. Substituer avant usage.
| Marqueur | Signification |
|---|---|
<IP_SERVEUR> | Adresse publique du serveur Zimbra |
<IP_ADMIN_1>, <IP_ADMIN_2> | Adresses fixes autorisées à l'administration |
<FQDN> | Nom d'hôte complet du serveur |
<DOMAINE_1>, <DOMAINE_N> | Domaines de messagerie hébergés |
<WEBROOT> | /opt/zimbra/jetty_base/webapps/zimbra |
<NOM_WEBSHELL> | Nom du fichier JSP malveillant identifié |
Les adresses IP de l'attaquant sont conservées telles quelles : elles constituent des indicateurs de compromission réutilisables.
Annexe B — Script d'automatisation#
Un script zimbra-antiminer.sh regroupe les opérations de ce document.
| Commande | Effet |
|---|---|
--scan | Détection seule, aucune action |
--clean | Gel → preuves → éradication → blocage → réparation |
--block | Applique le blocage IOC + blocage dynamique des C2 découverts |
--restrict | Ferme webmail/admin/SSH sauf IP admin, avec retour arrière automatique |
--confirm | Confirme la restriction (annule le retour arrière) |
--unrestrict | Retire la restriction |
--repair | Répare et redémarre Zimbra |
--diag | Diagnostic complet |
--fix-egress | Retire le filtrage egress (erreur LDAP) |
--unharden | Annule le durcissement fichiers (webapps Failed) |
--watch [SEC] | Surveillance continue avec éradication automatique |
--install | Installe le service systemd |
Paramètres à renseigner avant usage : ADMIN_IPS, ALERT_MAIL.
Options désactivées par défaut (effets de bord documentés en section 10) :
HARDEN_AFTER_REPAIR=0, EGRESS_FILTER=0, RESTRICT_CLIENTS=0.
Toujours simuler d'abord :
DRY_RUN=1 ./zimbra-antiminer.sh --cleanCode source complet#
Copier dans
/usr/local/sbin/zimbra-antiminer.sh, puis :chmod 700 /usr/local/sbin/zimbra-antiminer.shAvant tout usage : renseigner
ADMIN_IPS,ALLOW_IPSetALERT_MAILdans le bloc CONFIGURATION en tête de fichier. Les adresses fournies (203.0.113.x) sont des exemples de documentation, à remplacer.
#!/bin/bash
#===============================================================================
# zimbra-antiminer.sh v2 — Detection / eradication mineur, blocage, reparation
#
# Cible : Zimbra 8.8.15 sur Ubuntu 18.04 (non patchable)
# Contexte: XMRig "javab" + watchdogs .rguard/.khp/idle sous UID zimbra
#
# USAGE :
# ./zimbra-antiminer.sh --scan Detection seule, aucune action
# ./zimbra-antiminer.sh --clean Gel > preuves > eradication > blocage > reparation
# ./zimbra-antiminer.sh --block (Re)applique le blocage IOC
# ./zimbra-antiminer.sh --repair Repare et redemarre Zimbra uniquement
# ./zimbra-antiminer.sh --diag Diagnostic Zimbra (pourquoi c'est down)
# ./zimbra-antiminer.sh --harden Normalise les permissions du webroot
# ./zimbra-antiminer.sh --unharden Annule le durcissement (webapps Failed)
# ./zimbra-antiminer.sh --fix-egress Retire le filtrage egress (erreur LDAP)
# ./zimbra-antiminer.sh --restrict Ferme webmail/admin/ssh sauf IP admin
# ./zimbra-antiminer.sh --confirm Confirme la restriction (annule le retour auto)
# ./zimbra-antiminer.sh --unrestrict Retire la restriction d'acces
# ./zimbra-antiminer.sh --watch [SEC] Surveillance continue (defaut 60s)
# ./zimbra-antiminer.sh --install Service systemd de surveillance
# ./zimbra-antiminer.sh --status Etat blocage + derniers evenements
#
# Simulation : DRY_RUN=1 ./zimbra-antiminer.sh --clean
#===============================================================================
set -o pipefail
#------------------------------------------------------------------------------
# CONFIGURATION — A ADAPTER
#------------------------------------------------------------------------------
IR_DIR="/root/ir"
LOG="/var/log/zimbra-antiminer.log"
LOCK="/var/run/zimbra-antiminer.lock"
QUARANTINE="${IR_DIR}/quarantine"
BACKUP_DIR="${IR_DIR}/conf-backup"
ALERT_MAIL="" # ex: "admin@example.tld" (vide = pas de mail)
DRY_RUN="${DRY_RUN:-0}"
# IP admin a ne JAMAIS bloquer — METS TON IP FIXE ICI
ALLOW_IPS=( 127.0.0.1 203.0.113.10 ) # <-- REMPLACER par ton IP admin
# Durcissement webroot reapplique apres redemarrage ?
# 0 par defaut : le chattr +i et le chown root:zimbra empechent Jetty de
# deployer les webapps (zimbra/zimbraAdmin/zimlet -> Failed). Le filtrage IP
# (--restrict) protege bien mieux, sans casser Zimbra.
HARDEN_AFTER_REPAIR=0
#--- RESTRICTION D'ACCES -------------------------------------------------------
# IP/reseaux autorises a joindre le webmail et la console d'admin.
# Accepte une IP (203.0.113.10) ou un CIDR (203.0.113.0/24).
ADMIN_IPS=(
203.0.113.10 # <-- REMPLACER : poste admin
203.0.113.20 # <-- REMPLACER : bureau
)
# Ports fermes au public. 25 n'y est JAMAIS : le SMTP entrant doit rester ouvert.
RESTRICT_PORTS_WEB="80 443 7071 9071" # webmail, console admin
RESTRICT_PORTS_SSH="22" # administration
RESTRICT_PORTS_CLIENTS="110 143 993 995 465 587" # IMAP/POP/submission
# Inclure SSH et les ports clients dans la restriction ? (1 = oui)
RESTRICT_SSH=1
RESTRICT_CLIENTS=0 # /!\ 1 casse les acces mobiles/nomades
#--- FILTRAGE EGRESS -----------------------------------------------------------
# Restreint la sortie reseau de l'UID zimbra (empeche un mineur de joindre un pool).
# 0 = desactive. A n'activer qu'apres avoir verifie que Zimbra demarre bien :
# Zimbra se parle a lui-meme via son IP publique (ldap:389, lmtp:7025...).
EGRESS_FILTER=0
# Retour automatique en arriere si non confirme, en secondes (0 = desactive).
# Protection anti-lockout : si tu perds la main, tout revient seul.
AUTOREVERT=300
CONFIRM_FLAG="/run/zimbra-restrict.confirm"
WEBROOTS=(
"/opt/zimbra/jetty_base/webapps/zimbra"
"/opt/zimbra/jetty_base/webapps/zimbraAdmin"
"/opt/zimbra/mailboxd/webapps/zimbra"
"/opt/zimbra/mailboxd/webapps/zimbraAdmin"
)
SUSPECT_DIRS="/dev/shm /tmp /var/tmp /run /var/run"
SUSPECT_DIRS_RE='/dev/shm/|/tmp/|/var/tmp/'
IOC_NAMES='javab|\.rguard|\.khp|ksmd|ksmdr|krun|xmrig|kinsing|kdevtmpfs|kswaped|kworkerds|ssl-compet|sslmon|mnmonitor|cpuhide|gt_sys_wd|gt_sys_health|universal-miner|esocket4|joker\.'
# Process Zimbra legitimes — JAMAIS tues, meme si leur exe parait supprime
ZIMBRA_SAFE='/opt/zimbra/(common/)?(lib|bin|sbin|libexec)/|mysqld|slapd|nginx|master|qmgr|pickup|smtpd|cleanup|trivial-rewrite|proxymap|anvil|tlsmgr|lmtp|amavisd|clamd|freshclam|memcached|opendkim|saslauthd|zmmailboxdmgr|zmstat|swatch|logswatch|postfix|dovecot|/usr/lib/jvm/|java .*zimbra'
CRON_IOC='/dev/shm|/var/tmp/\.|javab|\.khp|\.rguard|ksmd|xmrig'
MINING_PORTS="3333 3334 4444 5555 6666 7777 9000 14433 14444 45560 45700"
BLOCK_IPS=(
141.94.96.195 45.74.7.226 45.74.7.234 87.229.64.49 185.119.90.206
89.117.0.154 66.245.218.125 213.111.178.179 38.54.108.101 45.32.35.241
8.209.238.39 193.149.176.114 172.86.88.209 153.75.94.48 144.172.104.250
107.189.23.121
)
BLOCK_NETS=( 213.176.26.0/24 )
BLOCK_HOSTS=( pool.supportxmr.com randomx.xmrig.com kworker.eth.limo kworker.eth.link
gulf.moneroocean.stream xmr.pool.minergate.com )
#------------------------------------------------------------------------------
# OUTILS
#------------------------------------------------------------------------------
C_R=$'\e[31m'; C_G=$'\e[32m'; C_Y=$'\e[33m'; C_B=$'\e[36m'; C_0=$'\e[0m'
log() {
local lvl="$1"; shift; local msg="$*"
echo "[$(date '+%F %T')] [$lvl] $msg" >> "$LOG"
case "$lvl" in
ALERT) echo "${C_R}[!] $msg${C_0}"; logger -t ZIMBRA-ANTIMINER "ALERT: $msg" ;;
WARN) echo "${C_Y}[~] $msg${C_0}" ;;
OK) echo "${C_G}[+] $msg${C_0}" ;;
*) echo "${C_B}[i] $msg${C_0}" ;;
esac
}
run() {
if [ "$DRY_RUN" = "1" ]; then echo " ${C_Y}(dry-run)${C_0} $*"
else "$@" >/dev/null 2>&1; fi
}
zrun() { # execute une commande en tant que zimbra
if [ "$DRY_RUN" = "1" ]; then echo " ${C_Y}(dry-run)${C_0} zimbra: $*"
else su - zimbra -c "$*" 2>&1; fi
}
require_root() { [ "$(id -u)" = "0" ] || { echo "Root requis."; exit 1; }; }
take_lock() {
if [ -e "$LOCK" ] && kill -0 "$(cat "$LOCK" 2>/dev/null)" 2>/dev/null; then
echo "Deja en cours (PID $(cat "$LOCK"))."; exit 0
fi
echo $$ > "$LOCK"; trap 'rm -f "$LOCK"' EXIT
}
notify() {
[ -n "$ALERT_MAIL" ] || return 0
command -v mail >/dev/null 2>&1 || return 0
printf '%s\n' "$2" | mail -s "[$(hostname -s)] $1" "$ALERT_MAIL" 2>/dev/null
}
backup_file() {
mkdir -p "$BACKUP_DIR"
local f="$1"
local b="$BACKUP_DIR/$(echo "$f" | tr '/' '_').orig"
[ -f "$f" ] && [ ! -f "$b" ] && cp -a "$f" "$b" && log INFO "Sauvegarde: $f"
return 0
}
is_private_ip() {
case "$1" in
10.*|127.*|192.168.*|169.254.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*|::1|fe80:*) return 0 ;;
*) return 1 ;;
esac
}
#------------------------------------------------------------------------------
# DETECTION
#------------------------------------------------------------------------------
detect_processes() {
for d in /proc/[0-9]*; do
local pid="${d##*/}"
[ -r "$d/status" ] || continue
local uid exe cwd cmd
uid=$(awk '/^Uid:/{print $2; exit}' "$d/status" 2>/dev/null); [ -n "$uid" ] || continue
exe=$(readlink "$d/exe" 2>/dev/null)
cwd=$(readlink "$d/cwd" 2>/dev/null)
cmd=$(tr '\0' ' ' < "$d/cmdline" 2>/dev/null)
# GARDE-FOU : ne jamais toucher un process Zimbra legitime
if echo "$exe $cmd" | grep -qE "$ZIMBRA_SAFE"; then
echo "$exe $cmd" | grep -qEi "$IOC_NAMES" || continue
fi
local hit=""
for sd in $SUSPECT_DIRS; do
case "$exe" in "$sd"/*) hit="exe dans $sd" ;; esac
[ -z "$hit" ] && case "$cwd" in "$sd"/*)
echo "$cmd" | grep -qE "$SUSPECT_DIRS_RE" && hit="cwd+cmd dans $sd" ;; esac
done
case "$exe" in *"(deleted)") [ "$uid" != "0" ] && hit="${hit:-binaire supprime}" ;; esac
echo "$cmd $exe" | grep -qEi "$IOC_NAMES" && hit="nom IOC connu"
case "$cmd" in "["*) [ "$uid" != "0" ] && hit="${hit:-faux thread noyau}" ;; esac
[ -n "$hit" ] || continue
echo "$pid|$uid|$hit|$exe|$cmd"
done
}
# Extrait les IP distantes des process suspects (nouveaux C2 non connus)
harvest_dynamic_iocs() {
local pids="$1" found=""
for p in $pids; do
ss -tunp 2>/dev/null | grep "pid=$p," | awk '{print $6}' | while read -r peer; do
local ip="${peer%:*}"; ip="${ip#[}"; ip="${ip%]}"
[ -n "$ip" ] || continue
is_private_ip "$ip" && continue
for a in "${ALLOW_IPS[@]}"; do [ "$ip" = "$a" ] && continue 2; done
echo "$ip"
done
done | sort -u
}
detect_network() {
local ports_re; ports_re=$(echo "$MINING_PORTS" | tr ' ' '|')
ss -tunp 2>/dev/null | awk 'NR>1' | while read -r line; do
local peer ip port
peer=$(echo "$line" | awk '{print $6}'); ip="${peer%:*}"; port="${peer##*:}"
[ -n "$port" ] || continue
echo "$port" | grep -qE "^($ports_re)$" && { echo "PORT|$line"; continue; }
for bad in "${BLOCK_IPS[@]}"; do [ "$ip" = "$bad" ] && echo "IOC|$line"; done
done
}
detect_files() {
for sd in /dev/shm /tmp /var/tmp; do
[ -d "$sd" ] || continue
find "$sd" -maxdepth 2 -type f 2>/dev/null | while read -r f; do
echo "$f" | grep -qEi "$IOC_NAMES" && { echo "IOC|$f"; continue; }
[ -x "$f" ] && head -c 4 "$f" 2>/dev/null | grep -q 'ELF' && echo "ELF|$f"
done
done
}
detect_webshells() {
for wr in "${WEBROOTS[@]}"; do
[ -d "$wr" ] || continue
find "$wr" \( -name '*.jsp' -o -name '*.jspx' \) -mtime -30 2>/dev/null \
| grep -vE '/(WEB-INF|META-INF)/' \
| while read -r f; do echo "JSP|$f|$(stat -c '%y' "$f" 2>/dev/null | cut -d. -f1)"; done
done
}
detect_cron() {
for u in $(cut -f1 -d: /etc/passwd); do
crontab -u "$u" -l 2>/dev/null | grep -E "$CRON_IOC" | while read -r l; do echo "CRON|$u|$l"; done
done
grep -rE "$CRON_IOC" /etc/cron.d/ /etc/crontab 2>/dev/null | while read -r l; do echo "CRONSYS|$l"; done
}
do_scan() {
local found=0 tmp; tmp=$(mktemp)
echo; log INFO "=== SCAN $(date '+%F %T') sur $(hostname) ==="
echo; echo "--- Processus ---"
detect_processes > "$tmp"
if [ -s "$tmp" ]; then
found=1
while IFS='|' read -r pid uid why exe cmd; do
log ALERT "PID $pid (uid $uid, cpu $(ps -o %cpu= -p "$pid" 2>/dev/null|tr -d ' ')%) : $why -> ${cmd:0:70}"
done < "$tmp"
else log OK "aucun processus suspect"; fi
echo; echo "--- Reseau ---"
if detect_network | grep -q .; then
found=1; detect_network | while IFS='|' read -r t l; do log ALERT "$t : $l"; done
else log OK "aucune connexion vers pool/IOC"; fi
echo; echo "--- Fichiers tmpfs ---"
if detect_files | grep -q .; then
found=1; detect_files | while IFS='|' read -r t f; do log ALERT "$t : $f"; done
else log OK "/dev/shm, /tmp, /var/tmp propres"; fi
echo; echo "--- Webshells ---"
if detect_webshells | grep -q .; then
found=1; detect_webshells | while IFS='|' read -r t f d; do log ALERT "JSP recent : $f ($d)"; done
else log OK "aucun .jsp recent"; fi
echo; echo "--- Persistance cron ---"
if detect_cron | grep -q .; then
found=1; detect_cron | while IFS='|' read -r t rest; do log ALERT "$t : $rest"; done
else log OK "crontabs propres"; fi
rm -f "$tmp"; echo
if [ "$found" = "1" ]; then
log ALERT "COMPROMISSION DETECTEE — lancer : $0 --clean"
notify "Compromission detectee" "$(tail -40 "$LOG")"
return 2
fi
log OK "Aucun indicateur detecte."
return 0
}
#------------------------------------------------------------------------------
# PREUVES
#------------------------------------------------------------------------------
collect_evidence() {
local dest="${IR_DIR}/$(date +%Y%m%d-%H%M%S)"
mkdir -p "$dest" "$QUARANTINE"
ps auxfww > "$dest/ps.txt" 2>&1
ss -tunp > "$dest/ss.txt" 2>&1
ls -laR /dev/shm /tmp /var/tmp > "$dest/tmpfs.txt" 2>&1
for u in $(cut -f1 -d: /etc/passwd); do echo "== $u"; crontab -u "$u" -l 2>/dev/null; done > "$dest/crontabs.txt" 2>&1
detect_processes | while IFS='|' read -r pid uid why exe cmd; do
{ echo "=== PID $pid ($why)"; ls -l "/proc/$pid/exe" "/proc/$pid/cwd" 2>&1
grep -E 'PPid|Uid|Name' "/proc/$pid/status" 2>/dev/null; echo "cmd: $cmd"; } >> "$dest/lineage.txt"
[ -r "/proc/$pid/exe" ] && cp "/proc/$pid/exe" "$dest/bin-$pid" 2>/dev/null && \
sha256sum "$dest/bin-$pid" >> "$dest/hashes.txt" 2>/dev/null
done
for sd in /dev/shm /tmp /var/tmp; do
find "$sd" -maxdepth 2 -type f 2>/dev/null | grep -Ei "$IOC_NAMES" | while read -r f; do
cp -a "$f" "$dest/$(echo "$f"|tr '/' '_')" 2>/dev/null
sha256sum "$f" >> "$dest/hashes.txt" 2>/dev/null
done
done
echo "$dest"
}
#------------------------------------------------------------------------------
# BLOCAGE
#------------------------------------------------------------------------------
block_ip() {
local ip="$1"
for a in "${ALLOW_IPS[@]}"; do [ "$ip" = "$a" ] && return 0; done
is_private_ip "$ip" && return 0
if command -v ipset >/dev/null 2>&1; then
run ipset add miner_ioc "$ip" -exist
else
iptables -C INPUT -s "$ip" -j DROP 2>/dev/null || run iptables -I INPUT -s "$ip" -j DROP
iptables -C OUTPUT -d "$ip" -j DROP 2>/dev/null || run iptables -I OUTPUT -d "$ip" -j DROP
fi
echo "$(date '+%F %T') $ip" >> "$IR_DIR/blocked-ips.txt"
log OK "IP bloquee : $ip"
}
do_block() {
log INFO "Application du blocage reseau"
if command -v ipset >/dev/null 2>&1; then
run ipset create miner_ioc hash:net -exist
iptables -C INPUT -m set --match-set miner_ioc src -j DROP 2>/dev/null || \
run iptables -I INPUT -m set --match-set miner_ioc src -j DROP
iptables -C OUTPUT -m set --match-set miner_ioc dst -j DROP 2>/dev/null || \
run iptables -I OUTPUT -m set --match-set miner_ioc dst -j DROP
else
log WARN "ipset absent (apt install ipset recommande) — repli sur iptables"
fi
for ip in "${BLOCK_IPS[@]}"; do block_ip "$ip"; done
for net in "${BLOCK_NETS[@]}"; do
if command -v ipset >/dev/null 2>&1; then run ipset add miner_ioc "$net" -exist
else iptables -C INPUT -s "$net" -j DROP 2>/dev/null || run iptables -I INPUT -s "$net" -j DROP; fi
done
# Blocage dynamique : IP vues en direct sur les process suspects
local live; live=$(harvest_dynamic_iocs "$(detect_processes | cut -d'|' -f1 | tr '\n' ' ')")
if [ -n "$live" ]; then
log ALERT "Nouveaux C2 detectes en direct : $(echo "$live" | tr '\n' ' ')"
for ip in $live; do block_ip "$ip"; done
fi
# Chaine egress pour l'UID zimbra
# /!\ CRITIQUE : Zimbra dialogue avec LUI-MEME via son IP PUBLIQUE
# (mailboxd -> ldap:389, lmtp:7025, memcached:11211, mysql:7306, clamd:3310...)
# Toute regle qui ignore ce fait casse le demarrage de mailboxd.
if [ "$EGRESS_FILTER" = "1" ]; then
if ! iptables -L ZIMBRA_OUT -n >/dev/null 2>&1; then
run iptables -N ZIMBRA_OUT
# 1) Connexions deja etablies : jamais coupees
run iptables -A ZIMBRA_OUT -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
# 2) Boucle locale
run iptables -A ZIMBRA_OUT -d 127.0.0.0/8 -j RETURN
# 3) TOUTES les IP locales de la machine (le point qui manquait)
local myips; myips=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}')
for a in $myips; do
run iptables -A ZIMBRA_OUT -d "$a" -j RETURN
log OK "Egress: trafic interne autorise vers $a"
done
# 4) Ports internes Zimbra, quelle que soit la destination (ceinture+bretelles)
run iptables -A ZIMBRA_OUT -p tcp -m multiport \
--dports 389,636,3310,7025,7047,7071,7072,7073,7110,7143,7306,7780,7993,7995,8465,11211 -j RETURN
run iptables -A ZIMBRA_OUT -p tcp --dport 10024:10032 -j RETURN
run iptables -A ZIMBRA_OUT -p tcp --dport 23232:23233 -j RETURN
# 5) Sortie Internet legitime
run iptables -A ZIMBRA_OUT -p tcp -m multiport --dports 25,53,80,443,465,587,993,995 -j RETURN
run iptables -A ZIMBRA_OUT -p udp --dport 53 -j RETURN
# 6) Le reste : journalise puis rejete
run iptables -A ZIMBRA_OUT -m limit --limit 5/min -j LOG --log-prefix "ZIMBRA-EGRESS-DROP: "
run iptables -A ZIMBRA_OUT -j DROP
run iptables -I OUTPUT -m owner --uid-owner zimbra -j ZIMBRA_OUT
log OK "Chaine ZIMBRA_OUT creee (trafic interne preserve)"
# 7) Auto-test : si le LDAP ne repond plus, on retire la chaine
sleep 2
if ! timeout 5 bash -c "exec 3<>/dev/tcp/127.0.0.1/389" 2>/dev/null && \
! su - zimbra -c 'zmprov -l gcf zimbraVersionCheckServer' >/dev/null 2>&1; then
log ALERT "Le LDAP ne repond plus apres application de l'egress -> annulation"
run iptables -D OUTPUT -m owner --uid-owner zimbra -j ZIMBRA_OUT
run iptables -F ZIMBRA_OUT
fi
fi
else
log INFO "Filtrage egress desactive (EGRESS_FILTER=0)"
if iptables -L ZIMBRA_OUT -n >/dev/null 2>&1; then
run iptables -D OUTPUT -m owner --uid-owner zimbra -j ZIMBRA_OUT
run iptables -F ZIMBRA_OUT
log OK "Ancienne chaine ZIMBRA_OUT neutralisee"
fi
fi
for p in $MINING_PORTS; do
iptables -C OUTPUT -p tcp --dport "$p" -j DROP 2>/dev/null || run iptables -I OUTPUT -p tcp --dport "$p" -j DROP
done
for h in "${BLOCK_HOSTS[@]}"; do
grep -q " $h\$" /etc/hosts 2>/dev/null || { [ "$DRY_RUN" = "1" ] || echo "0.0.0.0 $h" >> /etc/hosts; }
done
run sh -c 'iptables-save > /etc/iptables.rules.ir'
command -v ipset >/dev/null 2>&1 && run sh -c 'ipset save > /etc/ipset.rules.ir'
log OK "Blocage applique et sauvegarde"
}
#------------------------------------------------------------------------------
# ERADICATION
#------------------------------------------------------------------------------
do_clean() {
take_lock
log INFO "=== ERADICATION $(date '+%F %T') (DRY_RUN=$DRY_RUN) ==="
local pids; pids=$(detect_processes | cut -d'|' -f1)
if [ -n "$pids" ]; then
log WARN "Gel (SIGSTOP) de $(echo "$pids"|wc -w) processus"
for p in $pids; do run kill -STOP "$p"; done
sleep 1
local live; live=$(harvest_dynamic_iocs "$pids")
[ -n "$live" ] && for ip in $live; do block_ip "$ip"; done
fi
local dest; dest=$(collect_evidence)
log INFO "Preuves : $dest"
# Persistance AVANT les process
for u in $(cut -f1 -d: /etc/passwd); do
if crontab -u "$u" -l 2>/dev/null | grep -qE "$CRON_IOC"; then
log ALERT "Purge crontab de $u"
crontab -u "$u" -l 2>/dev/null > "$dest/crontab-$u.bak"
[ "$DRY_RUN" = "1" ] || crontab -u "$u" -l 2>/dev/null | grep -vE "$CRON_IOC" | crontab -u "$u" -
fi
done
for f in /etc/cron.d/*; do
[ -f "$f" ] && grep -qE "$CRON_IOC" "$f" 2>/dev/null && \
{ log ALERT "cron systeme : $f"; run cp -a "$f" "$dest/"; run rm -f "$f"; }
done
for s in /etc/systemd/system/javab.service /etc/init.d/javab; do
[ -e "$s" ] && { log ALERT "Persistance : $s"; run cp -a "$s" "$dest/"; run rm -f "$s"; }
done
run systemctl daemon-reload
for round in 1 2 3; do
pids=$(detect_processes | cut -d'|' -f1); [ -n "$pids" ] || break
log WARN "Kill round $round : $(echo "$pids"|wc -w) PID"
for p in $pids; do run kill -9 "$p"; done
sleep 2
done
local CHATTR="chattr"; [ -x /usr/bin/chattr ] || { [ -x /usr/bin/t ] && CHATTR="/usr/bin/t"; }
run $CHATTR -R -ia /dev/shm /tmp /var/tmp
for sd in /dev/shm /tmp /var/tmp; do
find "$sd" -maxdepth 2 2>/dev/null | grep -Ei "$IOC_NAMES" | while read -r f; do
log ALERT "Suppression : $f"; run rm -rf "$f"
done
done
if [ ! -x /usr/bin/chattr ] && [ -x /usr/bin/t ]; then
log ALERT "chattr renomme en /usr/bin/t => COMPROMISSION ROOT"; run mv /usr/bin/t /usr/bin/chattr
fi
run mount -o remount,noexec,nosuid,nodev /dev/shm
grep -q '/dev/shm' /etc/fstab 2>/dev/null || \
{ [ "$DRY_RUN" = "1" ] || echo 'tmpfs /dev/shm tmpfs defaults,noexec,nosuid,nodev 0 0' >> /etc/fstab; }
detect_webshells | while IFS='|' read -r t f d; do
log ALERT "Webshell en quarantaine : $f ($d)"
run cp -a "$f" "$QUARANTINE/$(basename "$f").$(date +%s)"
run sh -c "sha256sum '$f' >> '$QUARANTINE/hashes.txt'"
run rm -f "$f"
done
do_block
do_repair
log OK "Eradication terminee. Preuves : $dest"
notify "Eradication executee" "$(tail -60 "$LOG")"
log WARN "Relance --scan dans 10 min ET dans 2h. Si ca revient : l'entree est encore ouverte."
}
#------------------------------------------------------------------------------
# DIAGNOSTIC ZIMBRA
#------------------------------------------------------------------------------
do_diag() {
echo; log INFO "=== DIAGNOSTIC ZIMBRA ==="
echo; echo "--- Espace disque ---"
df -h /opt /var /tmp 2>/dev/null | grep -vE '^tmpfs|^udev'
df -h /opt 2>/dev/null | awk 'NR==2 && int($5)>90 {print " ATTENTION: /opt a "$5" utilise"}'
echo; echo "--- Inodes ---"
df -i /opt 2>/dev/null | awk 'NR==2 {print " "$5" d inodes utilises"}'
echo; echo "--- Montages noexec (bloquants pour Zimbra) ---"
mount | grep -E ' /tmp | /opt ' | grep noexec && \
log ALERT "/tmp ou /opt monte noexec : Zimbra ne pourra pas extraire ses libs natives"
mount | grep -E ' /tmp | /opt ' || echo " (pas de montage separe)"
echo; echo "--- Attributs immuables bloquants ---"
for p in /opt/zimbra/jetty_base/webapps /opt/zimbra/conf /opt/zimbra/data /opt/zimbra/log; do
[ -d "$p" ] && lsattr -d "$p" 2>/dev/null | grep -q 'i' && log ALERT "Immuable : $p"
done
find /opt/zimbra/jetty_base/webapps -maxdepth 2 -type d 2>/dev/null | while read -r d; do
lsattr -d "$d" 2>/dev/null | grep -qE '^-{4}i' && echo " +i : $d"
done
echo; echo "--- Integrite des fichiers de conf modifies ---"
for f in /opt/zimbra/jetty_base/etc/webdefault.xml /opt/zimbra/jetty_base/etc/jetty.xml; do
if [ -f "$f" ]; then
if command -v python3 >/dev/null 2>&1; then
python3 -c "import xml.dom.minidom,sys; xml.dom.minidom.parse('$f')" 2>/dev/null \
&& echo " OK $f" || log ALERT "XML INVALIDE : $f (restaurer la sauvegarde)"
fi
fi
done
echo; echo "--- Permissions ---"
ls -ld /opt/zimbra/jetty_base/webapps/zimbra 2>/dev/null
ls -ld /opt/zimbra/log /opt/zimbra/data/tmp 2>/dev/null
echo; echo "--- Etat des services ---"
su - zimbra -c 'zmcontrol status' 2>&1 | sed 's/^/ /'
echo; echo "--- Ports en ecoute ---"
ss -tlnp 2>/dev/null | grep -E ':(25|80|110|143|443|465|587|993|995|7071|7025|389|3310|11211) ' | sed 's/^/ /'
echo; echo "--- Connectivite interne Zimbra (cause n1 des echecs mailboxd) ---"
for hp in 127.0.0.1:389 "$(hostname -f):389" 127.0.0.1:7025 127.0.0.1:11211 127.0.0.1:7306; do
local h="${hp%:*}" pt="${hp##*:}"
if timeout 3 bash -c "exec 3<>/dev/tcp/$h/$pt" 2>/dev/null; then echo " OK $hp"
else echo " ECHEC $hp"; fi
done
if iptables -C OUTPUT -m owner --uid-owner zimbra -j ZIMBRA_OUT 2>/dev/null; then
log ALERT "Chaine ZIMBRA_OUT active : peut bloquer le trafic interne. Corriger : $0 --fix-egress"
iptables -L ZIMBRA_OUT -n 2>/dev/null | sed 's/^/ /'
fi
echo; echo "--- Webapps Jetty (cause n1 des Failed) ---"
ls -ld /opt/zimbra/jetty_base/webapps/zimbra 2>/dev/null | sed 's/^/ /'
echo " proprietaire attendu : zimbra:zimbra"
for d in /opt/zimbra/jetty_base/webapps/zimbra/public /opt/zimbra/jetty_base/webapps/zimbra/js; do
[ -d "$d" ] && lsattr -d "$d" 2>/dev/null | grep -q 'i' && log ALERT "IMMUABLE (bloquant) : $d"
done
find /opt/zimbra/jetty_base/webapps -name '*.QUARANTINE' 2>/dev/null | sed 's/^/ residu: /'
if command -v python3 >/dev/null 2>&1 && [ -f /opt/zimbra/jetty_base/etc/webdefault.xml ]; then
python3 -c "import xml.dom.minidom; xml.dom.minidom.parse('/opt/zimbra/jetty_base/etc/webdefault.xml')" 2>/dev/null \
&& echo " webdefault.xml : valide" || log ALERT "webdefault.xml INVALIDE -> restaurer le .bak"
fi
echo " --- zmmailboxd.out ---"
tail -15 /opt/zimbra/log/zmmailboxd.out 2>/dev/null | sed 's/^/ /'
echo; echo "--- MySQL (prerequis de mailbox/webapps/stats) ---"
su - zimbra -c 'zmmysqlstatus' 2>&1 | sed 's/^/ /'
ps aux | grep -E '[m]ysqld' | head -3 | sed 's/^/ /' || echo " aucun mysqld"
echo " --- mysql_error.log ---"
tail -20 /opt/zimbra/log/mysql_error.log 2>/dev/null | sed 's/^/ /'
echo; echo "--- Dernieres erreurs mailbox.log ---"
grep -iE 'ERROR|FATAL|Exception' /opt/zimbra/log/mailbox.log 2>/dev/null | tail -15 | sed 's/^/ /'
echo; echo "--- Dernieres erreurs zmmailboxd.out ---"
tail -20 /opt/zimbra/log/zmmailboxd.out 2>/dev/null | sed 's/^/ /'
echo; echo "--- Fichiers PID orphelins ---"
for p in /opt/zimbra/log/*.pid /opt/zimbra/data/*/state/*/*.pid; do
[ -f "$p" ] || continue
local pv; pv=$(cat "$p" 2>/dev/null)
kill -0 "$pv" 2>/dev/null || echo " PID mort : $p ($pv)"
done
echo
}
#------------------------------------------------------------------------------
# REPARATION / REDEMARRAGE ZIMBRA
#------------------------------------------------------------------------------
# zmmysqlstatus repond "mysqld is alive" (PAS "running") : triple verification
mysql_is_running() {
su - zimbra -c 'zmmysqlstatus' 2>&1 | grep -qiE 'alive|running|uptime' && return 0
pgrep -u zimbra -f '[m]ysqld ' >/dev/null 2>&1 && return 0
[ -S /opt/zimbra/data/tmp/mysql/mysql.sock ] && return 0
[ -S /opt/zimbra/db/mysql.sock ] && return 0
return 1
}
do_repair() {
log INFO "=== REPARATION ZIMBRA ==="
# 1) Retirer les attributs immuables qui empechent Zimbra d'ecrire
log INFO "Levee des verrous immuables"
local CHATTR="chattr"; [ -x /usr/bin/chattr ] || { [ -x /usr/bin/t ] && CHATTR="/usr/bin/t"; }
for wr in "${WEBROOTS[@]}"; do [ -d "$wr" ] && run $CHATTR -R -i "$wr"; done
run $CHATTR -R -ia /opt/zimbra/conf /opt/zimbra/data /opt/zimbra/log /tmp /var/tmp /dev/shm
# 2) /tmp doit rester executable pour la JVM (libs natives)
if mount | grep -qE ' /tmp .*noexec'; then
log WARN "/tmp est noexec — remontage avec exec (Zimbra en a besoin)"
run mount -o remount,exec,nosuid,nodev /tmp
[ "$DRY_RUN" = "1" ] || sed -i 's|\(tmpfs /tmp tmpfs [^ ]*\)noexec,|\1|' /etc/fstab
fi
# /dev/shm peut rester noexec sans impact sur Zimbra
run mount -o remount,noexec,nosuid,nodev /dev/shm
# 3) Restaurer les fichiers de conf invalides depuis les sauvegardes
for f in /opt/zimbra/jetty_base/etc/webdefault.xml; do
[ -f "$f" ] || continue
if command -v python3 >/dev/null 2>&1 && \
! python3 -c "import xml.dom.minidom; xml.dom.minidom.parse('$f')" 2>/dev/null; then
log ALERT "$f est un XML invalide"
local b; b=$(ls -t "$f".bak "$BACKUP_DIR/$(echo "$f"|tr '/' '_').orig" 2>/dev/null | head -1)
if [ -n "$b" ]; then log OK "Restauration depuis $b"; run cp -a "$b" "$f"
else log ALERT "Aucune sauvegarde — reinstalle le paquet zimbra-jetty ou corrige a la main"; fi
fi
done
# 3bis) Annuler tout durcissement qui empecherait le deploiement des webapps
do_unharden
# 4) Nettoyer le cache de compilation Jetty (JSP quarantines residuels)
run rm -rf /opt/zimbra/jetty_base/work/* /opt/zimbra/mailboxd/work/*
find /opt/zimbra/jetty_base/webapps -name '*.QUARANTINE' -delete 2>/dev/null
# 5) Permissions Zimbra
if [ -x /opt/zimbra/libexec/zmfixperms ]; then
log INFO "zmfixperms (peut prendre 1-2 min)"
run /opt/zimbra/libexec/zmfixperms -e
fi
# 6) Restaurer la crontab Zimbra si elle est vide
local nb; nb=$(crontab -u zimbra -l 2>/dev/null | grep -c 'zimbra')
if [ "${nb:-0}" -lt 5 ]; then
log ALERT "Crontab zimbra vide ou incomplete ($nb lignes) — restauration"
local cdir=""
for c in /opt/zimbra/conf/crontabs /opt/zimbra/conf; do
[ -f "$c/crontab" ] && cdir="$c" && break
done
if [ -n "$cdir" ]; then
cat "$cdir"/crontab "$cdir"/crontab.ldap "$cdir"/crontab.store \
"$cdir"/crontab.logger "$cdir"/crontab.mta > /tmp/crontab.zimbra 2>/dev/null
chown zimbra:zimbra /tmp/crontab.zimbra
[ "$DRY_RUN" = "1" ] || su - zimbra -c 'crontab /tmp/crontab.zimbra'
rm -f /tmp/crontab.zimbra
log OK "Crontab restauree : $(crontab -u zimbra -l 2>/dev/null | grep -c zimbra) lignes"
else log WARN "Templates crontab introuvables"; fi
fi
# 7) Nettoyer les PID orphelins qui empechent le demarrage
for p in /opt/zimbra/log/*.pid /opt/zimbra/data/ldap/state/run/*.pid \
/opt/zimbra/data/sasl2/state/*.pid /opt/zimbra/db/mysql.pid; do
[ -f "$p" ] || continue
local pv; pv=$(cat "$p" 2>/dev/null)
if [ -n "$pv" ] && ! kill -0 "$pv" 2>/dev/null; then
log WARN "PID orphelin supprime : $p"; run rm -f "$p"
fi
done
run rm -f /opt/zimbra/data/mailboxd/.lock /opt/zimbra/data/amavisd/var/amavisd.lock
# 7bis) Liberer l'egress : sinon mailboxd ne joint pas le LDAP
if iptables -C OUTPUT -m owner --uid-owner zimbra -j ZIMBRA_OUT 2>/dev/null; then
log WARN "Chaine ZIMBRA_OUT active — retrait avant demarrage"
do_fix_egress
fi
# 8) Arret propre puis demarrage
log INFO "Arret de Zimbra"
zrun 'zmcontrol stop' | tail -5 | sed 's/^/ /'
sleep 5
# Residus qui bloquent le redemarrage
for proc in mysqld slapd nginx amavisd clamd java memcached; do
pkill -u zimbra -f "$proc" 2>/dev/null
done
sleep 3
log INFO "Demarrage de Zimbra (patienter 1 a 3 min)"
zrun 'zmcontrol start' | tail -20 | sed 's/^/ /'
sleep 15
# 9) MySQL d'abord : tout en depend (mailbox, webapps, stats, zmconfigd)
log INFO "Verification de MySQL (prerequis de mailbox/webapps/stats)"
local mysql_ok=0
for try in 1 2 3; do
if mysql_is_running; then mysql_ok=1; log OK "MySQL operationnel"; break; fi
log WARN "MySQL arrete (tentative $try/3)"
run rm -f /opt/zimbra/db/mysql.sock /opt/zimbra/db/mysql.pid /opt/zimbra/log/mysql.pid
zrun 'mysql.server start' >/dev/null 2>&1
local waited=0
while [ $waited -lt 180 ]; do
mysql_is_running && break
# On ne regarde que la fin du log, sinon on lit de vieux evenements
if tail -40 /opt/zimbra/log/mysql_error.log 2>/dev/null \
| grep -qiE 'crash recovery|Rolling back|Apply batch'; then
log INFO "Reprise InnoDB en cours... (${waited}s)"
else
log INFO "Attente du demarrage MySQL... (${waited}s)"
fi
sleep 15; waited=$((waited+15))
done
mysql_is_running && { mysql_ok=1; log OK "MySQL operationnel"; break; }
done
if [ "$mysql_ok" = "0" ]; then
log ALERT "MySQL NE DEMARRE PAS — les services dependants resteront arretes."
log ALERT "Causes usuelles : disque plein, reprise InnoDB, socket perime."
echo " --- df /opt ---"; df -h /opt | sed 's/^/ /'
echo " --- 25 dernieres lignes de mysql_error.log ---"
tail -25 /opt/zimbra/log/mysql_error.log 2>/dev/null | sed 's/^/ /'
log ALERT "Corrige MySQL puis relance : $0 --repair"
return 1
fi
# 10) Relance des services reellement arretes (liste blanche de noms valides)
log INFO "Verification des services"
local VALID='amavis|antispam|antivirus|archiving|convertd|dnscache|imapd|ldap|logger|mailbox|memcached|mta|opendkim|proxy|service webapp|snmp|spell|stats|zimbra webapp|zimbraAdmin webapp|zimlet webapp|zmconfigd'
local st; st=$(su - zimbra -c 'zmcontrol status' 2>&1)
echo "$st" | sed 's/^/ /'
local down
down=$(echo "$st" | grep -i 'Stopped' \
| sed 's/[[:space:]]*Stopped[[:space:]]*$//' \
| sed 's/^[[:space:]]*//; s/[[:space:]]*$//' \
| grep -xE "$VALID")
if [ -n "$down" ]; then
log ALERT "Services arretes : $(echo "$down" | tr '\n' ',' | sed 's/,$//')"
echo "$down" | while read -r svc; do
[ -n "$svc" ] || continue
log WARN "Relance de '$svc'"
zrun "zmcontrol start '$svc'" >/dev/null 2>&1
sleep 3
done
sleep 10
log INFO "Etat apres relance :"
su - zimbra -c 'zmcontrol status' 2>&1 | sed 's/^/ /'
local still
still=$(su - zimbra -c 'zmcontrol status' 2>&1 | grep -ci 'Stopped')
[ "${still:-0}" -gt 0 ] && log ALERT "$still service(s) toujours arrete(s) — voir 'journalctl' et /opt/zimbra/log/"
else
log OK "Tous les services sont demarres"
fi
# 11) Test fonctionnel
echo
local http; http=$(curl -sk -o /dev/null -w '%{http_code}' https://127.0.0.1/ --max-time 15 2>/dev/null)
local smtp; smtp=$(timeout 5 bash -c 'exec 3<>/dev/tcp/127.0.0.1/25 && head -1 <&3' 2>/dev/null | head -c 40)
local imap; imap=$(timeout 5 bash -c 'exec 3<>/dev/tcp/127.0.0.1/7143 && head -1 <&3' 2>/dev/null | head -c 40)
if [ "$http" = "200" ] || [ "$http" = "302" ]; then log OK "Webmail HTTPS : $http"
else log ALERT "Webmail HTTPS : ${http:-injoignable}"; fi
if [ -n "$smtp" ]; then log OK "SMTP 25 : $smtp"; else log ALERT "SMTP 25 injoignable"; fi
if [ -n "$imap" ]; then log OK "IMAP 7143 : OK"; else log WARN "IMAP 7143 injoignable"; fi
# 12) Redurcissement optionnel du webroot
if [ "$HARDEN_AFTER_REPAIR" = "1" ] && { [ "$http" = "200" ] || [ "$http" = "302" ]; }; then
do_harden
elif [ "$HARDEN_AFTER_REPAIR" = "1" ]; then
log WARN "Durcissement webroot NON reapplique (Zimbra pas encore fonctionnel)"
fi
log OK "Reparation terminee. En cas d'echec : $0 --diag"
}
#------------------------------------------------------------------------------
# DURCISSEMENT WEBROOT
#------------------------------------------------------------------------------
do_harden() {
log WARN "ATTENTION : le durcissement du webroot peut empecher Jetty de deployer"
log WARN "les webapps (zimbra/zimbraAdmin/zimlet -> Failed au demarrage)."
log WARN "Prefere --restrict (filtrage IP), bien plus sur et sans effet de bord."
log INFO "Durcissement en cours (propriete zimbra:zimbra conservee)"
for wr in "${WEBROOTS[@]}"; do
[ -d "$wr" ] || continue
# NE JAMAIS mettre root:zimbra : Zimbra exige zimbra:zimbra
run chown -R zimbra:zimbra "$wr"
run find "$wr" -type d -exec chmod 755 {} \;
run find "$wr" -type f -exec chmod 644 {} \;
done
log OK "Permissions normalisees (aucun chattr +i pose)"
log INFO "Pour un verrou immuable, a tes risques : chattr +i <webroot>/public"
}
# Annule un durcissement qui empeche Zimbra de demarrer
do_unharden() {
log INFO "Annulation du durcissement webroot"
local CHATTR="chattr"; [ -x /usr/bin/chattr ] || { [ -x /usr/bin/t ] && CHATTR="/usr/bin/t"; }
for wr in "${WEBROOTS[@]}"; do
[ -d "$wr" ] || continue
run $CHATTR -R -i "$wr"
run chown -R zimbra:zimbra "$wr"
run find "$wr" -name '*.QUARANTINE' -delete
done
run chown -R zimbra:zimbra /opt/zimbra/jetty_base /opt/zimbra/mailboxd
run rm -rf /opt/zimbra/jetty_base/work /opt/zimbra/mailboxd/work
run mkdir -p /opt/zimbra/jetty_base/work
run chown zimbra:zimbra /opt/zimbra/jetty_base/work
[ -x /opt/zimbra/libexec/zmfixperms ] && { log INFO "zmfixperms..."; run /opt/zimbra/libexec/zmfixperms -e; }
log OK "Webroot restaure. Relance : su - zimbra -c 'zmmailboxdctl restart'"
}
#------------------------------------------------------------------------------
# RESTRICTION D'ACCES WEBMAIL / ADMIN
#------------------------------------------------------------------------------
current_client_ip() {
if [ -n "$SSH_CLIENT" ]; then echo "$SSH_CLIENT" | awk '{print $1}'
elif [ -n "$SSH_CONNECTION" ]; then echo "$SSH_CONNECTION" | awk '{print $1}'
else who am i 2>/dev/null | sed -n 's/.*(\(.*\)).*/\1/p' | head -1; fi
}
# Conversion IPv4 -> entier
ip2int() {
local IFS=. o1 o2 o3 o4
read -r o1 o2 o3 o4 <<< "$1"
[ -n "$o4" ] || return 1
echo $(( (o1<<24) + (o2<<16) + (o3<<8) + o4 ))
}
# Vrai test CIDR (gere /8 /16 /22 /24 /32 ...)
ip_in_admin_list() {
local ip="$1" ipi
[ -n "$ip" ] || return 1
ipi=$(ip2int "$ip") || return 1
for a in "${ADMIN_IPS[@]}"; do
local base bits basei mask
case "$a" in
*/*) base="${a%/*}"; bits="${a#*/}" ;;
*) base="$a"; bits=32 ;;
esac
basei=$(ip2int "$base") || continue
if [ "$bits" -eq 0 ]; then return 0; fi
mask=$(( 0xFFFFFFFF << (32 - bits) & 0xFFFFFFFF ))
[ $(( ipi & mask )) -eq $(( basei & mask )) ] && return 0
done
return 1
}
restrict_ports_list() {
local list="$RESTRICT_PORTS_WEB"
[ "$RESTRICT_SSH" = "1" ] && list="$list $RESTRICT_PORTS_SSH"
[ "$RESTRICT_CLIENTS" = "1" ] && list="$list $RESTRICT_PORTS_CLIENTS"
echo "$list"
}
do_restrict() {
take_lock
log INFO "=== RESTRICTION D'ACCES ==="
if [ "${#ADMIN_IPS[@]}" -eq 0 ]; then
log ALERT "ADMIN_IPS est vide — refus (tu te bloquerais toi-meme)"; exit 1
fi
# -- Garde-fou anti-lockout ------------------------------------------------
local me; me=$(current_client_ip)
log INFO "IP de ta session courante : ${me:-inconnue}"
if [ "$RESTRICT_SSH" = "1" ]; then
if [ -z "$me" ]; then
log WARN "IP de session indeterminee (console locale ?)"
elif ! ip_in_admin_list "$me"; then
log ALERT "Ton IP ($me) n'est PAS dans ADMIN_IPS et SSH serait ferme."
log ALERT "Ajoute-la a ADMIN_IPS, ou mets RESTRICT_SSH=0. Abandon."
exit 1
else
log OK "Ton IP est bien autorisee"
fi
fi
local ports; ports=$(restrict_ports_list)
log INFO "Ports restreints : $ports"
log INFO "Port 25 (SMTP entrant) reste ouvert au public"
# -- Sauvegarde avant modification ----------------------------------------
mkdir -p "$IR_DIR"
[ "$DRY_RUN" = "1" ] || iptables-save > "$IR_DIR/iptables.pre-restrict"
log OK "Regles sauvegardees : $IR_DIR/iptables.pre-restrict"
# -- Connexions etablies preservees (evite de couper ta propre session) ----
iptables -C INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || run iptables -I INPUT 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -C INPUT -i lo -j ACCEPT 2>/dev/null || run iptables -I INPUT 2 -i lo -j ACCEPT
# -- Chaine dediee ---------------------------------------------------------
run iptables -N ZWEB_IN
run iptables -F ZWEB_IN
for a in "${ADMIN_IPS[@]}"; do
run iptables -A ZWEB_IN -s "$a" -j ACCEPT
log OK "Autorise : $a"
done
run iptables -A ZWEB_IN -s 127.0.0.0/8 -j ACCEPT
run iptables -A ZWEB_IN -m limit --limit 3/min -j LOG --log-prefix "ZWEB-BLOCK: "
run iptables -A ZWEB_IN -j DROP
# -- Branchement des ports -------------------------------------------------
for p in $ports; do
iptables -C INPUT -p tcp --dport "$p" -j ZWEB_IN 2>/dev/null || run iptables -I INPUT -p tcp --dport "$p" -j ZWEB_IN
done
run sh -c "iptables-save > /etc/iptables.rules.ir"
# -- Alerte certificat -----------------------------------------------------
case "$ports" in
*80*) log WARN "Port 80 ferme : la validation Let's Encrypt HTTP-01 ne fonctionnera plus."
log WARN "Bascule sur DNS-01 avant le prochain renouvellement (acme.sh --dns)." ;;
esac
[ "$RESTRICT_CLIENTS" = "1" ] && log WARN "IMAP/POP/submission fermes : les acces nomades et mobiles vont casser."
# -- Retour automatique si non confirme ------------------------------------
if [ "$AUTOREVERT" -gt 0 ] && [ "$DRY_RUN" != "1" ]; then
rm -f "$CONFIRM_FLAG"
nohup setsid bash -c "sleep $AUTOREVERT; [ -f '$CONFIRM_FLAG' ] || { iptables-restore < '$IR_DIR/iptables.pre-restrict'; logger -t ZIMBRA-ANTIMINER 'RESTRICTION ANNULEE (non confirmee)'; }" >/dev/null 2>&1 &
echo
log ALERT "RETOUR AUTOMATIQUE dans ${AUTOREVERT}s si tu ne confirmes pas."
log ALERT "Verifie tes acces MAINTENANT, puis lance : $0 --confirm"
fi
echo
log OK "Restriction appliquee."
echo " Test depuis une IP non autorisee (mobile en 4G) :"
echo " curl -m 5 -sk https://$(hostname -f)/ -> doit echouer"
echo " nc -zv $(hostname -f) 25 -> doit repondre"
}
do_confirm() {
if [ ! -f "$IR_DIR/iptables.pre-restrict" ]; then
log WARN "Aucune restriction en attente de confirmation"; exit 0
fi
touch "$CONFIRM_FLAG"
run sh -c "iptables-save > /etc/iptables.rules.ir"
log OK "Restriction confirmee et rendue permanente."
log INFO "Pour la persistance au reboot : iptables-restore < /etc/iptables.rules.ir"
}
do_unrestrict() {
take_lock
log INFO "Retrait de la restriction d'acces"
for p in $(restrict_ports_list); do
while iptables -C INPUT -p tcp --dport "$p" -j ZWEB_IN 2>/dev/null; do
run iptables -D INPUT -p tcp --dport "$p" -j ZWEB_IN
done
done
run iptables -F ZWEB_IN
run iptables -X ZWEB_IN
rm -f "$CONFIRM_FLAG"
run sh -c "iptables-save > /etc/iptables.rules.ir"
log OK "Restriction retiree. Webmail et console de nouveau publics."
}
# Secours : retire toute regle egress qui empecherait Zimbra de fonctionner
do_fix_egress() {
log INFO "Retrait du filtrage egress (cause n1 de 'failed to initialize LDAP client')"
while iptables -C OUTPUT -m owner --uid-owner zimbra -j ZIMBRA_OUT 2>/dev/null; do
run iptables -D OUTPUT -m owner --uid-owner zimbra -j ZIMBRA_OUT
done
run iptables -F ZIMBRA_OUT
run iptables -X ZIMBRA_OUT
run sh -c "iptables-save > /etc/iptables.rules.ir"
log OK "Egress libere. Le blocage des IP malveillantes reste actif."
echo " Test LDAP :"
timeout 5 bash -c 'exec 3<>/dev/tcp/127.0.0.1/389' 2>/dev/null \
&& echo " 127.0.0.1:389 OK" || echo " 127.0.0.1:389 INJOIGNABLE"
local pub; pub=$(hostname -f)
timeout 5 bash -c "exec 3<>/dev/tcp/$pub/389" 2>/dev/null \
&& echo " $pub:389 OK" || echo " $pub:389 INJOIGNABLE"
}
#------------------------------------------------------------------------------
# SURVEILLANCE
#------------------------------------------------------------------------------
do_watch() {
local interval="${1:-60}"
log INFO "Surveillance active, intervalle ${interval}s"
while true; do
do_scan >/dev/null 2>&1
if [ $? -eq 2 ]; then
log ALERT "Detection -> eradication automatique"
do_clean
fi
sleep "$interval"
done
}
do_install() {
cp "$0" /usr/local/sbin/zimbra-antiminer.sh
chmod 700 /usr/local/sbin/zimbra-antiminer.sh
cat > /etc/systemd/system/zimbra-antiminer.service <<'EOF'
[Unit]
Description=Surveillance anti-mineur Zimbra
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/sbin/zimbra-antiminer.sh --watch 60
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now zimbra-antiminer
log OK "Service installe : systemctl status zimbra-antiminer"
}
do_status() {
echo "--- Blocage ---"
command -v ipset >/dev/null 2>&1 && ipset list miner_ioc 2>/dev/null | sed -n '1,8p;/Members/,$p' | head -40
echo; echo "--- IP bloquees dynamiquement ---"
tail -20 "$IR_DIR/blocked-ips.txt" 2>/dev/null || echo " (aucune)"
echo; echo "--- Restriction d'acces ---"
if iptables -L ZWEB_IN -n >/dev/null 2>&1; then
echo " ACTIVE — ports : $(restrict_ports_list)"
iptables -L ZWEB_IN -n --line-numbers 2>/dev/null | sed 's/^/ /'
[ -f "$CONFIRM_FLAG" ] && echo " Statut : confirmee" || echo " Statut : NON CONFIRMEE (retour auto possible)"
else
echo " inactive (webmail et console publics)"
fi
echo; echo "--- Zimbra ---"
su - zimbra -c 'zmcontrol status' 2>&1 | sed 's/^/ /'
echo; echo "--- 20 derniers evenements ---"
tail -20 "$LOG" 2>/dev/null
}
#------------------------------------------------------------------------------
require_root
mkdir -p "$IR_DIR" "$QUARANTINE" "$BACKUP_DIR"
touch "$LOG"; chmod 600 "$LOG"
case "${1:-}" in
--scan) do_scan ;;
--clean) do_clean ;;
--block) take_lock; do_block ;;
--repair) take_lock; do_repair ;;
--diag) do_diag ;;
--harden) do_harden ;;
--unharden) do_unharden ;;
--fix-egress) do_fix_egress ;;
--restrict) do_restrict ;;
--confirm) do_confirm ;;
--unrestrict) do_unrestrict ;;
--watch) do_watch "${2:-60}" ;;
--install) do_install ;;
--status) do_status ;;
*) sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
esacRéférences#
- CERT Polska — CVE-2026-73570, exploitation active
- CISA KEV — CVE-2026-73570
- Zimbra Wiki — 10 steps to check Zimbra server for compromise
- Zimbra Security Center — Avis de sécurité et versions correctives