Darwin: unbounded per-flow ICMP sockets can overflow XNU mbuf refcount and panic the kernel after wake
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 237
- Forks
- 228
- PR merge metrics
- No merged PRs in 30d
Description
Summary
On macOS, sing-tun's direct ICMP backend creates one unconnected ICMP socket for every (source, destination, identifier) flow and keeps the flows in an unbounded map. A sufficiently large number of matching ICMP sockets appears able to overflow XNU's 16-bit mbuf external-storage reference count while the kernel fans out one ICMP packet to the raw/ICMP PCB list.
I observed the same kernel panic twice. Both panics occurred immediately after wake, on the dlil_input_utun4 kernel thread, in rip_input_inner() while copying an ICMP packet for matching raw sockets.
Environment
- sing-box for Apple:
1.14.0 - sing-box core:
v1.14.0 - sing-tun:
v0.9.0-beta.4(f4c1f3ae265b930460774295b8fe5c73596c81cf) - macOS:
15.7.9 (24G830) - Darwin:
24.6.0, XNU11417.140.69.711.44~1 - Device: Apple Silicon,
Mac16,8 - TUN stack:
gvisor - TUN MTU: 1500
- No bridge configuration was in use
The affected Network Extensions were the sing-box for Apple system extensions (io.nekohasekai.sfavt.system and io.nekohasekai.sfamt.system).
Observed panics
The crashes occurred on 2026-08-27 and 2026-08-31. The panic assertion and normalized backtrace were identical.
panic(cpu 10 caller ...): assertion failed: new != 0,
file: .../xnu/bsd/kern/uipc_mbuf.c, line: 1920 @uipc_socket.c:8265
The panicked thread was:
dlil_input_utun4
After removing the kernel slide and symbolizing against the matching kernel.release.t6041, the relevant path is:
dlil_input_utun4
-> utun_proto_input
-> proto_input
-> IPv4 input/dispatch
-> icmp_input
-> rip_input
-> rip_input_inner
-> m_copym_mode
-> m_incref
-> VERIFY(new != 0)
The two particularly useful frames are:
rip_input_inner + 364 // return from m_copym_mode
rip_input + 60 // return from rip_input_inner
The uipc_socket.c:8265 suffix is the cold assfail() implementation that calls panic(); it is not the failing socket operation itself.
XNU failure mechanism
In the matching XNU source, m_incref() uses a 16-bit external-storage reference count:
static inline void
m_incref(struct mbuf *m)
{
uint16_t new = os_atomic_inc(&MEXT_REF(m), relaxed);
VERIFY(new != 0);
}
The caller is the copy inside rip_input_inner() in bsd/netinet/raw_ip.c:
LIST_FOREACH(inp, &ripcb, inp_list) {
...
if (last != NULL) {
struct mbuf *n = m_copym_mode(
m, 0, (int)M_COPYALL, M_DONTWAIT,
NULL, NULL, M_COPYM_MUST_COPY_HDR
);
...
}
last = inp;
}
m_copym_mode() copies the mbuf headers but shares external payload storage, incrementing the same external-storage reference count for each matching PCB. The assertion means that this reference count wrapped from 0xffff to zero.
Mathematically, this requires either approximately 65,536 references to the same packet storage during fan-out, or corruption/a cycle in the raw PCB list. The repeated identical crashes and the sing-box process state described below make a very large matching socket population the more likely explanation.
Relevant sing-tun behavior
ping.Port uses an unbounded flow map keyed by source, destination, and ICMP identifier:
type flowKey struct {
source netip.Addr
destination netip.Addr
identifier uint16
}
For every new key, flowFor() calls ConnectDestination() and stores the result in p.flows. There is no global flow limit or LRU eviction.
On Darwin, every destination creates a datagram ICMP socket:
unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, unix.IPPROTO_ICMP)
It is bound to the unspecified address and deliberately left unconnected because connected ICMP sockets return EPIPE inside a macOS Network Extension. As a result, these sockets participate in XNU's raw/ICMP PCB fan-out.
The existing requestsLimit = 1024 only bounds requests inside one Destination; it does not bound the number of Destination objects or system ICMP sockets in Port.flows.
Supporting process evidence
At both panics, the sing-box Network Extension had unusually high resident memory:
2026-08-27 io.nekohasekai.sfavt.system 1,180,206,928 bytes
2026-08-31 io.nekohasekai.sfamt.system 1,079,576,280 bytes
It was the largest resident process in the first stackshot and the largest in the second stackshot as well. This is consistent with a very large population of live ICMP flow objects, sockets, goroutines, and read buffers. The system was not under global memory pressure.
The stackshots do not contain a historical per-process file-descriptor listing, so I cannot directly prove the exact number of ICMP sockets after the fact.
Why wake appears to trigger it
The timing was highly consistent:
- First panic: approximately 246 seconds asleep, panic approximately 6 seconds after wake.
- Second panic: approximately 932 seconds asleep, panic in the same calendar second as wake.
The Apple Network Extension calls CommandServer.Pause() on sleep and Wake() on wake. DevicePause() does not close the direct outbound's ICMP Port, and the ping backend does not register a pause callback. Therefore the ICMP sockets survive sleep.
On wake, an ICMP packet can enter through utun before Go timers or interface-update callbacks have had an opportunity to close expired flows. That explains why wake is a reliable trigger even if some flows are already logically expired.
Expected behavior
High-cardinality ICMP traffic, network loss, and sleep/wake should not allow the direct ICMP backend to create enough wildcard ICMP sockets to trigger pathological kernel fan-out or a kernel panic.
Although XNU should not panic in response to a legal user-space socket pattern, sing-tun should avoid creating an unbounded number of matching ICMP sockets.
Suggested fixes
Short term:
- Add a hard limit to the number of entries/system sockets in
ping.Port.flowson Darwin, with LRU eviction or rejection of new flows. - Close all ICMP flows on device pause as well as on interface changes.
- Add warnings/metrics when the flow count crosses unusually high thresholds.
Long term:
- Use one shared unconnected ICMP socket per address family/outbound interface and demultiplex replies in user space by destination, identifier, and sequence, rather than opening one XNU PCB per ICMP flow.
A route rule rejecting ICMP avoids the panic as a downstream workaround, but disables ping/traceroute through the TUN.
I have not intentionally attempted a 65K-flow reproduction because the expected failure mode is a host kernel panic. Full panic logs can be provided if needed.
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 ping.Port, flowFor(), ConnectDestination(), and the Darwin ICMP socket path, then inspect how Port.flows and requestsLimit are managed. Trace CommandServer.Pause/Wake through DevicePause(), checking why ICMP flows survive pause. Done means preventing unbounded Darwin flow/socket growth and closing or limiting flows across sleep/wake, with regression coverage for the chosen behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go, macos
- Domain
- networking, operating-systems
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100