#16694

SO_PEERCRED returns the listener's credentials on AF_UNIX SOCK_SEQPACKET sockets

Opentomrittervgopened 1d ago

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 supported

Under 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 supported

Reproduces 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.1

docker version (if using docker)

not used

uname

Linux 6.18.41-gentoo

kubectl (if using Kubernetes)

not used

repo state (if built from source)

go branch, commit 063618a361eba871db68aa3d240173f48d1569f8

runsc debug logs (if available)

undefined

Activity

0

No activity yet.