Edited at
EmacsDay 12

Emacsからの安全なシェルコマンド実行

どうも、Emacs Lispアドベントカレンダーです。嘘です。

Emacsから外部コマンドを起動する方法として、大別すると「コマンドラインシェルで実行したい文字列(コマンド名と引数をまるごと含む)を渡す関数」 (shell-command系) と、「コマンドとコマンドライン引数を個別の引数として渡す関数」 (process系) の二種類の機能に分類できます。

シェルコマンドの方が使用方法がおてがる、かつポータブル(後述)なので私は特別な理由がない限りは前者のshell-command系の使用を勧めるのですが、これは同時に典型的なバグや脆弱性の温床なので注意が必要です。


用語

いろいろややこしい



  • コマンド


    • キーを入力したり、M-xを押してコマンド名を入力すると実行されるEmacsの機能のこと

    • コマンドは関数の一種で、Emacs Lispから関数として利用することできる




  • シェル / コマンドラインシェル



    • シェルは、広義にはOSに対して人間が操作するためのUIのこと

    • 狭義にはUnixシェルと、その影響を受けたコマンドラインインタプリタのこと

    • Emacsからは shell-file-name 変数にセットされたプログラムがシェルとして利用される


      • 基本的にはSHELL環境変数にセットされたシェルか、/bin/shが利用される

      • Windowsで無設定時には cmd.exe (コマンドプロンプト)が利用される






  • シェルコマンド


    • シェル(UNIXシェルあるいはcmd.exe)を操作するためのテキストのこと

    • 起動したいプログラム名に続いて空白文字区切りでプログラムに渡すパラメータををテキストで入力して記述する記法を基本的な操作体系とする

    • プログラムに対してファイルの内容を入力する<やプログラムの出力をファイルに書き込む>、プログラムの出力を別のプログラムの入力にする|などの記法がある




  • コマンド文字列


    • この記事では、シェルにシェルコマンドとして実行させるために(Lispで)組み立ててた文字列のこと




  • プログラム外部コマンド




  • プロセス



    • プロセスとは、情報処理においてプログラムの動作中のインスタンスを意味し、プログラムのコードおよび全ての変数やその他の状態を含む。 by プロセス - Wikipedia

    • ざっくり、実行中のプログラムのこと。




今回の記事の対象としない操作

Emacsの操作中にシェルコマンドを実行するのは極めて簡単です。特定のバッファを編集中にM-!を押せば、ターミナルからコマンドラインシェルに打ち込むのと同じように、シェルコマンドを実行できます。また、範囲をリージョンで選択してC-u M-|を押してシェルコマンドを入力すると、リージョン内を標準入力としてコマンドに渡し、実行結果で置換することができます。つまりテキスト編集中にEmacsから一歩も出ずにシェル芸ができるのです。

……とまあ、これらのコマンドはミニバッファにシェルコマンドを直接入力するようになっていますが、普通にターミナルからのシェルコマンド実行に慣れてればいつも通りに実行できると思うので、特別に注意すべきことはあまりありません。

問題は人間が直接入力して実行するのではなく、シェルに実行させるシェルコマンドをスクリプトで動的に構築→実行するような場合です。これには、いつものシェル実行とは別の意識が必要になります。


コマンドの「安全な実行」とは何か

外部コマンドを意図した通りに実行できることです。具体的には、ファイルやユーザー入力文字列に(空白文字)や$";が含まれても、特定の値を欠損させたり、パラメータを破壊したりせず正常に実行できるでしょうか。ホームディレクトリのpathが/home/hoge fuga/だったとしても問題ないでしょうか。

エラーが発生して動作が停止する程度ならまだましな方で、問題は意図しないコマンドが実行させられるようなパターンです。これはいわゆるOSコマンドインジェクションと呼ばれるもので、一種の脆弱性です。


これは完全に余談ですが、OSコマンドインジェクションができようとできまいとLispパッケージ作者はインストール時(バイトコンパイル時)にすらrm -rf ~のようなシェルコマンド実行が可能なので、Lispパッケージをインストールすることは実はそれなりにリスキーで、開発者を信頼しなければできない行為です。



該当する関数と該当しない関数

