Skip to content

Validate DNS responses before accepting a resolved address - #101

Closed
Watson1978 wants to merge 1 commit into
socketry:mainfrom
Watson1978:fix-dns-response-spoofing
Closed

Validate DNS responses before accepting a resolved address#101
Watson1978 wants to merge 1 commit into
socketry:mainfrom
Watson1978:fix-dns-response-spoofing

Conversation

@Watson1978

Copy link
Copy Markdown
Collaborator

Problem

Coolio::DNSResolver built every query with the constant transaction ID 2 and authenticated the reply with nothing but id == 2, while on_readable read the datagram off a never-connected UDPSocket with recvfrom_nonblock(...).first, discarding the sender address.

Both of the usual anti-spoofing defences were therefore absent: the transaction ID contributed zero entropy, and no check tied the datagram to the nameserver that was queried. Any host that could land a UDP packet on the resolver's ephemeral port — an off-path attacker spraying the port range, or anything on the same LAN — could choose the address a hostname resolved to, and Coolio::TCPSocket.connect(hostname, port) would open the application's session to it. A single unsolicited datagram could also deny the resolution outright, since anything that failed to parse became on_failure + detach.

Fix

  • Each query gets a random 16-bit transaction ID from SecureRandom, packed by request_message and compared by response_address.
  • A datagram is accepted only when it is at least 12 bytes, carries that transaction ID, and arrives from port 53 of an address this query was actually sent to. The check (solicited_response?) runs before the response is parsed, so the parser's attack surface shrinks as well.
  • A datagram that fails the check is ignored instead of being turned into a failure, so one spoofed packet can no longer kill a resolution. The existing TIMEOUT / RETRIES budget still bounds how long the resolver waits, and the Errno::ECONNREFUSED path still fails fast exactly as before.

Nameservers may be given as hostnames (DNSResolver.new(host, "ns.example.com")), which UDPSocket#send used to resolve on every send. To know the address a reply must come from, send_request now resolves the nameserver itself and remembers the numeric address, caching only successful lookups:

  • the constructor keeps accepting everything it accepted before and performs no lookup;
  • an unresolvable nameserver still surfaces as SocketError, raised from the same place it was raised from before;
  • a nameserver that is only transiently unresolvable is looked up again on the next retry;
  • IP literals — what /etc/resolv.conf yields, and the only path Coolio::TCPSocket uses — cost no lookup at all, where the previous code resolved on every retransmission.

Retries rotate through @nameservers, so a reply to an earlier retransmission is still accepted from any address already queried.

Tests

spec/dns_spec.rb gains 16 examples covering the transaction ID being unpredictable, responses rejected for a wrong ID / unqueried source address / wrong source port / truncated datagram / arrival before the request was sent, a spoofed response being ignored rather than resolved or failed, and the hostname-nameserver behaviour above (queried at its numeric address, looked up once, not rejected at construction, surfacing as SocketError, recovering from a transient failure).

rspec — 65 examples, 0 failures.

Notes

  • Behaviour visible to callers changes in one way: an unsolicited or malformed datagram no longer ends the resolution immediately; it is ignored, and the resolution ends via the existing timeout/retry path instead. That is the input the fix is about.
  • One residual difference worth naming: when a nameserver is given as a hostname that has multiple A records, UDPSocket#send used to try each resolved address, whereas this change sends to the first. Reaching it needs a hostname nameserver, multiple A records, and a partially broken route.
  • Not covered here: response_address still ignores ancount, so an A record placed in the authority or additional section is accepted. That is separate hardening, left for its own change.

🤖 Generated with Claude Code

DNSResolver built every query with the constant transaction ID 2 and
authenticated the reply with nothing but `id == 2`, while on_readable read
the datagram off a never-connected UDPSocket with
`recvfrom_nonblock(...).first`, discarding the sender. Any host that could
land a UDP packet on the resolver's ephemeral port could therefore choose
the address a hostname resolved to, and Coolio::TCPSocket.connect would
open the application's session to it.

Give each query a random 16-bit transaction ID from SecureRandom, and
accept a datagram only when it carries that ID and arrives from port 53 of
an address this query was actually sent to. The check runs before the
response is parsed. A datagram that fails it is ignored rather than turned
into a failure, so a single spoofed packet can no longer deny the
resolution either; the existing TIMEOUT/RETRIES budget still bounds the
wait.

Nameservers may be given as hostnames, so send_request resolves the
nameserver to a numeric address and remembers it, caching only successful
lookups. The constructor keeps accepting whatever it accepted before, an
unresolvable nameserver still surfaces as SocketError from the same place,
and one that is only transiently unresolvable recovers on the next retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Watson1978
Watson1978 force-pushed the fix-dns-response-spoofing branch from 67b4bb4 to aa9a50f Compare July 31, 2026 07:00
@Watson1978

Copy link
Copy Markdown
Collaborator Author

Closing: this change is already on main. The branch for #102 was accidentally based on this one, so both commits went in together with that squash merge (fa7ba96) — lib/cool.io/dns_resolver.rb and spec/dns_spec.rb there are identical to this branch. Nothing left to merge here.

Note for release notes: the commit title of fa7ba96 only mentions the Valgrind CI fix, so this DNS response validation change is not visible from git log --oneline.

@Watson1978 Watson1978 closed this Jul 31, 2026
@Watson1978
Watson1978 deleted the fix-dns-response-spoofing branch July 31, 2026 07:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant