TUN inbound: Block IPv4 too when it is not routed to the TUN on Windows

autoSystemWfpBlockLeak blocked IPv6 when the TUN had no IPv6 address or
no IPv6 route, and IPv4 never: with only IPv6 routed to the TUN, IPv4
went around it. Now each IP version is blocked when no route of it leads
to the TUN, except for loopback, DHCP, IPv6 neighbor and multicast
listener discovery, and Xray itself.

Addresses no longer count: without one of a version in gateway, Windows
gives the TUN a link-local one itself (fe80:: at once, 169.254.x.x after
some seconds), and what is routed to the TUN goes through it with that,
so a TUN with IPv6 routes but no IPv6 address had its IPv6 blocked for
nothing.

Tested on Windows 11, elevated: with only IPv6 routed, other programs'
IPv4 is denied, while loopback, a DHCP renew of Wi-Fi and Xray still
work; without gateway, IPv4 and IPv6 routed to the TUN enter it from
169.254.x.x and fe80::.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
patterniha
2026-09-30 03:07:14 +03:30
co-authored by Claude Opus 5.5
parent 4ec4fb8aab
commit de02da553a
4 changed files with 60 additions and 31 deletions
+3 -3
View File
@@ -202,15 +202,15 @@ When `dns` is set, those servers are applied to the adapter. Windows is kept fro
With `autoSystemWfpBlockLeak` and `autoSystemRoutingTable` set, Xray also adds Windows Filtering Platform filters that keep two kinds of traffic of every program but Xray itself from leaving outside the TUN:
- With `dns` set, DNS (port 53) only goes through the TUN. Windows keeps sending name queries to the DNS servers of the other interfaces as well, out through those interfaces whatever the routes say, and other programs reach a resolver on the local network (e.g. `192.168.1.1` handed out by DHCP) through its more specific LAN route instead of the TUN. On Windows 11 and Server 2022 and later, where those queries may also go over HTTPS or TLS, Windows' DNS Client service cannot connect outside the TUN at all, except for name resolution on the local network (LLMNR, mDNS). The `dns` servers therefore have to lie within `gateway` or `autoSystemRoutingTable` (a warning is logged otherwise), and DNS servers that should be reached directly belong in Xray's own `dns` settings.
- Unless the TUN carries IPv6, that is, has an IPv6 address in `gateway` and IPv6 routes in `autoSystemRoutingTable`, IPv6 is blocked entirely, in both directions, as it would bypass the TUN. Only loopback and what Windows itself needs on the local link (neighbor and multicast listener discovery, DHCPv6) remain allowed.
- An IP version without routes in `autoSystemRoutingTable`, IPv4 or IPv6, is blocked entirely, in both directions, as it would bypass the TUN. Only loopback and what Windows itself needs on the local link (DHCP, and for IPv6 neighbor and multicast listener discovery) remain allowed. An address of that version in `gateway` is not needed: without one, Windows gives the TUN link-local addresses itself, an IPv6 one at once and an IPv4 one from `169.254.0.0/16` after some seconds (until then, IPv4 routed to the TUN is unreachable), and what is routed to the TUN goes through it with those.
With the filters in place, Xray's own connections out also get past Windows Firewall's block rules (other firewalls may still block them), while connections to Xray's inbounds stay subject to them.
Names that Xray resolves through the system resolver, such as an outbound's server address given as a domain with the default `AsIs` domain strategy, would be looked up by Windows on Xray's behalf, and those queries would then go into the TUN too. While DNS is restricted this way and `autoOutboundsInterface` is in use (the default with `autoSystemRoutingTable`), Xray therefore resolves them itself, with its own queries to the DNS servers of the other interfaces. That bypasses Windows' DNS cache, and its name resolution on the local network (LLMNR, mDNS): a server address given as a domain is looked up again for every connection, and a DNS server that does not answer delays each lookup. Having Xray's own `dns` resolve it, through the outbound's `sockopt.domainStrategy`, avoids that. The `localhost` DNS server queries the same servers whenever `autoOutboundsInterface` is in use. Both skip the TUN's own DNS servers, unless another interface uses them as well: queried from Xray itself, they would lead back into it, or nowhere.
If the filters cannot be added, the TUN does not start (on Windows 10 and later; older versions only log a warning). They are removed when Xray exits. Not covered is name resolution on the local network (LLMNR, mDNS, NetBIOS), except over IPv6 while that is blocked.
If the filters cannot be added, the TUN does not start (on Windows 10 and later; older versions only log a warning). They are removed when Xray exits. Not covered is name resolution on the local network (LLMNR, mDNS, NetBIOS), except over an IP version that is blocked.
`autoSystemWfpBlockLeak` (Windows only) is `false` by default, as the filters break some setups: a local DNS resolver other programs use (e.g. on `127.0.0.1:53`), the DNS of another VPN on its own interface, IPv6 on the local network while the TUN has no IPv6 address, virtual machines whose NAT resolves names on the host, or signing in to a captive portal. Without the filters, DNS may leak as described above.
`autoSystemWfpBlockLeak` (Windows only) is `false` by default, as the filters break some setups: a local DNS resolver other programs use (e.g. on `127.0.0.1:53`), the DNS of another VPN on its own interface, IPv4 or IPv6 on the local network while no route of that version leads to the TUN, virtual machines whose NAT resolves names on the host, or signing in to a captive portal. Without the filters, DNS may leak as described above.
You can give the adapter ip address manually, you can live Windows to give it autogenerated ip address (which take few seconds), it doesn't matter, the traffic going _through_ the interface will be forwarded into the app for proxying. \
Minimal configuration that will work for local machine is routing passing the traffic on-link through the interface.
+16 -11
View File
@@ -247,17 +247,22 @@ startOver:
}
// With autoSystemWfpBlockLeak, once the system routes lead to the TUN,
// keep DNS if dns is set, and IPv6 if the TUN cannot carry it (no IPv6
// address, or no IPv6 route to it), from leaving through the other
// interfaces.
if blockDNS, blockIPv6 := len(dns) > 0, !address6 || !route6; t.options.AutoSystemWfpBlockLeak && (route4 || route6) && (blockDNS || blockIPv6) {
if t.wfp, err = blockLeaks(t.luid, blockDNS, blockIPv6); err != nil {
what := "DNS and IPv6"
if !blockIPv6 {
what = "DNS"
} else if !blockDNS {
what = "IPv6"
// keep DNS if dns is set, and an IP version no route of which leads to
// the TUN, from leaving through the other interfaces. Addresses do not
// matter: without one of a version in gateway, Windows gives the TUN a
// link-local one.
if blockDNS, blockIPv4, blockIPv6 := len(dns) > 0, !route4, !route6; t.options.AutoSystemWfpBlockLeak && (route4 || route6) && (blockDNS || blockIPv4 || blockIPv6) {
if t.wfp, err = blockLeaks(t.luid, blockDNS, blockIPv4, blockIPv6); err != nil {
var blocked []string
for _, b := range []struct {
on bool
what string
}{{blockDNS, "DNS"}, {blockIPv4, "IPv4"}, {blockIPv6, "IPv6"}} {
if b.on {
blocked = append(blocked, b.what)
}
}
what := strings.Join(blocked, " and ")
// Rather no TUN than a leaking one. Before Windows 10 the filters are
// untested, and sing-box's broke its TUN there (SagerNet/sing-box#3659),
// so older versions only get a warning.
@@ -266,7 +271,7 @@ startOver:
}
errors.LogWarningInner(context.Background(), err, "[tun] unable to block ", what, " outside the TUN, leaks are possible")
} else {
errors.LogInfo(context.Background(), "[tun] outside the TUN, blocked DNS: ", blockDNS, ", blocked IPv6: ", blockIPv6)
errors.LogInfo(context.Background(), "[tun] outside the TUN, blocked DNS: ", blockDNS, ", blocked IPv4: ", blockIPv4, ", blocked IPv6: ", blockIPv6)
if blockDNS {
covered := slices.Clone(addresses)
for _, route := range routesData {
+40 -16
View File
@@ -185,14 +185,18 @@ func condition(field *windows.GUID, typ uint32, value uintptr) fwpmFilterConditi
// queries over HTTPS or TLS, so there its DNS Client service may not
// connect outside the TUN at all, except for name resolution on the local
// link (mDNS, LLMNR).
// - ipv6: no IPv6 at all, in either direction, for a TUN that cannot carry
// it, except loopback and what Windows itself needs on the local link
// (neighbor and multicast listener discovery, DHCPv6), none of which can
// leave it.
// - ipv4, ipv6: no IPv4, or no IPv6, at all, in either direction, for a TUN
// that no route of it leads to, except loopback and what Windows itself
// needs on the local link (DHCP, and for IPv6 neighbor and multicast
// listener discovery), none of which can leave it. The TUN carries what
// is routed to it even without an address of that IP version in gateway:
// Windows gives it link-local ones itself, an IPv6 one at once, an IPv4
// one from 169.254.0.0/16 after some seconds (until then, IPv4 routed to
// the TUN is unreachable).
//
// The filters live in a dynamic WFP session: closing the returned engine handle
// with closeWFPEngine deletes them, and so does Windows when the process dies.
func blockLeaks(tun winipcfg.LUID, dns, ipv6 bool) (windows.Handle, error) {
func blockLeaks(tun winipcfg.LUID, dns, ipv4, ipv6 bool) (windows.Handle, error) {
engine, err := openWFPEngine()
if err != nil {
return 0, err
@@ -201,7 +205,7 @@ func blockLeaks(tun winipcfg.LUID, dns, ipv6 bool) (windows.Handle, error) {
closeWFPEngine(engine)
return 0, errors.New("FwpmTransactionBegin0 failed").Base(err)
}
err = addLeakFilters(engine, tun, dns, ipv6)
err = addLeakFilters(engine, tun, dns, ipv4, ipv6)
if err == nil {
if err = fwpmResult(procFwpmTransactionCommit0.Call(uintptr(engine))); err != nil {
err = errors.New("FwpmTransactionCommit0 failed").Base(err)
@@ -238,7 +242,7 @@ func closeWFPEngine(engine windows.Handle) {
// addLeakFilters adds the filters of blockLeaks in a sublayer of their own.
// blockLeaks runs it in a transaction, so that they take effect all at once.
func addLeakFilters(engine windows.Handle, tun winipcfg.LUID, dns, ipv6 bool) error {
func addLeakFilters(engine windows.Handle, tun winipcfg.LUID, dns, ipv4, ipv6 bool) error {
exe, err := os.Executable()
if err != nil {
return err
@@ -319,7 +323,7 @@ func addLeakFilters(engine windows.Handle, tun winipcfg.LUID, dns, ipv6 bool) er
// may also send the queries for an interface's servers over HTTPS or TLS,
// out through that interface and to any port. So there it may only
// connect through the TUN, except for mDNS and LLMNR, which stay on the
// local link (over IPv6 only while IPv6 is not blocked altogether).
// local link (over an IP version only while it is not blocked altogether).
// Earlier versions only query port 53, and may run the service in one
// process with others, which the filters would catch as well. Like
// Windows Firewall's rules for it, they recognize the service by its SID,
@@ -339,7 +343,7 @@ func addLeakFilters(engine windows.Handle, tun winipcfg.LUID, dns, ipv6 bool) er
key *windows.GUID
localLink bool
}{
{&fwpmLayerALEAuthConnectV4, true},
{&fwpmLayerALEAuthConnectV4, !ipv4},
{&fwpmLayerALEAuthConnectV6, !ipv6},
} {
if err := add(layer.key, "permit the DNS Client service through the TUN", 0, fwpActionPermit, 3, dnsClient, onTUN(&fwpmConditionIPLocalInterface), onTUN(&fwpmConditionIPNexthopInterface)); err != nil {
@@ -356,14 +360,34 @@ func addLeakFilters(engine windows.Handle, tun winipcfg.LUID, dns, ipv6 bool) er
}
}
if ipv6 {
// Both directions: replies to a connection accepted from outside
// would leave through the physical link as well.
loopback := fwpmFilterCondition0{
fieldKey: fwpmConditionFlags,
matchType: fwpMatchFlagsAllSet,
conditionValue: fwpValue0{typ: fwpUint32, value: fwpConditionFlagIsLoopback},
// Both directions: replies to a connection accepted from outside would
// leave through the physical link as well.
loopback := fwpmFilterCondition0{
fieldKey: fwpmConditionFlags,
matchType: fwpMatchFlagsAllSet,
conditionValue: fwpValue0{typ: fwpUint32, value: fwpConditionFlagIsLoopback},
}
if ipv4 {
// DHCP keeps the addresses of the other interfaces, which Xray's own
// connections use.
dhcp := []fwpmFilterCondition0{
condition(&fwpmConditionIPProtocol, fwpUint8, windows.IPPROTO_UDP),
condition(&fwpmConditionIPLocalPort, fwpUint16, 68),
condition(&fwpmConditionIPRemotePort, fwpUint16, 67),
}
for _, layer := range []*windows.GUID{&fwpmLayerALEAuthConnectV4, &fwpmLayerALEAuthRecvAcceptV4} {
if err := add(layer, "permit IPv4 loopback", 0, fwpActionPermit, 1, loopback); err != nil {
return err
}
if err := add(layer, "permit DHCP", 0, fwpActionPermit, 1, dhcp...); err != nil {
return err
}
if err := add(layer, "block IPv4", 0, fwpActionBlock, 0); err != nil {
return err
}
}
}
if ipv6 {
// Neighbor and multicast listener discovery, ICMPv6 130-137 and 143,
// whose type and code sit where the local and remote port are.
discovery := []fwpmFilterCondition0{condition(&fwpmConditionIPProtocol, fwpUint8, windows.IPPROTO_ICMPV6)}
+1 -1
View File
@@ -114,7 +114,7 @@ func TestLeakFiltersAccepted(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := addLeakFilters(engine, loopback, true, true); err != nil {
if err := addLeakFilters(engine, loopback, true, true, true); err != nil {
skipUnlessElevated(err)
}
}