Files
Xray-core/proxy/tun/tun_windows_wfp_test.go
T
patternihaandClaude Opus 5.5 2db099b34b TUN inbound: Block DNS and IPv6 leaks outside the TUN on Windows; Add strictRoute
Windows sends name queries to the DNS servers of all interfaces, and a
resolver on the local network (e.g. 192.168.1.1 from DHCP) is reached
through its more specific LAN route instead of the TUN, so DNS leaks
past it. IPv6 bypasses a TUN that cannot carry it.

With autoSystemRoutingTable set, the Windows TUN now adds Windows
Filtering Platform filters, all in one transaction and in a dynamic
session, so that they are removed when Xray exits, even if it crashes:
- DNS (port 53) only goes through the TUN, in both directions: its local
  address, and the interface it leaves or arrives by, must be the TUN's.
- IPv6 is blocked in both directions when the TUN has no IPv6 address or
  no IPv6 route, except loopback, neighbor and multicast listener
  discovery, and DHCPv6.
- Xray's own traffic is exempt: its connections out with a hard permit,
  which Windows Firewall rules do not override (like sing-box's
  strict_route), connections to its inbounds with an ordinary one.
If the filters cannot be added, the TUN does not start on Windows 10 and
later (only a warning on 7/8). The new `strictRoute` option (true by
default) turns them off.

Also on Windows:
- A warning for `dns` servers outside gateway and autoSystemRoutingTable,
  as queries to them cannot go through the TUN and are blocked.
- While DNS is restricted and autoOutboundsInterface is in use, Xray
  resolves the names it would ask Windows for itself (Go's resolver on
  its own sockets). Those lookups and the `localhost` DNS server skip the
  TUN's DNS servers, unless another interface uses them too, instead of
  looping back into the TUN.
- The DNS cache is flushed when the TUN starts and stops, and DNS
  registration is turned off on the TUN (through netsh before Windows 10
  1809).
- Close no longer panics when registering the route or interface change
  callbacks failed.
The README's Windows section describes all of it.

Tested on Windows 11, elevated, amd64 and 386: the filters, DNS arriving
through a real Wintun adapter and blocked outside it, the IPv6 block,
Windows Firewall rules, and a real Xray run. Windows 7/8 and Windows 10
before 1809 are untested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:15:40 +03:30

197 lines
6.6 KiB
Go

