6

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
    Commented 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
    Commented 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}"
2

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
    Commented Aug 25, 2022 at 20:32
  • @eelghEEz thanks for pointing out this, that is really dangerous.
    – osexp2000
    Commented Aug 31, 2022 at 11:26
  • @eelghEEz, strangely, I found that ./test.sh "\$(echo test >&2)" runs well, no injection happened, wondering why.
    – osexp2000
    Commented Aug 31, 2022 at 11:35
  • Got the reason, it is because I ran QUOTED_ARGS=${@@Q} first, so avoided the injection.
    – osexp2000
    Commented Aug 31, 2022 at 11:36
  • @eelghEEz I have tested the injection attempts, it is fine now, no worry.
    – osexp2000
    Commented Aug 31, 2022 at 11:45

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .