Brancher Mullvad sur une Kali pour pentester sans cramer son IP (et garder Claude Code en main)
Brancher Mullvad sur une Kali pour pentester sans cramer son IP (et garder Claude Code en main)
Le problème en 30 secondes
Tu fais du pentest sur Kali. Tu utilises Claude Code (ou n’importe quel agent IA en CLI) pendant la mission. Tu veux que tes scans/recon passent par Mullvad pour pas que ton IP FAI atterrisse dans les logs du client. Premier réflexe : t’installes l’app Mullvad, tu fais « Connect », tu lances ton nmap.
Sauf que là, deux pièges qui font mal :
- Killswitch ON, daemon Mullvad reroute tout → ta connexion à
api.anthropic.compasse aussi par le tunnel. Si Mullvad hoquète ou si le relais se vautre, tu perds ton agent IA en pleine mission. Galère. - Killswitch OFF, tu fais juste
nmap target→ c’est ton IP FAI qui sort. Aucun garde-fou. Une seule commande sans préfixe et c’est ton IP perso dans les logs SOC du client.
L’objectif : un truc où tu mets vpn nmap … et c’est tout. Le reste de la machine reste sur la connexion FAI normale, agent IA inclus, sans risque de leak en cas d’oubli.
La solution
On crée un network namespace Linux dédié, dans lequel on monte une interface WireGuard pointant vers Mullvad. Les commandes lancées dans ce namespace n’ont aucune autre route que le tunnel. Killswitch matériel par construction.
ROOT NETWORK NAMESPACE NETNS 'vpn'
───────────────────────── ─────────────────────────
Claude Code, browsing, wg-c3s (WireGuard)
Tailscale, install paquets DNS = 10.64.0.1 (Mullvad)
default → eth0 default → wg-c3s
│ │
▼ ▼
FAI (ton IP perso) Mullvad relay (sortie)
Tu utilises vpn nmap target → exec dans le netns → sortie Mullvad. Tu utilises nmap target directement → exec en root ns → sortie FAI. Et c’est ça, en gros. Le reste c’est de la mise en place propre, sécurisée, et pédagogique. Allons-y.
Zoom sur le trick WG-in-netns
C’est le détail qui fait que ça marche. L’interface WireGuard est créée dans le root ns, son socket UDP s’y attache (donc parle au relais via eth0), PUIS on déplace l’interface dans le netns. Le socket reste dans son ns d’origine — c’est documenté dans le code kernel WG. Résultat :
╔══════════════════════════════════════════════════════════╗
║ ROOT NETWORK NAMESPACE ║
║ ║
║ eth0 (IP FAI) ◄──────────────────┐ ║
║ │ ║
║ ┌────────────────────────────┐ │ paquets WG ║
║ │ socket UDP du tunnel WG │ │ chiffrés (UDP/51820)║
║ │ (créé ici, NE BOUGE PAS) │ ──┘ ║
║ └─────────────┬──────────────┘ ║
║ │ encapsulation/chiffrement ║
╚══════════════════│═══════════════════════════════════════╝
│
╔══════════════════│═══════════════════════════════════════╗
║ ▼ NETNS 'vpn' ║
║ ┌──────────────────────────────┐ ║
║ │ interface wg-c3s │ ║
║ │ IP 10.x.x.x/32 │ ║
║ │ default route via wg-c3s │ ║
║ └──────────────▲────────────────┘ ║
║ │ paquets en CLAIR ║
║ │ ║
║ ┌──────────────┴───────────────┐ ║
║ │ nmap, curl, dig… (via 'vpn') │ ║
║ └──────────────────────────────┘ ║
╚══════════════════════════════════════════════════════════╝
Conséquence : les paquets en clair n’existent que dans le netns, et les paquets chiffrés sortent par eth0. Si le tunnel tombe, le netns n’a plus aucune route → tout fail closed. Killswitch matériel par construction, pas besoin d’iptables ou de surveillance applicative.
Pré-requis
Une Kali récente, un compte Mullvad, et :
sudo apt install wireguard-tools jq dnsutils curl
Vérifie ton compteur de devices côté Mullvad — l’app web mullvad.net/account/devices te le montre. Tu vas en consommer 1.
Étape 1 — Clé WireGuard locale, isolée
Le truc important : la clé privée doit vivre dans son propre fichier. Comme ça si tu partages ton .conf en formation ou en debug, la clé ne fuite pas. C’est pas la pratique par défaut sur tous les tutos WG, mais c’est plus propre.
sudo install -d -m 755 /etc/wireguard
PRIV=$(wg genkey)
PUB=$(echo "$PRIV" | wg pubkey)
echo "$PRIV" | sudo install -m 400 -o root -g root /dev/stdin /etc/wireguard/mullvad-vpn.key
echo "Pubkey à enregistrer chez Mullvad : $PUB"
unset PRIV
La pubkey s’affiche, garde-la sous le coude. La privée est protégée à 400 root:root, le user kali ne peut même pas la lire.
Étape 2 — Enregistrer la pubkey chez Mullvad
L’API legacy de Mullvad est encore vivante en 2026 et beaucoup plus simple que la nouvelle API v1 device :
PUB="<TA_PUBKEY_DE_L_ETAPE_1>"
ACCOUNT="<TON_COMPTE_MULLVAD>" # 16 chiffres, depuis ton compte mullvad.net
curl -sSL --max-time 15 \
--data-urlencode account="$ACCOUNT" \
--data-urlencode pubkey="$PUB" \
https://api.mullvad.net/wg/
# Retour exemple :
# 10.x.x.x/32,fc00:bbbb:bbbb:bb01::y:zzzz/128
Tu récupères deux adresses : IPv4 (10.x.x.x/32) et IPv6. Garde l’IPv4, ignore l’IPv6 — on va bloquer IPv6 dans le netns pour zéro leak v6.
Côté Mullvad, ton compte affiche maintenant un nouveau device avec un nom auto-généré (genre « Exact Goat », « Brave Wolf », « Pure Prawn »). À noter : Mullvad ne permet pas de renommer ce device via l’API publique. Si tu veux un nom propre côté UI, fais-le dans le panneau web.
Étape 3 — Choisir un relais Mullvad
curl -sS https://api.mullvad.net/app/v1/relays | jq -r '
.wireguard.relays[]
| select(.hostname | startswith("ch-zrh-wg"))
| select(.active==true)
| "\(.hostname) ipv4=\(.ipv4_addr_in) pubkey=\(.public_key)"
' | head -3
Pourquoi un relais CH (Suisse) ? Hors juridiction 5/9/14-Eyes, jurisprudence privacy correcte, latence raisonnable depuis l’Europe. Si tu préfères Roumanie (ro-buc-wg), Islande (is-rkv-wg), Suède (se-sto-wg) ou Panama (pa-pty-wg), même principe. Pour la majorité des cas pentest, CH-Zurich passe sans drama.
Note la pubkey du relais et son ipv4_addr_in.
Étape 4 — Conf WireGuard peer-only
# /etc/wireguard/mullvad-vpn.conf
[Peer]
PublicKey = <PUBKEY_DU_RELAIS>
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = <IPv4_DU_RELAIS>:51820
PersistentKeepalive = 25
Pas de section [Interface], pas de PrivateKey ici. La clé sera injectée par le script de démarrage, depuis /etc/wireguard/mullvad-vpn.key. Format compatible avec wg setconf.
Étape 5 — Le script vpn-up.sh
Le pattern canonique WireGuard-in-netns : créer l’interface WG dans le root namespace d’abord (pour que son socket UDP utilise la connexion FAI pour parler au relais), puis la déplacer dans le netns. Comme ça les paquets en clair n’existent que dans le netns, et les paquets chiffrés sortent par eth0.
#!/bin/bash
# /usr/local/sbin/vpn-up.sh
set -euo pipefail
WG_NETNS=vpn
WG_INTERFACE=wg-c3s
WG_ADDRESS_V4="<IP_MULLVAD_ATTRIBUEE>/32" # depuis l'étape 2
WG_KEY=/etc/wireguard/mullvad-vpn.key
WG_CONF=/etc/wireguard/mullvad-vpn.conf
# 1) Création netns (idempotent)
ip netns list | awk '{print $1}' | grep -qx "$WG_NETNS" || ip netns add "$WG_NETNS"
# 2) Loopback up dans le netns
ip -n "$WG_NETNS" link set lo up
# 3) Anti-leak IPv6 par sysctl, scope namespace
ip netns exec "$WG_NETNS" sysctl -qw net.ipv6.conf.all.disable_ipv6=1
ip netns exec "$WG_NETNS" sysctl -qw net.ipv6.conf.default.disable_ipv6=1
# 4) Création de l'interface WG dans le root ns
ip link show "$WG_INTERFACE" 2>/dev/null && ip link del "$WG_INTERFACE"
ip link add "$WG_INTERFACE" type wireguard
# 5) Charge peer info, puis injecte la clé privée depuis le fichier isolé
wg setconf "$WG_INTERFACE" "$WG_CONF"
wg set "$WG_INTERFACE" private-key "$WG_KEY"
# 6) Déplace dans le netns. Le socket UDP reste dans root ns
# = c'est là toute l'astuce du pattern.
ip link set "$WG_INTERFACE" netns "$WG_NETNS"
# 7) Adresse + up + route default unique
ip -n "$WG_NETNS" addr add "$WG_ADDRESS_V4" dev "$WG_INTERFACE"
ip -n "$WG_NETNS" link set "$WG_INTERFACE" up
ip -n "$WG_NETNS" route add default dev "$WG_INTERFACE"
echo "[vpn-up] Tunnel UP via $WG_INTERFACE"
Lance avec sudo. Le vpn-down.sh symétrique fait juste ip netns del + ip link del, je te laisse l’écrire.
Étape 6 — DNS dans le netns
sudo install -d -m 755 /etc/netns/vpn
echo "nameserver 10.64.0.1" | sudo tee /etc/netns/vpn/resolv.conf
echo "options edns0" | sudo tee -a /etc/netns/vpn/resolv.conf
La magie de ip netns exec : ce fichier est automatiquement bind-monté comme /etc/resolv.conf à l’intérieur du namespace. Pas de fallback vers ton resolver système. 10.64.0.1 est le DNS Mullvad accessible depuis n’importe quel relais via le tunnel.
Étape 7 — Le wrapper vpn
Le but : vpn nmap target exécute dans le netns en root, puis redescend les privilèges vers l’utilisateur appelant via setpriv. Ta commande utilisateur ne tourne jamais en root. C’est important parce que ip netns exec requiert root, mais on veut pas que nmap ait un UID 0.
Le helper root-side, autorisé NOPASSWD via sudoers verrouillé sur ce chemin précis :
#!/bin/bash
# /usr/local/sbin/vpn-exec
set -euo pipefail
[ -n "${SUDO_UID:-}" ] || { echo "must be called via sudo" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || exit 1
ip netns list | awk '{print $1}' | grep -qx vpn || exit 2
exec ip netns exec vpn setpriv \
--reuid="$SUDO_UID" --regid="$SUDO_GID" --init-groups \
-- "$@"
# /etc/sudoers.d/vpn-netns
<USER> ALL=(root) NOPASSWD: /usr/local/sbin/vpn-exec
Defaults!/usr/local/sbin/vpn-exec env_keep += "HOME USER LANG PATH TERM"
Le wrapper utilisateur final :
#!/bin/bash
# ~/bin/vpn
set -euo pipefail
ip netns list | awk '{print $1}' | grep -qx vpn || { echo "[vpn] tunnel down — lance vpn-up.sh" >&2; exit 2; }
EGRESS=$(sudo /usr/local/sbin/vpn-exec curl -s --max-time 3 https://ipinfo.io/ip 2>/dev/null || true)
[ -n "$EGRESS" ] || { echo "[vpn] pas de sortie publique"; exit 3; }
echo "[vpn] Sortie Mullvad: $EGRESS"
exec sudo /usr/local/sbin/vpn-exec env \
HOME="$HOME" USER="$USER" LANG="${LANG:-fr_FR.UTF-8}" PATH="$PATH" "$@"
Pourquoi en deux temps wrapper-user → helper-root ? Parce que sudo NOPASSWD sur un script au chemin fixe et root-owned est un compromis raisonnable entre ergonomie (zéro password à chaque commande) et surface d’attaque (le binaire est immutable côté user). Si tu mettais NOPASSWD direct sur ip netns exec, n’importe qui pourrait skip le setpriv et exécuter en root. Non.
Schéma : vie d’une commande vpn nmap target
USER (kali, UID 1000)
│ tape: vpn nmap target
▼
┌─────────────────────────┐
│ ~/bin/vpn (wrapper) │
│ ├─ verif netns existe │
│ ├─ verif sortie publique│
│ └─ exec sudo vpn-exec │
└────────────┬────────────┘
│
▼ sudo NOPASSWD (sudoers verrouillé)
┌─────────────────────────┐
│ /usr/local/sbin/vpn-exec│ (tourne en UID 0)
│ ├─ verif SUDO_UID set │
│ └─ exec ip netns exec…│
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ ip netns exec vpn │ entre dans le namespace
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ setpriv --reuid=1000 │ REDESCEND aux privs user
│ --regid=1000 │ drop CAP_*, drop UID 0
└────────────┬────────────┘
│
▼ (UID 1000, dans netns 'vpn')
┌─────────────────────────┐
│ nmap target │ exec final, jamais root
└────────────┬────────────┘
│ trafic
▼
wg-c3s ──► relais Mullvad ──► cible
Trois invariants sont garantis : (1) ta commande ne tourne jamais en root, (2) elle ne peut pas sortir hors du tunnel (pas de route alternative dans le netns), (3) sudo n’autorise QUE le chemin /usr/local/sbin/vpn-exec — un attaquant local qui réussirait à invoquer sudo ne pourrait pas pivoter ailleurs.
Étape 8 — Les 5 tests d’étanchéité
Après sudo vpn-up.sh, tu valides :
# 1) Sortie Mullvad — doit afficher l'IP du relais
vpn curl -s ipinfo.io/ip
# → 77.243.x.x (Zurich) ou similaire
# 2) Sortie FAI sans wrapper — doit afficher ton IP FAI
curl -s ipinfo.io/ip
# → <IP_FAI>
# 3) IPv6 doit échouer rapidement (anti-leak v6)
time vpn timeout 5 curl -6 https://ifconfig.me
# → exit non-zero, durée < 1s (Network unreachable)
# 4) DNS Mullvad respecté
vpn dig +short api.anthropic.com @10.64.0.1
# → adresse IP, pas de NXDOMAIN
# 5) Pas d'accès LAN host depuis le netns
vpn timeout 4 bash -c '</dev/tcp/192.168.1.1/22' || echo "isolation OK"
# → timeout (Mullvad ne route pas le RFC1918 du LAN)
Si les 5 passent → le sas est étanche, tu peux y aller.
Astuce : confirme côté Mullvad avec vpn curl -s https://am.i.mullvad.net/json | jq — le boolean mullvad_exit_ip: true est plus fiable qu’un regex sur les ranges IP (Mullvad change de fournisseur d’IPs régulièrement).
Bonus : automatiser la discipline avec les hooks Claude Code
Tu peux oublier vpn devant ton nmap. Hé oui. Solution : un hook PreToolUse qui intercepte chaque commande Bash que Claude Code essaie de lancer, et la bloque si elle viole tes règles de mission.
Le .claude-project à la racine du projet
---
project: audit-client-x
mode: pentest # pentest | osint | audit_interne | dev
vpn: required # required | optional | none
scope:
- audit-client-x.fr
- "*.audit-client-x.fr"
- 89.224.10.0/24
contract: "Mission n°2026-042"
authorized_by: "DSI client"
log_commands: true
Le hook PreToolUse dans ~/.claude/settings.json
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "/home/<USER>/.claude/hooks/pretool-network-guard.sh",
"timeout": 5
}]
}],
"SessionStart": [{
"hooks": [{
"type": "command",
"command": "/home/<USER>/.claude/hooks/session-start-project-detect.sh",
"timeout": 5
}]
}]
}
}
⚠️ Chemin absolu obligatoire. Claude Code n’expanse PAS le
~dans
les chemins de hook. J’ai perdu 30 minutes là-dessus, fais pas comme moi.
Logique du hook (résumé)
Le pretool-network-guard.sh lit le .claude-project (en remontant cwd), identifie le mode, et bloque les commandes selon 3 tiers :
| Tier | Outils | Quand bloquer ? |
|---|---|---|
| 1 | nmap, masscan, nikto, gobuster, ffuf, hydra, sqlmap, … | Toujours (mode pentest/osint, sans vpn) |
| 2 | theHarvester, shodan, censys, recon-ng | Toujours (mode pentest/osint, sans vpn) |
| 3 | curl, wget, dig, host, nslookup, ping, traceroute, whois | Si la commande touche le scope du projet |
Le hook split la commande sur &&, ||, ;, | pour gérer les compound commands (cd /target && nmap ... est intercepté correctement).
Bypass exceptionnel : préfixer par noverify permet de passer outre, mais chaque bypass est loggué dans ~/.claude/logs/<projet>/bypass.log avec horodatage et commande originale. Trace audit.
Schéma : flux du hook PreToolUse
Tu écris dans Claude Code:
"lance nmap audit-x.fr"
│
▼
┌──────────────────────────────────────┐
│ Claude (l'agent IA) prépare l'appel │
│ Tool: Bash │
│ Input: { command: "nmap audit-x.fr" }
└──────────────────────┬───────────────┘
│ stdin = JSON
▼
┌──────────────────────────────────────┐
│ Hook PreToolUse │
│ pretool-network-guard.sh │
│ │
│ 1. trouve .claude-project ? │
│ non → exit 0 (allow) │
│ 2. mode = pentest/osint ? │
│ non → exit 0 (allow) │
│ 3. cmd commence par 'noverify ' ? │
│ oui → log bypass + allow │
│ 4. cmd commence par 'vpn ' ? │
│ oui → log + allow │
│ 5. split sur && || ; | │
│ pour chaque segment : │
│ - Tier 1/2 sans vpn ? → BLOCK │
│ - Tier 3 + scope match → BLOCK │
│ 6. sinon : allow │
└──────────────────────┬───────────────┘
│ stdout = JSON verdict
▼
┌──────────────────────────────────────┐
│ Claude Code reçoit : │
│ { permissionDecision: "deny", │
│ permissionDecisionReason: "..." }│
└──────────────────────┬───────────────┘
│
┌──────────┴──────────┐
▼ ▼
DENY → l'agent ALLOW → la commande
voit la raison, s'exécute via Bash
retente avec
"vpn nmap audit-x.fr"
L’élégance du truc : le hook bloque, l’agent IA lit la raison (« Tier 1/2 doit être préfixé par ‘vpn' »), et retente automatiquement avec le bon préfixe. Tu n’as rien à faire. Filet de sécurité totalement transparent côté usage.
SessionStart hook
Au démarrage de Claude Code dans un dossier projet, le SessionStart hook injecte le brief mission dans le contexte initial de l’agent : nom du projet, mode, scope, contrat, autorisation. L’agent IA sait sur quoi tu travailles, dans quelles limites.
Le code complet des deux hooks (~80 lignes chacun) est dispo sur demande — l’idée du tutoriel ici est de t’expliquer le pattern, pas de te faire copier-coller.
Limites & pièges connus
- Compound commands & quotes : le hook split sur les opérateurs shell mais ne respecte pas les quotes.
bash -c "nmap target"n’est pas analysé en profondeur (le segment vu estbash). Solution : préfixer la commande externe globale parvpn(vpn bash -c "nmap target"). - Watcher settings.json : Claude Code charge les hooks au démarrage de la session. Si tu modifies
settings.jsonen cours de route, ouvre/hooks(menu UI) ou redémarre Claude Code pour reload. - Daemon Mullvad cohabitant : si tu laisses tourner l’app Mullvad en parallèle, elle va aussi tenter de modifier la table de routage du root ns. Stoppe-le (
systemctl stop mullvad-daemon && systemctl disable mullvad-daemon) — le netns est self-suffisant. - 5 devices Mullvad max : chaque pubkey enregistrée consomme un slot. Si t’arrives à 5/5, fais le ménage côté UI web mullvad.net/account/devices.
- Outils gourmands en cap_net_raw :
pingdans le netns marche pas en user (drop de cap par setpriv). Utilisetraceroute -Tou les TCP probes viabash /dev/tcp/....
Schéma : architecture complète du projet
Ce que tu obtiens à la fin, en une vue :
~/Desktop/vpn-namespace/ # documentation
├── README.md (archi netns, lifecycle, security)
├── WORKFLOW-PROJECTS.md (workflow par projet)
└── article-connect3s.md (cet article)
~/bin/ # scripts utilisateur (PATH)
├── vpn-up.sh / vpn-down.sh (lifecycle tunnel)
├── vpn-status (état compact ANSI)
├── vpn (wrapper d'exec dans netns)
├── claude-project-init (génère .claude-project interactif)
└── smart-net (wrapper shell hors Claude Code)
/etc/wireguard/ # secrets & conf WG
├── mullvad-vpn.conf [644 root] (peer info, public)
├── mullvad-vpn.env [644 root] (variables interface)
└── mullvad-vpn.key [400 root] ★ CLÉ PRIVÉE ISOLÉE ★
/etc/netns/vpn/ # overrides namespace
└── resolv.conf [644 root] (DNS Mullvad 10.64.0.1 isolé)
/usr/local/sbin/ # helpers root-side NOPASSWD
├── vpn-exec [755 root] (entre netns + drop privs)
└── vpn-stat [755 root] (read-only WG state)
/etc/sudoers.d/ # permissions verrouillées
└── vpn-netns [440 root] (NOPASSWD limité aux helpers)
~/.claude/ # hooks Claude Code globaux
├── settings.json (config + hooks)
├── hooks/
│ ├── session-start-project-detect.sh (brief mission au démarrage)
│ └── pretool-network-guard.sh (interception Bash réseau)
└── logs/<projet>/
├── commands.log (verdicts allow/block/bypass)
└── bypass.log (chaque 'noverify' tracé)
<dossier-mission-pentest>/ # par projet client
└── .claude-project (YAML : mode, scope, contrat)
Trois zones de sécurité :
| Zone | Qui peut écrire ? | Qui peut lire ? |
|---|---|---|
/etc/wireguard/mullvad-vpn.key |
root uniquement | root uniquement (la clé privée WG) |
/etc/sudoers.d/vpn-netns |
root uniquement | tout user (mais effet : NOPASSWD restreint au helper) |
/usr/local/sbin/vpn-exec |
root uniquement | tout user (binary verrouillé) |
~/.claude/logs/ |
user kali | user kali (logs audit) |
Tout le reste (scripts user, conf publique, doc) est en 644 ou 755 — pas de secret en clair côté user.
Conclusion
Le pattern WireGuard-in-netns est une primitive Linux propre, robuste, et pédagogiquement intéressante. Pour qui fait du pentest régulier et utilise un agent IA en parallèle, c’est le bon compromis entre étanchéité opérationnelle et confort de session.
Le hook PreToolUse de Claude Code transforme la discipline manuelle (« ne pas oublier vpn« ) en filet de sécurité automatique. Couplé au .claude-project qui documente le contrat client, c’est aussi une trace d’audit pour les rapports de mission — utile en compliance.
Mon retour d’expérience : depuis que cette stack est en place, je ne pense plus à « passer par le VPN », je pense juste « lancer la commande ». Le filet fait le reste.
Références
- WireGuard official docs
- Mullvad app — open source
- Network namespaces — kernel.org
- Claude Code hooks
- WireGuard-in-netns canonical pattern
CONNECT3S — pentest TPE/PME et formation cybersécurité.
Contact via le formulaire sur connect3s.fr.




Actions de formation