tmux Format Expansion Runs Shell Commands: A Template Language With a Footgun

The #{...} format syntax is a small template language with a shell escape hatch, an environment-variable fallback, and a re-expansion modifier. Understanding it is the difference between a safe status line and an unsafe one.

TL;DR: tmux formats are code, and they execute where they’re expanded. #(command) runs a shell, #{VAR} splices an environment variable in verbatim, and #{E:VAR} re-expands that value as a format — which turns a plain environment variable into shell execution.

tmux formats are strings with #{...} holes in them. The status line, the pane border, display-message, the #{...} interpolation in hook commands — all of them are format strings, and the format language has grown a surprising amount of power: it can run shell commands, read environment variables, and re-expand its own output.

This is the anatomy of that language, and the places where it stops being a templating convenience and starts being a security-relevant primitive.

The pieces

The format language lives in format.c (about 7300 lines). The core syntax handled by format_expand1():

SyntaxMeaning
#{name}Look up a format variable (window_name, pane_pid, …)
#{name:modifier}Look up with modifiers (:s/a/b/, :t, :p, …)
#(command)Run a shell command and use its output
##[style]Look up a style
%%A literal %

Every command that takes a format expands it before doing anything else. run-shell, if-shell, pipe-pane, display-message, send-keys, new-window, split-window, set-option for format options, and the status line itself.

The shell escape hatch

#(command) runs a command. The implementation is format_job_get():

