wisp: eine Reverse-Shell über DNS — und warum das Protokoll immer noch ein Sicherheitsproblem ist

DNS als C2-Kanal ist kein Relikt: dnscat2 stammt aus den 2010ern, aber das Grundproblem — fast jedes Netz lässt DNS raus — ist ungelöst. wisp ist eine Machbarkeitsstudie in Rust, die das Konzept auf modernen Stand bringt: E2E-verschlüsselte Reverse-Shell über DNS, X25519/Ed25519/ChaCha20-Poly1305, Forward Secrecy, Wordlist-Encoding statt Base32, DoH-CDN-Egress — und ein Bootstrap-Modell ohne jemals geliefertes PSK: jedes gestempelte Binary trägt sein eigenes Token, das beim ersten Frame verbraucht wird. Dazu `!get`-Exfiltration über denselben Tunnel und ein IRC-Betreiberkanal über Tor. Ein Vergleich mit dnscat2 — und konkrete SNORT-Regeln, mit denen ein SOC genau solche Tunnel aufspürt.
Table of contents

DNS ist das Protokoll, das jeder Firewall durchlässt. Nicht aus Konfigurationsfehler, sondern aus Notwendigkeit: Ohne Namensauflösung funktioniert in einem modernen Netz schlicht nichts — also bleibt Port 53/udp offen, fast immer sowohl Richtung des rekursiven Resolvers des Hauses als auch, häufig genug, Richtung beliebiger öffentlicher Resolver im Internet. Genau das ist der Spalt, durch den sich Exfiltration und Command-and-Control seit drei Jahrzehnten quetschen. dnscat2 hat das 2014 vorgemacht; das Tool ist in die Jahre gekommen, das Prinzip nicht.

Dieser Beitrag stellt wisp (ursprünglich dc - für dnscat) vor — ein Projekt, das ich als Machbarkeitsstudie gebaut habe, um zwei Dinge zu zeigen: erstens, dass ein modernes, kryptografisch hartes DNS-Tunnel-Tool heute ohne viel Aufwand möglich ist, und zweitens — und das ist mir mindestens genauso wichtig —, dass die Verteidigungsseite das nur erkennt, wenn sie es aktiv sucht. wisp ist zugleich Angriffs-POC und Argument für DNS-Firewalling, Response-Rate-Limiting, Egress-Filtering freier Resolver und DoH-Blocking. Wer nur eines davon macht, verliert schon. Zur Reverse-Shell kommt eine !get-Exfiltration über denselben E2E-Kanal — und ein Bootstrap-Modell, das kein PSK jemals ausliefert: jedes gestempelte Binary trägt sein eigenes Token, das beim ersten Frame verbraucht wird.

POC — nur für autorisierte Systeme. wisp ist eine Machbarkeitsstudie für Security-Research und autorisierte Red-Team-Demonstrationen. Es darf ausschließlich in Netzen und an Systemen betrieben werden, die man selbst besitzt oder für die man ausdrücklich autorisiert ist. Unerlaubter Einsatz ist illegal. E2E verschlüsselt den Inhalt des Tunnels, nicht seine Existenz — wer einen solchen Kanal aufbaut, erzeugt messbaren DNS-Verkehr, und genau dieser Verkehr ist, wie der zweite Teil des Beitrags zeigt, erkennbar.

Warum DNS immer noch ein Problem ist

Die Hintergrund-Annahme, auf der alles hier steht: Das Netz des Opfers erlaubt egress-DNS zu seinem rekursiven Resolver. Das ist fast immer erfüllt. NAC-Layer-4-Firewalls blocken selten DNS, weil dann nichts mehr geht. Und der rekursive Resolver erreicht — über normale Delegation — einen Nameserver, den der Angreifer kontrolliert. Damit ist die Richtung offen: Das Opfer kann rausfragen, der Angreifer kann antworten, und das alles über ein Protokoll, das im SIEM oft nur als Rauschen auftaucht.

Was sich seit 2014 verändert hat, ist nicht das Grundproblem, sondern die Optionen:

  • DNS-over-HTTPS (DoH) macht die DNS-Strecke für das lokale Netz unsichtbar — es sieht nur TLS zu einem Resolver, idealerweise zu einer CDN-IP, die massenhaft legitimen Traffic trägt.
  • Moderne Krypto ist günstig geworden: X25519, Ed25519, ChaCha20-Poly1305 und HKDF kosten Millisekunden und ein paar Kilobyte Binary.
  • ML-basierte Subdomain-Entropie-Analytics existieren auf der Verteidigungsseite — aber sie müssen aktiv betrieben werden, und das tun die wenigsten.

