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
0700per-user directory, and an optional in-memory ACL keyed on the peer’s real uid/gid fromgetpeereid(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:
- the permissions on the socket file,
- the permissions on the directory containing it,
- an optional in-memory ACL keyed on the peer’s uid and gid,
- 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):
/* 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:
| Socket | Selected by | umask | Resulting mode |
|---|---|---|---|
| default | no -S | 0117 | 0660 — owner and group read/write |
| custom | -S /path | 0177 | 0600 — 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:
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, notstat(). A symlink pointing at a directory is rejected becauselstatsees the symlink, not the target. This is the classic/tmp/tmux-$UIDsymlink attack, closed.- The owner must be the current uid, so a directory left behind by a recycled uid is refused.
TMUX_SOCK_PERMis007— other rwx is forbidden. Group bits are explicitly allowed, matching the0660socket.
The directory list itself is validated. check_socket_path() rejects
relative paths and any .. component:
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():
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:
tmux -L ../victimresolves 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):
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:
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():
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:
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 theCMD_READONLYflag:
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:
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 = 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():
/* 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 attachas an allowed user gives full command execution, includingrun-shell,source-fileand#()format jobs. - The default socket is group-writable. Being in the right group is
equivalent to being the user who started the server. Use
-Swith 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.