5

I have a bash shell variable containing a string formed of multiple words delimited by whitespace. The string can contain escapes, such as escaped whitespace within a word. Words containing whitespace may alternatively be quoted.

A shell variable that is used unquoted ($FOO instead of "$FOO") becomes multiple words but quotes and escapes in the original string have no effect.

How can a string be split into words, giving consideration to quoted and escaped characters?

Background

A server offers restricted access over ssh using the ForceCommand option in the sshd_config file to force execution of a script regardless of the command-line given to the ssh client.

The script uses the variable SSH_ORIGINAL_COMMAND (which is a string, set by ssh, that contains the command-line provided to the ssh client) to set its argument list before proceeding. So, a user doing

$ ssh some_server foo 'bar car' baz

will see the script execute and it will have SSH_ORIGINAL_COMMAND set to foo bar car baz which would become four arguments when the script does

set -- ${SSH_ORIGINAL_COMMAND}

Not the desired result. So the user tries again:

$ ssh some_server foo bar\ car baz

Same result - the backslash in the second argument needs to be escaped for the client's shell so ssh sees it. What about these:

$ ssh some_server foo 'bar\ car' baz
$ ssh some_server foo bar\\ car baz

Both work, as would a printf "%q" quoting wrapper that can simplify the client-side quoting.

Client-side quoting allows ssh to send the correctly quoted string to the server so that it receives SSH_ORIGINAL_COMMAND with the backslash intact: foo bar\ car baz.

However there is still a problem because set does not consider the quoting or escaping. There is a solution:

eval set -- ${SSH_ORIGINAL_COMMAND}

but it is unacceptable. Consider

$ ssh some_server \; /bin/sh -i

Very undesirable: eval can't be used because the input can't be controlled.

What is required is the string expansion capability of eval without the execution part.

2
  • I thought I might indirect through an array and use declare instead of eval. But that's just as evil. And so is local.
    – starfry
    Nov 25, 2016 at 18:37
  • 1
    I could not find this in the manual, but declare -a m="(${s})" appears to expand quotes yet does not allow injecting a command by using a semicolon ; or a closing brace ). It does throw a runtime syntax error that cannot be caught.
    – eel ghEEz
    Nov 24, 2020 at 5:34

2 Answers 2

5

Use read:

read -a ssh_args <<< "${SSH_ORIGINAL_COMMAND}"
set -- "${ssh_args[@]}"

This will parse words from SSH_ORIGINAL_COMMAND into the array ssh_args, treating backslash (\) as an escape character. The array elements are then given as arguments to set. It works with an argument list passed through ssh like this:

$ ssh some_server foo 'bar\ car' baz
$ ssh some_server foo bar\\ car baz

A printf "%q" quoting ssh wrapper allows these:

$ sshwrap some_server foo bar\ car baz
$ sshwrap some_server foo 'bar car' baz

Here is such a wrapper example:

#!/bin/bash
h=$1; shift
QUOTE_ARGS=''
for ARG in "$@"
do
  ARG=$(printf "%q" "$ARG")
  QUOTE_ARGS="${QUOTE_ARGS} $ARG"
done
ssh "$h" "${QUOTE_ARGS}"
1

How to quote an string:

See ${parameter@operator} section in https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html

  • For a single var:
"${var@Q}"

a -> 'a', a'b -> 'a'\''b'.

  • For an array,
"@{array[@]@Q}"

will quote each element in the array then join with space into a big string.

  • For program arguments $@
"${@@Q}"

(probably require Bash 4.4+, if not available you can use printf "%q" ... but you lose the ability of quoting each elements in array)

How to de-quote the quoted string back into array

I only found 1 safe way is as @eel ghEEz pointed:

declare -a array="($QUOTED_ARGS)"

EDIT 2022/08/31: it is important to pass quoted args to avoid command injection.

More precisely, it should be

JOINED_ARGMENTS_STRING="......"
declare -a array="(${JOINED_ARGMENTS_STRING@Q})"

Sample:

cat <<'EOF' > show_args
#!/bin/bash
for arg in "$@"; do
  echo "ARG_$((++i))=$arg"
done
EOF
chmod +x show_args
cat <<'EOF' > test.sh
#!/bin/bash
QUOTED_ARGS=${@@Q}    # this is important!!!!!!
echo QUOTED_ARGS is "$QUOTED_ARGS"

echo de-quote QUOTED_ARGS
declare -a args="($QUOTED_ARGS)"
./show_args "${args[@]}"
EOF
chmod +x test.sh

Tests:

ARGS=("a a a" "b'b'b" 'c"c"c')
./show_args "${ARGS[@]}"

you can show it is an array with 3 elements

ARG_1=a a a
ARG_2=b'b'b
ARG_3=c"c"c

Let us see how the quotes and de-quotes work

./test.sh "${ARGS[@]}"

or

./test.sh "a a a" "b'b'b" 'c"c"c'

The result is

QUOTED_ARGS is 'a a a' 'b'\''b'\''b' 'c"c"c'
de-quote QUOTED_ARGS
ARG_1=a a a
ARG_2=b'b'b
ARG_3=c"c"c

EDIT 2022/08/31: added a test of injection attempts:

./test.sh "\$(echo test >&2)"

Result:

QUOTED_ARGS is '$(echo test >&2)'
de-quote QUOTED_ARGS
ARG_1=$(echo test >&2)

The "echo test" command did not get called, that is fine.

Successfully restored.

6
  • 1
    This still seems to be implemented unsafely by Bash, similarly to eval. inc="\$(echo test >&2)", then declare -a x="("${inc}"/*)" outputs test immediately (to stderr).
    – eel ghEEz
    Aug 25, 2022 at 20:32
  • @eelghEEz thanks for pointing out this, that is really dangerous.
    – osexp2003
    Aug 31, 2022 at 11:26
  • @eelghEEz, strangely, I found that ./test.sh "\$(echo test >&2)" runs well, no injection happened, wondering why.
    – osexp2003
    Aug 31, 2022 at 11:35
  • Got the reason, it is because I ran QUOTED_ARGS=${@@Q} first, so avoided the injection.
    – osexp2003
    Aug 31, 2022 at 11:36
  • @eelghEEz I have tested the injection attempts, it is fine now, no worry.
    – osexp2003
    Aug 31, 2022 at 11:45

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.