shell-command系と分類するのは、以下の関数/コマンドです。



  • shell-command


    • 基本的にはコマンドとして利用する(M-!またはM-x shell-command)

    • Lispから利用することはない

    • コマンド文字列に & を含めることで非同期実行もできる




  • shell-command-to-string


    • シェルコマンドを同期的に実行し、結果を文字列として得たいときに利用する関数


    • 同期実行する(つまり、コマンド実行完了するまでブロックする)ため、時間のかかるコマンドには向かない




  • call-process-shell-command


    • コマンドを同期的に実行する関数




  • shell-command-on-region



    • M-| または C-u M-|でコマンドとして利用できる

    • 内部はcall-shell-region




  • call-shell-region


    • コマンドの


    • バッファの内容をコマンドに送ることができる


    • 実行結果を任意のバッファに書き込むことができる

    • 標準入出力や標準エラー出力を得たいときに利用する

    • コマンド文字列に & を含めることで非同期実行もできる




  • compile



    • 非同期実行しながら出力をバッファに表示するコマンドに利用する

    • 典型的にはコンパイルやユニットテストの実行など

    • コマンドを実行しながら、ユーザーにはEmacs上で別の操作をさせることができる




  • async-shell-command


    • コマンドをバックグラウンド実行できる関数

    • 中ではコマンド文字列に & を付けて shell-command を呼んでる




  • start-process-shell-command


    • シェルのプロセスを起動してシェルコマンドを実行し、processオプジェクトを返す



なんか抜けてるものがあるかもしれませんが、だいたいこんな感じです。 多すぎだろ

今回の主題ではありませんが、process系の関数はこの記事の問題が当てはまるものもありますし、当てはまらないものもあります。このあと言及する「シェルのエスケープ」の件は当てはまりません。


よろしくないシェルコマンドの組み立て


concatする

ありがちです。

(shell-command-to-string (concat "echo " a " " b " "))

変数 ab にIFS(空白文字や改行などの区切り文字)が含まれると、引数パラメータの位置がずれる(正しい引数として渡せない)など、意図しない挙動をします。また、;が含まれる場合シェルはOSコマンドインジェクションが可能です。


引数部分を " で括る

実コードで使ってるところはめったに見ませんが、これもありえます。

(shell-command-to-string (format "echo \"%s\" \"%s\"" a b))

実際動くのですが、これも変数 ab" などの文字列が含まれるとコマンド入力としての"が消える、引数パラメータの位置がずれるなど意図しない挙動が起きます。当然、OSコマンドインジェクションも可能です。

一般にコマンドラインにおいて、以下の三行は同じ結果になります。

$ echo --hoge="piyo  piyo"

$ echo "--hoge=piyo piyo"
$ echo --hoge=piyo\ \ piyo

"の位置に拘る必要はまったくありません。また、"はIFS文字や~をクォートする効果はありますが、"の中であっても$などの変数は展開されることに気をつけてください。


どうすればよいのか

shell-quote-argumentを利用します。これはシェルで特別な意味をもって解釈されうる文字をエスケープします。

(shell-command-to-string

(mapconcat #'shell-quote-argument
(list "echo" a b)
" "))

mapconcatは第二引数に渡したリストのそれぞれの要素に第一引数で渡した関数を適用し、それを第三引数の文字列で結合します。

ここでは、文字列"echo", 変数a, 変数bのそれぞれをshell-quote-argumentで変換した結果を" "(空白文字)でくっつける意味になります。


注意

shell-quote-argumentはシェルで特別な意味を持つ文字列をエスケープする目的の機能なので、process系の関数の引数に対して利用してはいけません。



combine-and-quote-stringsは使ってはいけない

shell-quote-argumentと似た関数としてcombine-and-quote-stringsがあります。これは引数に文字列のリストをとり、それぞれの要素を " でエスケープして 結合して返す関数です。

これは一見して、mapconcatshell-quote-argumentの組み合せよりもすっきり書けそうに見えます。しかしながらこの記法には罠があり、"の中でも展開される記法に対する耐性がありません。

(shell-command-to-string

(combine-and-quote-strings (list "echo" a b)))

つまり、OSコマンドインジェクションも可能です。

;; 危険

(let* ((a "$(whoami)"))
(shell-command-to-string
(combine-and-quote-strings (list "echo" a)))) ; => "tadsan\n"

;; 安全
(let* ((a "$(whoami)"))
(shell-command-to-string
(mapconcat #'shell-quote-argument
(list "echo" a)
" "))) ; => "$(whoami)\n"

そんな用法を期待すべき場面など原則ありませんので、shell-quote-argumentを使ってください。


それでもはまりがちなパターン集

ここから紹介するパターンは仕様の不備や脆弱性そのものではないが、人間の認識違いや配慮の不足によって不具合を引き起こしがちなものです。

これらの多くはshell-command系だけではなく、process系の機能でも該当します。


~の展開

(shell-command-to-string "ls ~")は通常意図通りに動きますが、以下のようにすると動かなくなります。

(shell-command-to-string

(mapconcat #'shell-quote-argument
(list "ls" "~")
" "))
; => "ls: cannot access ~: No such file or directory\n"

これは、UNIXにおいてホームディレクトリを意味する~は基本的にシェルで実行されるためです。ためしに任意のUNIX環境(macOS, Linux含む)のコマンドラインシェルで echo ~echo * を実行してみてください。これは、echoコマンドを実行する前にシェル側で展開されることを意味します。

ではどうするか。ファイル名に相当する値はexpand-file-nameを利用してシェルに渡す前に展開します。

(let ((file (expand-file-name "~")))

(shell-command-to-string
(mapconcat #'shell-quote-argument
(list "ls" file)
" ")))

これは実際に、私が開発したphpunit.elで埋め込んでしまった不具合でもあります。

Expand path to PHPUnit execubatle file by zonuexe · Pull Request #60 · nlamirault/phpunit.el


コマンドラインオプションの --

これもshell-command系だけではなく、process系の機能でも該当する問題です。

例としてcatコマンドには-nオプションがありますが-nというファイルを標準出力することと区別できません。意味は異なりますがlsにも-nオプションがあります。

これらのコマンドはオプションの解釈が優先されるため、もしコマンドライン文字列を組み立てた結果が "echo -n""cat -n" になったとき、これは期待通りに実行されません。

このようなUNIXコマンドの多くでは、オプションと区別する目的で、コマンドライン引数 -- 以降はオプション引数とは解釈しないという慣習があります。

;; 不適切

(let* ((default-directory (expand-file-name "~"))
(msg "-n"))
(shell-command-to-string
(mapconcat #'shell-quote-argument
(list "cat" "--" msg)
" ")))

;; 適切
(let* ((default-directory (expand-file-name "~"))
(msg "-n"))
(shell-command-to-string
(mapconcat #'shell-quote-argument
(list "cat" "--" msg)
" ")))

ただし、これはあくまで慣習なので、コマンドによって解釈は異なります。例としてはUNIXの標準的なコマンドであってもechoコマンドは、-- のあとはオプションとして解釈を回避できますが、 -- も自体も出力に含まれます。つまり、echoを使って(--を含まず)-n-Eといった文字列だけを出力することはできません。 (これまでechoコマンドを使ったサンプルコードを紹介してきましたが、それ自体が良くないコードだとわかりますね?1)

まして、UNIX標準ではない、その辺の誰かが作ったコマンドやスクリプトなどではこのような問題を想定して実装されてない場合が往々にして存在するので、自分ではない誰かが作ったコマンドラインアプリをラップしたLispパッケージを作成する場合は、くれぐれも十分に注意してください


リダイレクトの>やパイプの|、コマンド区切りの;、バックグラウンド実行&

process系ではなくshell-command系の機能を利用するメリット(本当?)の一つは、コマンドライン文字列内で複数のコマンドを組み合せて実行しやすいことです。このような記号はshell-quote-argumentでエスケープしないように気をつけて組み立てる必要があります。


default-directoryに気を配る

EmacsにはTRAMPと呼ばれる、リモートサーバーを擬似的なファイルシステムとして利用することができます。その中でもSSHで接続した場合は、シェルコマンドをも透過的に利用することができます

