tmux Sanitises Window Names Against Injection

Window names, session names, pane titles and paste buffer names all flow through clean_name(), which escapes them vis(3)-style. A small function with an interesting history of security fixes.

TL;DR: clean_name() is tmux’s one defence against name-based injection, and it works by encoding rather than filtering: it stores names in a form where the dangerous characters are unrepresentable, rather than keeping a blocklist of bad names.

Window names in tmux are not just strings. They end up in status lines, in control-mode notifications, in #{window_name} format expansions, and in hook commands that are expanded and then parsed as commands. A window name containing #{...} or a quote is a potential injection point.

The defence is a single function called clean_name(). Its history is a good illustration of how injection defence actually evolves: patch, get bypassed, patch again, get bypassed again.

The function

tmux.c:

c
char *
clean_name(const char *name, int untrusted)
{
	char	*copy, *cp, *new_name;

	if (!utf8_isvalid(name))
		return (NULL);
	copy = xstrdup(name);
	for (cp = copy; *cp != '\0'; cp++) {
		if (untrusted && cp[0] == '#' && cp[1] == '(')
			*cp = '_';
	}
	utf8_stravis(&new_name, copy, VIS_OCTAL|VIS_CSTYLE|VIS_TAB|VIS_NL);
	free(copy);
	return (new_name);
}

Three things happen:

  1. UTF-8 validation. An invalid sequence is rejected outright, and the caller gets NULL. This isn’t cosmetic: utf8_stravis() walks the string with the UTF-8 decoder, and a malformed string would desynchronise the walk.
  2. #( → _( for untrusted names. The untrusted flag marks names that came from inside a pane — a program that set the terminal title via OSC 0 or OSC 2. A title is the one place where a hostile process can put arbitrary bytes into a name, so #( is defanged there.
  3. vis(3)-style escaping. utf8_stravis() escapes everything that isn’t plainly printable.

Why vis(3) and not a hand-rolled check

tmux uses the BSD vis(3) family — bundled in compat/vis.c and compat/unvis.c — because “escape the dangerous characters” is a problem with a long tail, and vis has already enumerated the tail.

The important part is what happens to the characters that are not printable. vis()’s definition of visibility (compat/vis.c) is:

c
#define	isvisible(c,flag)						\
	(((c) == '\\' || (flag & VIS_ALL) == 0) &&			\
	(((u_int)(c) <= UCHAR_MAX && isascii((u_char)(c)) &&		\
	(((c) != '*' && (c) != '?' && (c) != '[' && (c) != '#') ||	\
		(flag & VIS_GLOB) == 0) && isgraph((u_char)(c))) ||	\

The #, *, ? and [ exclusions only apply when VIS_GLOB is set. tmux doesn’t set it, so those characters are ordinary visible characters and are stored as-is. # in a window name survives as #.

What does get escaped is everything non-printable, because VIS_CSTYLE and VIS_OCTAL together make vis() fall through to its encoding branches:

c
	if (flag & VIS_CSTYLE) {
		switch(c) {
		case '\n':
			*dst++ = '\\';
			*dst++ = 'n';
			goto done;
		case '\r':
			*dst++ = '\\';
			*dst++ = 'r';
			goto done;

so a newline in a name becomes the two characters \ and n, a tab becomes \t, a carriage return becomes \r, and any other non-printable byte becomes an octal escape such as \001. That matters for the control-mode protocol, where a raw newline in a %%window-renamed notification would forge a protocol line.

The consequence for format injection is subtler than it looks. A window name can contain #{, and it can contain #(. Neither is re-expanded when the name is substituted into a status line, because a format expansion’s result isn’t itself expanded — only the E and T modifiers re-expand. So the #{...} in a name is inert text. The #( in a name is not inert, because a format can contain a shell job, and that’s exactly what the explicit rewrite in clean_name() is for.

c
	if (flag & VIS_CSTYLE) {
		switch(c) {
		case '\n':
			*dst++ = '\\';
			*dst++ = 'n';
			goto done;
		case '\r':
			*dst++ = '\\';
			*dst++ = 'r';
			goto done;

so a newline in a name becomes the two characters \ and n. That is important for the control-mode protocol, where a raw newline in a %%window-renamed notification would forge a protocol line.

VIS_NL is in the flag list too, and it’s worth being precise about what it does: in BSD vis, VIS_NL means “also encode newline characters” — it is an instruction to encode, not an exemption. Combined with VIS_CSTYLE the newline comes out as \n.

Where it is applied

clean_name() is called from a small, deliberate set of places:

Call siteContext
window_set_name() (window.c)window renames, untrusted flag passed through
screen.c:242pane title from OSC
screen.c:256pane path from OSC 7
input.c:3284OSC title parsing, untrusted = 1
cmd-rename-session.c:63session rename
cmd-new-window.c:92, cmd-new-session.c-n names
names.c:169the shared “find next unused name” helper
paste.c:232, paste.c:300paste buffer names
cmd-break-pane.c:173broken-pane names

The untrusted distinction shows up only in the OSC title path. A name set by a command (rename-window foo) keeps its # characters, because a user typing # into a window name is not an attack. A name set by a program inside a pane has #( rewritten to _(.

Note that the stored name is the sanitised one — there’s no separate “display” form that gets un-escaped. A name that contained a newline is stored with a literal backslash-n in it and displayed that way. That keeps the invariant simple: whatever is in w->name is safe to interpolate into a status line, a control-mode notification or a format.

The history

The git log tells the story of how this went wrong, repeatedly.

d339ab51 — “Sanitize pane titles and window and session names more consistently and strictly, prevents C0 characters and other nonvisible characters causing problems. Reported (with a different fix) by Chris Monardo in GitHub issue 4999.”

Before this, some names were validated and some were not. C0 characters in a window name could produce all kinds of weirdness in the status line and in control-mode output.

64e83caf / eb653314 — “Be more strict about what names and titles we allow and reject them immediately when possible, but allow # again for those directly set by commands (but not escape sequences). From Barrett Ruth in GitHub issue 5175.”

This is the interesting one. The strict version rejected names containing #. That was too strict: users legitimately want # in window names (vim #1, ssh box#2), and the fix was to allow # where it’s harmless while keeping it defanged where it isn’t — the untrusted flag.

746dd91e — “Sanitize paste buffer names in paste_set and paste_rename, GitHub issue 5032 from Barrett Ruth.”

Paste buffer names were a separate hole. They’re format-expanded in places, and they weren’t being cleaned. Fixed by routing them through clean_name() too.

518fcf7e — “Do not sanitize title when popping it from stack, also add a limit to number of pushed titles.”

Terminals push and pop titles with OSC 9/10 and OSC 133. Popping a title restores a previously sanitised title, so sanitising again would double-escape it. The fix was to skip sanitisation on pop, and to cap the title stack so a program can’t grow it without bound.

That last one is a nice illustration of the general tension: sanitisation isn’t idempotent, and a system that both stores and restores sanitised values has to know which side of the boundary each value came from.

What is not defended

A few things to be clear about:

  • Session and window names aren’t restricted in length or character set beyond “valid UTF-8”. They can contain spaces, quotes, semicolons. The defence is escaping, not rejection.
  • The command parser’s ${VAR} splicing is a separate issue. A name is escaped; an environment variable isn’t. See How tmux Format Expansion Runs Shell Commands.
  • Control-mode output from commands isn’t escaped. Names are; command output isn’t. See tmux Control Mode Is an Unauthenticated Command Channel.
  • A name isn’t escaped for the shell. run-shell "echo #{window_name}" will expand the name and then hand the result to /bin/sh -c. The name is escaped for tmux’s format and control-mode syntaxes, not for sh.

That last one is the most common way people get burned. If a window name comes from an untrusted source and you interpolate it into a shell command, you need shell quoting. cmd_template_replace() provides %% and %1 for exactly this, and they have different escaping rules:

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.
 */

%1 is deliberately not escaped, on the assumption you’ll quote it yourself. That’s the trap: %1 inside double quotes is fine, %1 inside single quotes isn’t, and %% inside single quotes escapes the quote character but isn’t safe in double quotes.

Wrapping up

clean_name() is a good example of defence that works by encoding rather than filtering. tmux doesn’t maintain a blocklist of dangerous window names. It stores names in a form where the dangerous characters are unrepresentable.

A blocklist would have to know about #{, #(, newlines, quotes, ;, backticks, $(, and every future syntax tmux adds. An encoder only has to know about the characters that aren’t printable — plus the one piece of syntax that genuinely has to be spelled out, #(, which is why that one is handled by hand in clean_name() rather than by vis.

The cost is that the stored and displayed forms are the same, which is simple, but that sanitisation isn’t idempotent. Anything that stores a sanitised value and later restores it — the terminal title stack — has to know which side of the boundary each value came from. That bug is exactly what commit 518fcf7e fixed.

TmuxSecurityInternalsSanitisation