c
/* Find a job. */
static char *
format_job_get(struct format_expand_state *es, const char *cmd)
{
	...
	fj0.tag = ft->tag;
	fj0.cmd = cmd;
	if ((fj = RB_FIND(format_job_tree, jobs, &fj0)) == NULL) {
		fj = xcalloc(1, sizeof *fj);
		fj->client = ft->client;
		fj->tag = ft->tag;
		fj->cmd = xstrdup(cmd);

		RB_INSERT(format_job_tree, jobs, fj);
	}

	format_copy_state(&next, es, FORMAT_EXPAND_NOJOBS|
	    FORMAT_EXPAND_NOCYCLE);
	next.flags &= ~FORMAT_EXPAND_TIME;

	expanded = format_expand1(&next, cmd);
	...
	if (force || (fj->job == NULL && fj->last != t)) {
		fj->job = job_run(expanded, 0, NULL, NULL, NULL,
		    server_client_get_cwd(ft->client, NULL), format_job_update,
		    format_job_complete, NULL, fj, JOB_NOWAIT, -1, -1);

Four things worth noticing:

  • The command is format-expanded first (format_expand1(&next, cmd)), so a job command can itself contain #{...} substitutions.
  • The expansion is done with FORMAT_EXPAND_NOJOBS, so jobs don’t recurse into more jobs.
  • Results are cached per client in a red-black tree keyed on the command string and the format tag, and the cache is refreshed at most once per second unless FORMAT_FORCE is set. A status line with #(date) doesn’t fork a shell sixty times a second.
  • job_run() with JOB_NOWAIT means the job runs in the background and the previous result is returned until the new one lands. That’s why a slow command in a status line shows stale output for a second rather than blocking the redraw.

That last point is genuinely nice design. The status line stays responsive even when your format is running curl.

The environment variable fallback

This is the subtle one. If a format key is not a known format and not an option, tmux falls back to looking it up as an environment variable (format.c):

c
	if (~modifiers & FORMAT_TIMESTRING) {
		envent = NULL;
		if (ft->s != NULL)
			envent = environ_find(ft->s->environ, key);
		if (envent == NULL)
			envent = environ_find(global_environ, key);
		if (envent != NULL && envent->value != NULL) {
			found = xstrdup(envent->value);
			goto found;
		}
	}

So #{HOME} in a format expands to the environment variable HOME. The session environment is consulted first, then the global environment.

That means an environment variable can be printed into a status line, into a control-mode notification, or into the argument of run-shell:

tmux
set -g status-right '#(echo $SECRET)'

The environment variable’s value is inserted verbatim, with no escaping. Whatever ends up in the status line ends up on the user’s screen (and, if the client is a control-mode client, on the wire).

Re-expansion: the E and T modifiers

The E modifier expands the value again as a format (format.c):

c
done:
	/* Expand again if required. */
	if (modifiers & FORMAT_EXPAND) {
		new = format_expand1(es, value);
		free(value);
		value = new;
	} else if (modifiers & FORMAT_EXPANDTIME) {
		format_copy_state(&next, es, FORMAT_EXPAND_TIME);
		new = format_expand1(&next, value);
		free(value);
		value = new;
	}

#{E:SOMEVAR} takes the value of SOMEVAR and parses it as a format string. #{T:SOMEVAR} does the same with time formats enabled.

This is the classic escalation path. If an attacker can set an environment variable whose value contains #{...} or #(cmd), and something expands that variable with the E modifier, the value becomes live format text — and #(cmd) is live shell execution.

The E modifier isn’t enabled by default for every consumer, but it is available to anyone who can write a format string. That includes anyone who can write a tmux.conf or a hook.

${VAR} in the command language

Formats aren’t the only expansion mechanism. The tmux command parser also expands environment variables, and it does so by raw string splicing. From cmd-parse.y:

c
	envent = environ_find(global_environ, name);
	if (envent != NULL && envent->value != NULL) {
		value = envent->value;
		log_debug("%s: %s -> %s", __func__, name, value);
		yylex_append(buf, len, value, strlen(value));
	}

The value is appended to the token buffer as-is. It is not re-quoted, not re-escaped and not re-scanned. So a global environment variable whose value contains a quote, a semicolon or a space changes the meaning of the command being parsed.

set-environment -g writes to the global environment, and the global environment is what the parser reads. That’s a genuine injection path from “can set a global environment variable” to “can run arbitrary tmux commands, including run-shell”.

The parser’s quoting rules are subtle and have been fixed several times. cmd_template_replace() in cmd.c documents the intended behaviour:

c
/*
 * Replace the first %% or any %idx in template by s. %% is intended for use in
 * single quotes, so ' is escaped. %%% and %idx% are for double quotes, so a
 * list of special characters is escaped. %idx is left unescaped.
 */

with the double-quote set "\"\\$;~" and single quotes doubled. The %% behaviour was tightened in commit a177d0f5 — “Change %% to escape single quotes … to avoid a single quote being able to terminate quoted sections of commands” — because a single quote in the substituted value could otherwise close a quoted section early.

Hooks: expand, then parse

Hooks take the union of formats and commands. hooks_parse() in hooks.c:

c
/* Parse a hook command. */
static struct cmd_parse_result *
hooks_parse(struct hooks_data *hd, struct cmd_find_state *fs,
    const char *value)
{
	struct cmd_parse_result	*pr;
	struct format_tree		*ft;
	char			*expanded;

	if (!hd->expand)
		return (cmd_parse_from_string(value, NULL));

	ft = format_create_defaults(NULL, hd->client, fs->s, fs->wl, fs->wp);
	if (hd->formats != NULL)
		format_merge(ft, hd->formats);
	expanded = format_expand(ft, value);
	format_free(ft);

	pr = cmd_parse_from_string(expanded, NULL);
	free(expanded);
	return (pr);
}

The hook command text is format-expanded and then parsed as tmux commands. For monitor-based hooks, the expansion includes the monitored value — a window name or a pane title — which is attacker-influenced text if some other user can rename a window.

hooks.c builds the format tree with FORMAT_NOJOBS, so the expansion itself won’t run a shell job. But the result is still parsed as commands, so a monitored value containing ; or a newline that survives sanitisation is a problem.

This is why the sanitisation of names matters, and it’s the subject of How tmux Sanitises Window Names Against Injection.

The read-only escape hatch

For clients that are read-only (via server-access -r), shell jobs in formats are switched off entirely:

c
	if (c != NULL && (c->flags & CLIENT_READONLY))
		ft->flags |= FORMAT_NOJOBS;

and format_expand1() honours the flag:

c
			if ((ft->flags & FORMAT_NOJOBS) ||

so #(cmd) expands to nothing at all. This is the single most important mitigation for a shared tmux server: a read-only client cannot get code execution through the status line, through display-message, or through any format-consuming command.

Summary of the attack surface

PrimitiveWhere it comes fromConsequence
#(cmd)any format stringshell execution as the server user
#{VAR}environment variablevalue injected verbatim into output
#{E:VAR}environment variablevalue reinterpreted as format, so #(cmd) inside it runs
${VAR} in the parserglobal environmentraw splice into the command being parsed
hook expansionmonitored valueuser text parsed as tmux commands
%% / %1 substitutioncommand argumentsquoting subtleties, historically exploitable

Wrapping up

The unifying theme: tmux formats are code, and they execute where they are expanded.

Every mitigation lives in the same family — decide early whether the client is trusted, and refuse to expand untrusted text into a format or a command.

Two things to internalize. First, never let untrusted text reach a format that uses E or T; that modifier turns a data value into code. Second, if you’re running a shared tmux server, server-access -r is the single switch that turns off the worst of it.

TmuxSecurityInternalsFormats