dnscat2 hat das Feld aufgemacht, aber es ist ziemlich laut: Base32-Subdomains, niedriger TTL, eigener Handshake mit PSK-only-Krypto, kein Forward Secrecy. Ein aufmerksamer SOC findet dnscat2 heute schnell — wenn er sucht. wisp fragt: Wie weit kann man das Konzept treiben, wenn man es mit heutigen Mitteln angeht?

Was wisp ist — und was nicht

wisp ist ein Reverse-Shell-Tunnel über DNS, in Rust, als Workspace mit vier Crates:

CrateRolle
wisp-commonKrypto-Kern (X25519 + Ed25519 + ChaCha20-Poly1305 + HKDF), ARQ-Framing, Base32- und Wordlist-Encoding, Kompression, Protokolltypen
wisp-serverAutoritativer NS (raw UDP), Handshake-Responder, Multi-Session-Relay, Operator-Bridge, profil-mutierbare Control-REPL, IRC-Bot-Relay (primärer Betreiberkanal), HTTP-Binary-Server zur Auslieferung gestempelter Clients + !get-Artefakte
wisp-clientEgress (System-Resolver / --ns / --doh), Handshake-Initiator, PTY-Shell (das Opfer), zieht sich das live-Profil vom Server und gehorcht; !get-Exfil-Pfad (FileFetch/FileData)
wisp-opOperator-Tool: attach (TTY-Shell) und control (Remote-Profil-REPL)

Die Architektur ist eine Reverse-Shell: Die interaktive Shell (PTY) läuft auf dem Client — dem Opfer im „sicheren" Netz, das nur DNS sprechen darf. Der Server — der autoritative NS, außerhalb — vermittelt einen Operator zum Client über den E2E-verschlüsselten DNS-Tunnel. Transport ist reines DNS: Upstream in den Labels des Query-Names, Downstream in der TXT-Antwort auf exakt diese Query. Eine Round-Trip pro Interaktion, kein Store-and-Forward, kein DDNS im Hauptpfad. Der Operator betreibt den Server primär über ein IRC-Bot-Relay (via Tor) — dieselbe SessionManager-Schnittstelle wie der lokale wisp-op attach, nur dass der Operator nicht auf dem Server-Host sitzen muss; wisp-op bleibt als Low-Latency-Alternative. Gestempelte Client-Binaries und !get-Exfil-Artefakte liefert ein kleiner HTTP-Server hinter einem Einmal-Token aus.

flowchart LR op["Operator<br/>(IRC über Tor)"] srv["wisp-server<br/>auth NS · relay · IRC-bot · HTTP-server"] cli["wisp-client / Opfer<br/>PTY /bin/sh — kann nur DNS raus"] op == "IRC (Tor): Steuerung + Shell" ==> srv op -. "torsocks curl: gestempelte Binary / !get-Artefakt" .-> srv srv == "DNS 53 / DoH-CDN · QNAME+TXT · E2E AEAD" ==> cli cli == "PTY stdout / FileData [enc]" ==> srv srv == "session stdout / exfil-url" ==> op

Die Grenzen - gleich vorweg:

  • wisp besiegt nicht die DNS-Tunnel-Erkennung (Query-Volumen, Entropie-Signaturen, TXT-Größe). Das ist auch nicht das Ziel — das Ziel ist aufzuzeigen, dass diese Erkennung nötig ist.
  • Eine dauerhafte interaktive Shell ist „raise-the-bar", nicht „unsichtbar". Gegen ein entschlossenes SOC mit Full-Packet-Capture und cross-domain-Entropie-ML wird eine lang laufende, bandbreitenintensive Session sichtbar — die sustained volume selbst ist die Signatur. Realistisch verdeckt sind (a) Low-and-Slow-Exfiltration in kurzen Bursts und (b) Netze, die DoH-zu-CDN erlauben (dann ist der lokale Inspector blind).
  • wisp besiegt nicht Response-Rate-Limiting am autoritativen NS, nicht Egress-Filtering freier Resolver, nicht DoH-Blocking. Das Tool ist der Beweis, dass man diese Controls braucht.

Der Vergleich mit dnscat2

dnscat2 ist der Referenzrahmen, und zwar zu Recht — es hat das DNS-Tunneling als praktisches Tool etabliert. Aber es ist ein Kind seiner Zeit. Die Tabelle ist der Kern des Beitrags:

Aspektdnscat2 (~2014)wisp
Sprache / DeploymentC-Client, Ruby-ServerRust, statische musl-Binaries, Cross-Compile
KryptoPSK-only, eigener Protokoll-KryptoX25519-ephemeral + Ed25519 + AEAD, Forward Secrecy
AuthentifizierungPSKEd25519-Server-Signatur (Pin required) + PSK-Client-Auth
Zuverlässigkeiteigene ARQARQ + IPsec-style Sliding-Window-Replay-Schutz
Encodinghex/base32base32 oder Wordlist (CDN-artige Labels); raw-TXT-Bytes downstream
Shell„exec"-Kommando, begrenzte InteraktivitätPTY, voll interaktiv, Resize, stderr
StreamsChannelsMulti-Stream stdin/stdout/stderr/control
DNS-Streckeplaintext DNSoptionales DoH-over-CDN ⇒ lokales Netz blind
Cachingmanuellcache-safe by construction (per-attempt nonce)
TTLniedrig (Tell!)normaler TTL (z. B. 300 s)
Algo-Aushandlungteilweisefixed (kein Downgrade)
Operator-Linkinteraktive „window"-KonsoleIRC-Bot über Tor (primär, voller Befehlssatz) + lokaler wisp-op attach als Low-Latency-Alternative
Auth (Opfer-Binary)PSK im Binarypro-Binary-Token T = PSK; nie auf dem Wire; beim ersten Frame verbraucht
StealthkeineWordlist/Kompression/TXT-Shaping/Multi-Zone/Jitter
Stealth-Deckelauthebt die Latte; Low-and-Slow blendet, sustained interaktiv ist „raise-the-bar"

Drei Punkte daraus verdienen Beachtung, weil sie das eigentliche Argument tragen:

Forward Secrecy. dnscat2 leitet seine Schlüssel aus der PSK ab — wer den PSK einmal erbeutet, kann alle Sessions, auch vergangene, nachvollziehen. wisp macht ephemeralen X25519-ECDH auf beiden Seiten, die ephemeralen Schlüssel werden nach dem Handshake genullt. Kompromittierung der Server-Signatur oder der PSK nachträglich entschlüsselt keine vergangenen Sessions. Das ist kein Luxus, das ist 2026er Standard. Und für gestempelte Opfer-Binaries gibt es überhaupt kein globales PSK mehr: jedes Binary erhält sein eigenes 32-Byte-Token T, das im signierten Footer steht, als PSK in den Handshake eingeht und beim ersten AEAD-verifizierten Frame verbraucht wird — eine aufgefundene Binary ist danach inert, und ein zweiter Versuch mit demselben Token wird als NXDOMAIN beantwortet.

Fixed crypto, kein Downgrade. Es gibt keine Algorithmen-Aushandlung — X25519, Ed25519, ChaCha20-Poly1305, HKDF-SHA256 sind einkompiliert. Was nicht verhandelt wird, kann nicht heruntergehandelt werden. dnscat2 hatte partielle Aushandlung; die ist eine Angriffsfläche, die hier schlicht nicht existiert.

Wordlist statt Base32. Das ist der größte Einzel-Sprung in der Tarnung. Base32-Ciphertext in Labels ergibt lange, zufällig aussehende Subdomains wie q2a7f... — Entropie ~5 bit/Zeichen, das stärkste Einzelsignal für Detection. wisp mappt stattdessen Plaintext-Bytes auf eine Wortliste von ~2048 Wörtern (~11 bit/Wort), die Labels werden zu api.reports.edge.v2.… — von einem CDN/API-Hostname nicht zu unterscheiden. Per-Zeichen-Entropie fällt auf ~1,8 bit/Zeichen, mitten im Bereich echter Subdomains. Der Preis: ~2,7× mehr Queries für dieselben Daten — ein bewusster Trade-off Stealth vs. Volumen, und genau der Trade-off ist es, den die Verteidigung aufbrechen kann (mehr dazu bei den SNORT-Regeln weiter unten).

Der Handshake in einem RTT

Der Handshake ist 1-RTT bis zu ersten Server-Daten, und er verdient ein paar Sätze, weil an ihm die Designentscheidungen besonders klar werden:

sequenceDiagram participant C as Client (Opfer) participant S as Server (auth NS) C->>S: Msg1: hs1.<sid>.<E>.<nonce>.zone (sid ‖ E ‖ client_nonce) Note over S: gen f,F, K=X25519(f,E), sig=Ed25519(transcript), derive keys S->>C: Msg2: TXT — F ‖ server_nonce ‖ sig [ ‖ enc first S→C frame ] Note over C: verify sig w/ pinned Spk (MUST vor decrypt), K=X25519(e,F), derive keys C->>S: Msg3: d.<ctr>.<nonce>.<sid>.<enc OPEN>.zone (AEAD ⇒ PSK-Beweis) Note over S: AEAD-open OPEN ⇒ client auth via PSK-bound key S->>C: Msg4: TXT (enc OPEN_ACK) — session live

