Skip to content

Commit aa9a50f

Browse files
Watson1978claude
andcommitted
Validate DNS responses before accepting a resolved address
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>
1 parent 41be316 commit aa9a50f

2 files changed

Lines changed: 208 additions & 5 deletions

File tree

lib/cool.io/dns_resolver.rb

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
#++
1919

2020
require 'resolv'
21+
require 'securerandom'
22+
require 'socket'
2123

2224
module Coolio
2325
# A non-blocking DNS resolver. It provides interfaces for querying both
@@ -76,6 +78,13 @@ def initialize(hostname, *nameservers)
7678
@nameservers = nameservers.dup
7779
@question = request_question hostname
7880

81+
# A guessable ID would let an off-path attacker forge a response
82+
@request_id = SecureRandom.random_number(1 << 16)
83+
84+
# Numeric addresses this query was sent to, and the lookups behind them
85+
@queried_addresses = []
86+
@numeric_addresses = {}
87+
7988
@socket = UDPSocket.new
8089
@timer = Timeout.new(self)
8190

@@ -115,25 +124,43 @@ def on_timeout
115124
# Send a request to the DNS server
116125
def send_request
117126
@nameservers.rotate!
127+
128+
# Send to the numeric address, so we know where a response must come from
129+
address = numeric_address(@nameservers.first)
130+
@queried_addresses << address unless @queried_addresses.include?(address)
131+
118132
begin
119-
@socket.send request_message, 0, @nameservers.first, DNS_PORT
133+
@socket.send request_message, 0, address, DNS_PORT
120134
rescue Errno::EHOSTUNREACH # TODO figure out why it has to be wrapper here, when the other wrapper should be wrapping this one!
121135
end
122136
end
123137

124138
# Called by the subclass when the DNS response is available
125139
def on_readable
126-
datagram = nil
140+
datagram = sender = nil
127141
begin
128-
datagram = @socket.recvfrom_nonblock(DATAGRAM_SIZE).first
142+
datagram, sender = @socket.recvfrom_nonblock(DATAGRAM_SIZE)
129143
rescue Errno::ECONNREFUSED
130144
end
131145

146+
# Ignore anything we didn't ask for, rather than resolving or failing on it.
147+
# The query stays outstanding, so the retry timer still bounds us.
148+
return if datagram and not solicited_response?(datagram, sender)
149+
132150
address = response_address datagram rescue nil
133151
address ? on_success(address) : on_failure
134152
detach
135153
end
136154

155+
# Is this a reply to our query, from an address we sent it to?
156+
# Retries rotate through @nameservers, so any address already queried counts.
157+
def solicited_response?(datagram, sender)
158+
return false unless datagram.size >= 12
159+
return false unless sender and sender[1] == DNS_PORT and @queried_addresses.include?(sender[3])
160+
161+
datagram[0..1].unpack('n').first.to_i == @request_id
162+
end
163+
137164
def request_question(hostname)
138165
raise ArgumentError, "hostname cannot be nil" if hostname.nil?
139166

@@ -151,7 +178,7 @@ def request_question(hostname)
151178

152179
def request_message
153180
# Standard query header
154-
message = [2, 1, 0].pack('nCC')
181+
message = [@request_id, 1, 0].pack('nCC')
155182

156183
# One entry
157184
qdcount = 1
@@ -166,7 +193,7 @@ def request_message
166193
def response_address(message)
167194
# Confirm the ID field
168195
id = message[0..1].unpack('n').first.to_i
169-
return unless id == 2
196+
return unless id == @request_id
170197

171198
# Check the QR value and confirm this message is a response
172199
qr = message[2..2].unpack('B1').first.to_i
@@ -210,6 +237,17 @@ def reject_ipv6_nameservers(nameservers)
210237
nameservers.reject { |ns| ns.include?(':') }
211238
end
212239

