X

shellスクリプト文字列のエスケープ

(Last Updated On: 2022年10月6日)

大抵のシェルスクリプトはセキュリティ対策を考慮する必要がないので与えられたパラメーターはそのまま利用されています。シェルスクリプトを利用して権限の無いユーザーが勝手なコマンドを実行できないようにするにはエスケープが必要になります。

  • setuid/setgidをしたシェルスクリプト
  • 信頼できない入力を処理するシェルスクリプト

このようなスクリプトで

  • sshなどでリモートコマンドを実行するスクリプト
  • 処理を書き出して実行するスクリプト

この場合はエスケープ(やバリデーション)が必要になります。

シェルスクリプト特殊文字

文字列を正しく安全に処理するには特別な意味を持つ特殊文字を理解する必要があります。どのような言語でも同じです。特殊文字を理解し、特殊文字を適切にエスケープすれば、文字列リテラルをコマンドとして意図しない処理をしてしまうバグを防止できます。

UNIX Power Toolsによるとシェルの特殊文字には以下のようなモノあります。

Character Where  Meaning 
ESC  csh  Filename completion.
RETURN  csh, sh  Execute command.
space  csh, sh  Argument separator.
TAB  csh, sh  Argument separator.
TAB  bash  Filename completion.
#  csh, sh  Start a comment.
`  csh, sh  Command substitution (backquotes).
"  sh  Weak quotes.
"  csh  Weak quotes.
'  sh  Strong quotes.
'  csh  Strong quotes.
\  sh  Single-character quote.
\  csh  Single-character quote. 
$var  csh, sh  Variable.
${var}  csh, sh  Same as $var.
$var:mod csh  Edit var with modifier mod
${var-default} sh  If var not set, use default.
${var=default} sh  If var not set, set it to default and use that value.
${var+instead} sh  If var set, use instead. Otherwise, null string.
${var?message} sh  If var not set, print message (else default). If var set, use its value.
${var#pat} ksh, bash Value of var with smallest pat deleted from start.
${var##pat} ksh, bash Value of var with largest pat deleted from start.
${var%pat} ksh, bash Value of var with smallest pat deleted from end.
${var%%pat} ksh, bash Value of var with largest pat deleted from end.
|  csh, sh  Pipe standard output.
|&  csh  Pipe standard output and standard error.
^  sh only  Pipe character (obsolete). 
^  csh, bash Edit previous command line.
&  csh, sh  Run program in background.
?  csh, sh  Match one character.
*  csh, sh  Match zero or more characters.
;  csh, sh  Command separator.
;;  sh  End of case statement.
~  csh, ksh, bash Home directory.
~user  csh, ksh, bash Home directory of user.
!  csh, bash Command history.
-  Programs Start of optional argument.
-  Programs Read standard input. (Only certain programs.)
$#  csh, sh  Number of arguments to script.
"$@"  sh  Original arguments to script.
$*  csh, sh  Arguments to script.
$-  sh  Flags set in shell.
$?  sh  Status of previous command.
$$  csh, sh  Process identification number.
$!  sh  Process identification number of last background job.
$<  csh  Read input from terminal.
cmd1 && cmd2 csh, sh  Execute cmd2 if cmd1 succeeds.
cmd1 || cmd2 csh, sh  Execute cmd2 if cmd1 fails.
$(..)  ksh, bash Command substitution.
((..))  ksh, bash Arithmetic evaluation. 
\. file  sh  Execute commands from file in this shell.
:  sh  Evaluate arguments, return true.
:  sh  Separate values in paths.
:  csh  Variable modifier.
[]  csh, sh  Match range of characters.
[]  sh  Test.
%job  csh, ksh, bash Identify job number.
(cmd;cmd) csh, sh  Run cmd;cmd in a subshell.
{}  csh, bash In-line expansions.
{cmd;cmd; } sh  Like (cmd;cmd) without a subshell.
>file  csh, sh  Redirect standard output.
>>file  csh, sh  Append standard output.
<file  csh, sh  Redirect standard input.
<<word  csh, sh  Read until word, do command and variable substitution.
<<\word  csh, sh  Read until word, no substitution.
<<-word  sh  Read until word, ignoring leading TABs.
>>! file csh  Append to file, even if noclobber set and file doesn't exist.
>! file  csh  Output to file, even if noclobber set and file exists.
>| file  ksh, bash Output to file, even if noclobber set and file exists.
>& file  csh  Redirect standard output and standard error to file.
m> file  sh  Redirect output file descriptor m to file.
m>> file sh  Append output file descriptor m to file.
m< file  sh  Redirect input file descriptor m from file.
<&m  sh  Take standard input from file descriptor m.
<&-  sh  Close standard input.
>&m  sh  Use file descriptor m as standard output.
>&-  sh  Close standard output.
m<&n  sh  Connect input file descriptor n to file descriptor m.
m<&-  sh  Close input file descriptor m.
n>&m  sh  Connect output file descriptor n to file descriptor m.
m>&-  sh  Close output file descriptor m.

通常、文字列リテラルはシングルクオートかダブルクオートで囲みます。これらの文字全てを文字列リテラルの中でエスケープする必要はありませんが、予期しない変数展開を防ぐにはシングルクオートとダブルクオートをエスケープするだけではダメであると分かります。

クオート文字を使わずに文字列リテラルを安全に利用するのは著しく困難であることは、上の特殊文字一覧で良く分かると思います。もしもクオートせずに文字列リテラルを使いたい場合、バリデーションして絶対に安全であることを保障してから使います。

シェルスクリプト文字列のエスケープ

先の特殊文字一覧からシェルが変わると特殊文字も変わると分かります。シェル仕様の違いを全て理解して安全にエスケープするのは手間がかかります。安全なシェルスクリプトを作りたいプログラマーとしては「文字列リテラルが文字列リテラルであること」を保障できるエスケープ方法を理解しているだけでOKです。

printfコマンドの%qが利用できます。

       %q     Output the corresponding argument in a format that can be
reused as shell input

%qによるエスケープは安全に出力できるモノだけそのまま出力(ホワイトリスト方式で出力)し、他をエスケープしてくれます。

printf命令でクオート済みに整形(エスケープ)して出力する方法が良いでしょう。以下の2つはシェルスクリプト引数のhost名とipアドレスをエスケープしています。

printf -v host "%q" "$1"
printf -v ip "%q" "$2"

host、ipは文字列リテラルの中に埋め込んでも誤ってコマンドとして解釈されることがなくなり、マニュアルに記載の通りシェルの入力として安全に利用できる形にエスケープされています。

文字列以外のフォーマット

printfは文字列以外にもで整数や浮動小数に整形することも可能です。数値をクオートして利用するのが不適切な場合に便利です。

$ printf -v v "%d" "123abc"; echo "$v"
-bash: printf: 123abc: 無効な数字です
123

$ printf -v v "%d" "x01234"; echo "$v"
-bash: printf: x01234: 無効な数字です
0

$ printf -v v "%d" "0x1234"; echo "$v"
4660
$ printf -v v "%f" "123.456abc"; echo $v
-bash: printf: 123.456abc: 無効な数字です
123.456000

$ printf -v v "%f" "123.456"; echo $v
123.456000

$ printf -v v "%f" "123e10"; echo $v
1230000000000.000000

利用可能なフォーマットには以下のようなモノがあります。

  • %b – Print the argument while expanding backslash escape sequences.
  • %q – Print the argument shell-quoted, reusable as input.
  • %d, %i – Print the argument as a signed decimal integer.
  • %u – Print the argument as an unsigned decimal integer.
  • %o – Print the argument as an unsigned octal integer.
  • %x, %X – Print the argument as an unsigned hexadecimal integer. %x prints lower-case letters and %X prints upper-case.
  • %e, %E – Print the argument as a floating-point number in exponential notation. %e prints lower-case letters and %E prints upper-case.
  • %a, %A – Print the argument as a floating-point number in hexadecimal fractional notation. %a prints lower-case letters and %A prints upper-case.
  • %g, %G – Print the argument as a floating-point number in normal or exponential notation, whichever is more appropriate for the given value and precision. %g prints lower-case letters and %G prints upper-case.
  • %c – Print the argument as a single character.
  • %f – Print the argument as a floating-point number.
  • %s – Print the argument as a string.
  • %% – Print a literal % symbol.

注意事項

通常、シェルスクリプトの変数は一つの引数としてシェルスクリプト内では扱われます。(そのような外部スクリプトなどを呼ばなければ普通は引数がコマンドとして実行されることはない。execve()などで引数配列として渡される)”%q”のクオートが特に役立つのはバッチジョブなどのスクリプトを書き出して実行する場合です。この場合、変数が文字列展開されるので、他のプログラミング言語からのコマンド実行と同じ注意が必要です。

「ファイルにコマンドを出力して実行なんてほぼない」かも知れませんが「sshを使用してリモートホストでコマンドを実行する」のは結構よく行われていると思います。この場合、”%q”エスケープが無いと安全なコマンド実行は困難になります。

printf -v quoted_args '%q ' "$@"
ssh somehost 'bash -s' <<EOF
command $quoted_args
EOF

といった感じのエスケープが必須です。

※ これはサンプルコードです!! $@で全ての引数をそのまま渡すようなプログラムだと簡単にSSRFに脆弱なプログラムになります。勿論ですがエスケープなしのパラメータ引渡しは論外にNGです。

bashの-sオプションも必要でしょう。

       -s        If the -s option is present, or if no arguments remain after option processing, then commands are read from the standard input.  This option allows the positional parameters to be set when invoking an interactive shell or when reading input through a pipe.

ファイルに書き出したり、sshでコマンドを送ったりする場合、「シェル変数の文字列展開が遅延できず、コマンドと引数の分離が不可能なる」、これがエスケープが欠かせなくなる理由です。

printfはコマンドなのでシステムによって仕様が異なります。Linuxの場合は一般的にはcoreutilsのprintfが使わているのでここで紹介した通りの動作のはずです。使用するprintfコマンドが期待する動作をするか確認してから利用します。

printfの%qを利用するとクオート無しでも一つの文字列リテラルと扱えるようにエスケープしますが、文字列は基本的にはクオートして利用する方が良いでしょう。

$ printf -v v "%q" "abc def"; echo $v
abc\ def

$ printf -v  v "%q" "abc def"; echo "$v"
abc\ def

文字列をクオートして利用する方が良いのは、もし空文字列だった場合に引数の順序が変わってしまい、それ以前のバリデーション処理を台無しにしたりするからです。

今は全てUTF-8だと思うので文字エンコーディングが問題になることは稀だと思いますが、SJISを使わなければならない、といった場合にはエンコーディングのバリデーションするべきでしょう。

参考 – コマンドライン引数によるコマンド実行

composerのリモートコード実行脆弱性

gitやhgコマンドの様に、コマンドを実行するようなオプションがあるコマンドの引数を渡す場合、エスケープしてもダメなのでバリデーションが必要です。composerのサプライチェーン脆弱性はブラックリスト型で対策しオプション文字列の開始文字”-“を廃除しています。

しかし、ブラックリスト型のセキュリティ対策は構造的に脆弱なのでホワイトリスト型対策が可能な場合はホワイトリスト型でバリデーションするべきです。$identifierとなるような文字列は言語の識別子(関数名や変数名)のようなかなり安全性が高い文字列形式(最初の一文字は英数字のみ、なと)に限定する仕様にすべきという教訓になるような脆弱性です。言語の識別子のように最初の一文字を英数字のみと限定しないと記号文字が意図しない処理につながる可能性を完全に廃除できないからです。

UNIXコマンドで直ぐに思い付く類似のコマンドはfindとそのオプションの-execですが、手元のLinux環境のbashとfindutilsのfindコマンドだとシェルスクリプト内の変数を-execの引数として渡せなくなっており、composerのgit/hgコマンドの引数としてコマンドが実行できてしまうような事、にはならないようになっています。bashとfindのどちらで対策しているのか調べていませんが、どちらで対策しているにしてもコマンドに渡す引数に注意を払っていないと問題を作る可能性が残ります。

yohgaki:
Related Post
Leave a Comment