Drei Dinge, die hier hard-gelocked sind:

  1. Server-Auth = Ed25519-Signatur, gepinnt. Die Signatur wird über das vollständige Transkript gebildet, bevor irgendein Schlüssel abgeleitet oder ein Frame entschlüsselt wird. Ein aktiver MITM ohne den privaten Server-Schlüssel kann E oder F nicht substituieren, ohne dass die Signaturverifikation scheitert. Das Opfer wird nicht kompromittiert; schlimmstenfalls öffnet der Angreifer eine Session als er selbst (und braucht dafür trotzdem den PSK). Der Pin kommt auf zwei Wegen: beim generischen CLI-Client als --server-fp <hex> auf der Kommandozeile, beim gestempelten Binary implizit — der Client holt sich Spk aus dem TXT-Record pub.server.<zone> und verifiziert damit die Footer-Signatur; die Footer-Sig ist der Pin (kein --server-fp wird ausgeliefert).
  2. Client-Auth = PSK, bewiesen über ein gültiges AEAD-Tag. Die PSK fließt zusammen mit dem ECDH-Geheimnis in das ikm des HKDF-Extrakts. Falsche PSK ⇒ falsches prk ⇒ falsches K_c2s ⇒ das OPEN-Frame fällt durch. Es gibt keinen separaten Auth-Handshake — die Fähigkeit, ein Frame zu entschlüsseln, ist der Beweis.
  3. Stärker als behauptet: Wer nur den Server-Signaturschlüssel Ssk erbeutet (also K kennt), aber nicht den PSK, kann trotzdem keinen Server gegenüber PSK-haltigen Clients impersonieren — weil der PSK in den KDF eingeht, leitet der Angreifer ein anderes prk ab, das OPEN des Opfers entschlüsselt nicht, OPEN_ACK ist nicht schmiedbar. Volle Server-Impersonation eines live-Kanals braucht Ssk und PSK zusammen. Das ist ein netter Nebeneffekt des Second-Factor-Designs.

Die Schlüsselableitung:

prk   = HKDF-Extract( salt = SHA256("dc-extract" ‖ transcript),
                       ikm  = K ‖ PSK )
K_c2s = HKDF-Expand(prk, "dc-c2s-key", 32)
K_s2c = HKDF-Expand(prk, "dc-s2c-key", 32)
IV_c2s= HKDF-Expand(prk, "dc-c2s-iv", 8)
IV_s2c= HKDF-Expand(prk, "dc-s2c-iv", 8)
nonce = IV_dir ‖ counter   //  counter == ARQ-seq, pro Richtung monoton
AD    = "dc-aead-v1" ‖ session_id ‖ dir ‖ counter

Ein einzelner monotoner Zähler pro Richtung dient gleichzeitig als AEAD-Nonce-Counter und ARQ-Sequenznummer — ein eleganter Verzicht auf eine zweite Koordinationsgröße, und Nonce-Eindeutigkeit ist durch die Monotonie strukturell garantiert. Replay-Schutz ist ein IPsec-style Sliding-Window auf demselben Zähler.

Gestempelte Binaries: Bootstrap ohne geliefertes PSK

Der generische wisp-client braucht --zone, --psk und --server-fp auf der Kommandozeile — Material, das ein Analyst aus der Prozessliste lesen kann. Der gestempelte Pfad entfernt das: der Server stempelt eine pro-Ziel-Plattform vorgebaute stamped substrate-Binary (silent: kein clap, keine Flag-Strings, cerr!-kollabierte Diagnosen, remappte Panic-Pfade — strings findet nichts Identifizierendes) mit einem Ed25519-signierten Bootstrap-Footer, und das Artefakt läuft mit einem positionalen Argument (der Zone) und keinem Secret auf der Kommandozeile.

Der Clou ist das per-Binary-Token T: 32 Zufallsbytes, die der Server mintet, in den Footer legt und in einer Registry unter token_id = SHA-256(T)[..16] vorhält. T ist der PSK für den Handshake — aber T steht niemals auf dem Wire. Der gestempelte Client macht seinen Handshake mit dem Marker "bh" statt "hs1" und trägt im QNAME nur den nicht-geheimen token_id plus seinen ephemeralen Public Key:

sequenceDiagram participant C as Client (Opfer, gestempelt) participant S as Server (auth NS + Registry) Note over C: Footer: T (im Binary, NICHT auf dem Wire) C->>S: bh.<token_id>.<E>.<nonce>.zone Note over S: registry.lookup_token(token_id) → T<br/>None/consumed ⇒ NXDOMAIN (inert) S->>C: TXT — F ‖ server_nonce ‖ sig Note over C: verify sig w/ Spk (pub.server TXT), K=X25519(e,F), psk=T C->>S: d.<ctr>.<nonce>.<token_id>.<enc OPEN> (AEAD ⇒ T-Beweis) Note over S: ★ CONSUME T (registry.remove) — beim ersten validen Frame S->>C: TXT (enc OPEN_ACK) — session live

Eigenschaften die bei der Umsetzung Priorität hatten:

  • Kein Interception-Vektor. T nie auf dem Wire ⇒ ein Beobachter kann keine Session-Schlüssel ableiten, selbst wenn er den gesamten DNS-Verkehr sieht. Die Registry-Suche nach token_id liefert nur T oder NXDOMAIN — ein unbekanntes oder verbrauchtes Token verrät nicht einmal, dass es existierte.
  • Kein Race/Replay-DoS. T wird nur bei einer vollständigen Handshake-Verifikation verbraucht (erstes valides AEAD-Frame). Wer eine captured bh-Msg1 replayed, bekommt zwar eine Msg2, verbraucht T aber nicht — das echte Opfer läuft weiterhin durch. Replay kann höchstens, wenn es vor dem echten Client einen captured AEAD-Frame einspeist, T verbrauchen (DoS, keine Credentials) — und das ist der gesamte Schaden. Die früher erwogene Bearer-OTP-Idee (OTP im QNAME redeemen) hatte genau diese Race-Schwäche; Token-als-PSK hat sie strukturell nicht.
  • Single-use, inert-after-run. Client-Binary nach dem ersten Lauf entdeckt, abgefangen und analysiert ⇒ T verbraucht ⇒ inert. Binary vor dem ersten Lauf analysiert ⇒ der inhärente Self-Bootstrap-Fall, gebunden durch Single-Use (der Operator weiß spätestens beim Verbrauch, dass jemand das Artefakt gehoben hat).
  • Zone nicht im Binary. Die Zone kommt als positionales Argument oder optional aus einer zone_url (plain-HTTP-GET, die Zone ist nicht geheim) — sie steht nicht im Footer. Ein Analyst, der das Artefakt hebt, sieht nicht einmal, welche Zone der Tunnel anspricht. Ein Stealth-Gewinn.

Auslieferung läuft über einen kleinen hand-rolled HTTP/1.1-Server auf dem Server, der das gestempelte Artefakt hinter einem Einmal-Token D ausgibt. D ist an T gebunden und bleibt gültig, bis das Opfer T verbraucht (danach ist die Binary unhosted, ein Re-Fetch gibt 404). Der Operator holt sich das Artefakt z.B. via torsocks curl. Die Fetch-Strecke ist aktuell noch observable Clearnet-HTTP, torsocks versteckt nur die Operator-Herkunft, TLS auf dem HTTP-Listener ist für Zukunft geplant. Der Client braucht diese Strecke nie — er ist DNS-only und berührt den HTTP-Server überhaupt nicht. Der ganze Bootstrap braucht ebenfalls kein DoH für Vertraulichkeit — er reitet auf dem eigenen Egress des Tunnels (system|ns|doh). DoH bleibt weiterhin eine optionale Härtung.

Ausgelöst wird alles vom Operator über IRC — !templates listet die verfügbaren Ziele (cross-compile client builds), !emit-client linux-arm64 mintet T, stempelt, legt die fertige Binary im httproot ab und antwortet im Query mit token:, url: und sha256: (query-only + operator-gated, wie !invite). Der Operator übernimmt das Artefakt z.B. per torsocks curl, droppt es auf dem Victim und startet es mit der Zone als Argument.

Cache-safe by construction

