mirror of
https://github.com/amnezia-vpn/amneziawg-go.git
synced 2026-10-13 21:34:25 +00:00
[PR #199] feat: drop outbound packets of apps that bypass split tunneling #200
Labels
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
amnezia-vpn/amneziawg-go#200
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
📋 Pull Request Information
Original PR: https://github.com/amnezia-vpn/amneziawg-go/pull/199
Author: @Fast-and-Pythonic
Created: 9/22/2026
Status: 🔄 Open
Base:
master← Head:pr/strict-split-tunneling📝 Commits (9)
4873714feat: drop outbound packets of apps that bypass split tunneling4fa1ba9perf(uidfilter): judge new flows off the tun reader, keyed on the full 5-tuple5864fd3fix(uidfilter): per-device state, fresh SYNs, a clock that survives idling2a9f1f1fix(uidfilter): keep allowed verdicts when the verdict cache is fullba14bc1fix(uidfilter): do not lock workers to their OS threads195e166fix(device): hold the encryption queue open while releasing a held packet35f643ffix(uidfilter): hold up to 16 packets of a flow while it is judged9c673effix(uidfilter): judge an active UDP flow again before its verdict expires4215308fix(uidfilter): age a verdict from its lookup, not from when it is collected📊 Changes
8 files changed (+1425 additions, -0 deletions)
View changed files
📝
device/device.go(+4 -0)📝
device/send.go(+13 -0)➕
device/uidfilter.go(+62 -0)➕
device/uidfilter_test.go(+33 -0)➕
uidfilter/supported_android.go(+8 -0)➕
uidfilter/supported_other.go(+8 -0)➕
uidfilter/uidfilter.go(+585 -0)➕
uidfilter/uidfilter_test.go(+712 -0)📄 Description
This is the Go half of a fix for amnezia-vpn/amnezia-client#2457 on Android, the first of three PRs that depend on each other (order below).
The problem
With per-app split tunneling, an app excluded from the VPN can still bind a socket to the tunnel interface and send traffic through it. The destination then sees the VPN server's address. From the issue, with Termux excluded:
The same works with
SO_BINDTODEVICEon UDP, for example a STUN request or a DNSTXTquery too-o.myaddr.l.google.com. It also works with ICMP echo from an unprivileged ping socket. Android enforces split tunneling through routing, and binding to the device bypasses routing.What this adds
A
uidfilterpackage, a one-line hook inRoutineReadFromTUN, and a field onDevice:uidfilter.Set(f)installs aPacketFilter. The platform injects the policy andSet(nil)removes it. On Android, the policy looks up the owning app withConnectivityManager.getConnectionOwnerUidand applies the split-tunnel lists (amneziawg-android and amnezia-client PRs).uidfilter.Gate(Device.tun.uidGate), whose zero value is ready.Gate.AllowOutboundPacketparses the 5-tuple of each packet read from tun and checks it against the verdict for that flow. A denied packet is dropped exactly like the existingpeer == nilpath. The hook sits afterelem.padding = padding, so the element is fully initialised.Device.ReleaseOutboundPacket, or drops it. No packet of a flow leaves before its verdict, and flows already judged never wait. Holding is bounded per device: 16 packets per flow, 256 flows, 1 MiB, and 2 s per packet. Past those limits, packets of new flows are dropped (fail-closed); established TCP connections never are, and allowed UDP flows keep their cached verdicts (below).uidfilter.Supportedis a build-time constant, soif uidfilter.Supported && !device.tun.uidGate.AllowOutboundPacket(...)leaves no call behind on other platforms —go tool nmfinds the method in the android build alone. On Android with no filter installed the hook is one atomic load that returnstrue, and nothing is parsed or allocated.tun0with denied flows cannot push allowed flows out.tun0and reach a host it controls. The same holds for a UDP datagram whose socket was closed before its owner could be looked up, even from an allowed app. That is expected: the platform cannot name the owner of a socket that no longer exists.time.Now()costs more than the lookup it guards.Setreplaces the filter and invalidates every cache in one atomic store, and no packet is sent on a replaced filter's verdict.go test -benchon linux/amd64 (i5-4210U), per packet: filter off 4 ns (and no code at all off Android); a packet of an established TCP connection 14 ns; a packet of a cached UDP flow 62 ns, no allocations.Changes since the second review (2026-09-28)
Measured on the same phone with an experimental build in which each change could be switched on and off within one tunnel. The commits implement the same logic as the default.
ba14bc1: workers are no longer locked to their OS threads. This fixes a crash. A locked goroutine that exits takes its thread with it, and the thread's exit handlers run with every signal blocked (runtime.mexit). The JNI bridge detaches the thread from the JVM in such a handler (amneziawg-android#104), and a fault the JVM would normally handle there kills the process, with no tombstone. Workers exit whenever the filter is replaced or removed, that is on every disconnect and reconnect. Removing and restoring the filter under a steady 3000 new flows/s killed the VPN service once in 60 toggles, 28–97 ms after the removal; with this commit, none in 300. It also happened once on an ordinary reconnect. This corrects what we wrote in the reply to the review: the workers are not pinned to four threads any more, and a few more threads may end up attached.9c673ef: an active UDP flow is judged again before its verdict expires. Before, every active flow met its expiry once per 10 s, and the packets past the hold cap were dropped while it was judged again. One socket sending 20 DNS queries every 100 ms lost 16 about every 10 s, 0.8–1.0%. Now 0.2–0.4%, the same as with the filter off, at one background lookup every 2–3 s for that flow.4215308: a verdict is aged from its lookup, not from when it is collected. A worker's verdict reaches the cache when the tun reader next sees the flow or any new one, and was stamped with the reader's clock at that moment. On a quiet tunnel a verdict looked up a minute earlier was taken as fresh, so a socket that had taken over the 5-tuple meanwhile passed on it for 2 s more. Now the cache ages it from the lookup, a first verdict collected more than 2 s late is not used, and a late refresh starts the next lookup at once. On a phone new flows collect verdicts within milliseconds, so this closes the quiet-tunnel case only.35f643f: 16 packets are held per flow, not 4. A new socket sending 20 datagrams back to back got 4 through, 3 times out of 3; now 16. Memory is still bounded by the 1 MiB per device.195e166: a release holds the encryption queue open. Found by reading the code: every other caller ofSendStagedPacketsholds a reference on the queue, the workers did not. A stress test of 3000 releases racingClosedid not reproduce a failure.Known limitations, with fixes on separate branches
Kept out of this PR so it does not grow; each is one commit with tests, on top of this branch:
followup/inbound-synack(5 of 5, twice). An excluded app cannot use this: Android routes its SYN-ACK away fromtun0in any case.followup/fragments(16 and 20 of 20; an excluded app's fragments still get nothing through).tun0is denied: the platform's lookup does not find sockets bound to a device. No fix on the app side; stated in amnezia-client#3199.With every one of these changes switched on at once, an app outside the VPN still got 0 of 6 attempts through, and ICMP and fragmented UDP via
tun0got no answer.Relation to #174
@makekryl opened #174 for the same issue in August, and this PR owes it more than we first credited:
Thank you for all of that.
The two differ mainly in scope. #174 is the Go side only, and the Android wrapper there is a patch attached to the PR. This series covers the whole path, from this filter through a JNI bridge in amneziawg-android to a setting in the client, and it was measured on a device (below). The Go sides still differ in a few details:
If you prefer the shape of #174 on the Go side, we are glad to adapt the bridge and the client to it instead. The rest of the series does not depend on how this part is shaped.
Related work outside Amnezia
The same owner check exists in TeapodStream's own tun2socks and, by its README, in OlConnect. sing-box can block flows with an unknown owner, but only through a rule written by hand. v2rayNG closed the same report as not planned, and we found nothing from WireGuard for Android, the commercial clients or AOSP. Sources and details: a comment on amnezia-vpn/amnezia-client#2457.
Testing
go test -race ./uidfiltercovers parsing, the 5-tuple key, holding and releasing in order, the limits, fresh SYNs, expiry after an idle period, a burst passing while an active flow is judged again, a taken-over 5-tuple denied within 2 s, a full cache flooded with denied verdicts, independent gates, and a late verdict afterSet.go test ./devicecoversReleaseOutboundPacketon a device pair, and passes exceptTestAWGDevicePing, which fails the same way on a cleanmasterhere.tun0(6 attempts)tun0(All rows re-run on the current commits except include/off. With the switch off no filter is registered, so that path is unchanged.)
Traffic of apps inside the VPN kept working with the filter on: browsing, video, DNS.
Notes, the traps found on the way and the probes used: https://github.com/amnezia-tun0-fix/strict-split-tunneling-notes
Reproducing the measurements
Everything here was measured with tools that are public in the notes; docs/references/device-testing.md says how they fit together.
adb shell setprop debug.awg.ctl "cmd=reconnect mode=include add=com.termux strict=1", thenadb shell am start -n org.amnezia.vpn.strict/org.amnezia.vpn.AmneziaActivity --ez adb_ctl true(wrapped bytools/awgctl.sh). It connects, disconnects, and changes the split-tunnel mode, the app list and strict mode. Only the shell can set that property, so another app can at most repeat the last command. This is fork-only and not part of any of the three PRs.adb forward, plays the app outside or inside the VPN, depending on the list. The probes are standard-library Python:leak_probe.py(6 bypass attempts),icmp_probe.py,udp_flow_probe.py(loss on one long UDP flow, fragmented datagrams),inbound_probe.py(connections into the phone through the tunnel).churn_probe, in Go, generates load fromadb shell.exp/labbranch of this fork, in which each change could be switched on and off within one tunnel and the filter logged counters every 5 s. It can also remove and restore the filter live, which is how the crash was reproduced.Series and merge order
go.modonce this is releasedawg-androidonce (2) is released🤖 Generated with Claude Code
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.