Compare commits

..

2 Commits

Author SHA1 Message Date
rawdigits 3dea496c7f overlay/tio: KeepAlive poll-path readv/writev buffers too
The Poll fallback (used when IFF_VNET_HDR can't be enabled) has the
same unsafe-pointer-via-uintptr pattern as Offload.rawWrite:
readOne/writeOne build a [2]syscall.Iovec on the stack, pass it to
syscall.Syscall as uintptr, and the kernel then DMAs in/out of the
to/from slices whose Base pointers the iovec holds.

Escape analysis can't see that use, so under GC pressure the
backing memory could be collected or moved mid-syscall.

Add runtime.KeepAlive on the iovec and the user buffer around both
the SYS_READV and SYS_WRITEV syscalls. Same pattern and rationale
as the prior commit on the offload path.
2026-04-24 21:44:37 +00:00
rawdigits 7c38aa7e6b overlay/tio: KeepAlive writev iovec and payloads through the syscall
rawWrite passed the iovec pointer to syscall.Syscall as a uintptr, so
the Go compiler's escape analysis could not keep the underlying
[]unix.Iovec (or the payload slices its Base pointers reach) rooted
across the syscall. Under heavy sustained write load, GC could
collect or move these before tun_chr_write_iter finished reading
them, at which point the kernel read freed memory.

Observed on a UniFi UXG-Pro (Annapurna Labs Alpine V2, arm64, Linux
4.19.152) forwarding 1 Gbps iperf3 -R between LAN and a remote
Nebula peer, as two paired kernel warnings in the same second:

  refcount_t: underflow; use-after-free
    sock_wfree -> skb_release_head_state -> kfree_skb
    -> skb_release_data -> __kfree_skb -> tcp_recvmsg ...

  refcount_t: addition on 0; use-after-free
    skb_set_owner_w -> sock_alloc_send_pskb
    -> tun_get_user -> tun_chr_write_iter -> do_iter_write
    -> vfs_writev -> do_writev -> __arm64_sys_writev

The Annapurna watchdog then soft-rebooted the device. No crash or
kernel WARN after patching; box ran sustained 1 Gbps iperf3 -R
without issue.

Fix: add a variadic `keepAlive ...interface{}` parameter to
rawWrite, and call runtime.KeepAlive on the iovec plus every
supplied root after the syscall returns. writeWithScratch now
passes its buffer + iovec; WriteGSO passes the iovec array, the
header buffer, and the payload fragment slice.

runtime.KeepAlive is a compiler directive, not a runtime barrier,
so the cost is effectively zero: it just forces the compiler's
liveness analysis to treat the object as used at that point.
2026-04-24 21:40:51 +00:00
3 changed files with 37 additions and 6 deletions
+23 -4
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"runtime"
"sync/atomic" "sync/atomic"
"syscall" "syscall"
"unsafe" "unsafe"
@@ -263,12 +264,24 @@ func (r *Offload) writeWithScratch(buf []byte, iovs *[2]unix.Iovec) (int, error)
// to validVnetHdr during Offload construction so we don't rebuild it here. // to validVnetHdr during Offload construction so we don't rebuild it here.
iovs[1].Base = &buf[0] iovs[1].Base = &buf[0]
iovs[1].SetLen(len(buf)) iovs[1].SetLen(len(buf))
return r.rawWrite(unsafe.Slice(&iovs[0], len(iovs))) iovPtr := unsafe.Pointer(&iovs[0])
// Pin the caller's buffer AND the iovec array through the syscall.
return r.rawWrite(iovPtr, 2, buf, iovs)
} }
func (r *Offload) rawWrite(iovs []unix.Iovec) (int, error) { func (r *Offload) rawWrite(iovs unsafe.Pointer, iovcnt int, keepAlive ...interface{}) (int, error) {
for { for {
n, _, errno := syscall.Syscall(unix.SYS_WRITEV, uintptr(r.fd), uintptr(unsafe.Pointer(&iovs[0])), uintptr(len(iovs))) n, _, errno := syscall.Syscall(unix.SYS_WRITEV, uintptr(r.fd), uintptr(iovs), uintptr(iovcnt))
// Anchor the iovec array + every user-supplied payload slice
// through the syscall return. Without these, Go's GC may move or
// collect the underlying backing arrays while the kernel is still
// reading them via DMA (we pass the iovec as uintptr, so the
// compiler does not keep it live). Observed in practice as a
// kernel refcount underflow on tun_chr_write_iter / sock_wfree.
runtime.KeepAlive(iovs)
for _, ka := range keepAlive {
runtime.KeepAlive(ka)
}
if errno == 0 { if errno == 0 {
if int(n) < virtioNetHdrLen { if int(n) < virtioNetHdrLen {
return 0, io.ErrShortWrite return 0, io.ErrShortWrite
@@ -351,7 +364,13 @@ func (r *Offload) WriteGSO(hdr []byte, pays [][]byte, gsoSize uint16, isV6 bool,
r.gsoIovs[2+i].SetLen(len(p)) r.gsoIovs[2+i].SetLen(len(p))
} }
_, err := r.rawWrite(r.gsoIovs) iovPtr := unsafe.Pointer(&r.gsoIovs[0])
iovCnt := len(r.gsoIovs)
// Pin EVERYTHING the kernel might still read via DMA: the backing iovec
// slice, the IP/TCP header buffer, and every individual payload
// fragment. Skipping any of these risks a use-after-free in
// tun_chr_write_iter if GC runs mid-syscall.
_, err := r.rawWrite(iovPtr, iovCnt, r.gsoIovs, hdr, pays)
return err return err
} }
+12
View File
@@ -3,6 +3,7 @@ package tio
import ( import (
"fmt" "fmt"
"os" "os"
"runtime"
"sync/atomic" "sync/atomic"
"syscall" "syscall"
"unsafe" "unsafe"
@@ -120,6 +121,13 @@ func (t *Poll) readOne(to []byte) (int, error) {
} }
for { for {
n, _, errno := syscall.Syscall(syscall.SYS_READV, uintptr(t.fd), uintptr(unsafe.Pointer(&iovecs[0])), 2) n, _, errno := syscall.Syscall(syscall.SYS_READV, uintptr(t.fd), uintptr(unsafe.Pointer(&iovecs[0])), 2)
// Pin the iovec + destination buffer backing array across the syscall.
// Without these the Go runtime may move/GC them while the kernel is
// still writing via DMA (we pass the iovec as uintptr, which hides it
// from escape analysis). Same class of bug as rawWrite in the Offload
// path.
runtime.KeepAlive(iovecs)
runtime.KeepAlive(to)
if errno == 0 { if errno == 0 {
bytesRead := int(n) bytesRead := int(n)
if bytesRead < 4 { if bytesRead < 4 {
@@ -166,6 +174,10 @@ func (t *Poll) Write(from []byte) (int, error) {
} }
for { for {
n, _, errno := syscall.Syscall(syscall.SYS_WRITEV, uintptr(t.fd), uintptr(unsafe.Pointer(&iovecs[0])), 2) n, _, errno := syscall.Syscall(syscall.SYS_WRITEV, uintptr(t.fd), uintptr(unsafe.Pointer(&iovecs[0])), 2)
// Pin the iovec + source buffer backing array across the syscall.
// See readOne's KeepAlive comment for rationale.
runtime.KeepAlive(iovecs)
runtime.KeepAlive(from)
if errno == 0 { if errno == 0 {
return int(n) - 4, nil return int(n) - 4, nil
} }
+2 -2
View File
@@ -139,7 +139,7 @@ func newTun(c *config.C, l *logrus.Logger, vpnNetworks []netip.Prefix, multiqueu
return nil, err return nil, err
} }
vnetHdr := true vnetHdr := true
name, err := tunSetIff(fd, nameStr, baseFlags|unix.IFF_VNET_HDR) name, err := tunSetIff(fd, nameStr, baseFlags|unix.IFF_VNET_HDR|unix.IFF_NAPI)
if err != nil { if err != nil {
_ = unix.Close(fd) _ = unix.Close(fd)
vnetHdr = false vnetHdr = false
@@ -307,7 +307,7 @@ func (t *tun) NewMultiQueueReader() error {
flags := uint16(unix.IFF_TUN | unix.IFF_NO_PI | unix.IFF_MULTI_QUEUE) flags := uint16(unix.IFF_TUN | unix.IFF_NO_PI | unix.IFF_MULTI_QUEUE)
if t.vnetHdr { if t.vnetHdr {
flags |= unix.IFF_VNET_HDR flags |= unix.IFF_VNET_HDR | unix.IFF_NAPI
} }
if _, err = tunSetIff(fd, t.Device, flags); err != nil { if _, err = tunSetIff(fd, t.Device, flags); err != nil {
_ = unix.Close(fd) _ = unix.Close(fd)