Ein subtiles Problem, das dnscat2 manuell lösen musste und wisp strukturell nicht hat: Caching. Ein Tunnel, dessen Queries sich wiederholen, wird vom Resolver-Cache mit alten Antworten bedient — der Operator sieht veralteten Output. dnscat2 zieht TTL=0 als Keule; das ist wirksam, aber erhöht die Entdeckung drastisch („wer setzt schon TTL=0 auf echte Records?").

wisp gibt jeder Query einen frischen per-attempt-Nonce, also ist jeder QNAME einzigartig — kein Cache bedient je eine fremde Tunnel-Antwort. Daraus folgt: TTL darf normal aussehen (z. B. 300 s). Das ist ein reiner Stealth-Gewinn, und er fällt einfach dadurch ab, dass man das Caching-Problem richtig löst.

Die Runtime-mutable Tunnel-Profile

Eine Designentscheidung, die mir besonders gefällt: der Client hat keine Konfigurationsknöpfe. Encoding, Kompression, RR-Typ, TTL, Poll-Kadenz, Multi-Zone-Pool, Chunk-Caps — alles sitzt serverseitig. Der Client zieht sich das authentifizierte Profil (versioniert, in Msg2 AEAD-gebunden; danach periodisch via cfg-Query) und gehorcht. Das hat zwei Gründe:

  1. Der Client ist das Artefakt, das auf dem Opfer landet. Er soll keine Tasten haben, die ein Analyst auslesen kann, um den Tunnel zu verstehen.
  2. Das Profil ist authentifiziert an den gepinnten Server gebunden — ein MITM kann nicht words auf base32 herabhandeln oder die Kompression abschalten, um den Traffic lauter zu machen.

Und es ist runtime-mutabel: Der Operator treibt das live TunnelProfile — primär über IRC (!show, !set <field> <value>, !apply, !rotate words|psk), alternativ über die Control-REPL (wisp-server --interactive oder wisp-op control) — und ein apply bumpt die Version. Die betroffenen Clients ziehen sich die Änderung beim nächsten cfg-Fetch, mid-session, ohne Tunnel-Neuaufbau. Encoding- und RR-Typ-Wechsel sind per-Query, Kompression per-Frame — alles stream-safe, ein Mismatch heilt via ARQ selbst. Wer mitten in einer Session von Base32 auf Wordlist wechselt, zahlt höchstens einen kurzen Burst an Retransmits.

!get — Exfiltration über denselben Tunnel

Reverse-Shell ist die eine Hälfte; die andere ist File-Exfiltration, und die läuft über denselben E2E-Kanal — kein zweites Protokoll, kein neuer Egress. Der Operator schickt im Query !get <path> [--max-mib N] (query-only, operator-gated, braucht eine angehängte Session), der Server reicht den Pfad als FILE_FETCH-Frame (S→C) ans Opfer, und der Client streamt die Datei als FILE_DATA-Frames (C→S) über den Tunnel zurück — Chunk für Chunk, mit einem End-Marker, der Länge + SHA-256 der Gesamtdatei trägt.

Der Server reassembliert, verifiziert Länge + SHA-256 gegen den End-Marker (Dateigröße- oder Hash-Mismatch ⇒ ExfilFailed, nichts wird gehostet) und legt die Datei unter einem zufälligen Namen (8 Zufallsbytes, base32, ~13 Kleinbuchstaben) ab — weder der URL noch der Content-Disposition-Header verraten, wo die Datei auf dem Opfer lag. Das Artefakt wird hinter demselben Einmal-Token D wie die gestempelten Binaries gehostet und dem Operator per IRC gemeldet:

exfil ready: <random-name> (<bytes> B, sha256 <hex>)
url: http://<svc>/<random-name>?d=<D>
fetch (one-time): torsocks curl -OJ '<url>'

Zwei Eigenschaften, die mir wichtig sind: die Victim→Server-Strecke ist E2E-verschlüsselt (derselbe Tunnel, derselbe AEAD-Schutz wie die Shell); und der Opfer-Pfad erscheint nirgendwo — nicht im QNAME, nicht in der IRC-Antwort, nicht in der URL. Der HTTP-Server läuft hinter einem Proxy, also gibt die URL nur den zufälligen Basename preis. D ist Einmal: das Artefakt ist gehostet, bis der Operator es pullt, dann 404. --max-mib N deckelt die Übertragung (der Client verweigert eine größere Datei mit einem Error-Frame statt zu streamen). Wer an !get-Traffic denkt: er ist von der Wire-Form her nicht von Shell- oder cfg-Traffic zu unterscheiden — es sind dieselben AEAD-Frames im selben DNS-Tunnel. Wer Exfil erkennt, erkennt sie am Volumen-Muster (ein großer File-Pull erzeugt einen Bursts sustained hoher Query-Rate), nicht am Inhalt. Genau deshalb weiter unten die Volumen-Regeln.

Wie man es erkennt — SNORT-Regeln

Und jetzt der Teil, der mich an der ganzen Sache am meisten interessiert: wie findet ein SOC so etwas? Weil genau das die Aussage ist, für die wisp gebaut ist — DNS ist nur dann kein Sicherheitsproblem, wenn die Verteidigung aktiv sucht.

Die Detection-Surface von DNS-Tunneln ist — glücklicherweise für die Verteidigung — relativ konstant, egal wie hart die Krypto ist. Die Krypto verbirgt den Inhalt, nicht die Form. Was bleibt:

  1. Subdomain-Entropie & -Länge — Base32-Ciphertext in Labels ⇒ lange, zufällige, hoch-entropische Labels. Das stärkste Einzelsignal. (Wordlist-Encoding drückt genau das — der Gegenpfeil.)
  2. TXT-Frequenz & -Größe — große/zahlreiche TXT-Antworten; TXT ist seltener als A/AAAA.
  3. Volumen & Kadenz — anhaltend hohe Query-Rate zu einer Zone, maschinen-regelmäßiges Polling. Interaktive Shells sind bursty-aber-sustained.
  4. Niedriger TTL / NXDOMAIN — das klassische dnscat2-Tell. (wisp vermeidet es bewusst — wer auf niedrigen TTL filtert, findet dnscat2, nicht wisp.)
  5. Ziel-Reputation — frisch registrierte Domains, single-purpose.

Hier sind konkrete Snort 3-Regeln. Sie sind Heuristiken — Snort kann Entropie nicht nativ berechnen, also nähern wir über Länge und Zeichenklasse an (pcre) plus Volumen-basierte Filter. Wer es ernst meint, ergänzt das mit einem Zeek-Entropie-Skript und RRL am Resolver (dazu unten mehr).

Regel 1 — Lange, hoch-entropisch aussehende Base32-Subdomain-Labels. Trifft dnscat2 und wisp-im-Base32-Modus hart. Das Wordlist-Modus von wisp umgeht diese spezifische Regel — was der Punkt von S1 ist, und warum Regel 2/3 dazugehören.

alert dns $HOME_NET any -> any 53 ( \
  msg:"[LAB-DNS-TUNNEL] long high-entropy base32 subdomain label"; \
  dns.query; \
  pcre:"/(^|\.)([a-z2-7]{32,})(\.|$)/Ui"; \
  metadata:service dns; \
  classtype:trojan-activity; \
  sid:9001001; rev:1; )

Erklärung: [a-z2-7] ist das exakte RFC-4648-Base32-Alphabet (Groß-/Kleinschreibung via i-Flag ignoriert), {32,} verlangt ein Label ≥ 32 Zeichen Base32 — kommt in echten Hostnames praktisch nicht vor, für einen Tunnel ist das eine Standard-Chunkgröße. Wer Wordlist-Encoding einsetzt, sieht api.reports.edge.… — diese Regel bleibt stumm, und genau da muss Regel 3 greifen.

Regel 2 — Overhead an Queries pro Quelle in kurzer Zeit. Ein anhaltend pollender Client produziert Volumen, das ein menschlicher DNS-Client nie erreicht. detection_filter trackt pro Quell-IP.

alert dns $HOME_NET any -> any 53 ( \
  msg:"[LAB-DNS-TUNNEL] high query rate from single source"; \
  dns.query; \
  detection_filter:track by_src, count 120, seconds 30; \
  metadata:service dns; \
  classtype:trojan-activity; \
  sid:9001002; rev:1; )

120 Queries in 30 s aus einer Quelle zum DNS-Port ist für einen normalen Host extrem; ein interaktiver Tunnel pollt dauerhaft in diesem Bereich. (Der Schwellenwert ist ein Tuning-Parameter — in einem Entwickler-Netz mit vielen CI-Runs liegt er zu niedrig; im Lab-NOC passt er.)

Regel 3 — Anhaltend hohes Query-Volumen zu einer einzelnen Zone. Der „many queries to ONE domain"-Detector. Wir tracken pro Quell-IP und schränken via pcre auf einen Suffix ein — praktisch setzt man hier seine eigenen kontrollierten Zonen ein oder lässt den Suffix weg und aggregiert auf Quell-IP + Suffix-Heuristik (Snort-gruppierung nach Registered Domain braucht einen Preprocessor; als Heuristik reicht „ein Label-Count ≥ 4 und viel Volumen").