これはローカルサーバーではなくリモートサーバーにインストールされたコマンドを非常に簡単に実行できる(: SSHで接続した特定の開発サーバー上でPHPUnitを実行する)のが非常に有意義である一方で、「リモートサーバー上に対象に期待したコマンドが存在しない」「リモートサーバー上にもコマンドは存在するが、ローカルにあるファイルに(当然)アクセスできない」などといった意図しない挙動を生じることがあります。

Emacs Lispのdefault-directory変数はカレントディレクトリを意味する変数です。find-fileでファイルシステム上のファイルを開いたときのdefault-directoryはそのファイルが属するディレクトリのパスで、shell-command系の機能を実行したときのカレントディレクトリも、この値になります。

ssh hogeでアクセスできるリモートサーバー上の~/fuga.txtをEmacsのTRAMP機能を使ってリモートサーバー上のファイルを開いた場合は、buffer-file-name"/ssh:hoge:/home/myuser/fuga.txt"default-directory"/ssh:hoge:/home/myuser/"になります。

どのサーバーに存在するコマンドが実行されるべきかはコマンドの機能設計によるので対処方法はケースバイケースです。例としてはgit greprgといった検索系のコマンドはそれぞれのリモートサーバーにあるコマンドが実行されるべきですし、jqのような特定のディレクトリに依存せずに実行できるフィルタコマンドはローカルマシン上で実行しても支障ないからです。

Emacs Lispは変数がダイナミックスコープの言語であり2let式を使って変数の変更を局所化することができます。default-directoryが異常な値になると操作に支障をきたすので、default-directoryを変更するときはsetqではなくletを利用するとよいです。

(let* ((default-directory (expand-file-name "~")))

(shell-command-to-string
(mapconcat #'shell-quote-argument
(list "pwd")
" ")))

カレントディレクトリをセットする際は、コマンド文字列内でcdするのではなく、letを使ってセットする習慣をつけてください。


筆者による実装

@tadsanは外部コマンドをラップするLispパッケージをいくつも実装してきたので設計パターンについての意見や反省はいくつもあるのですが、この記事の余白がないので、筆者が開発(あるいはメンテナンスに携った)ものを紹介して短いコメントをつけるに留めます。



  • zonuexe/pandoc.el


    • コマンドを実行する部分はcall-processです

    • ローカルファイル以外も変換して表示できるように作り込んであります




  • zonuexe/php-util.el


    • PHP Modeに機能を実装する前の個人的な実験場です

    • PHPのビルトインサーバーを起動する機能はmake-comint(process系)です


    • php-util-thingatpt-php-tokencall-process-regionで実装してます




  • nlamirault/phpunit.el


    • コマンドを実行する部分はcompile です

    • PHPUnitをSSHサーバー上やDockerを使って実行するために小細工をいろいろやってます




  • emacs-php/composer.el


    • Composerのラッパーですが、コマンドと関数のどちらとしても使えるインターフェイスを提供しています

    • 実行部分がshell-command-to-stringasync-shell-commandcompile でスイッチできるよう作り込んであります




  • emacs-php/phpactor.el


    • コマンド実行そのものよりもRPCをdispatchしてphpactorコマンドと相互に通信する周りが作りこんで楽しかったです

    • 私はshell-command系だけで実装したかったのですが…


    • バッファの扱いがめんどくさい場合があって押し切られてcall-process-regionも使ってるよくばりセットです

    • この記事を書いてて気付いたのですが、call-shell-regionにしておけばよかったのでは…?




  • emacs-php/phpstan.el (flycheck-phpstan)


    • コマンドを実行する周りはFlycheckにお任せしてます

    • Flycheckの制約を抜け出してDockerを使って実行させるために邪なテクニックを編み出すのが楽しかったです




  • zonuexe/easel.el




  • emacs-php/php-runtime.el


    • Emacs LispからPHPのコードを実行するためのラッパーです

    • これはshell-command系ではなく、call-processを使って起動してます



「俺はshell-command系がおすすめだ」と言っておきながら、意外とprocess系が多かったですね…


まとめ

本当はOSコマンドインジェクションの話だけをするつもりだったんだ。信じてくれ…





  1. この例はprintfコマンドを利用することで適切に変更できます 



  2. このことは、letがレキシカルスコープを作成することとは矛盾しません