Operating system
Linux
System version
Ubuntu 22.04, kernel 6.8.0-136-generic, x86_64
Installation type
Others (the sing-box binary shipped inside a third-party GUI client)
Version
sing-box version 1.12.12
Environment: go1.25.3 linux/amd64
Tags: with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale
The same defect is present in testing (common/process/socket_diag_linux.go).
Description
resolveSocketByNetlink accepts the first message of an NLM_F_DUMP answer without checking that
it describes the socket that was asked about. When the kernel returns an entry for a different
socket, the lookup does not fail — it returns the uid/inode of that other socket, and
resolveProcessNameByProcSearch then correctly resolves the process owning it. The caller receives
a confident, wrong answer.
This matters because process_name route rules act on that answer. A rule meant to keep sing-box's
own traffic out of the tunnel can match an unrelated application's connection and route it to
direct, sending it outside the tunnel. From the user's side this looks like a rare, silent leak.
In 1.12.12 the dump is the only path. In testing the primary path is an exact-match query
(socketDiagConn.query, dump=false) and is sound, but searcher_linux.go still falls back to
querySocketDiagOnce — dump=true, source only — whenever the destination is unset, the address
families differ, or the exact-match query returns ErrNotFound, and
unpackSocketDiagMessages there takes the first socketDiagByFamily entry with a non-zero
inode/uid without comparing it to the request.
Reproduction
The defect is in message selection and reproduces with no network, no TUN and no client — a unit
test against unpackSocketDiagMessages is enough. Against testing:
// common/process/socket_diag_linux_test.go
func socketDiagMessage(source netip.AddrPort, uid, inode uint32) syscall.NetlinkMessage {
data := make([]byte, socketDiagResponseMinSize)
binary.BigEndian.PutUint16(data[4:6], source.Port())
copy(data[8:24], source.Addr().Unmap().AsSlice())
binary.NativeEndian.PutUint32(data[64:68], uid)
binary.NativeEndian.PutUint32(data[68:72], inode)
return syscall.NetlinkMessage{
Header: syscall.NlMsghdr{Type: socketDiagByFamily},
Data: data,
}
}
func TestUnpackSocketDiagMessagesSkipsForeignSocket(t *testing.T) {
wanted := netip.MustParseAddrPort("198.18.0.1:55358")
foreign := netip.MustParseAddrPort("192.168.1.101:44444")
messages := []syscall.NetlinkMessage{
socketDiagMessage(foreign, 0, 1111),
socketDiagMessage(wanted, 1000, 2222),
}
inode, uid, err := unpackSocketDiagMessages(messages)
// current behaviour: inode 1111, uid 0 — the foreign socket
// expected: inode 2222, uid 1000
}
The kernel answers NLM_F_DUMP with every socket it considers relevant, so an unmatched entry can
come first; packSocketDiagRequest in 1.12.x leaves the destination zeroed and sets states to
0xFFFFFFFF, which widens the answer further.
How it was found
Honesty about the origin: I noticed this on a third-party GUI client using TUN, where a
process_name rule sent one connection out of ~144 to the same destination through direct
instead of the proxy:
inbound/tun[tun-in]: inbound connection to 160.79.104.10:443
router: found process path: /opt/happ/bin/tun/sing-box, user: root
outbound/direct[direct]: outbound connection to 160.79.104.10:443
The connection belonged to a client application, not to sing-box. 14 such misattributions appear
across three days of logs, two of them matching user-visible failures a minute or two later
(HTTP keep-alive keeps the leaked connection in use). The same logs contain 90 279 occurrences of
router: failed to search process: netlink message: NLMSG_ERROR.
That environment is not something you should have to reproduce, which is why the reproduction above
uses only a unit test. I did not establish why the mismatched entry tends to be a sing-box socket
specifically — that would need a netlink-level experiment I have not run.
Suggested fix
Compare the inet_diag_sockid of each returned message with the requested endpoint before trusting
it, and skip entries that do not match. The source port sits at offset 4 and the source address at
offset 8 of inet_diag_msg — the same struct unpackSocketDiagResponse already reads uid/inode
from at offsets 64 and 68.
case socketDiagByFamily:
if validateSource.IsValid() && !socketDiagResponseMatches(&message, validateSource) {
continue
}
inode, uid = unpackSocketDiagResponse(&message)
with validateSource threaded in from querySocketDiagOnce (the dump path) and left zero for the
exact-match path, where the kernel has already filtered.
I have this as a patch against testing with tests covering IPv4/IPv6 matching, wrong port, wrong
address, truncated messages, preserved behaviour without validation, and a live local TCP connection
resolving through the dump path. go build ./common/process/, go vet and go test ./common/process/
pass. Happy to open a pull request if that is welcome.
Unrelated but adjacent
syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, &syscall.Timeval{Usec: 100})
Usec: 100 is 100 microseconds, which looks like a typo for 100000 (100 ms) and would explain the
volume of failed lookups above. I left it out of the patch as a separate decision.
Operating system
Linux
System version
Ubuntu 22.04, kernel 6.8.0-136-generic, x86_64
Installation type
Others (the
sing-boxbinary shipped inside a third-party GUI client)Version
The same defect is present in
testing(common/process/socket_diag_linux.go).Description
resolveSocketByNetlinkaccepts the first message of anNLM_F_DUMPanswer without checking thatit describes the socket that was asked about. When the kernel returns an entry for a different
socket, the lookup does not fail — it returns the uid/inode of that other socket, and
resolveProcessNameByProcSearchthen correctly resolves the process owning it. The caller receivesa confident, wrong answer.
This matters because
process_nameroute rules act on that answer. A rule meant to keep sing-box'sown traffic out of the tunnel can match an unrelated application's connection and route it to
direct, sending it outside the tunnel. From the user's side this looks like a rare, silent leak.In 1.12.12 the dump is the only path. In
testingthe primary path is an exact-match query(
socketDiagConn.query,dump=false) and is sound, butsearcher_linux.gostill falls back toquerySocketDiagOnce—dump=true, source only — whenever the destination is unset, the addressfamilies differ, or the exact-match query returns
ErrNotFound, andunpackSocketDiagMessagesthere takes the firstsocketDiagByFamilyentry with a non-zeroinode/uid without comparing it to the request.
Reproduction
The defect is in message selection and reproduces with no network, no TUN and no client — a unit
test against
unpackSocketDiagMessagesis enough. Againsttesting:The kernel answers
NLM_F_DUMPwith every socket it considers relevant, so an unmatched entry cancome first;
packSocketDiagRequestin 1.12.x leaves the destination zeroed and sets states to0xFFFFFFFF, which widens the answer further.How it was found
Honesty about the origin: I noticed this on a third-party GUI client using TUN, where a
process_namerule sent one connection out of ~144 to the same destination throughdirectinstead of the proxy:
The connection belonged to a client application, not to sing-box. 14 such misattributions appear
across three days of logs, two of them matching user-visible failures a minute or two later
(HTTP keep-alive keeps the leaked connection in use). The same logs contain 90 279 occurrences of
router: failed to search process: netlink message: NLMSG_ERROR.That environment is not something you should have to reproduce, which is why the reproduction above
uses only a unit test. I did not establish why the mismatched entry tends to be a sing-box socket
specifically — that would need a netlink-level experiment I have not run.
Suggested fix
Compare the
inet_diag_sockidof each returned message with the requested endpoint before trustingit, and skip entries that do not match. The source port sits at offset 4 and the source address at
offset 8 of
inet_diag_msg— the same structunpackSocketDiagResponsealready reads uid/inodefrom at offsets 64 and 68.
with
validateSourcethreaded in fromquerySocketDiagOnce(the dump path) and left zero for theexact-match path, where the kernel has already filtered.
I have this as a patch against
testingwith tests covering IPv4/IPv6 matching, wrong port, wrongaddress, truncated messages, preserved behaviour without validation, and a live local TCP connection
resolving through the dump path.
go build ./common/process/,go vetandgo test ./common/process/pass. Happy to open a pull request if that is welcome.
Unrelated but adjacent
Usec: 100is 100 microseconds, which looks like a typo for100000(100 ms) and would explain thevolume of failed lookups above. I left it out of the patch as a separate decision.