//go:build windows
package tun
import (
"context"
go_errors "errors"
"net"
"net/netip"
"slices"
"testing"
"unsafe"
"github.com/xtls/xray-core/transport/internet"
"golang.org/x/sys/windows"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
)
// The WFP structures are handed to fwpuclnt.dll as they are, so their layout
// has to match what MSVC produces for 64-bit and for 32-bit Windows.
func TestWFPStructLayout(t *testing.T) {
check := func(name string, got, want64, want32 []uintptr) {
t.Helper()
want := want32
if unsafe.Sizeof(uintptr(0)) == 8 {
want = want64
}
if !slices.Equal(got, want) {
t.Errorf("%s: size and offsets are %v, want %v", name, got, want)
}
}
var blob fwpByteBlob
check("FWP_BYTE_BLOB",
[]uintptr{unsafe.Sizeof(blob), unsafe.Offsetof(blob.data)},
[]uintptr{16, 8}, []uintptr{8, 4})
var value fwpValue0
check("FWP_VALUE0",
[]uintptr{unsafe.Sizeof(value), unsafe.Offsetof(value.value)},
[]uintptr{16, 8}, []uintptr{8, 4})
var display fwpmDisplayData0
check("FWPM_DISPLAY_DATA0",
[]uintptr{unsafe.Sizeof(display), unsafe.Offsetof(display.description)},
[]uintptr{16, 8}, []uintptr{8, 4})
var action fwpmAction0
check("FWPM_ACTION0",
[]uintptr{unsafe.Sizeof(action), unsafe.Offsetof(action.filterType)},
[]uintptr{20, 4}, []uintptr{20, 4})
var cond fwpmFilterCondition0
check("FWPM_FILTER_CONDITION0",
[]uintptr{unsafe.Sizeof(cond), unsafe.Offsetof(cond.matchType), unsafe.Offsetof(cond.conditionValue)},
[]uintptr{40, 16, 24}, []uintptr{28, 16, 20})
var session fwpmSession0
check("FWPM_SESSION0",
[]uintptr{
unsafe.Sizeof(session), unsafe.Offsetof(session.displayData), unsafe.Offsetof(session.flags),
unsafe.Offsetof(session.txnWaitTimeoutInMSec), unsafe.Offsetof(session.processID), unsafe.Offsetof(session.sid),
unsafe.Offsetof(session.username), unsafe.Offsetof(session.kernelMode),
},
[]uintptr{72, 16, 32, 36, 40, 48, 56, 64},
[]uintptr{48, 16, 24, 28, 32, 36, 40, 44})
var sublayer fwpmSublayer0
check("FWPM_SUBLAYER0",
[]uintptr{
unsafe.Sizeof(sublayer), unsafe.Offsetof(sublayer.displayData), unsafe.Offsetof(sublayer.flags),
unsafe.Offsetof(sublayer.providerKey), unsafe.Offsetof(sublayer.providerData), unsafe.Offsetof(sublayer.weight),
},
[]uintptr{72, 16, 32, 40, 48, 64},
[]uintptr{44, 16, 24, 28, 32, 40})
var filter fwpmFilter0
check("FWPM_FILTER0",
[]uintptr{
unsafe.Sizeof(filter), unsafe.Offsetof(filter.displayData), unsafe.Offsetof(filter.flags),
unsafe.Offsetof(filter.providerKey), unsafe.Offsetof(filter.providerData), unsafe.Offsetof(filter.layerKey),
unsafe.Offsetof(filter.subLayerKey), unsafe.Offsetof(filter.weight), unsafe.Offsetof(filter.numFilterConditions),
unsafe.Offsetof(filter.filterCondition), unsafe.Offsetof(filter.action), unsafe.Offsetof(filter.providerContextKey),
unsafe.Offsetof(filter.reserved), unsafe.Offsetof(filter.filterID), unsafe.Offsetof(filter.effectiveWeight),
},
[]uintptr{200, 16, 32, 40, 48, 64, 80, 96, 112, 120, 128, 152, 168, 176, 184},
[]uintptr{152, 16, 24, 28, 32, 40, 56, 72, 80, 84, 88, 112, 128, 136, 144})
}
// TestLeakFiltersAccepted has WFP validate the filters by adding them inside a
// transaction that is then aborted, which leaves the system untouched. Adding
// filters requires an elevated process.
func TestLeakFiltersAccepted(t *testing.T) {
skipUnlessElevated := func(err error) {
t.Helper()
if go_errors.Is(err, windows.ERROR_ACCESS_DENIED) {
t.Skipf("WFP filters can only be added by an elevated process: %v", err)
}
t.Fatal(err)
}
engine, err := openWFPEngine()
if err != nil {
skipUnlessElevated(err)
}
defer closeWFPEngine(engine)
if err := fwpmResult(procFwpmTransactionBegin0.Call(uintptr(engine), 0)); err != nil {
skipUnlessElevated(err)
}
defer procFwpmTransactionAbort0.Call(uintptr(engine))
// Any interface stands in for the TUN; the loopback one always exists.
loopback, err := winipcfg.LUIDFromIndex(1)
if err != nil {
t.Fatal(err)
}
if err := addLeakFilters(engine, loopback, true, true); err != nil {
skipUnlessElevated(err)
}
}
func TestDNSOutsideTUN(t *testing.T) {
prefixes := []netip.Prefix{
netip.MustParsePrefix("198.51.100.1/30"), // gateway, not masked
netip.MustParsePrefix("203.0.113.0/24"), // route
}
servers := []netip.Addr{
netip.MustParseAddr("198.51.100.2"),
netip.MustParseAddr("203.0.113.53"),
netip.MustParseAddr("::ffff:203.0.113.54"),
netip.MustParseAddr("8.8.8.8"),
netip.MustParseAddr("2001:db8::53"),
}
want := []netip.Addr{netip.MustParseAddr("8.8.8.8"), netip.MustParseAddr("2001:db8::53")}
if got := dnsOutsideTUN(servers, prefixes); !slices.Equal(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func TestResolveOnOwn(t *testing.T) {
internet.SkipDNSServers([]netip.Addr{netip.MustParseAddr("::ffff:203.0.113.53")})
t.Cleanup(func() { internet.SkipDNSServers(nil) })
preferGo, dial := net.DefaultResolver.PreferGo, net.DefaultResolver.Dial
saved := resolveOnOwn()
t.Cleanup(saved.restore)
if !net.DefaultResolver.PreferGo || net.DefaultResolver.Dial == nil {
t.Fatal("net.DefaultResolver is unchanged")
}
if _, err := net.DefaultResolver.Dial(context.Background(), "udp", "203.0.113.53:53"); err == nil {
t.Error("the TUN's DNS server was not skipped")
}
conn, err := net.DefaultResolver.Dial(context.Background(), "udp", "127.0.0.1:53")
if err != nil {
t.Fatal(err)
}
conn.Close()
saved.restore()
if net.DefaultResolver.PreferGo != preferGo || (net.DefaultResolver.Dial == nil) != (dial == nil) {
t.Error("net.DefaultResolver is not restored")
}
}
// TestTunOnlyDNS checks that a DNS server another interface uses as well is
// not skipped, while one of the TUN alone is.
func TestTunOnlyDNS(t *testing.T) {
adapters, err := winipcfg.GetAdaptersAddresses(windows.AF_UNSPEC, winipcfg.GAAFlagIncludeGateways)
if err != nil {
t.Fatal(err)
}
var other netip.Addr
for _, adapter := range adapters {
if adapter.OperStatus == winipcfg.IfOperStatusUp && adapter.FirstGatewayAddress != nil && adapter.FirstDNSServerAddress != nil {
other, _ = netip.AddrFromSlice(adapter.FirstDNSServerAddress.Address.IP())
other = other.Unmap()
break
}
}
if !other.IsValid() {
t.Skip("no interface with a gateway and a DNS server")
}
tunOnly := netip.MustParseAddr("203.0.113.53")
// LUID 0 is no interface, so every one counts as another.
got, err := tunOnlyDNS(0, []netip.Addr{other, tunOnly})
if err != nil {
t.Fatal(err)
}
if !slices.Equal(got, []netip.Addr{tunOnly}) {
t.Errorf("got %v, want [%v]", got, tunOnly)
}
}
func TestFlushDNSCache(t *testing.T) {
if err := flushDNSCache(); err != nil {
t.Fatal(err)
}
}