OpenSSH has some peculiar handling around command line arguments
blog.devops.dev
blog.devops.dev
When you keep in mind that the given command string will be parsed twice, first by your local shell and then again by the remote shell, it becomes clear why a running a remote ssh command behaves like this.
Also the reason echoing a MOTD from rc files or similar crap breaks tools like rsync or scp which use SSH as a neutral transport. SFTP isn’t affected because, while using the three-pipe as a transport, it’s a separate subsystem with its own SSH request type to initiate the channel (just like X11 or port forwarding).
If you control client and server you can define your own subsystems and invoke them directly, which avoids this whole mess.
However, sshd(8) clearly documents that it will always execute the login shell, even when a command has been passed. ("All commands are run under the user's login shell as specified in the system password database.")
Subsystems open secondary channels to communicate separately from stdin/stdout of the remote command. I used the X11 forwarding before to run a remote command with sudo without getting the password prompt interfering with the protocol: https://raimue.blog/2016/09/25/how-to-run-rsync-on-remote-ho...
$ ssh localhost figlet foobar bar\ baz
execve("/usr/bin/ssh", ["ssh", "localhost", "figlet", "foobar", "bar baz"], …
execve("/usr/bin/figlet", ["figlet", "foobar", "bar", "baz"], …
in practice it looks more like this (traced with execsnoop): PCOMM PID PPID RET ARGS
ssh 4255 2058 0 "/usr/bin/ssh" "localhost" "figlet" "foobar" "bar baz"
sshd 4256 2147 0 "/usr/bin/sshd" "-D" "-R"
bash 4259 4258 0 "/bin/bash" "-c" "figlet foobar bar baz"
figlet 4259 4258 0 "/usr/bin/figlet" "foobar" "bar" "baz"It really becomes one hell of a puzzle sometimes, especially if you're necessarily nesting another layer of escaping. It feels like you're trying to write a quine.
This works:
ssh host -- ls "folder\ name"
This also works: ssh host -- ls \"folder name\"
This works: ssh host -- sh -c \"ls \\\"folder name\\\"\"
OK, so clearly, just throwing more escaping at it fixes it. But even if you figure that out, the real mental gymnastics would be figuring out which of the three shells interpreting your command line in the last case would handle shell expansion.In this case, it's the host:
ssh host -- sh -c \"ls \\\"folder nam\\\"*\"
In this case it's the remote: ssh host -- sh -c "\"ls \\\"folder nam\\\"*\""
Of course where you put the quotes makes no difference. All it does is prevent your shell from processing it. So this works just as well: ssh host -- "sh -c \"ls \\\"folder nam\\\"*\""
If you sit and think each layer through, it usually isn't completely impossible to understand, but the odds that you are going to get something wrong the first time is astonishingly high.It does make me wonder why ssh handles it the way it does, though. Because with the way SSH handles it, it may as well just automatically escape the spaces. Right now, not putting an SSH command in quotes doesn't make much sense unless you for some reason want local shell expansion for something.
ssh host -- ls "folder\ name"
> This also works: ssh host -- ls \"folder name\"
Uh, why not ssh host -- ls '"folder name"'
? Single quotes are the shell’s ultimate bulk “no touchy” escape, so if you don’t need them in the inner command, it seems easier to use them for everything. (Also when passing programs to sed, awk, jq, xmlstarlet, etc.)At that point, though, I’d try to write a general shell escaping function, because I don’t trust myself to figure out which host things are OK to include unescaped in such a situation. (Here’s when I start to long for Tcl, despite all the times I’ve had to spell out [string index ...].)
The optimal solution would be to have a separate side channel for passing things to the quoted program, like -v in awk or --arg in jq (or whatever it is in your favourite SQL DBMS binding) but I don’t think SSH will let you do that.
ssh host -- ls "'"'folder$name'"'" ssh host 'ls "folder name"'
and practically ignore the `command [arguments]` split in SSHs synopsis.
It's all passed to a shell and parsed again, anyway.Alternatively,
echo ls \"folder name\"|ssh -T host
or cat > 1
ls "folder name"
^D
ssh -T host < 1 ssh -T host <<EOF
ls "folder name"
EOFWhen the shell executes "cmd folder/file", the "folder/file" is just a string as far as the shell is concerned. It is the command that uses that string with a function like unlink or open.
I don't think this is some specific design goal of OpenSSH, I think it's just a side effect of how shell escaping works.
> When you keep in mind that the given command string will be parsed twice, first by your local shell and then again by the remote shell, it becomes clear why a running a remote ssh command behaves like this.
I get that this behavior may be surprising to new users, but anybody working with ssh regularly will encounter these kinds of escaping issues. SSH isn't even the only place you'll encounter this. Things like docker etc will have the same "problem".
In the case of ssh you can simply write your commands to a file and send them via stdin, or copy a script to the target.
The tone of this blog post rubs me wrong. Yes this is a footgun (in the same way many POSIX shell-related things are), but it's not like it's some "problem" with the design of SSH.
The SSH server has to figure out how to deal with it, most SSH servers to my knowledge invoke a shell. Parsing the passed command and trying to invoke it without a shell can be unsafe as escaping is shell-specific. (Imagine, for example, SSH'ing into a Windows machine, which the protocol entirely supports.)
[1] https://datatracker.ietf.org/doc/html/rfc4254#section-6.5
ssh localhost sh -c 'cd /tmp && pwd'
to pass "sh -c 'cd /tmp && pwd'" over the network, which would trigger the expected behavior. But the arguments are not quoted so you end up with just: "sh -c cd /tmp && pwd". ssh localhost sh -c "'cd /tmp && pwd'"
works but (a) ICK (b) I usually only remember this on about attempt three at the earliest. echo "cd /tmp && pwd" | ssh localhost
Or with a here string that bash and a few others support: ssh localhost <<<"cd /tmp && pwd"
I often use the here string method. Maybe one of those would be easier to remember.Much appreciated.
['sh', '-c', 'cd /tmp && pwd']
How should SSH know what shell is running on the other end and escape it properly? Will it escape for Sh? Bash? Fish? Powershell? Python shell? Custom script? The SSH client would need to know what shell is running on the other end, which it can't. The server, which knows what will run, cannot fix this because it only gets the already mangled string.This is not solvable the way the protocol currently works because you can't support all possible constellations of client/server applications and shells.
Rule 5 of http://cr.yp.to/qmail/guarantee.html remains completely true. Don't parse. When you try to use a user interface as a command interface, you have to parse. And some day it will bite you.
The solution is to remotely invoke programs designed for the purpose, and then pass data to them in a structured format.
It's not even a problem with OpenSSH, the SSH protocol itself (RFC4254[2], Section 6.5) specifies that the command is sent as a string, and there's just no sane way to convert between an array of strings and a string that's also human-friendly to the command line.
My work-around was to allow only a single argument in "judo -c CMD HOST", where CMD is to be interpreted by the target's shell. If you need to do anything more complex, the "judo -s SCRIPT HOST" form is more-or-less equivalent to "chmod +x SCRIPT && ./SCRIPT".
OpenSSH updates tend to be rolled out very slowly though. So if you wanted any kind of widespread usability of this option, you would've needed to add this about ten years ago.
* this is of course alreay taken, it seems only -u and -z are free at this point. You could use -U and pretend to be a roman (EXECUE).
To me, this is a bit like the scp vs sftp topic. You really want a new remote-invocation CLI that throws away the rsh compatibility concept and is designed with "modern" goals. And, it probably needs new protocol elements to properly engineer it.
But this is also similar to the "system" callouts from multiple languages which abdicate responsibility and pass a command string to an external interpreter. Are all these API designers so naive, or do they have a philosophy of being so inclusive that they are not willing to assume that a command on a target system involves an executable and an array of arguments?
Remember APIs are made for humans, (assuming we're talking local processes) nobody can stop you from using raw fork(2)+pipe(2)+execve(2) every time, yet you will usually reach for popen(3).
I found that SSH is more complicated than I would have expected at a low level, and far more barebones than I would have expected at a higher level. It's incredibly sensitive to certain types of small delay for its packets. On the other hand, the actual terminal traffic is basically just a pair of streams. For an interactive SSH session, there's no useful concept of "send a command and wait for it to finish on the remote system", because the remote system is just piping the output of the shell back to the SSH client. You can hack in something like generating a random tag and appending "; echo '---complete:{random_tag}---'" to every command and assuming the command is complete when that string appears, but it's not built in, and of course every SSH server OS can have different syntax requirements, so now the client has to detect and handle each of those.
It makes sense given that SSH is a general-purpose protocol that's supposed to work for just about any CLI, so I was surprised for the same reason as the author of the article. Their issue makes sense in context as well - SSH has to support server shells like the Windows command prompt that don't even have the concept of an array of arguments at a low level, just the command string.
The README contains my own explanation of the phenomenon.
If it takes 23 years before software does something you don't expect it can't be that bad.
Argument parsing, whitespace detection, tokenizing a string, these are really difficult things even when a remote host is not involved. Try writing a shell script that always handles variables so perfectly that you can access any pathname in the filesystem, whether it has spaces, tabs, unprintables, Unicode, or whatever.
There are, of course, solutions, or at least workarounds to this. ssh can be configured on the receiving side so that it runs a particular command, and parsing whitespace/arguments should be significantly easier when you configure it this way. Alternatively, you could write custom Bash or Python to do what you want, and just have that tool on the receiving end. (The latter assumption is easier said than done!)
Configuring ssh to run a dedicated command line is probably an underutilized, underrated mode of operation. I used it to safely operate LAN backups: I had a client machine that would ssh in to a special user account, which had no actual shell, but immediately kicked off the backup process. That's the stereotypical use.
But for ad hoc use cases, like this blogger was probably trying to implement, you don't really have a chance to pre-populate the receivers with scripts or special configurations. And yeah, that becomes a pain when you're trying to do a clever one-liner and it chokes. I feel your pain. (Have you considered improvising a Python or Perl one-liner for ssh to run?)
Write a script in say, python. Then from memory you can do something like:
$ ssh $remotehost python < script
Wrap that in a for remotehost in ${hosts[@]}; do
Maybe? Has some merit on occasion.
This is an instance of the problems caused by throwing everything into a simple plain stream, instead of having a modal stream that can tell what kind of thing is going there.
Try writing a python script that always handles variables so perfectly that you can access any pathname in the system. It's funny to even write that, because it's trivial (barring UTF-8 issues that require using a non-obvious API). But you can only do that because of those annoying quotes that create a new idiom inside them. Sh saves you from that annoyance.
Yes — but it will always, ALWAYS launch your shell as (typically) defined in /etc/passwd, and will pass it two string arguments: "-c" "and your particular command with arguments and all". Go strace it, go look at the argument vector, you'll see. Thus if your shell is bash, zsh, tcsh, dash, sh, whatever: the moment that it sees and tokenizes "and your particular command with arguments and all", you'll have lost.
The only winning move is not to play.
> Configuring ssh to run a dedicated command line is probably an underutilized, underrated mode of operation.
Perhaps, but in any case, it doesn't save you. As I explained above, your shell runs first, so you've already lost. Maybe it's not underrated, maybe it's rated exactly right because of this problem!
But what if I tell you you can make your problems go away. You juuuuuust have to use this very special shell. I don't want to be downvoted for linking it for a third time on this page, so I won't. Thus: see my other comments on this page :-)
https://gist.github.com/comex/2faf21ebee93b0bd64841c30c7d280...
The quoting is done with Python's shlex.quote(), whose output should parse properly in any POSIX-compatible shell. (Before editing this comment I had linked a similar script from Stack Overflow, but it uses bash's printf %q to quote, which produces output that uses ANSI-C quoting, which isn't supported by dash.)
lol im just gonna leave those typos (automatically double escaped asterixes) there which were caused by writing this comment on a non logged in page after logging in on a different tab. perfect example of the same string flinging garbage
ssh host 'single command string "with regular shell quoting"'
and that avoids the problem.ssh user@host << EOF
Your script
EOF
Beware of local variables interpretation though!
But if you need a mix of local and remote expansion, you're back to doing a lot of escaping.
(tcmalloc is commonly used to fix memory 'leaks' (fragmentation?) in Pytorch, so this happens a lot to me. At least, I've been bitten twice.)
It's hard to imagine what might be going on to make it sensitive to the details of malloc/free... and I'm not sure that I want to.
Sure, sanitizing the environment for process calls is doable, but this also means I can't use libssh2 at all.
They all worked fine without LD_PRELOAD though, so...
It has nothing to do with OpenSSH....
> After this, the client either requests an interactive shell or execution or a non-interactive command, which sshd will execute via the user's shell using its -c option
Its not sshd doing the argument splitting, its the shell on the server side.
So many things could be broken if we didn't insist on backwards compatibility with ancient systems. Virtually everything, for just one example.
Can you imagine if we didn't bother with backwards compatibility for servers? Or even just your desktop - you try to boot one day and find that your desktop doesn't boot because we dropped compatibility with MBR systems and only support UEFI, or that you choose ext4 for your filesystem, but the kernel only supports superqFS version 2023.07 ...
or that SSH backup script that has been working fine for 20 years suddenly, silently, stops working, and then your critical production systems die.
Or how about your favorite photo software decided to just change all of its keybindings, or stopped loading your photos -- from last year!
I know one qa team at Google had the slogan "if it isn't broken, you aren't working hard enough" but they are the qa team. It is their job to find defects before anyone else.
It is not our job as developers to create defects for no reason.
the developers should have access to the updated API/ABI code and maybe some migration documentation in order to update the program. the user should have a clear migration process to avoid data loss or corruption.
in this case, the package manager's core functionality should not depend upon any other packages. this way you can just store all the previous versions of the program and provide an upgrade path. sure, it will take a bit if you're doing a big upgrade, but that's tour fault for not keeping stuff updated
Not to detract from your overall point but for anyone out there wondering about a way to handle this in general: your job should update or annotate something (a file, a table, a bucket, etc.) upon success. Then you use a "dead man's switch"-style check/monitor that alerts if the job hasn't updated its proof of life, so to speak.
SSH is a protocol. Just have the client negotiate the version of the protocol when establishing a connection. Use the old protocol by default, write new scripts with a flag requiring the new protocol from the server. Boom. You maintain backwards compatibility with ancient systems, but you don't force old mistakes upon current day users.
The problem isn't so much the protocol (SSH already does the client/server protocol negotiation), but the command line interface to the SSH tool. Changing the interface can stop older scripts from working in the same manner, so either you have to add completely new options to the CLI so that the older usage still works, or you have a renamed version of the tool (e.g. nussh) that won't need to worry about backwards compatibility.
Next time, read the whole comment before replying.
Last wins is how most other UNIX tools work, since it's the laziest approach (parse from first to last and just overwrite any old value). But I guess somebody tried to be explicit with the order of everything from the configuration file including the command line, and now this happened.
I really wish this would be reverted, but now it's probably been so long that it would break things for people again.
Just ask the developers directly via the official mailing list [1], or send a bug report as you are invited to do by the developers [2].
It's weird that this has been known for over a decade, and noone ever added a 'pass on the argv[] array unchanged' in all that time
Now that I know I can't fix this in stock openSSH I think I'll just look into throwing argv[] into a base64 encoded JSON array and somehow have jq fix and exec it on the other end.
ssh foo@bar baz "$(printf '%q\n' "something that needs $quoting")
For cases where one or both sides are not bash, but still POSIX like, it's relatively easy to write a function that uses single quotes; just replace every single quote with '\'' before wrapping in single quotes.