Skip to content

Process lookup accepts an unmatched netlink dump entry, attributing connections to the wrong process #4501

Description

@toliklunev

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
querySocketDiagOncedump=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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions