Unix domain sockets provide local interprocess communication without IP addresses, TCP ports, or an external network route. They still use kernel socket facilities, buffers, scheduling, and an application protocol. A pathname socket exposes a filesystem name, but Unix sockets can also be unnamed, and Linux supports abstract names. These naming forms have different access rules, so “a Unix socket is just a protected file” is incomplete.

When it breaks

An application can find a socket path yet fail to connect because the listener has exited. Closing a pathname socket does not automatically remove its filesystem entry. Restarting by blindly binding the same path can then fail with EADDRINUSE, even though no live service accepts connections there. File existence is useful diagnostic information, but it is not a health check.

On Linux, connecting to a pathname stream socket requires write permission on the socket and search permission on the directories used to reach it. Socket-file execute bits do not serve the same purpose as directory traversal bits. A permissive socket mode cannot compensate for an inaccessible parent directory. Conversely, making a sensitive control socket world-writable can grant local processes substantial application privileges.

These checks are not universal across naming forms or operating systems. Linux abstract sockets are independent of filesystem paths, so chmod on a pathname cannot protect their names. Portable programs must not assume every Unix implementation enforces socket-file permissions identically. Peer credentials and application authorization should follow the platform and identity model actually used, rather than the visual appearance of a path.

Check it quickly

Save this as check-unix-socket.py and run python3 check-unix-socket.py with Docker. The pinned Linux environment uses Python 3.11.16. A server creates a pathname stream socket in a disposable directory. A separate client process can drop to UID and GID 65534; no host account, socket, or permission is changed:

import json,subprocess,uuid
image="python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84"
program=r"""
from pathlib import Path
from tempfile import TemporaryDirectory
import errno,json,socket,socketserver,stat,struct,subprocess,sys,threading

class Handler(socketserver.StreamRequestHandler):
    def handle(self):
        self.request.settimeout(1);assert self.rfile.readline()==b'ping\n'
        pid,uid,gid=struct.unpack('3i',self.request.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,struct.calcsize('3i')))
        self.wfile.write(json.dumps({'uid':uid,'gid':gid,'pid_positive':pid>0}).encode()+b'\n')
client_code=r'''
import json,os,socket,sys
if sys.argv[2]=='drop':os.setgroups([]);os.setgid(65534);os.setuid(65534)
try:
    with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as s:
        s.settimeout(1);s.connect(sys.argv[1]);s.sendall(b'ping\n');out=s.makefile('rb')
        with out:print(out.readline().decode().strip())
except PermissionError:print(json.dumps({'error':'EACCES','uid':os.geteuid()}))
'''
def client(path,drop=False):
    p=subprocess.run([sys.executable,'-c',client_code,str(path),'drop' if drop else 'keep'],capture_output=True,text=True,timeout=3)
    assert p.returncode==0,p.stderr
    return json.loads(p.stdout)
with TemporaryDirectory(prefix='unix-socket-') as directory:
    root=Path(directory);root.chmod(0o755);path=root/'service.sock'
    server=socketserver.UnixStreamServer(str(path),Handler)
    path.chmod(0o600);thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
    try:
        owner=client(path);denied=client(path,True)
        assert owner['uid']==0 and denied=={'error':'EACCES','uid':65534}
        path.chmod(0o666);allowed=client(path,True);assert allowed['uid']==65534 and allowed['gid']==65534
        root.chmod(0o700);directory_denied=client(path,True);assert directory_denied==denied
        root.chmod(0o755)
    finally:server.shutdown();server.server_close();thread.join(timeout=2);assert not thread.is_alive()
    assert stat.S_ISSOCK(path.stat().st_mode)
    with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as probe:
        try:probe.connect(str(path));raise AssertionError('stale socket accepted')
        except ConnectionRefusedError:stale='ECONNREFUSED'
    with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as duplicate:
        try:duplicate.bind(str(path));raise AssertionError('stale path reused')
        except OSError as error:assert error.errno==errno.EADDRINUSE
    path.unlink()
    with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as replacement,socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as probe:
        replacement.bind(str(path));replacement.listen(1);replacement.settimeout(1);probe.settimeout(1);probe.connect(str(path))
        peer,_=replacement.accept()
        with peer,probe.makefile('rb') as incoming:
            peer.sendall(b'restarted');assert incoming.read(9)==b'restarted'
    print(json.dumps({'owner_peer':owner,'mode_0600_denied':denied,'mode_0666_peer':allowed,
        'directory_0700_denied':directory_denied,'closed_path_remains_socket':True,'stale_connect':stale,
        'stale_bind':'EADDRINUSE','restarted_after_owned_path_unlink':True}))
"""
name='xbcatm-socket-'+uuid.uuid4().hex[:10]
try:
    result=subprocess.run(['docker','run','--rm','-i','--name',name,'--network','none',
        '--memory','128m','--pids-limit','64',image,'python3','-'],input=program,
        capture_output=True,text=True,timeout=60)
    assert result.returncode==0,result.stderr
    print(json.dumps(json.loads(result.stdout),ensure_ascii=False,sort_keys=True))
finally:
    subprocess.run(['docker','rm','-f',name],capture_output=True,text=True,timeout=10)

With a searchable parent directory and socket mode 0600, the root client connects and receives peer-credential data from the server. The client that drops privileges receives EACCES. The server obtains the connected peer’s credentials through SO_PEERCRED, rather than accepting an identity string supplied in the request. The fixture checks the actual effective UID reported at the connection boundary.

Temporarily changing the socket to 0666 allows the unprivileged client to connect. The server now reports UID and GID 65534. Changing only the parent directory to 0700 denies that same client again, even though the socket remains world-writable. The permissive mode exists solely to isolate the two permission checks inside the disposable container; it is not a suggested mode for a privileged service.

After shutting down and closing the server, the pathname still has socket file type. A connection to it fails with ECONNREFUSED, while a fresh bind to that name fails with EADDRINUSE. These observations distinguish a stale entry from a live listener. The script then removes its own known socket entry, binds a replacement, and verifies a restarted response through a real connection.

The stream request uses a newline delimiter, and the response reader respects its framing. Unix stream sockets do not promise that one write equals one read. Local transport removes IP routing from the exchange, not the need to handle partial reads, deadlines, or a peer that closes unexpectedly. A different Unix socket type may provide message boundaries, but this example explicitly exercises SOCK_STREAM.

Treat local access as an application capability

Choose directory ownership and a socket group that represent the intended callers, then verify access as an actual member and nonmember. A process with access to an administrative socket may be able to perform powerful operations regardless of its inability to reach a TCP port. Evaluate the server’s commands and authorization, rather than judging the capability from whether an IP listener exists.

For pathname sockets, the client also needs the correct filesystem view. A container that cannot see the server’s path will not discover it merely because it shares a host. Conversely, deliberately sharing the socket path can expose the service to that container. Linux abstract naming uses a different namespace boundary. Document the selected naming form instead of treating every local IPC endpoint as interchangeable.

Design restart ownership explicitly. Only remove a stale path when the service manager can establish that it owns the name and will not detach another active instance’s endpoint. Measure performance with the real workload if it motivates changing transports; this exercise tests access and lifecycle semantics, not a universal latency advantage. Keep listener health, caller identity, and protocol correctness observable together.

Sources

  • Linux unix(7), pathname permissions, abstract names, stream semantics and peer credentials, checked 2026-09-12.
  • Python 3.11 socket, AF_UNIX streams, socket options and descriptor lifecycle, checked 2026-09-12.