DEV Community

TiltedLunar123
TiltedLunar123

Posted on

My DNS tool applied a no-log resolver, then leaked lookups over IPv6

DNS-Benchmark is a PowerShell script I wrote that times 17 public resolvers on your actual network, scores them on speed, reliability, security, and consistency, and sets the winner as your system DNS. It backs up your old settings first and asks before it changes anything.

It had a hole I didn't notice for months. It only ever set IPv4 DNS.

Here's why that matters. Say the benchmark crowns Mullvad or Quad9 because you care about the no-logging policy. The script sets 194.242.2.2 as your primary, flushes the cache, done. You feel good. But you're on a dual-stack network, and your router is still advertising its own IPv6 DNS over router advertisements. Windows is happy to use it. So a chunk of your lookups keep going to whatever resolver your ISP handed the router, over IPv6, while the tool tells you you're now on a private resolver.

You benchmarked for privacy and the tool quietly half-applied it.

The fix

A new opt-in switch, -IncludeIPv6. When it's set, the script also sets the winner's published IPv6 anycast addresses. Most of the providers in the table carry them now (Cloudflare is 2606:4700:4700::1111, Quad9 is 2620:fe::fe, Google is 2001:4860:4860::8888). A few are IPv4-only and stay that way.

The part I spent the most time on was making a half-apply impossible to miss. Windows lets you send both families in one Set-DnsClientServerAddress call, it routes each address to its own family. So the apply is one call, but the verify checks both:

$addresses = @($PrimaryDns, $SecondaryDns)
if ($applyV6) { $addresses += @($PrimaryDnsV6, $SecondaryDnsV6) }

Set-DnsClientServerAddress -InterfaceIndex $InterfaceIndex -ServerAddresses $addresses -ErrorAction Stop
$null = Clear-DnsClientCache 2>$null

$newDns = (Get-DnsClientServerAddress -InterfaceIndex $InterfaceIndex -AddressFamily IPv4 -ErrorAction Stop).ServerAddresses
$ipv4Ok = ($newDns[0] -eq $PrimaryDns) -and ($newDns.Count -ge 2 -and $newDns[1] -eq $SecondaryDns)

if (-not $applyV6) { return $ipv4Ok }

$newDns6 = (Get-DnsClientServerAddress -InterfaceIndex $InterfaceIndex -AddressFamily IPv6 -ErrorAction Stop).ServerAddresses
$ipv6Ok = ($newDns6[0] -eq $PrimaryDnsV6) -and ($newDns6.Count -ge 2 -and $newDns6[1] -eq $SecondaryDnsV6)

$ipv4Ok -and $ipv6Ok
Enter fullscreen mode Exit fullscreen mode

If IPv4 takes but IPv6 doesn't, the function returns $false and the script says verification failed instead of pretending it worked.

Then the guardrails, because I did not want this thing touching anyone's IPv6 config by surprise. It only applies v6 when all three are true: you passed -IncludeIPv6, the winner actually publishes IPv6, and the adapter has the IPv6 stack bound. That last check is its own function, and it fails safe:

function Test-IPv6Available {
    param([Parameter(Mandatory)][string]$AdapterName)
    try {
        $binding = Get-NetAdapterBinding -Name $AdapterName -ComponentID 'ms_tcpip6' -ErrorAction Stop
    } catch {
        return $false
    }
    [bool]($binding -and $binding.Enabled)
}
Enter fullscreen mode Exit fullscreen mode

If it can't read the binding, it treats IPv6 as unavailable and applies IPv4 only. Better to under-apply than to throw on an adapter with IPv6 switched off. The backup carries the old IPv6 servers too, so -Restore puts both families back, not just v4.

The switch is off by default. If you don't ask for it, nothing about your IPv6 setup changes.

What's still not airtight

Three honest gaps, because none of this is as clean as it sounds.

The winner is still chosen on IPv4 latency only. The v6 addresses ride along on faith. Nothing benchmarks the IPv6 path, so it's possible the resolver that was fastest over v4 is slower or briefly unreachable over v6 and you'd never know from the output.

Test-IPv6Available checks that the binding is enabled, not that IPv6 actually works. An adapter can have the stack bound and still have no usable global address or route. The check is "is IPv6 turned on for this NIC," not "does IPv6 reach the internet."

And it only fixes the interface it applies to. Static DNS on that adapter wins over the router's advertised DNS for that adapter, which is the whole point, but a VPN NIC or a second adapter is on its own. Browser-level secure DNS (Firefox and Chrome doing their own DoH) also skips the OS entirely, so setting system DNS doesn't move it.

The address I typed by hand

I don't trust an IPv6 literal I hand-typed into a table. Drop a group or swap a digit and it still looks right. So the tests parse the resolver list back out of the script's own source with the PowerShell AST and check every IPv6 entry is a real, paired literal:

It "Should only use real IPv6 literals for the v6 addresses" {
    foreach ($s in $script:DnsServers) {
        if (-not [string]::IsNullOrWhiteSpace($s.PrimaryV6)) {
            ([System.Net.IPAddress]::Parse($s.PrimaryV6)).AddressFamily |
                Should -Be ([System.Net.Sockets.AddressFamily]::InterNetworkV6)
            ([System.Net.IPAddress]::Parse($s.SecondaryV6)).AddressFamily |
                Should -Be ([System.Net.Sockets.AddressFamily]::InterNetworkV6)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

If I fat-finger a v6 address, that fails in CI instead of on someone's adapter. It does not check the address is the current correct one for that provider, only that it parses as IPv6 and is paired with a secondary. A provider could rotate an anycast address and this would still pass.

Thirteen cases in total around the IPv6 work, including apply-and-verify both families, fail-when-v6-doesn't-take, and stay-IPv4-only-when-only-one-v6-given.

It works. Not perfect, but it catches the thing it was built to catch: telling you you're private when you're only half private.

Repo: https://github.com/TiltedLunar123/DNS-Benchmark

Top comments (0)