Safely pass arguments to a command executed via su - bash

With sudo, it is possible to execute a command as an other user and really safely pass arguments to that command.
Example nasty argument:
nastyArg='"double quoted" `whoami` $(whoami) '"'simple quoted "'$(whoami)'"'"
Expected output, run via a termninal as congelli501:
% echo "$nastyArg"
"double quoted" `whoami` $(whoami) 'simple quoted $(whoami)'
Execute as congelli501, via sudo:
# sudo -u congelli501 -- echo "$nastyArg"
"double quoted" `whoami` $(whoami) 'simple quoted $(whoami)'
Execute as congelli501, via su (usual escape method):
# su congelli501 -c "echo '$nastyArg'"
"double quoted" `whoami` $(whoami) simple quoted congelli501
As you can see, the argument is not safely passed as it is re-interpreted by a shell.
Is there a way to launch a command via su and pass its arguments directly, as you can do with sudo ?

Passing the command as the shell script argument in su seems to work:
# su congelli501 -s "$(which echo)" -- "$nastyArg" "'another arg'"
"double quoted" `whoami` $(whoami) 'simple quoted $(whoami)' 'another arg'
Example usage:
# Safely execute a command as an other user, via su
#
# $1 -> username
# $2 -> program to run
# $3 .. n -> arguments
function execAs() {
user="$1"; shift
cmd="$1"; shift
su "$user" -s "$(which -- "$cmd")" -- "$#"
}
execAs congelli501 echo "$nastyArg" "'another arg'"

The fundamental problem here is that you are trying to nest quotes. You would hope su -c "stuff "with" double "quotes" to be parsed as |su|, |-c|, |stuff "with" double "quotes"| but it actually gets parsed as |su|, |-c|, |stuff |, |with|, | double|, |quotes| where however the last four tokens are pasted together as one string after evaluation (notice the spaces where the "inner" quotes terminated the ostensible "outer" quotes instead of wrapping inside them).
Within double quotes, `whoami` gets expanded by the shell before anything gets passed to su.
What you can do instead is either (a) add yet more quoting around the values in $nastyArg (pretty much doomed) or (b) define it in the context of the shell executed by su. A distinct third option is to (c) pass in the value in a way which disarms it, such as on standard input.
printf '%s\n' "$nastyArg" | su -c 'cat' congelli501
This may seem overtly simplistic, but is really the way to go when you do not have complete trust over what will possibly be evaluated by root somehow.
This is one of the reasons sudo is preferred over su.

Related

Is it possible using "su -c" with multiple commands but in one session?

I am trying run the following two command in one command.
eval "$(ssh-agent)"
ssh add ~/.ssh/id_rsa
I tried with many possible solutions:
su username -c "{ eval $(ssh-agent -s) }; ssh-add ~/.ssh/id_rsa"
su username -c "eval $(ssh-agent -s)" ; ssh-add ~/.ssh/id_rsa
su username -c "eval $(ssh-agent -s)" && ssh-add ~/.ssh/id_rsa
su username -c "eval $(ssh-agent -s)" && "ssh-add ~/.ssh/id_rsa"
It seems like the first command run successfully but the second either response with Permission denied message (means it run with the current user) or cannot connect to the authentication agent (means it probably created a new session for the second command).
Error messages:
Could not open a connection to your authentication agent.
Error connecting to agent: Permission denied
If I run them separately in order, it works:///
The purpose is to create a bash script with these commands with variables, something like this:
folder=/home/q12
username=q12
su $username -c "{ eval $(ssh-agent -s) }; ssh-add $folder/.ssh/id_rsa"
Because of the variables I can not quote the whole command because it will be sensitive to ";" and "&&".
Can anyone help me with this?
Thank you!
You need single quotes. Otherwise the command substitution is evaluated in the current context.
su username -c 'eval "$(ssh-agent -s)"; ssh-add ~/.ssh/id_rsa'
Edit:
To get conditional execution of ssh-add, you can do:
su username -c 'script=$(ssh-agent -s) || exit 1; eval "$script"; ssh-add ~/.ssh/id_rsa'
# or
su username -c 'set -e; script=$(ssh-agent -s); eval "$script"; ssh-add ~/.ssh/id_rsa'
The argument in su -c STRING is a command string, similar to bash -c STRING. We can also nest double quotes inside single quotes.
The first of your tries is closest, but inside double-quotes things like $(ssh-agent -s) get evaluated by your shell before they're passed to su as part of an argument. Net result: the ssh agent is running under the current user, so the other user can't use it.
You need to delay evaluation until the other-user shell. Using single-quotes instead of double would do this, but in the actual command you have $folder, which you clearly want evaluated by your shell (it won't be defined in the other-user shell), so you don't want to delay evaluation of that. The simplest way to do this is to escape the $ that you want to delay evaluation of (your shell will remove the escape, so the other-user shell will see & evaluate it):
su "$username" -c "eval \$(ssh-agent -s); ssh-add $folder/.ssh/id_rsa"
# ^ Note the escape
(BTW, I also added double-quotes around the username, as that's generally-good scripting hygiene. Quoting $folder is more complicated, and shouldn't be necessary as long as it doesn't contain any weird characters, so I skipped it. Also, the { } weren't necessary, and if they were used there needed to be a ; before the }... so I just removed them.)
Another option is to single-quote part of the command and double-quote another part (mixed quoting looks weird, but is perfectly legal in shell syntax):
su "$username" -c 'eval $(ssh-agent -s);'" ssh-add $folder/.ssh/id_rsa"
# ^ single-quoted part ^^ double-quoted part ^
The reason the rest of your attempts didn't work is that the delimiter between the commands (; or &&) wasn't in the quoted string, and hence was treated as a delimiter by your shell, so the second command was run under your user ID rather than as part of the su command.

