Description
getsockopt(SO_PEERCRED) on an accepted AF_UNIX SOCK_SEQPACKET socket returns the credentials of the listening process rather than the connecting peer. SOCK_STREAM is unaffected and behaves correctly.
Expected (and what Linux does): the pid/uid/gid of the process that called connect().
Observed under gVisor: the pid/uid/gid of the process that called listen() — i.e. a server reads its own identity back.
This breaks peer authentication over SEQPACKET sockets. Any server that authorizes a client by comparing SO_PEERCRED's pid against a pid it obtained some other way (e.g. the return value of fork()/posix_spawn for a child it launched) will reject every legitimate peer, because it is comparing the client's expected pid against its own. Notably, Rust's ipc-channel crate creates its one-shot server with socket(AF_UNIX, SOCK_SEQPACKET, 0), so anything layering peer-pid attestation on top of it fails closed under gVisor while working on Linux.
Cause
pkg/sentry/socket/unix/transport/connectioned.go, in connectionedEndpoint.BidirectionalConnect, gates the credentials swap on the socket type:
if e.stype == linux.SOCK_STREAM {
if err := e.swapPeerCredsLocked(ctx, ce, ne); err != nil {
return err
}
returnConnect(&streamQueueReceiver{queueReceiver: queueReceiver{readQueue: readQueue}}, peer)
} else {
returnConnect(&queueReceiver{readQueue: readQueue}, peer)
}
swapPeerCredsLocked is what gives the newly accepted endpoint the connector's credentials. For SOCK_SEQPACKET it is skipped, so the assignment made a few lines earlier — ne.peerCreds = e.peerCreds, copying from the listener — is what survives.
swapPeerCredsLocked is a plain exchange with nothing stream-specific in it, and SOCK_SEQPACKET uses the same connectionedEndpoint type, so hoisting the call out of the conditional appears sufficient:
readQueue.IncRef()
if err := e.swapPeerCredsLocked(ctx, ce, ne); err != nil {
return err
}
if e.stype == linux.SOCK_STREAM {
returnConnect(&streamQueueReceiver{queueReceiver: queueReceiver{readQueue: readQueue}}, peer)
} else {
returnConnect(&queueReceiver{readQueue: readQueue}, peer)
}
I built that change and confirmed it fixes the behaviour, against an unpatched control built from the same commit with the same flags (see Steps to reproduce).
Happy to open a PR if that's the preferred route.
### Steps to reproduce
The script below exercises `SO_PEERCRED` on each `AF_UNIX` socket type from one process, on one kernel, varying nothing but the type. It needs only the Python standard library.
```python
#!/usr/bin/env python3
"""SO_PEERCRED attribution per AF_UNIX socket type, same process, same kernel."""
import os, socket, struct, tempfile
def trial(sock_type, name):
path = os.path.join(tempfile.mkdtemp(prefix="p5-"), "socket")
srv = socket.socket(socket.AF_UNIX, sock_type); srv.bind(path); srv.listen(1)
kid = os.fork()
if kid == 0:
c = socket.socket(socket.AF_UNIX, sock_type)
c.connect(path); c.sendall(b"hi"); c.recv(8)
os._exit(0)
conn, _ = srv.accept()
peer = struct.unpack("3i", conn.getsockopt(
socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i")))[0]
conn.recv(8); conn.sendall(b"x")
os.waitpid(kid, 0)
me = os.getpid()
who = ("connector" if peer == kid else
"LISTENER" if peer == me else f"other({peer})")
print(f" {name:12} listener={me:<7} connector={kid:<7} "
f"SO_PEERCRED={peer:<7} -> {who:10} correct={peer == kid}")
print(f" kernel: {os.uname().release}")
for st, n in ((socket.SOCK_STREAM, "SOCK_STREAM"),
(socket.SOCK_SEQPACKET, "SOCK_SEQPACKET"),
(socket.SOCK_DGRAM, "SOCK_DGRAM")):
try: trial(st, n)
except Exception as e: print(f" {n:12} skipped: {type(e).__name__}: {e}")Run it natively, then under runsc:
python3 probe.py runsc --rootless --ignore-cgroups do python3 probe.py
Native (Linux 6.18.41) — both types correct:
kernel: 6.18.41-gentoo
SOCK_STREAM listener=840564 connector=840565 SO_PEERCRED=840565 -> connector correct=True
SOCK_SEQPACKET listener=840564 connector=840566 SO_PEERCRED=840566 -> connector correct=True
SOCK_DGRAM skipped: OSError: [Errno 95] Operation not supportedUnder runsc — SEQPACKET reports the listener:
kernel: 4.19.0-gvisor
SOCK_STREAM listener=1 connector=2 SO_PEERCRED=2 -> connector correct=True
SOCK_SEQPACKET listener=1 connector=3 SO_PEERCRED=1 -> LISTENER correct=False
SOCK_DGRAM skipped: OSError: [Errno 95] Operation not supportedReproduces identically on release-20260817.0 and release-20261026.0.
To confirm the cause, I built runsc and the sentry from go-branch commit 063618a361eba871db68aa3d240173f48d1569f8 with CGO_ENABLED=0 go build ./runsc ./runsc/cmd/sentry, once unmodified and once with the hoist above. Unmodified reports LISTENER; patched reports connector. Both binaries must be static and built from the same commit — a dynamically linked build fails in setCapsAndCallSelf with error executing /proc/self/exe: no such file or directory, and mixing a released runsc with a sentry from go-branch HEAD fails with flag provided but not defined: -start-sync-fd.
runsc version
runsc version release-20260817.0
spec: 1.2.1
Also reproduced on:
runsc version release-20261026.0
spec: 1.2.1docker version (if using docker)
not useduname
Linux 6.18.41-gentoo
kubectl (if using Kubernetes)
not usedrepo state (if built from source)
go branch, commit 063618a361eba871db68aa3d240173f48d1569f8
runsc debug logs (if available)
undefinedActivity
0No activity yet.