SagerNet / SagerNet/sing-box

UDP traffic to the exact TUN self-address can loop through the direct outbound on macOS

Open Beginner friendly
#4,367 4 comments 3 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
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: true
  • strict_route: true
  • auto_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 the direct outbound, 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.