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