UDP traffic to the exact TUN self-address can loop through the direct outbound on macOS
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 38.1k
- Forks
- 4.6k
- Avg merge
- 19d 15h
- Merged PRs (30d)
- 1
Description
Environment
- OS: macOS 27.0 (26A5388g), Darwin 27.0.0, arm64
- Installation: original sing-box command-line program
- TUN stack:
system auto_route: truestrict_route: trueauto_redirect: not enabled
sing-box version 1.14.0-beta.3
Environment: go1.26.5 darwin/arm64
Tags: with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,with_usbip,with_openvpn,with_openconnect,badlinkname,tfogo_checklinkname0
Revision: 1569c52957beaea308497860380d99b841c282b8
CGO: enabled
Summary
I observed a high-rate UDP self-route loop involving a destination equal to the TUN interface's own IPv4 address.
In a read-only StartedService/SubscribeConnections snapshot:
- 4094 of 4096 active UDP/IPv4 connections had the form
172.19.0.1:<ephemeral> -> 172.19.0.1:59871. - Every matching connection entered through the TUN inbound, matched
ip_is_private, used thedirectoutbound, and was attributed to the sing-box process. - The matching connections used different ephemeral source ports.
- During one occurrence, sing-box used approximately 560%-600% CPU and TUN output reached approximately 182k packets/s, while physical-interface output was approximately 259 packets/s.
This evidence is consistent with direct output being recaptured by the same TUN and creating a new UDP session with a new source port on each pass. A static connection snapshot does not independently prove that per-packet reinjection mechanism.
Port 59871 was held by a separate local wildcard UDP listener. I did not establish which process emitted the initial packet.
Relevant configuration
This is a reduction of the relevant routing path, not a claim that it is a deterministic standalone reproducer:
{
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"interface_name": "utun9",
"address": ["172.19.0.1/30"],
"auto_route": true,
"strict_route": true,
"stack": "system"
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct"
}
],
"route": {
"auto_detect_interface": true,
"rules": [
{
"ip_is_private": true,
"action": "route",
"outbound": "direct"
}
]
}
}
Exact-address guard behavior
The direct outbound's TUN loop guard currently checks:
if prefix.Addr() != address && prefix.Contains(address) {
return true
}
For an interface prefix represented as 172.19.0.1/30 and a destination of exactly 172.19.0.1, prefix.Contains(address) is true, but prefix.Addr() != address is false. The exact assigned TUN address is therefore excluded from this guard.
The predicate was added in 761b7f4e and is still present at 4f7f89463ccf.
The behavior can be characterized deterministically without creating a TUN device. At revision 1569c52957beaea308497860380d99b841c282b8, add protocol/direct/outbound_exact_tun_test.go:
package direct
import (
"net/netip"
"testing"
)
func TestLoopGuardRejectsExactTUNAddress(t *testing.T) {
outbound := &Outbound{}
outbound.myAddresses.Store([]netip.Prefix{
netip.MustParsePrefix("172.19.0.1/30"),
})
if !outbound.isMyLoopbackAddress(netip.MustParseAddr("172.19.0.1")) {
t.Fatal("exact TUN interface address is not rejected")
}
}
Run:
go test ./protocol/direct -run '^TestLoopGuardRejectsExactTUNAddress$' -count=1 -v
Complete output:
=== RUN TestLoopGuardRejectsExactTUNAddress
outbound_exact_tun_test.go:15: exact TUN interface address is not rejected
--- FAIL: TestLoopGuardRejectsExactTUNAddress (0.00s)
FAIL
FAIL github.com/sagernet/sing-box/protocol/direct
FAIL
This test characterizes the guard predicate only. It does not by itself reproduce the macOS packet reinjection or the UDP session amplification.
Workaround and observation
I placed the following rule before all direct and mode-routing rules, then restarted the same beta.3 binary:
{
"inbound": "tun-in",
"network": "udp",
"ip_cidr": ["172.19.0.1/32"],
"action": "reject",
"method": "drop"
}
With this rule active:
- an initial four-minute observation showed ordinary, non-amplifying TUN packet rates;
- a follow-up 2h35m after the same restart still showed normal CPU, memory usage, and TUN packet rates;
- sing-box was at approximately 2.9% CPU and 60 MiB RSS at that follow-up;
- the TUN had no interface errors or drops, and the service had not exited or restarted.
I did not take another connection-API snapshot after applying the workaround, so I am not claiming a measured zero matching-session count.
Expected behavior
The direct outbound should not feed a packet indefinitely back into its originating TUN when the destination is the exact assigned address of that TUN.
The connection should either be rejected by the loop guard or delivered through a host-local path that cannot re-enter the same TUN. If the equality exemption is intentional for reaching host-local services, could it be restricted to safe local delivery?
Kernel panic context
The host panicked twice in separate boot sessions with the same assertion:
dlil_if_ref: wraparound refcnt @dlil_subr.c:410
In both panic reports, sing-box was the panicked task and the normalized high-level path originated from UDP sendto. The first panic occurred before I began runtime loop sampling. The loop was directly observed after the first reboot before the second panic, and was observed again after the second reboot.
This is included only as safety-impact context. It does not establish that sing-box created or leaked the XNU interface reference, that the loop was active at either exact panic instant, or that the loop was the root cause of the kernel failure.
Related reports
- #4178 reports a TUN/direct self-route loop on Windows with IPv6 and public destinations rather than the exact assigned IPv4 TUN address.
- #4309 reports a similar random-source-port/UDPNAT amplification pattern, but on Linux/OpenWrt with IPv6,
auto_redirect, and a public destination. - #4086 reports a TUN/direct routing loop on Linux involving a different routing path.
This report is specifically about macOS, IPv4 UDP, no auto_redirect, and a destination exactly equal to the assigned TUN address.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with protocol/direct/outbound.go, especially isMyLoopbackAddress, and run the proposed protocol/direct/outbound_exact_tun_test.go test command to confirm the current failure. Review nearby loop-guard tests before making the smallest behavior change needed. Done means the exact assigned TUN address is rejected and the direct package tests pass.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- networking
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100