tmux Secures its UNIX Socket

tmux has no authentication at all. The whole security model lives in the permissions of a UNIX socket, a per-user directory under /tmp, an in-memory ACL keyed on getpeereid(3), and path checks that stop the classic /tmp symlink attack.

TL;DR: There is no password, no token, no challenge-response in tmux. If you can open the socket, you own the session — so the entire security model is socket permissions, a 0700 per-user directory, and an optional in-memory ACL keyed on the peer’s real uid/gid from getpeereid(3).

A tmux server is a daemon that owns real ptys. Any process that can talk to its UNIX socket can run commands as the user who started the server, including run-shell and source-file.

There is no password, no token, no challenge-response anywhere in the tmux source — grep -rn "passphrase" . returns nothing.

That means the entire security model of tmux is:

  1. the permissions on the socket file,
  2. the permissions on the directory containing it,
  3. an optional in-memory ACL keyed on the peer’s uid and gid,
  4. and a pile of path handling to make sure the socket ends up somewhere an attacker cannot pre-create.

This is a walk through all four, based on the OpenBSD-flavoured tmux tree at commit 6f655f48 (version 3.7b plus ~1300 commits).

The socket is the boundary

The server creates its socket in server_create_socket() (server.c):

c
/* Create server socket. */
int
server_create_socket(uint64_t flags, char **cause)
{
	struct sockaddr_un	sa;
	size_t			size;
	mode_t			mask;
	int			fd, saved_errno;

	memset(&sa, 0, sizeof sa);
	sa.sun_family = AF_UNIX;
	size = strlcpy(sa.sun_path, socket_path, sizeof sa.sun_path);
	if (size >= sizeof sa.sun_path) {
		errno = ENAMETOOLONG;
		goto fail;
	}
	unlink(sa.sun_path);

	if ((fd = socket(AF_UNIX, SOCK_STREAM, 0)) == -1)
		goto fail;

	if (flags & CLIENT_DEFAULTSOCKET)
		mask = umask(S_IXUSR|S_IXGRP|S_IRWXO);
	else
		mask = umask(S_IXUSR|S_IRWXG|S_IRWXO);
	if (bind(fd, (struct sockaddr *)&sa, sizeof sa) == -1) {

The umask() dance is the first line of defence, and it’s deliberately different for the two kinds of socket:

SocketSelected byumaskResulting mode
defaultno -S01170660 — owner and group read/write
custom-S /path01770600 — owner only

The default socket is group-writable on purpose: it lives in a private 0700 directory, but the group bits let a shared tmux work for a team without every member needing to be root or the same user. Everything else about group membership is handled by the ACL below.

Note also unlink(sa.sun_path) before bind(). The server removes whatever file is already at that path. That’s convenient — it cleans up stale sockets — but it means the socket path must be somewhere only the owner can write to. Otherwise another user could plant a file there and have it deleted, or, worse, race the server for the name.

The 0700 directory under /tmp

The default socket path is built by make_label() (tmux.c). The steps matter:

c
	xasprintf(&base, "%s/tmux-%ld", path, (long)uid);
	free(path);
	if (mkdir(base, S_IRWXU) != 0 && errno != EEXIST) {
		xasprintf(cause, "couldn't create directory %s (%s)", base,
		    strerror(errno));
		goto fail;
	}
	if (lstat(base, &sb) != 0) {
		...
	}
	if (!S_ISDIR(sb.st_mode)) {
		xasprintf(cause, "%s is not a directory", base);
		goto fail;
	}
	if (sb.st_uid != uid || (sb.st_mode & TMUX_SOCK_PERM) != 0) {
		xasprintf(cause, "directory %s has unsafe permissions", base);
		goto fail;
	}
	xasprintf(&path, "%s/%s", base, label);

Four separate protections:

  • The directory is created 0700, so only the owner can list or enter it.
  • lstat() is used, not stat(). A symlink pointing at a directory is rejected because lstat sees the symlink, not the target. This is the classic /tmp/tmux-$UID symlink attack, closed.
  • The owner must be the current uid, so a directory left behind by a recycled uid is refused.
  • TMUX_SOCK_PERM is 007 — other rwx is forbidden. Group bits are explicitly allowed, matching the 0660 socket.

The directory list itself is validated. check_socket_path() rejects relative paths and any .. component:

c
static int
check_socket_path(const char *path, char **cause)
{
	char	*copy, *next, *tmp;

	if (*path != '/') {
		xasprintf(cause, "socket directory %s is not an absolute path",
		    path);
		return (0);
	}
	copy = tmp = xstrdup(path);
	while ((next = strsep(&tmp, "/")) != NULL) {
		if (strcmp(next, "..") == 0) {
			xasprintf(cause, "socket directory %s contains ..",
			    path);
			free(copy);
			return (0);
		}
	}

The list comes from TMUX_SOCK, which is "$TMUX_TMPDIR:" _PATH_TMP, and each candidate is realpath()-resolved before use.

One rough edge: the -L label

Look again at the last line of make_label():

c
	xasprintf(&path, "%s/%s", base, label);

The label isn’t validated at all. -L takes a name for the socket inside the tmux-$UID directory, and a label containing a slash or .. escapes that directory:

text
tmux -L ../victim

resolves to /tmp/tmux-1000/../victim. The socket is then created there with mode 0660 and unlink()ed by the client on startup.

This isn’t a privilege escalation by itself — you still need to be the same user — but it means the 0700 directory guarantee doesn’t hold for -L labels, and the unlink() in client_connect() will delete a file at that path. The -S option is worse in the same way: it skips the directory entirely, and only the 0600 socket mode protects it.

Gotcha: if you use -L for multiple tmux servers, treat the label as trusted input. It isn’t validated the way the socket path is.

The ACL: real peer credentials

The optional access control list is the interesting part, because it’s based on the credentials of the connecting process rather than anything the client claims. The kernel gives the server the peer’s uid and gid via getpeereid(3), and tmux reads them once, at accept time (proc.c):

c
	imsgbuf_allow_fdpass(&peer->ibuf);
	event_set(&peer->event, fd, EV_READ, proc_event_cb, peer);

	if (getpeereid(fd, &peer->uid, &peer->gid) != 0) {
		peer->uid = (uid_t)-1;
		peer->gid = (gid_t)-1;
	}

The failure case is nicely handled: if getpeereid fails, the peer gets uid and gid of (uid_t)-1, and every ACL lookup for them fails. It fails closed.

The check itself is in server-acl.c:

c
static struct server_acl_entry *
server_acl_check(struct client *c)
{
	struct server_acl_entry	*entry;
	uid_t			 uid;
	gid_t			 gid;

	uid = proc_get_peer_uid(c->peer);
	if (uid == (uid_t)-1)
		return (NULL);

	entry = server_acl_entry_find(uid, 0);
	if (entry != NULL)
		return (entry);

	gid = proc_get_peer_gid(c->peer);
	if (gid == (uid_t)-1)
		return (NULL);

	return (server_acl_entry_find(gid, SERVER_ACL_IS_GROUP));
}

So the policy is:

  • the uid entry wins if there is one; only otherwise is the gid consulted,
  • there’s no “other” or “anyone” entry type — only users and groups,
  • each entry is either read-only or read-write,
  • root is always allowed, and the owner is always allowed, and neither can be changed.

The default policy is set in server_acl_init():

c
void
server_acl_init(void)
{
	RB_INIT(&server_acl_entries);

	if (getuid() != 0)
		server_acl_allow(0, 0);
	server_acl_allow(getuid(), 0);
}

Note the entries live in a plain in-memory red-black tree. There’s no ACL file. Restart the server and the list is gone. That’s a deliberate trade-off: it keeps the attacker’s ability to write to a well-known configuration file out of the picture.

The server-access command is a thin wrapper over this. It resolves names with getpwnam()/getgrnam(), refuses to change the owner or root, and treats -r/-w as implying -a:

c
	if ((~flags & SERVER_ACL_IS_GROUP) && (id == 0 || id == getuid())) {
		cmdq_error(item, "%s owns the server, can't change access", name);
		return (CMD_RETURN_ERROR);
	}

Read-only is enforced in two places

A read-only ACL entry sets CLIENT_READONLY on the joining client (server_acl_join()). That flag is then checked twice:

  • in cmd-queue.c, a read-only client may only run commands whose entry has the CMD_READONLY flag:
c
	flags = !!(state->flags & CMDQ_STATE_CONTROL);
	cmdq_guard(item, "begin", flags);
	if (flags &&
	    saved != NULL &&
	    (saved->flags & CLIENT_READONLY) &&
	    (~entry->flags & CMD_READONLY)) {
		cmdq_error(item, "client is read-only");
  • in format.c, shell jobs in formats are disabled outright for read-only clients:
c
	if (c != NULL && (c->flags & CLIENT_READONLY))
		ft->flags |= FORMAT_NOJOBS;

That second one is the important one, because #(command) inside a format string is a shell execution, and a status line is a format string.

The ACL is only consulted at accept time

server_accept() is the only place the ACL gates entry:

c
	c = server_client_create(newfd);
	if (!server_acl_join(c)) {
		c->exit_message = xstrdup("access not allowed");
		c->retval = 1;
		c->flags |= CLIENT_EXIT;
	}

Clients that are already connected are only re-evaluated when the ACL itself is changed — and even then, only for deny/allow-write/deny-write operations, not for a plain server-access -a, because server_acl_update() isn’t called from server_acl_allow(). A client that connected before you added it to the ACL keeps its access.

Also worth noting: the very first client — the one that caused the server to start — is created directly in server_client_create() and never passes through server_accept(), so it’s implicitly authorised.

The execute bit dance

There’s one more piece of socket permission logic, in server_update_socket():

c
/* Update socket execute permissions based on whether sessions are attached. */
void
server_update_socket(void)
{
	struct session	*s;
	static int	 last = -1;
	int		 n, mode;
	struct stat      sb;

	n = 0;
	RB_FOREACH(s, sessions, &sessions) {
		if (s->attached != 0) {
			n++;
			break;
		}
	}

	if (n != last) {
		last = n;

		if (stat(socket_path, &sb) != 0)
			return;
		mode = sb.st_mode & ACCESSPERMS;
		if (n != 0) {
			if (mode & S_IRUSR)
				mode |= S_IXUSR;
			if (mode & S_IRGRP)
				mode |= S_IXGRP;
			if (mode & S_IROTH)
				mode |= S_IROTH;
		} else
			mode &= ~(S_IXUSR|S_IXGRP|S_IXOTH);
		chmod(socket_path, mode);
	}
}

On a UNIX socket the “execute” bit means “may connect”. tmux adds it while a session is attached and removes it when nothing is attached, so an idle server isn’t accepting connections from a group that has access to the directory.

The stat()/chmod() pair follows symlinks, which is a small sharp edge when -S points into a world-writable directory.

What this means in practice

The threat model is simple to state and worth internalising:

  • Anyone who can connect() to the socket owns the session. There’s no second factor. tmux -S /tmp/x attach as an allowed user gives full command execution, including run-shell, source-file and #() format jobs.
  • The default socket is group-writable. Being in the right group is equivalent to being the user who started the server. Use -S with a private path if that matters, or set an explicit ACL.
  • The ACL is coarse. It’s uid or gid, read-only or read-write. There’s no per-command policy, and it doesn’t survive a restart.
  • Read-only is a genuinely useful downgrade, because it also disables shell jobs in formats — the usual way an untrusted user would try to get code execution out of a status line.

Wrapping up

The design is defensible. tmux assumes the socket directory is a trusted per-user boundary, and it defends that boundary carefully. The lstat() check, the ownership check, the .. rejection and the 0700 directory are all there specifically because /tmp is hostile.

What it doesn’t do is pretend that being able to open the socket is the same as being harmless.

Here’s the thing: the model is “the socket is the credential”. That’s fine for a single-user desktop, and it’s the reason tmux needs no password machinery at all. But the moment you share a tmux server, you’re sharing a shell, and the only knob is server-access -r.

TmuxSecurityInternalsUnix