SSH might surprise you â in all the wrong ways
OpenSSH has some very peculiar handling around command line arguments.
It was only after nearly 23 years of using OpenSSH on an almost daily basis that I encountered this issue.
TL;DR
If you just want the short summary, I suggest you skip to âA few simple examples where this really bites youâ section.
Background
A while back I was assisting a mentee with a script to automate some tasks and we were using ssh
to execute commands on the remote server.
I was just feeding him commands off the top of my head without testing them myself, and he got back to me and said that they didnât work.
Surely he was mistaken? Turns out, he wasnât.
Intrigued? I certainly was; Keep reading.
Before we dig into the gory details, letâs examine how processes are actually executed and how command line arguments are passed to the respective program.
Letâs talk about how a program gets executed
When you type a command in the shell, the following description provides a simplified explanation of the underlying process:
- The shell breaks what you wrote into distinct arguments, observing well-defined rules around what is a unique argument and what isnât.
- The shell searches the directories specified in the
$PATH
environment variable to locate the requested program. - The shell employs the
exec
family of POSIX system calls (such asexecl()
,execv()
, and others) to execute the desired program. In essence, a system call is a mechanism by which an application solicits the kernel to perform an action on its behalf, and theexec
family of system calls are used to execute another program.
Example:
$ figlet foobar âbar bazâ
You shell will search the $PATH
environment variable for a program named figlet
, and then calls fork()
followed by execve()
with the arguments /usr/bin/figlet
, figlet
, foobar
and bar baz
as show on the below strace
output (strace
is a Linux-specific application that shows which system calls an application performs, but similar tools exist for other unices):
execve(â/usr/bin/figletâ, [âfigletâ, âfoobarâ, âbar bazâ], â¦
This output from strace
shows us that our shell did a system call to the execve
routine in the kernel (In the rest of this article, I will interleave output from invocations of execve()
calls with the command line illustrations. If you want to try this out yourself you can use the strace -f -e trace=execve
command to observe which calls to execve
are actually made, and which arguments are passed to them)
As you can see, the quotes around âbar bazâ are not passed to execve()
because they are part of the shellâs parsing process.
We could have expressed this in a number of functionally equivalent ways using various quoting and expansion primitives, e.g:
$ figlet foobar bar\ baz
execve("/usr/bin/figlet", ["figlet", "foobar", "bar baz"], â¦
$ figlet foobar "bar baz"
execve("/usr/bin/figlet", ["figlet", "foobar", "bar baz"], â¦
$ BAZ='bar baz' figlet foobar "${BAZ}"
execve("/usr/bin/figlet", ["figlet", "foobar", "bar baz"], â¦
As long as we maintain proper quoting, we can nest invocations of sub-shells etc as much as we want, and still get the same result. For example:
$ sh -c 'sh -c "figlet foobar bar\ baz"'
execve("/usr/bin/sh", ["sh", "-c", "sh -c \"figlet foobar bar\\ baz\""], â¦
execve("/usr/bin/sh", ["sh", "-c", "figlet foobar bar\\ baz"], â¦
execve("/usr/bin/figlet", ["figlet", "foobar", "bar baz"], â¦
In each step the quoting/separating into individual arguments works exactly as expected.
After some time, the nesting of quotes and escape characters becomes second nature to us, and we seldom devote much thought to it.
Which is why the next part was so confusing.
Enter OpenSSH
The ssh
man page says the following in the SYNOPSIS
: (Iâve removed some of the options for brevity)
NAME
ssh â OpenSSH remote login client
SYNOPSIS
ssh [-â¦] destination [command [argument â¦]]
This looks awfully similar to the man page of for example the dash
shell:
NAME
dash â command interpreter (shell)
SYNOPSIS
dash [-aCefnuvxIimqVEbp] [+aCefnuvxIimqVEbp] [-o option_name]
[+o option_name] [command_file [argument â¦]]
At this point, Iâd argue that most people expect ssh
to behave in a transparent fashion (i.e. like a regular sub-shell or any other command), and that the command line arguments are passed to the remote shell according to regular shell expansion rules. Much like rsh
does; After all â the concept behind the SSH client command line utility was to create a secure drop-in replacement for utilities like rsh
, offering enhanced security features. rsh
in turn, was an extension to run sh
commands on a remote host.
In other words, we would expect the following commands to be functionally equivalent:
$ figlet foobar bar\ baz
execve("/usr/bin/figlet", ["figlet", "foobar", "bar baz"], â¦
$ ssh localhost figlet foobar bar\ baz
execve("/usr/bin/ssh", ["ssh", "localhost", "figlet", "foobar", "bar baz"], â¦
execve("/usr/bin/figlet", ["figlet", "foobar", "bar", "baz"], â¦
WAT? Apparently our single argument âbar bazâ which was passed to ssh
as a single argument, was broken up by ssh
into two distinct arguments, namely âbarâ and âbazâ.
Letâs nest it one level deeper, using standard shell expansion rules:
$ sh -c 'figlet foobar bar\ baz'
execve("/usr/bin/sh", ["sh", "-c", "figlet foobar bar\\ baz"], â¦
execve("/usr/bin/figlet", ["figlet", "foobar", "bar baz"], â¦
$ ssh localhost sh -c 'figlet foobar bar\ baz'
execve("/usr/bin/ssh", ["ssh", "localhost", "sh", "-c", "figlet foobar bar\\ baz"], â¦
execve("/usr/bin/sh", ["sh", "-c", "figlet", "foobar", "bar baz"], â¦
execve("/usr/bin/figlet", ["figlet"], â¦
Now surely, weâve even embedded the âbar bazâ argument inside single quotes â no way is ssh
messing with that, right?
Wrong.
Youâll notice that /usr/bin/sh
in the ssh case suddenly was invoked with [âshâ, â-câ, âfigletâ, âfoobarâ, âbar bazâ]
and not [âshâ, â-câ, âfiglet foobar bar\\ bazâ]
as we would expect.
Since we can see on the following line, ssh
is invoked with properly quoted arguments:
execve("/usr/bin/ssh", ["ssh", "localhost", "sh", "-c", "figlet foobar bar\\ baz"], â¦
but we still end up with the wrong arguments executed on the remote server. (In this case, figlet
gets called without any arguments, making it seemingly âhangâ since itâs expecting input on stdin).
The ssh
manpage vaguely alludes to this behaviour with the following sentence:
If a command is specified, it will be executed on the remote host instead
of a login shell. A complete command line may be specified as command,
or it may have additional arguments. If supplied, the arguments will be
appended to the command, separated by spaces, before it is sent to the
server to be executed.
The sentence Iâm referring to is If supplied, the arguments will be appended to the command, separated by spaces, but to my mind it is very unclear and fails to convey the fact that ssh
does its own expansion of command line arguments containing spaces themselves.
A few simple examples where this really bites you
$ ssh localhost 'cd /tmp && pwd'
/tmp
$ ssh localhost sh -c 'cd /tmp && pwd'
/home/mad
$ ls documentation\ directory
readme.txt
$ ssh localhost ls documentation\ directory
ls: cannot access 'documentation': No such file or directory
ls: cannot access 'directory': No such file or directory
In conclusion:
How anyone could think this was a good idea is beyond me, but at this point Iâm going to assume that it was a either unintentional or intentional design decision taken long ago and at this point openssh
canât fix/change it with the risk of breaking existing code and installations.
Do you have any insights into this? Know the history behind this behavior? Feel like spending a few hours digging through historical openssh releases to uncover the back story behind this?
Iâd love to hear about it!