Files
sans.pub/incidents/2026-09-14-gitea-cryptominer.md
T
Pierre MartinandClaude Opus 5.5 f974203973 docs: backup restic remis en place, cron Nextcloud réparé, plan de migration
- Backup : l'ancien Duplicity (2020) ne tournait plus ; remplacé par restic
  vers OVH Swift (script versionné dans backup/restic-backup.sh, cron 03:30),
  premier snapshot complet et restauration testés le 2026-10-03
- Nextcloud : cron en panne depuis le 2026-08-09 (DNS overlay perdu) corrigé ;
  rétention corbeille/versions à "auto, 30"
- Incident Gitea : vérification de non-réinfection, mouvement latéral et
  intégrité de l'hôte (rien trouvé)
- plan-migration.md : état des lieux, décisions à prendre, phases

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:32:31 +02:00

14 KiB

Incident : mineur de cryptomonnaie via Gitea (packObjectsHook)

  • Statut : contenu, secrets serveur rotés et Gitea mis à jour vers 1.27.3 (2026-09-23). Reste : mots de passe utilisateurs/tokens/webhooks.
  • Date de compromission : 2026-09-14 ~15:16-15:17
  • Date de détection : 2026-09-22 (via un git clone cassé)
  • Impact : exécution de code sur le serveur hébergeant Gitea (et potentiellement l'hôte partagé, ~40 services CapRover) ; minage de cryptomonnaie (Monero) actif pendant ~8 jours

Découverte

Un git clone sur git.sans.pub échouait systématiquement (SSH et HTTPS) avec :

remote: wget: server returned error: HTTP/1.1 404 Not Found
remote: curl: (22) The requested URL returned error: 404
fatal: fin de fichier prématurée
fatal: fetch-pack : sortie d'index de pack invalide

Le problème touchait tous les dépôts, pas un seul — signe d'un problème serveur global plutôt qu'un souci de repo.

Analyse

GIT_TRACE_PACKET=1 a montré que le serveur, juste après avoir annoncé packfile, envoyait sur le canal d'erreur (sideband 2) le texte wget: ... 404 / curl: ... 404 puis coupait la connexion — donc un script serveur s'exécute pendant la génération du pack, avant même l'envoi des données.

Cause trouvée dans /data/gitea/home/.gitconfig (dans le volume Docker captain--gitea-data) : 13 lignes [uploadpack] packObjectsHook = sh /data/gitea/home/p0_XXXXXXXX.sh injectées, chacune maquillée en fin de ligne par un faux commentaire imitant une entrée de log Gitea :

;#router: completed POST /api/internal/manager/add-logger for 10.0.1.105:XXXXX, 200 OK ...

Cette technique fait croire, à la lecture rapide du fichier, qu'il s'agit d'une ligne de log et non de config active.

Vecteur d'entrée (hypothèse la plus probable)

L'endpoint interne Gitea /api/internal/manager/add-logger (normalement réservé à un usage interne, protégé par INTERNAL_TOKEN) semble avoir été atteignable depuis l'extérieur via le reverse-proxy public (l'IP source 10.0.1.105 observée dans les logs correspond au conteneur captain-nginx, donc toutes les requêtes publiques passent par cette IP côté Gitea — la requête malveillante est arrivée par le chemin HTTP public normal). Ceci est cohérent avec des vulnérabilités connues de Gitea permettant l'injection de config git via cette API interne mal exposée.

Décision : pas de restriction réseau supplémentaire sur /api/internal/* au niveau du reverse-proxy. L'authentification par INTERNAL_TOKEN est le bon périmètre de sécurité pour cet endpoint (pas l'adresse IP source) ; ajouter un blocage réseau serait redondant. Le vecteur réel reste donc la compromission du INTERNAL_TOKEN lui-même (fuite non identifiée), pas son exposition réseau — token désormais roté (voir plus bas), ce qui referme ce point sans action supplémentaire.

À vérifier/confirmer : version exacte de Gitea au moment de l'attaque (gitea/gitea:1.25.4), CVE correspondant à une éventuelle fuite/faiblesse du INTERNAL_TOKEN.

Charge malveillante

Les scripts injectés (p0_c9cd952f.sh, p0_9759db49.sh) :

  1. Téléchargent un binaire déguisé : https://sunnyeye.solarvest.my/health.bin → sauvegardé sous /data/gitea/.sys_health_s3
  2. Écrivent une config XMRig (JSON encodé en base64 dans le script) pointant vers des pools Monero (gulf.moneroocean.stream, pool.hashvault.pro) avec une adresse de wallet dédiée
  3. Assurent la persistance :
    • via crontab (*/3 * * * * + @reboot) si cron/crond est disponible
    • sinon via une boucle sh -c 'while sleep 180; do <watchdog>; done' détachée (nohup)
  4. Se camouflent sous des noms plausibles : .sys_health_s3, .gitea_cron_health, .wp_s2_cron, .gitea_watchdog.pid

Le mineur tournait en continu depuis le 2026-09-14 (le conteneur Gitea n'avait pas redémarré depuis), consommant ~298% CPU / ~2.4 Go RAM au moment de la détection. Il a cessé de fonctionner correctement quand le domaine sunnyeye.solarvest.my s'est mis à répondre 404 — c'est cet échec de téléchargement qui a cassé les clones et permis la détection.

Remédiation effectuée (2026-09-22)

  • Process du mineur et du watchdog tués (kill -9 sur les PID hôte)
  • Fichiers de persistance supprimés : .sys_health_s3, .sys_health_s3.json, .sys_health_s3.log, .gitea_cron_health, .gitea_watchdog.pid
  • .gitconfig nettoyé des 13 lignes packObjectsHook injectées (backup conservé : /data/gitea/home/.gitconfig.bak-20260922234958)
  • Clone git re-testé et fonctionnel
  • Audit de la base Gitea (voir ci-dessous) : aucune autre persistance trouvée
  • (2026-09-23, oubli corrigé) Les scripts malveillants eux-mêmes (p0_c9cd952f.sh, p0_9759db49.sh) et un fichier sonde (.s3w) étaient toujours présents sur le disque (/data/gitea/home/) — seule la référence dans .gitconfig avait été nettoyée le 09-22, pas les fichiers. Repéré en creusant une alerte Gitea sans rapport ("hooks broken" sur elevenbook, finalement bénigne — hash identique à un repo sain, juste pas repoussé depuis 2023). Fichiers supprimés, recherche exhaustive des IOC sur tout le volume confirmée vide, aucun process malveillant actif, .gitconfig toujours propre (pas de réinfection).

Audit Gitea (base de données) — résultat

Aucune anomalie détectée au-delà de l'injection packObjectsHook :

Élément vérifié Résultat
Comptes utilisateurs 3 comptes (pierre admin, Sans.pub, Duogeeks), tous antérieurs à l'incident
Clés SSH publiques 3 clés, aucune ajout suspect autour du 09-14
Tokens d'accès personnels 4 tokens, tous antérieurs et cohérents avec l'usage habituel
Webhooks 4 webhooks de déploiement CapRover, tous de 2020-2021
Sources de login externes 1 seule, préexistante (2020)
Collaborateurs sur les repos Rien d'ajouté récemment
Organisations Aucune organisation suspecte

Conclusion : l'attaque semble limitée à l'exécution de commande via le hook git (cryptojacking), sans prise de compte ni persistance au niveau applicatif Gitea. Mais l'attaquant avait un accès en lecture au système de fichiers du conteneur, donc à app.ini (secrets en clair) — voir rotation ci-dessous.

Rotation des secrets — effectuée le 2026-09-23

Confirmé via docker service inspect puis via l'API CapRover (double vérification) : les variables d'env CapRover de l'app gitea sont RUN_MODE, DB_TYPE, DB_HOST, DB_USER, DB_PASSWD — aucune au format GITEA__<section>__<clé> attendu par l'image officielle pour être réinjectée dans app.ini. Elles sont donc inertes pour Gitea (c'est app.ini dans le volume qui fait foi). Conséquence : INTERNAL_TOKEN/LFS_JWT_SECRET n'ont pas de variable CapRover à mettre à jour ; seul DB_PASSWD avait une copie dormante à resynchroniser.

Effectué :

  • INTERNAL_TOKEN régénéré (gitea generate secret INTERNAL_TOKEN) dans app.ini
  • LFS_JWT_SECRET régénéré (gitea generate secret LFS_JWT_SECRET) dans app.ini
  • Mot de passe MySQL de l'utilisateur gitea@% changé (ALTER USER ... IDENTIFIED BY ...)
  • app.ini mis à jour avec les 3 nouvelles valeurs (backup : app.ini.bak-20260923004549)
  • Service Gitea redémarré (docker service update --force srv-captain--gitea), reconnexion DB confirmée dans les logs, aucune erreur
  • Clone git testé et fonctionnel après rotation
  • Variable CapRover DB_PASSWD resynchronisée via l'API (/user/apps/appDefinitions/update), vérifiée par comparaison de hash

Outillage utilisé : CLI caprover (installé localement via fnm + npm install -g caprover, authentifié avec caprover login — voir section CapRover CLI dans CLAUDE.md).

Mise à jour Gitea 1.25.4 → 1.27.3 — effectuée le 2026-09-23

Corrige 15 advisories de sécurité publiées le 2026-08-29 (dont au moins 2 RCE en sévérité haute : bypass d'approbation sur les runners self-hosted, RCE via rendu externe de markup). Aucune ne correspond exactement au vecteur de cet incident (API interne), mais l'upgrade était largement justifiée.

Effectué :

  • Backup complet avant upgrade : dump SQL (mysqldump, 114 tables) + tarball du volume de données complet (2,1 Go), dans /home/zuck/backups/gitea-upgrade-20260923005619/ sur le serveur
  • Déploiement de gitea/gitea:1.27.3 via l'API CapRover (POST /user/apps/appData/gitea avec captainDefinitionContent)
  • Démarrage vérifié sans erreur de migration, Git version: 2.54.0

Incident secondaire causé pendant l'upgrade (auto-résolu le jour même)

La mise à jour CapRover de DB_PASSWD faite plus tôt (rotation des secrets, cf section dédiée) avait été envoyée avec un payload incomplet — le champ containerHttpPort n'était pas inclus. CapRover l'a réinitialisé à sa valeur par défaut (80) au lieu de conserver 3000 (le port réel utilisé par l'image officielle Gitea). Conséquence : git.sans.pub a répondu en 502 pendant plusieurs minutes (visible dans les logs nginx avec du trafic réel externe), le temps de diagnostiquer (connect() failed (111: Connection refused) vers 10.0.1.x:80) et de renvoyer un payload complet avec containerHttpPort: 3000 restauré.

Leçon : toute mise à jour via POST /user/apps/appDefinitions/update doit toujours renvoyer l'intégralité des champs de la définition actuelle (récupérés via un GET /user/apps/appDefinitions juste avant), pas seulement le(s) champ(s) qu'on veut changer — l'API ne fait pas de merge partiel, elle remplace.

Vérification de non-réinfection — 2026-10-03

Contrôle en lecture seule, ~10 jours après le nettoyage. Aucun signe de nouvelle compromission.

  • .gitconfig propre (aucun packObjectsHook/fsmonitor/sshCommand/hooksPath, ni faux commentaire ;#router:) ; seules occurrences dans le backup .bak-20260922234958
  • Aucun fichier IOC (p0_*.sh, .sys_health*, .wp_s2*, .s3w…) dans le conteneur, sur l'hôte ni dans /var/lib/docker/volumes
  • Processus du conteneur : uniquement s6, gitea web, sshd ; crontab Alpine par défaut
  • Hooks des 22 dépôts identiques entre eux et générés par Gitea (# AUTO GENERATED BY GITEA), aucun modifié depuis le nettoyage ; config des dépôts sans clé suspecte
  • Logs Gitea (conservés depuis le 2026-09-26) : aucun appel add-logger, seuls des appels internes légitimes (/api/internal/ssh/.../update depuis 10.0.0.2), aucune mention solarvest/moneroocean/hashvault/xmrig
  • Hôte : load average 0,56, aucun processus gourmand, aucune connexion vers des ports de pools ; crontabs système inchangées depuis 2021-2022

Limites : les logs du 23 au 25/09 ont été perdus par rotation ; vérification ciblée sur ce mode opératoire, ne remplace pas le scan antirootkit ci-dessous.

Mouvement latéral et intégrité de l'hôte — 2026-10-03

Contrôle en lecture seule. Aucun signe de mouvement latéral ni de compromission de l'hôte.

Surface d'évasion depuis Gitea :

  • Conteneur Gitea non privilégié, sans CapAdd, sans PID/réseau hôte, seul montage = son volume de données → pas d'accès direct à l'hôte
  • Seul conteneur montant docker.sock : captain-captain (CapRover, normal)

Autres conteneurs :

  • Aucun processus suspect dans les ~40 conteneurs (faux positifs vérifiés : p0f dans mail = fingerprinting poste.io ; jetty dans scenari)
  • Aucun conteneur gourmand en CPU (max ~2 %)
  • Aucun fichier IOC ni chaîne IOC (solarvest, moneroocean, hashvault, packObjectsHook) dans les volumes, /captain ou les couches inscriptibles ; aucun fichier récent dans les /tmp, /var/tmp, /dev/shm des conteneurs
  • Redis : une-espace-redis protégé par mot de passe ; peertube-redis sans authentification (joignable depuis le réseau overlay, donc depuis Gitea pendant la compromission) mais config intacte (dir=/data, dbfilename=dump.rdb, pas de slaveof, pas de module chargé)

Hôte :

  • dpkg --verify : aucun binaire/bibliothèque de paquet modifié (seule différence : zoneinfo/Europe/Paris, bénin)
  • Aucun fichier modifié depuis le 2026-09-01 dans /etc, /bin, /sbin, /usr/bin, /usr/sbin, /usr/local, systemd ; aucun binaire setuid modifié depuis 2025 ; pas de /etc/ld.so.preload
  • Aucun processus caché (comparaison /proc vs ps)
  • Comptes : root, zuck, cloud, gemini (serveur Gemini agate, 2023, jamais connecté), postgres ; authorized_keys : zuck (1 clé, modifié le 2026-09-22 pendant la remédiation), cloud (1 clé, 2023), admin (ancien utilisateur LDAP YunoHost UID 1007, 1 clé pierre@pierre de 2020, utilisateur non résolu par getent donc inutilisable en SSH) — aucune clé récente ; sudoers inchangés depuis 2021
  • SSH : PasswordAuthentication no, PermitRootLogin no ; connexions acceptées uniquement depuis 88.179.22.122 et 92.88.171.59 (à confirmer : IP mobile ?)
  • Crontabs système inchangées ; pare-feu INPUT DROP avec liste blanche (les ports publiés par Docker contournent INPUT : 3000, 996, 1001, 2242 exposés via l'ingress Swarm)

Reste à faire

  • Changer les mots de passe des 3 comptes Gitea (pierre, Sans.pub, Duogeeks) par précaution
  • Révoquer et régénérer les 4 tokens d'accès personnels (LML, Piaire CI/Deploy, Piaire, Laptop .npmrc) — penser à mettre à jour partout où ils sont utilisés
  • Régénérer les 4 tokens de webhook CapRover (déploiement tmp-fc, dechets-agglo-muretain, etherpad, domi-marche) et mettre à jour les URLs de webhook correspondantes dans Gitea
  • Vérifier l'intégrité des autres services sur le même hôte (mouvement latéral) — fait le 2026-10-03, rien trouvé (voir section dédiée)
  • Vérification d'intégrité des binaires système sur l'hôte — fait le 2026-10-03 via dpkg --verify, rien trouvé
  • Scan antirootkit outillé (rkhunter/chkrootkit) — aucun outil installé ; nécessite une installation (dépôts Debian 9 archivés) ou un scan hors ligne
  • Revoir si DOMAIN = localhost dans app.ini ([server]) est correct ou un reliquat de config

Indicateurs de compromission (IOC)

  • Domaine : sunnyeye.solarvest.my
  • Fichiers : p0_*.sh, .sys_health_s3, .sys_health_s3.json, .sys_health_s3.log, .gitea_cron_health, .gitea_watchdog.pid, .wp_s2_cron, .wp_s2_wd.pid, .s3w
  • Chemins ciblés pour la persistance (dans cet ordre) : /data/gitea, /data/git, /home/git, /var/tmp, /dev/shm, /tmp, $HOME
  • Pattern de commentaire d'obfuscation dans les configs git : ;#router: completed POST /api/internal/manager/add-logger for <ip>, 200 OK ...