SagerNet / SagerNet/sing-box

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

Open
#4,501 0 comments 0 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

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.

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 in common/process/socket_diag_linux.go, especially unpackSocketDiagMessages and the dump path in querySocketDiagOnce. Run the focused common/process tests and inspect the existing socket-diag response parsing. Done means foreign netlink entries are ignored, matching IPv4 and IPv6 responses still resolve correctly, and go build, go vet, and go test ./common/process/ pass.

Written by the indexing model from the issue text.

Assessment

Tech stack
go, linux
Domain
networking, operating-systems
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.