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:
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:
- 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. #(→_(for untrusted names. Theuntrustedflag 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.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:
#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:
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.
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 site | Context |
|---|---|
window_set_name() (window.c) | window renames, untrusted flag passed through |
screen.c:242 | pane title from OSC |
screen.c:256 | pane path from OSC 7 |
input.c:3284 | OSC title parsing, untrusted = 1 |
cmd-rename-session.c:63 | session rename |
cmd-new-window.c:92, cmd-new-session.c | -n names |
names.c:169 | the shared “find next unused name” helper |
paste.c:232, paste.c:300 | paste buffer names |
cmd-break-pane.c:173 | broken-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 forsh.
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:
/*
* 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.