240+
# The address of a nameserver, which may be given as a hostname.
241+
# Only successful lookups are cached, so a transient failure is looked up again.
242+
def numeric_address(nameserver)
243+
@numeric_addresses[nameserver] ||= begin
244+
addrinfo = Addrinfo.getaddrinfo(nameserver, nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM).first
245+
raise SocketError, "getaddrinfo: no IPv4 address for #{nameserver}" if addrinfo.nil?
246+
247+
addrinfo.ip_address
248+
end
249+
end
250+
213251
class Timeout < TimerWatcher
214252
def initialize(resolver)
215253
@resolver = resolver

spec/dns_spec.rb

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,169 @@ def on_resolve_failed
7676
expect(nameservers).to eq(["8.8.4.4"])
7777
end
7878
end
79+
80+
describe "nameserver normalization" do
81+
let(:localhost_address) do
82+
Addrinfo.getaddrinfo("localhost", nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM).first.ip_address
83+
end
84+
85+
it "keeps the nameserver list as given" do
86+
resolver = Coolio::DNSResolver.new("example.com", "localhost")
87+
88+
expect(resolver.instance_variable_get(:@nameservers)).to eq(["localhost"])
89+
end
90+
91+
it "queries the numeric address of a nameserver given as a hostname" do
92+
resolver = Coolio::DNSResolver.new("example.com", "localhost")
93+
resolver.__send__(:send_request)
94+
95+
expect(resolver.instance_variable_get(:@queried_addresses)).to eq([localhost_address])
96+
end
97+
98+
it "accepts responses from a nameserver which was given as a hostname" do
99+
resolver = Coolio::DNSResolver.new("example.com", "localhost")
100+
resolver.__send__(:send_request)
101+
response = dns_response_for(resolver)
102+
103+
expect(
104+
resolver.__send__(:solicited_response?, response, ["AF_INET", 53, localhost_address, localhost_address])
105+
).to be true
106+
end
107+
108+
it "looks a nameserver up once and reuses the result on retries" do
109+
resolved = Addrinfo.getaddrinfo("127.0.0.1", nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM)
110+
resolver = Coolio::DNSResolver.new("example.com", "127.0.0.1")
111+
112+
expect(Addrinfo).to receive(:getaddrinfo).once.and_return(resolved)
113+
114+
3.times { resolver.__send__(:send_request) }
115+
end
116+
117+
it "does not reject an unresolvable nameserver at construction" do
118+
allow(Addrinfo).to receive(:getaddrinfo).and_raise(SocketError, "getaddrinfo: Name or service not known")
119+
120+
expect do
121+
Coolio::DNSResolver.new("example.com", "no-such-nameserver.invalid")
122+
end.to_not raise_error
123+
end
124+
125+
it "surfaces an unresolvable nameserver as a SocketError from the request" do
126+
allow(Addrinfo).to receive(:getaddrinfo).and_raise(SocketError, "getaddrinfo: Name or service not known")
127+
resolver = Coolio::DNSResolver.new("example.com", "no-such-nameserver.invalid")
128+
129+
expect { resolver.attach(@loop) }.to raise_error(SocketError)
130+
expect(@loop.watchers).to be_empty
131+
end
132+
133+
it "recovers when a nameserver is only transiently unresolvable" do
134+
resolved = Addrinfo.getaddrinfo("127.0.0.1", nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM)
135+
resolver = Coolio::DNSResolver.new("example.com", "ns.example.test")
136+
137+
attempts = 0
138+
allow(Addrinfo).to receive(:getaddrinfo) do
139+
attempts += 1
140+
raise SocketError, "getaddrinfo: Name or service not known" if attempts == 1
141+
142+
resolved
143+
end
144+
145+
expect { resolver.__send__(:send_request) }.to raise_error(SocketError)
146+
expect { resolver.__send__(:send_request) }.to_not raise_error
147+
expect(resolver.instance_variable_get(:@queried_addresses)).to eq(["127.0.0.1"])
148+
end
149+
end
150+
151+
describe "response validation" do
152+
let(:nameserver) { "127.0.0.1" }
153+
let(:sender) { ["AF_INET", 53, nameserver, nameserver] }
154+
let(:resolver) do
155+
Coolio::DNSResolver.new("example.com", nameserver).tap { |r| r.__send__(:send_request) }
156+
end
157+
158+
it "uses an unpredictable transaction ID for each query" do
159+
ids = 10.times.map do
160+
request_id_of(Coolio::DNSResolver.new("example.com", nameserver))
161+
end
162+
163+
expect(ids.uniq.size).to be > 1
164+
end
165+
166+
it "accepts a response carrying our transaction ID from the queried nameserver" do
167+
expect(
168+
resolver.__send__(:solicited_response?, dns_response_for(resolver), sender)
169+
).to be true
170+
end
171+
172+
it "rejects a response carrying a different transaction ID" do
173+
forged = dns_response_for(resolver, id: (request_id_of(resolver) + 1) % 65536)
174+
175+
expect(resolver.__send__(:solicited_response?, forged, sender)).to be false
176+
end
177+
178+
it "rejects a response from a source address we did not query" do
179+
response = dns_response_for(resolver)
180+
181+
expect(
182+
resolver.__send__(:solicited_response?, response, ["AF_INET", 53, "10.11.12.13", "10.11.12.13"])
183+
).to be false
184+
end
185+
186+
it "rejects a response arriving before the request was sent" do
187+
unsent = Coolio::DNSResolver.new("example.com", nameserver)
188+
189+
expect(unsent.__send__(:solicited_response?, dns_response_for(unsent), sender)).to be false
190+
end
191+
192+
it "rejects a response from a source port other than the DNS port" do
193+
response = dns_response_for(resolver)
194+
195+
expect(
196+
resolver.__send__(:solicited_response?, response, ["AF_INET", 4444, nameserver, nameserver])
197+
).to be false
198+
end
199+
200+
it "rejects a truncated datagram" do
201+
expect(resolver.__send__(:solicited_response?, "\0\0", sender)).to be false
202+
end
203+
204+
it "resolves from a response sent by the queried nameserver" do
205+
response = dns_response_for(resolver, address: "1.2.3.4")
206+
allow(resolver.instance_variable_get(:@socket)).to receive(:recvfrom_nonblock).and_return([response, sender])
207+
208+
expect(resolver).to receive(:on_success).with("1.2.3.4")
209+
expect(resolver).to receive(:detach)
210+
211+
resolver.__send__(:on_readable)
212+
end
213+
214+
it "ignores a spoofed response instead of resolving or failing it" do
215+
forged = dns_response_for(resolver, address: "6.6.6.6")
216+
allow(resolver.instance_variable_get(:@socket)).to receive(:recvfrom_nonblock)
217+
.and_return([forged, ["AF_INET", 53, "10.11.12.13", "10.11.12.13"]])
218+
219+
expect(resolver).to_not receive(:on_success)
220+
expect(resolver).to_not receive(:on_failure)
221+
expect(resolver).to_not receive(:detach)
222+
223+
resolver.__send__(:on_readable)
224+
end
225+
end
226+
227+
def request_id_of(resolver)
228+
resolver.__send__(:request_message)[0..1].unpack('n').first
229+
end
230+
231+
# A response to the resolver's own query: header plus the echoed question,
232+
# and an A record when an address is given.
233+
def dns_response_for(resolver, id: request_id_of(resolver), address: nil)
234+
question = resolver.instance_variable_get(:@question)
235+
answer = if address
236+
# Compressed name pointer, type A, class IN, TTL, RDLENGTH, RDATA
237+
[0xc00c, 1, 1, 60, 4].pack('nnnNn') + address.split('.').map(&:to_i).pack('CCCC')
238+
else
239+
""
240+
end
241+
242+
[id, 0x81, 0x80, 1, answer.empty? ? 0 : 1, 0, 0].pack('nCCnnnn') + question + answer
243+
end
79244
end

0 commit comments

Comments
 (0)