Wiki GeRgOsNet Notes d’administration système, réseau et domotique

Vous consultez une ancienne version, enregistrée le 26 août 2026 par GreG. Voir la version actuelle.

Compromission Zimbra — Cryptomineur XMRig « javab »

Création

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#

  1. Résumé
  2. Symptômes
  3. Chronologie de l'attaque
  4. Indicateurs de compromission (IOC)
  5. Procédure de diagnostic
  6. Procédure d'éradication
  7. Blocage réseau
  8. Rotation des secrets
  9. Durcissement sans mise à jour possible
  10. Pièges à éviter
  11. Dépannage : Zimbra ne redémarre plus
  12. Annexe A — Conventions de nommage
  13. 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ômeDétail observé
Charge CPU8 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étaireUID zimbra (999)
ParentPPID 1 — processus réattachés à init
Uptime747 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/
uptime

3. Chronologie de l'attaque#

DateÉvénement
J-11, 06:40Dépôt du premier webshell 430a8f33.jsp dans /public/
J-11, 06:41zmprov -l gaa — énumération de tous les comptes
J-11, 06:41Lecture puis régénération des clés PreAuth des 7 domaines
J-11, 06:42zmslapcat — dump complet du LDAP
J-11, 06:42Archive .tar.gz déposée dans le webroot, téléchargée, puis effacée
J-11, 06:42zmlocalconfig -e snmp_notify=no — l'attaquant referme sa propre porte
J-6 à J-1Dépôt de 7 webshells supplémentaires, IP sources variées
J-1, 20:04Dépôt de info.jsp (lecteur de localconfig.xml)
J-1, 21:26Lancement du mineur XMRig — première manifestation visible

Enseignements#

  1. 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.
  2. 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.
  3. Les IP sources changent au fil des jours : accès partagé ou revendu entre plusieurs opérateurs.

4. Indicateurs de compromission (IOC)#

Fichiers#

CheminRôleSHA-256
/dev/shm/javabXMRig 6.26.0 repackagé4d1a59ee3632459f3795ecf7e85921cb0d86f8befc9c99608978810f9517ad3e
/dev/shm/.rguardTue les mineurs concurrents (boucle 0,1 s)248a6797e9f33ac0a2e048e1e380d186d3003cca575d27771d4310fe2cb0b185
/dev/shm/idleWatchdog : relance javab s'il meurt9dfdc9563ded053a0e95e80e1744793f3154cf035e1e356ee9465d856b4f465e
/dev/shm/.khpInstalleur de persistance, re-téléchargement2d96f6a038eacd89d55fadce05956aca784abebb7cccdd417ecd5050198882cd
/dev/shm/.khp_tsHorodatage du throttle (7200 s)—

Infrastructure de l'attaquant#

Commande et contrôle

141.94.96.195:8080

Serveurs 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 idle et décodées à l'exécution via python3, xxd, perl ou printf selon 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.121

Scan de masse sur /service/admin/soap/

213.176.26.0/24

Pools de minage

pool.supportxmr.com:80,443,8080,9000
randomx.xmrig.com:443

Portefeuille Monero

87G3m8dwSx5ie2CECUCnJSWBA51WcDjCjVEWGGAkFrwmRXrhwxurG8zQMVoip9aWXbHDEhHsyp2nXYuEVpBNyE8CB7Sjm6R

Webshells (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.jsp

Syntaxe 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 | esocket4

Comportements caractéristiques

  • Processus dont /proc/<pid>/exe pointe 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/shm est 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
ObservationInterprétation
chattr présent, pas de /usr/bin/tBranche root non exécutée
chattr absent, /usr/bin/t présentRoot compromis — reconstruction obligatoire
lsattr affiche i minusculeImmuable posé par le malware
lsattr affiche I majusculeIndex 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-reload

6.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 vide

6.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/.rguard

6.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"
done

6.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.zimbra

6.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 DROP

7.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
EOF

7.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=drop

Ce que cette mesure neutralise#

VecteurStatut
CVE-2022-37042 / 27925 (mboximport)Inatteignable
Webshells déjà déposésInertes
Clés PreAuth volées (/service/preauth)Inatteignable
Jetons d'administration forgésInatteignable
Console d'administration (7071)Inatteignable
Scan de masseStoppé

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.txt

8.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.

AttributContenu
zimbraDataSourcePasswordIdentifiants des comptes externes des utilisateurs
zimbraGalLdapBindPasswordCompte de service annuaire
zimbraDomainAuthLdapSearchBindPasswordCompte 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.txt

Ces 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.txt

9. 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.

CVEMécanismeNeutralisation
CVE-2026-73570SNMP via SMTP → commandes shellzmlocalconfig -e snmp_notify=no
CVE-2024-45519postjournal analyse le champ CCzmlocalconfig -e postjournal_enabled=false
CVE-2022-41352Pièce jointe → amavis/cpio → écriture webrootapt 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'

pax remplace cpio pour l'extraction des archives par amavis, ce qui neutralise la traversée de chemin. Ne pas désinstaller le paquet zimbra-snmp : il est référencé par zmcontrol et 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 403

Retirer la ligne preauth si 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-watch

9.4 — Alternatives à la migration#

OptionEffetEffort
Ubuntu ProCorrectifs de sécurité 18.04 jusqu'en 2028. Gratuit ≤ 5 machines.pro attach <token>
Zimbra 8.8.15 P46Ferme mboximport, cpio, postjournal, privesc nginx. Pas de changement de version majeure ni d'OS.~1 h d'interruption
Migration 10.1.20+ / CarbonioSolution pérenneVM 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 FAIRE

Consé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 FAIRE

Consé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 quel

Consé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ômeCause probableCorrection
failed to initialize LDAP clientFiltrage egress bloquant le port 389Retirer la chaîne ZIMBRA_OUT
zimbra webapp ... Failedchattr +i ou chown root:zimbrachattr -R -i, chown -R zimbra:zimbra, zmfixperms -e
mysql.server is not runningInnoDB en reprise, socket périmé, disque pleinVoir ci-dessous
Unable to determine enabled services from ldapConséquence de mailboxd arrêtéRéparer mailboxd d'abord
Tout Stopped sauf ldap/mta/proxyMySQL en panneMySQL 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"
done

MySQL 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 logAction
InnoDB: Starting crash recoveryAttendre 5 à 15 min, ne rien toucher
No space left on devicePurger /opt/zimbra/data/mailboxd/imap-*-session-cache_*
Can't start server: Bind on unix socketSupprimer 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.

MarqueurSignification
<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.

CommandeEffet
--scanDétection seule, aucune action
--cleanGel → preuves → éradication → blocage → réparation
--blockApplique le blocage IOC + blocage dynamique des C2 découverts
--restrictFerme webmail/admin/SSH sauf IP admin, avec retour arrière automatique
--confirmConfirme la restriction (annule le retour arrière)
--unrestrictRetire la restriction
--repairRépare et redémarre Zimbra
--diagDiagnostic complet
--fix-egressRetire le filtrage egress (erreur LDAP)
--unhardenAnnule le durcissement fichiers (webapps Failed)
--watch [SEC]Surveillance continue avec éradication automatique
--installInstalle 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 --clean

Code source complet#

Copier dans /usr/local/sbin/zimbra-antiminer.sh, puis : chmod 700 /usr/local/sbin/zimbra-antiminer.sh

Avant tout usage : renseigner ADMIN_IPS, ALLOW_IPS et ALERT_MAIL dans 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 ;;
esac

Ré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