alert dns $HOME_NET any -> any 53 ( \
  msg:"[LAB-DNS-TUNNEL] sustained queries to a deep single-zone name"; \
  dns.query; \
  pcre:"/^[a-z0-9][a-z0-9.-]{0,40}\.[a-z0-9.-]{4,}\.(wisp1|wisp2)\.example\.com$/i"; \
  detection_filter:track by_src, count 80, seconds 30; \
  metadata:service dns; \
  classtype:trojan-activity; \
  sid:9001003; rev:1; )

(Hier wisp1.example.com / wisp2.example.com als Platzhalter für die eigenen kontrollierten Zonen einsetzen. In einer echten Detection hat man den Suffix nicht — dann greift die Kombination aus „Label-Tiefe ≥ 4 und hohem Volumen" als Annäherung, ergänzt um einen Registered-Domain-Preprocessor wie Zeeks conn/dns-Join.)

Regel 4 — Auffällig große TXT-Antworten. TXT ist seltener als A/AAAA; ein Tunnel, der Downstream über TXT schaufelt, erzeugt viele/volle TXT-Records. wisp capp TXT absichtlich auf ~200 B und pad auf konstante Größe, um genau diese Signatur zu drücken — aber das heißt nicht, dass die Signatur weg ist, sondern dass nicht-gecapter Traffic (dnscat2, Quick-and-Dirty-Exfil) sofort auffällt.

alert dns any 53 -> $HOME_NET any ( \
  msg:"[LAB-DNS-TUNNEL] oversized TXT answer"; \
  dns.query; \
  byte_test:1,>,200,0,relative; \
  threshold:type both, track by_src, count 20, seconds 60; \
  metadata:service dns; \
  classtype:trojan-activity; \
  sid:9001004; rev:1; )

(Der byte_test-Offset gegen die TXT-RDATA-Länge muss an den eigenen Snort-Builds/Inspektor angepasst werden — in Snort 3 lässt sich die TXT-RDATA eleganter über dns.rr.txt referenzieren, falls der DNS-Inspector das Accessor exportiert. Die Regel ist die Form, nicht der letzte Feinschliff.)

Was SNORT nicht gut kann — und was man dazutun muss. Reine Entropie (Shannon über die Labels) ist in Snort nicht nativ; die pcre-Längen-/Zeichenklassen-Annäherung ist ein Proxy, kein Äquivalent. Wer wisp-Wordlist-Traffic erkennen will — Labels wie api.reports.edge.v2.…, die pro Query einzeln unauffällig sind —, kommt an Zeek mit einem kleinen Entropie-Skript nicht vorbei: join der dns.log-Zeilen pro Registered Domain, gleitendes Fenster über Query-Rate und Label-Entropie-Score, Alarm ab Schwelle. Die dritte Säule ist RRL (Response Rate Limiting) am autoritativen NS — nicht Erkennung, sondern Bremsung: ein Tunnel, der pro Sekunde hunderte Queries stellt, wird vom NS rate-limited, und der Client verhungert. BIND/Unbound/PowerDNS haben das an Bord; es muss nur konfiguriert sein. Und schließlich: Egress-Filtering freier Resolver. Wer verhindert, dass interne Hosts 8.8.8.8/1.1.1.1 direkt anfunken können, zwingt DNS-Tunnel durch den kontrollierten rekursiven Resolver — wo man dann Rules 1–4 anwendet und RRL dreht.

Wo es im Lab steht

Im Lab läuft wisp-server als normales Deployment — unprivilegierter Container, nur DNS als ingress. Das Image ist signiert und wird beim Admission verifiziert, kein unsigniertes Image läuft dort. Die Identität (PSK + Ed25519-Seed) ist env-sourced aus einem Secret, bleibt also über Restarts stabil, ohne dass ein PVC nötig wäre; gestempelte Binaries und !get-Artefakte landen in einem flüchtigen Daten-Verzeichnis. Die Operator-Anbindung läuft über den IRC-Bot-Relay, der die identische Bridge nutzt wie der lokale wisp-op attach (gleiche SessionManager-Schnittstelle), nur dass der Operator nicht auf dem Server-Host sitzen muss — die Authentifizierung der Operatoren delegiert über SASL und account-tag ans IRCd, kein Shared-Secret im Bot. Wie genau die externen Pfade (öffentlicher DNS-Eingang, Onion für die Fetch-Strecke) im Cluster landen, spare ich mir hier — wer das nachbauen will, findet ein kustomize-Beispiel-Deployment bei den Sourcen und die live Konfiguration im GitOps Repo..

Die Einordnung

wisp ist kein „undetectable C2". Es ist auch nicht als solches gebaut. Es ist eine Machbarkeitsstudie mit einer doppelten Aussage:

  1. Angriffsseite: Ein kryptografisch hartes, modernes DNS-Tunnel-Tool ist heute mit überschaubarem Aufwand möglich. Forward Secrecy, gepinnter Server, PSK-gated Client (per-Binary-Token statt geliefertem PSK), Wordlist-Encoding, DoH-CDN, runtime-mutable Profil, !get-Exfil über denselben Kanal — alles da. Die Latte für „naive Detection" (Base32-Regex, niedriger TTL, einzelne hoch-entropische Queries) ist spürbar höher als bei dnscat2.
  2. Verteidigungsseite: Und genau deshalb ist DNS weiterhin ein Sicherheitsproblem — weil die Latte nur dann höher ist, wenn die Verteidigung sucht. Ein SOC ohne Subdomain-Entropie-Analytics, ohne RRL am autoritativen NS, ohne Egress-Filtering freier Resolver und ohne DoH-Blocking sieht diesen Kanal als Rauschen. Die Controls existieren alle, sie müssen nur konfiguriert sein. wisp ist das Werkzeug, mit dem man im autorisierten Rahmen zeigt, was passiert, wenn man es nicht tut.

Wer aus dem Beitrag etwas mitnimmt, dann das: DNS gehört auf die Allowlist der Dinge, die man aktiv überwacht — nicht als „irgendwelche Queries", sondern mit Entropie- und Volumen-Analytics, RRL und Egress-Filtering. dnscat2 hat das 2014 gerufen; die Botschaft ist heute nicht leiser.


wisp ist Rust, MIT-lizenziert, und lebt in meinem Radicle-Seed . Die SNORT-Regeln oben sind LAB-Heuristiken, kein fertiges Ruleset — tweakt sie an euer Netzwerk-Rauschen, bevor ihr sie scharfschaltet.