tmux Control Mode Is an Unauthenticated Command Channel
Control mode turns a tmux socket into a line protocol that runs any tmux command and streams events back. There's no authentication, and one part of the protocol is a raw sink for untrusted text.
TL;DR:
tmux -Cgives you a scriptable protocol with zero authentication — anything that can open the socket can runrun-shell. Worse, command output is written into the stream verbatim, so a program that prints a line starting with%can forge a notification in your client.
tmux -C puts tmux into control mode: instead of drawing a screen, the
client speaks a line protocol and gets structured events back. It’s the
mechanism behind tmux integrations in editors, status bars, scripts and CI
systems.
It’s also, in its current form, an unauthenticated remote command channel with a subtle framing problem.
This article is about how control mode works, what it will let you do, and where the sharp edges are.
What control mode looks like
A control-mode client is an ordinary tmux client that happens to have
CLIENT_CONTROL set instead of CLIENT_TERMINAL. The flag is set in
tmux.c:
case 'C':
if (flags & CLIENT_CONTROL)
flags |= CLIENT_CONTROLCONTROL;
else
flags |= CLIENT_CONTROL;
break;-C is control mode. -CC is “control control”: the client puts its own
terminal into a raw mode and wraps the protocol in ESC P 1000 p ... ESC \
so that a control client’s output can be embedded in a terminal without
being interpreted.
The protocol itself is not a separate wire format. It’s the ordinary
client/server IPC (imsg over the UNIX socket), plus a text protocol layered
on top. tmux-protocol.h has no control-mode messages at all; everything
goes through MSG_COMMAND.
The grammar is documented in the man page under CONTROL MODE, and the
implementation is in control.c (about 1100 lines) plus
control-notify.c.
Reading commands
The whole command surface is this function (control.c):
/* Control client input callback. Read lines and fire commands. */
static void
control_read_callback(__unused struct bufferevent *bufev, void *data)
{
struct client *c = data;
struct control_state *cs = c->control_state;
struct evbuffer *buffer = cs->read_event->input;
char *line, *error;
struct cmdq_state *state;
enum cmd_parse_status status;
for (;;) {
line = evbuffer_readln(buffer, NULL, EVBUFFER_EOL_LF);
if (line == NULL)
break;
log_debug("%s: %s: %s", __func__, c->name, line);
if (*line == '\0') { /* empty line detach */
free(line);
c->flags |= CLIENT_EXIT;
break;
}
state = cmdq_new_state(NULL, NULL, CMDQ_STATE_CONTROL);
status = cmd_parse_and_append(line, NULL, c, state, &error);
if (status == CMD_PARSE_ERROR)
cmdq_append(c, cmdq_get_callback(control_error, error));
cmdq_free_state(state);
free(line);
}
}Every line is parsed with the same parser as an interactive tmux command
line and appended to the command queue. There is no allow-list of “safe”
control-mode commands. run-shell, source-file, new-session, kill-server
— all of it is available.
That’s the point of the design: control mode is meant to be a complete
alternative interface, not a restricted one. The restriction story is the
server ACL, and it’s described in
How tmux Secures its UNIX Socket.
Short version: if you can open the socket, you can run commands. A
read-only ACL entry limits you to commands flagged CMD_READONLY and
disables #() shell jobs in formats.
The reply framing
Replies are wrapped in a guard block so a client can associate output with the command that produced it:
%begin 1712345678 0 1
%output %3 some text
%end 1712345678 0 1The guard lines are emitted from exactly one place, which is deliberate
(control.c):
/*
* Write a %begin, %end or %error guard around a command's output. This is
* the only place guard lines are produced, so the block depth is maintained
* here; when the outermost block closes any deferred notifications are
* flushed after it. "guard" is always one of the fixed strings from cmdq_guard,
* never user text.
*/
void
control_write_guard(struct client *c, const char *guard, long t, u_int number,
int flags)
{
struct control_state *cs = c->control_state;
char *line;
if (cs == NULL)
return;
if (strcmp(guard, "begin") == 0)
cs->guard_depth++;
xasprintf(&line, "%%%s %ld %u %d", guard, t, number, flags);
control_write_line(c, line);
if (strcmp(guard, "begin") != 0 && cs->guard_depth > 0 &&
--cs->guard_depth == 0)
control_flush_deferred(c);
}Notifications (things like %window-renamed or %layout-change) are
deferred while a guard block is open, so a notification can never appear
in the middle of a command’s output. That was a bug fix in its own right
(commit 6db5175e, issue 5458).
Where the protocol gets interesting: the raw sink
Here’s the function that writes arbitrary text into the control stream:
/*
* Write a line of command output or error text. This is a sink for arbitrary
* user-controlled text (command output, capture-pane, error messages), so it
* must never try to interpret the content: guard tracking is done only in
* control_write_guard.
*/
void
control_write(struct client *c, const char *fmt, ...)
{
struct control_state *cs = c->control_state;
char *line;
va_list ap;
if (cs == NULL)
return;
va_start(ap, fmt);
xvasprintf(&line, fmt, ap);
va_end(ap);
control_write_line(c, line);
}The comment is the whole story. control_write() is a sink: it takes text
that may have come from anywhere — a run-shell job, capture-pane,
display-message, an error message that echoes back user input — and puts
it on the wire verbatim.
Now look at what reaches it:
cmdq_error()incmd-queue.cwrites error text straight through.server_client_print()writes command output.capture-pane -pwrites the pane contents.display-messagewrites a%%message %sline.
Only one of these paths escapes the payload: pane output streamed with
%output, which is octal-escaped (control.c):
for (i = 0; i < size; i++) {
if (new_data[i] < ' ' || new_data[i] == '\\') {
evbuffer_add_printf(message, "\\%03o", new_data[i]);
} else {
start = i;
while (i + 1 < size &&
new_data[i + 1] >= ' ' &&
new_data[i + 1] != '\\')
i++;
evbuffer_add(message, new_data + start, i - start + 1);
}
}So bytes from a pane are safe, but bytes from a command are not.
The consequence is that a program running in a pane, or a run-shell job,
that prints a line starting with % can inject a forged notification into
the client’s stream:
%output %1 fake pane output
%window-renamed @1 pwnedThe tmux server itself will not be confused — that’s exactly what the
control_write() comment is defending. But a client that parses
%-prefixed lines will act on it. If your control-mode client does
something security-relevant with %%window-renamed (updating a supervisor’s
window list, say), then any command whose output you forward into the
control stream is a potential injection point.
The practical mitigation for a client is to only trust % lines that arrive
in the right structural position — inside a %begin/%end block, or in the
notification section — and to treat the body of a block as opaque data.
A note on window names
Notifications interpolate names directly:
if (winlink_find_by_window_id(&cs->windows, w->id) != NULL) {
control_notify_write(c, "%%window-renamed @%u %s",
w->id, w->name);A window name cannot contain a raw newline, because names go through
clean_name(), which uses utf8_stravis() with VIS_OCTAL|VIS_CSTYLE|VIS_NL.
VIS_CSTYLE turns newlines into the two characters \n, and VIS_OCTAL
turns # into \043 (since # is not a “visible” character to vis(3)).
So a name cannot forge a new line, but it can contain spaces and %,
which a naive client might still mis-parse.
DoS protections
Control mode has accumulated a few defences against misbehaving clients, mostly added in response to issues:
#define CONTROL_BUFFER_LOW 512
#define CONTROL_BUFFER_HIGH 8192
#define CONTROL_MAXIMUM_AGE 300000
#define CONTROL_MAXIMUM_REPLY_BUFFER (64 * 1024 * 1024)CONTROL_BUFFER_HIGHis a watermark on the write buffer: a client that stops reading will be paused rather than allowed to grow the server’s memory without bound.CONTROL_MAXIMUM_AGE(five minutes) drops clients that have not consumed their data.CONTROL_MAXIMUM_REPLY_BUFFER(64 MB) bounds the memory used by buffered command replies — commita3129249, issue 5565. A client that issues a command producing a huge reply gets disconnected with “too far behind” rather than being allowed to exhaust the server.
There’s also a pile of fixes around clients dying at awkward moments: “do not attempt to write to control clients which have been destroyed”, “do not let a stuck client prevent the server from exiting”, “do not make pty fds blocking again or control mode clients can get stuck”.
Using it responsibly
If you’re writing a control-mode client:
- Do not trust the framing of text you did not generate. Treat the
contents of a
%begin/%endblock as opaque bytes, not as protocol. - Do not pipe the output of an arbitrary command into a control client.
- Remember there is no authentication. The socket permissions are the entire access control. If your control client listens on a socket other users can reach, it is a remote shell.
- Consider
server-access -r. A read-only ACL entry is the supported way to give a less-trusted integration query-only access.
Wrapping up
Control mode is a good idea that has not yet grown the security model the
rest of the world expects from a network protocol. Everything is
UNIX-socket-local, which is why it has survived for a decade — but the
control_write() sink means it is one run-shell away from being a
protocol injection target.
Here’s the thing: control mode is a decade-old protocol that has outgrown the era it was designed in. Everything that makes it safe today — UNIX-socket locality, filesystem permissions — is an accident of its deployment, not a property of its design. Treat it that way.