Run a subshell as root

Consider you have a Linux/UNIX machine with Bash. You have a file secret.txt that only root can read. You want to use a command that takes a string as an argument, say,
sample-command <string>
Log in as a root user and run the command using the first line of the text file:
root ~ $ sample-command $(sed '1!d' secret.txt)
Can this be done by non-root, sudoer users?
Note. sudo sh -c "<command>" doesn't help since subshells don't carry over the root/sudo privilege. For example,
sarah ~ $ sudo sh -c "echo $(whoami)"
gives you sarah, not root.
Expansions like command substitution will be processed by the shell before executing the actual command line:
sudo sh -c "echo $(whoami)"
foouser
Here the shell will first run whoami, as the current user, replace the expansion by it's result and then execute
sudo sh -c "echo foouser"
Expansions doesn't happen within single quotes:
sudo sh -c 'echo "$(whoami)"'
root
In this example $(whoami) won't get processed by calling shell because it appears within single quotes. $(whoami) will therefore get expanded by subshell before calling echo.

eval bash function arguments without misescaping

In a Bash script, I want to create a function that wraps commands, printing them before executing them.
So, in a script command like this:
mkdir -p "~/new/dir/tree/"
rsync -e 'ssh -p 22' -av "src/" "user#${HOST}:dest/"
I could put a command in front of the others like this:
run mkdir -p "~/new/dir/tree/"
run rsync -e 'ssh -p 22' -av "src/" "user#${HOST}:dest/"
I define the function run as:
run (){
echo -e "FANCY FORMATING CMD> $# FANCY FORMAT ENDING"
eval "$#"
return $?
}
It works fine for most cases. When I need the command in front of run it should work with it or without it whether you remove the run part or unset the run. I mean, all three commands should work exactly the same way:
run original_comand arg1 "arg2 subarg2" etc;
unset run;
run original_comand arg1 "arg2 subarg2" etc;
original_comand arg1 "arg2 subarg2" etc;
But if I try with this line:
run rsync -e 'ssh -p 22' -av "src/" "user#${HOST}:dest/"
the -e argument became unquoted and the command actually run is
run rsync -e ssh -p 22 -av src/ user#${HOST}:dest/
If I escape the quote on the -e argument, it may work with the run command, but it would not work without it.
As far as I could read from Bash documentation, the "$#" should work, but clearly I'm missing something here.
The arguments you pass to run already underwent all sorts of expansion when they were passed to run, and are also already properly positioned, so you don't need eval to invoke the command in $1. In fact, it is an error to use eval in this case (which erroneously applies expansions and word-splitting of sorts yet another time) as you can tell by the plenty error messages you get.
The proper way to invoke the command in $1 and passing it all the arguments in "$#" (except for the first, i.e. "${#:2}"), is to simply put "$#" on a single line without eval.
Your arguments are not "unquoted" by calling your function. They are "unquoted" by putting them in a string and echoing it. The quotes are not part of the arguments, but special characters in the shell that prevents word-splitting (and in the case of single quotes, variable expansion).
The shell does word-splitting (which quotes protect against), then quote removal. This means that the quotes would be removed, yes, but that the arguments would not be split on quoted whitespace.
In
utility a "b c"
the utility would not get "b c" as its second argument, but b c (not b and c, but b<space>c). The same goes for your function.
This shows that you still get properly separated arguments in the function:
#!/bin/sh
run () {
printf 'Arg: %s\n' "$#"
}
HOST=example.com
run mkdir -p "~/new/dir/tree/"
echo '---'
run rsync -e 'ssh -p 22' -av "src/" "user#${HOST}:dest/"
This generates
Arg: mkdir
Arg: -p
Arg: ~/new/dir/tree/
---
Arg: rsync
Arg: -e
Arg: ssh -p 22
Arg: -av
Arg: src/
Arg: user#example.com:dest/
As you can see, ssh -p 22 is still delivered to printf as a separate argument, not three arguments.
Doing eval "$#" in your function will do the right thing. Escaping the quotes would definitely not do the right thing as it would include the quotes in the actual argument (mkdir \"dir\" would create a directory called "dir", including the quotes, and rsync -e \"ssh -p 22\" would be delivered to rsync as -e, "ssh, -p, 22").
Also, note that ~ is not expanded since it was in double quotes. Use $HOME instead.

Pass commands as input to another command (su, ssh, sh, etc)

I have a script where I need to start a command, then pass some additional commands as commands to that command. I tried
su
echo I should be root now:
who am I
exit
echo done.
... but it doesn't work: The su succeeds, but then the command prompt is just staring at me. If I type exit at the prompt, the echo and who am i etc start executing! And the echo done. doesn't get executed at all.
Similarly, I need for this to work over ssh:
ssh remotehost
# this should run under my account on remotehost
su
## this should run as root on remotehost
whoami
exit
## back
exit
# back
How do I solve this?
I am looking for answers which solve this in a general fashion, and which are not specific to su or ssh in particular. The intent is for this question to become a canonical for this particular pattern.
Adding to tripleee's answer:
It is important to remember that the section of the script formatted as a here-document for another shell is executed in a different shell with its own environment (and maybe even on a different machine).
If that block of your script contains parameter expansion, command substitution, and/or arithmetic expansion, then you must use the here-document facility of the shell slightly differently, depending on where you want those expansions to be performed.
1. All expansions must be performed within the scope of the parent shell.
Then the delimiter of the here document must be unquoted.
command <<DELIMITER
...
DELIMITER
Example:
#!/bin/bash
a=0
mylogin=$(whoami)
sudo sh <<END
a=1
mylogin=$(whoami)
echo a=$a
echo mylogin=$mylogin
END
echo a=$a
echo mylogin=$mylogin
Output:
a=0
mylogin=leon
a=0
mylogin=leon
2. All expansions must be performed within the scope of the child shell.
Then the delimiter of the here document must be quoted.
command <<'DELIMITER'
...
DELIMITER
Example:
#!/bin/bash
a=0
mylogin=$(whoami)
sudo sh <<'END'
a=1
mylogin=$(whoami)
echo a=$a
echo mylogin=$mylogin
END
echo a=$a
echo mylogin=$mylogin
Output:
a=1
mylogin=root
a=0
mylogin=leon
3. Some expansions must be performed in the child shell, some - in the parent.
Then the delimiter of the here document must be unquoted and you must escape those expansion expressions that must be performed in the child shell.
Example:
#!/bin/bash
a=0
mylogin=$(whoami)
sudo sh <<END
a=1
mylogin=\$(whoami)
echo a=$a
echo mylogin=\$mylogin
END
echo a=$a
echo mylogin=$mylogin
Output:
a=0
mylogin=root
a=0
mylogin=leon
A shell script is a sequence of commands. The shell will read the script file, and execute those commands one after the other.
In the usual case, there are no surprises here; but a frequent beginner error is assuming that some commands will take over from the shell, and start executing the following commands in the script file instead of the shell which is currently running this script. But that's not how it works.
Basically, scripts work exactly like interactive commands, but how exactly they work needs to be properly understood. Interactively, the shell reads a command (from standard input), runs that command (with input from standard input), and when it's done, it reads another command (from standard input).
Now, when executing a script, standard input is still the terminal (unless you used a redirection) but the commands are read from the script file, not from standard input. (The opposite would be very cumbersome indeed - any read would consume the next line of the script, cat would slurp all the rest of the script, and there would be no way to interact with it!) The script file only contains commands for the shell instance which executes it (though you can of course still use a here document etc to embed inputs as command arguments).
In other words, these "misunderstood" commands (su, ssh, sh, sudo, bash etc) when run alone (without arguments) will start an interactive shell, and in an interactive session, that's obviously fine; but when run from a script, that's very often not what you want.
All of these commands have ways to accept commands by ways other than in an interactive terminal session. Typically, each command supports a way to pass it commands as options or arguments:
su root -c 'who am i'
ssh user#remote uname -a
sh -c 'who am i; echo success'
Many of these commands will also accept commands on standard input:
printf 'uname -a; who am i; uptime' | su
printf 'uname -a; who am i; uptime' | ssh user#remote
printf 'uname -a; who am i; uptime' | sh
which also conveniently allows you to use here documents:
ssh user#remote <<'____HERE'
uname -a
who am i
uptime
____HERE
sh <<'____HERE'
uname -a
who am i
uptime
____HERE
For commands which accept a single command argument, that command can be sh or bash with multiple commands:
sudo sh -c 'uname -a; who am i; uptime'
As an aside, you generally don't need an explicit exit because the command will terminate anyway when it has executed the script (sequence of commands) you passed in for execution.
If you want a generic solution which will work for any kind of program, you can use the expect command.
Extract from the manual page:
Expect is a program that "talks" to other interactive programs according to a script. Following the script, Expect knows what can be expected from a program and what the correct response should be. An interpreted language provides branching and high-level control structures to direct the dialogue. In addition, the user can take control and interact directly when desired, afterward returning control to the script.
Here is a working example using expect:
set timeout 60
spawn sudo su -
expect "*?assword" { send "*secretpassword*\r" }
send_user "I should be root now:"
expect "#" { send "whoami\r" }
expect "#" { send "exit\r" }
send_user "Done.\n"
exit
The script can then be launched with a simple command:
$ expect -f custom.script
You can view a full example in the following page: http://www.journaldev.com/1405/expect-script-example-for-ssh-and-su-login-and-running-commands
Note: The answer proposed by #tripleee would only work if standard input could be read once at the start of the command, or if a tty had been allocated, and won't work for any interactive program.
Example of errors if you use a pipe
echo "su whoami" |ssh remotehost
--> su: must be run from a terminal
echo "sudo whoami" |ssh remotehost
--> sudo: no tty present and no askpass program specified
In SSH, you might force a TTY allocation with multiple -t parameters, but when sudo will ask for the password, it will fail.
Without the use of a program like expect any call to a function/program which might get information from stdin will make the next command fail:
ssh use#host <<'____HERE'
echo "Enter your name:"
read name
echo "ok."
____HERE
--> The `echo "ok."` string will be passed to the "read" command

How can I allow bash -c for sudoers (followed by multiple commands)?

I have this in /etc/sudoers:
%wheel myhostname =NOPASSWD: /bin/bash -c "echo foo && echo bar", \
/bin/bash -c echo foo
Executing sudo /bin/bash -c echo foo works without being prompted for a password.
However, sudo /bin/bash -c "echo foo && echo bar" still asks for a password.
I've tried many variations to this, but nothing is being accepted.
Where's the error? / How can I allow -c followed by multiple commands?
The problem is in how your shell interprets arguments. If I am in bash (most other shells work the same way), and I type the command
sudo /bin/bash -c "echo foo && echo bar"
sudo is invoked with everything after it as arguments. However, the shell processes each argument before passing it in to sudo. One of the things it does is remove quotes around quoted arguments. Therefore, the arguments that sudo gets as its argv value are an array that looks like this (one argument per line):
/bin/bash
-c
echo foo && echo bar
sudo combines these with spaces and compares that to the commands in the sudoers file (it is actually a bit more complicated than this since it does wildcard replacement, etc.). Thus, the command it actually sees you executing, for the purposes of checking permissions is
/bin/bash -c echo foo && echo bar
When I put that command in my sudoers file, I am not prompted for a password when I enter
sudo /bin/bash -c "echo foo && echo bar"
However I am also not prompted for a password when I enter any of these commands or other like them.
sudo /bin/bash "-c echo foo && echo bar"
sudo /bin/bash "-c echo" foo "&& echo" bar
sudo /bin/bash -c echo "foo && echo" bar
In general, as far as I know, there is no way for sudo (or any program) to know exactly what command got entered, only what it gets converted to by the shell for execution purposes.
At least with my sudo (OS X 10.9, sudo 1.7.10p7), quote marks in the /etc/sudoers are matched literally. That is, specifying
/bin/bash -c "echo foo && echo bar"
means that the users literally have to run
sudo /bin/bash -c '"echo foo && echo bar"'
i.e. the quote marks have to be passed to the program.
Therefore, all you have to do is just drop the quote marks in /etc/sudoers:
%wheel myhostname =NOPASSWD: /bin/bash -c echo foo && echo bar
While this looks kind of weird, it completely works on my machine: users can execute /bin/bash -c "echo foo && echo bar" without a password. This works because, according to man sudoers, the only characters that must be escaped are ',', ':', '=' and '\'.
Note that this implies that sudo is basically concatenating all the command-line args together with only spaces to determine a match: users can also execute /bin/bash -c "echo foo" "&&" "echo bar". Therefore, you must take care that none of the arguments could individually be a security risk (e.g. that foo, && and bar aren't things that could be used to exploit your computer).

Resources