見出し画像

[cdwdoc-2023-001] ALTLオーバーフローについて

この記事では、筆者が「A L T L エーエルティーエル オーバーフロー」(ALTL overflow)と呼んでいる概念について解説する。

「ALTLオーバーフロー」とは、プログラムを混乱させる手法の一つである。

なお、この記事はプログラミング初心者が読むことは想定していない。

 

◆  ◆  ◆  ◆  ◆  ◆
※この記事は、CC BY 4.0(表示 4.0 国際)適用です。
This article is licensed under CC BY 4.0.
https://creativecommons.org/licenses/by/4.0/deed.ja
◆  ◆  ◆  ◆  ◆  ◆

まずは出題

 

まずは、出題。

ノーヒントで攻略したいという人のために、最初にまず問題を提示することにする。この記事の中で「冒頭の出題」といえば、このセクションの中で今から提示する問題のことである。

最初に提示はしておくものの、問題を飛ばして次のセクション「根本原理について」に進んで、先に解説のほうをじっくり読んでから挑戦、という形でもかまわない。あるいは単にスルーして先に解説のほうに進んでもらってもかまわない。

ちなみにこの記事の解説部分については、シェル(BourneシェルあるいはPOSIXシェル)の詳細な仕様を忘れてしまったという人にもある程度配慮はしたつもりである。

これから提示する出題は、問題としてはシンプルである。

ユーザーの正しいパスワードを知ることなく、正当なユーザーとして誤って認識されるようにプログラムを混乱させてみよ、というものである。

すでにUnixのユーザーランドの環境があるなら、環境構築の手間などは一切ない。シェルスクリプト2つとテキストファイル1つを同じディレクトリに置くだけである。

ただし、sha256sum コマンドが利用可能であるか、それが無ければ openssl コマンドが利用可能であることが前提である。また、それぞれの指定可能なオプションや期待される出力形式は以下のようなものであることを前提としている。

$ type sha256sum 
sha256sum is /usr/bin/sha256sum
$ printf aaa | sha256sum 
9834876dcfb05cb167a5c24953eba58c4ac89b1adf57f28f2f9d09af107ee8f0  -
$ type openssl
openssl is /usr/bin/openssl
$ printf aaa | openssl sha256
SHA256(stdin)= 9834876dcfb05cb167a5c24953eba58c4ac89b1adf57f28f2f9d09af107ee8f0

なお、提示するコードが筆者の意図した通りに振る舞うことを確認した環境、それはつまりこれが「ちゃんと解ける問題」として成立していることを筆者自身が実際に動作させて確認した環境は、以下のOSである。

  • Lubuntu 22.04.1

  • Debian 11.6

  • Knoppix 5.1.1(KNOPPIX_V5.1.1CD-2007-01-04-EN.iso)

  • Knoppix 7.2.0(KNOPPIX_V7.2.0CD-2013-06-16-EN.iso)

  • Knoppix 9.1(KNOPPIX_V9.1CD-2021-01-25-EN.iso)

  • Kali Linux 2022.4(kali-linux-2022.4-vmware-amd64.7z)

  • Fedora 37 Workstation

  • Slackware 15.0

  • Tiny Core Linux 13.1のCorePlus版(CorePlus-13.1.iso)

  • Alpine Linux 3.17.1 Standard Edition(glibcではなくmusl libc)

  • FreeBSD 13.1-RELEASE

  • OpenBSD 7.2

  • Oracle Solaris 11.4

  • OpenIndiana 2022.10(OpenSolaris後継)

もしかしたら、コードの一部を修正するとmacOSでも動くかもしれない。これについてはこのセクションの終盤で述べる。

また、カーネル再構築を含めていろいろとシステムの構成を変更してしまっている場合には「実は解けない」という環境になってしまっている可能性もある。

こういう問題の作成者が環境を明確に「指定」するのはやはりライブ起動のisoがいいだろうということで、Knoppix 9.1(2021年1月リリース)のCD版の英語版のisoをVirtualBoxやVMware Workstation Playerで起動させた時(それぞれ、可能ならハードウェア構成はHDDなしでEFIは無効)こそがこの問題の正式な環境なのだ、ということにしてもいいかもしれない。

$ openssl md5 KNOPPIX_V9.1CD-2021-01-25-EN.iso
MD5(KNOPPIX_V9.1CD-2021-01-25-EN.iso)= 5f582a85d0d79c5d6c751b8b80ad8401

なお、Knoppix起動後のターミナルについては、画面左下のメニューから [System Tools] -> [Terminator] で起動できる。画面左下のChromiumのアイコンの2つ右にある赤っぽいアイコンも、このTerminatorのショートカットである。

2017年あたりからDockerが旋風を巻き起こしているが、Knoppixのようなものについては古いバージョンも含めてisoイメージファイルが今後も配布され続けてほしいと個人的には願っている。OSのブートのあり方を含めた環境そのものがまるごと冷凍保存されているというのは凄いことなのだ。

それはともかく、出題のプログラムについて。

まずは1つ目のファイルから。解く人はこれをターミナルから直接実行する。

ファイル名は cdwdoc-2023-001_challenge.sh である。

#!/bin/sh
# written by cleemy desu wayo / see [cdwdoc-2023-001] / Licensed under CC0 1.0

LANG=C   ; export LANG
LC_ALL=C ; export LC_ALL

username="$1"
password="$2"

valid_password_hash=$(cat cdwdoc-2023-001_challenge_passwd.txt \
      | grep "^${username}:" \
      | head -1 \
      | awk -F':' '{print $2}')

user_is_valid=1

if ./cdwdoc-2023-001_challenge2.sh --user-input="$password" --valid-hash="$valid_password_hash" ; then
  user_is_valid=0
fi

if [ "x$user_is_valid" = "x1" ]; then
  echo 'you are a valid user! [ user name : "'"$username"'" ]' >&2
  exit 0
else
  echo 'error: you are not a valid user' >&2
  exit 1
fi

GitLab.com 上にも置いてある( https://gitlab.com/-/snippets/2494925 )。

なお GitLab.com 上のGitLab Snippetsのページに移動してしまうと、ヒントになることがコメントなどに書かれている可能性があるので注意。ページの下のほうにあるコメント欄を視界に入れないようにすれば特に問題ない。また、筆者の他のスニペットにヒントが書かれている場合もある。

そして、2つ目のファイル。解く人はこれを直接実行してはならない。このファイルは cdwdoc-2023-001_challenge.sh から呼ばれる。

cdwdoc-2023-001_challenge2.sh ( https://gitlab.com/-/snippets/2494925 )

#!/bin/sh
# written by cleemy desu wayo / see [cdwdoc-2023-001] / Licensed under CC0 1.0

LANG=C   ; export LANG
LC_ALL=C ; export LC_ALL

get_hash() {
  if type sha256sum > /dev/null; then
    sha256sum | awk '{print $1}'
  elif type openssl > /dev/null; then
    openssl sha256 | awk '{print $2}'
  fi
}

valid_hash=`      printf '%s\n' "$2" | sed 's/^--valid-hash=//' `
user_input_hash=` printf '%s\n' "$1" | sed 's/^--user-input=//' | tr -d '\n' | get_hash`

if [ "x$user_input_hash" = "x$valid_hash" ]; then
  exit 1
fi

exit 0

以上の2つが、今回この記事のために、意図的に脆弱になるように筆者が書いたスクリプトである。

そして最後、3つ目のファイル。これはスクリプトではなくデータである。内容は3行だけで、ユーザー名とハッシュ値らしきもののリストである。要するに、ただのユーザー情報のデータベースだ。

cdwdoc-2023-001_challenge_passwd.txt
( https://gitlab.com/-/snippets/2494925 )

user1:9834876dcfb05cb167a5c24953eba58c4ac89b1adf57f28f2f9d09af107ee8f0
user2:28ac62f4e66742848b75319b3d861bc67657abb31cc2077758dcf2669dbf3c47
user3:636c65656d792064657375207761796f636c65656d792064657375207761796f

サフィックス(拡張子)が .sh のファイルが2つと .txt のファイルが1つ。これら3つを同じディレクトリに置き、.sh のファイルには実行権限を付与しておく。

$ ls -l cdwdoc-2023-001_challenge*
-rwx---r-x 1 kali kali 633 Feb  6 02:52 cdwdoc-2023-001_challenge.sh
-rwx---r-x 1 kali kali 533 Feb  6 02:50 cdwdoc-2023-001_challenge2.sh
-rw----r-- 1 kali kali 213 Jan 22 16:49 cdwdoc-2023-001_challenge_passwd.txt

そして以下のようにして cdwdoc-2023-001_challenge.sh を実行し、「you are a valid user!」と出れば準備の第1段階は完了である。

$ ./cdwdoc-2023-001_challenge.sh user1 aaa
you are a valid user! [ user name : "user1" ]

1つ目の引数がユーザー名で、2つ目の引数はパスワードである。

認証が成功したと判断されれば、「you are a valid user!」が表示される。

2つ目の引数、つまりパスワードを「aaaa」に変更して実行してみると、今度は「you are not a valid user」と出るはずである。

$ ./cdwdoc-2023-001_challenge.sh user1 aaaa
error: you are not a valid user

正しいパスワードを入力すれば「you are a valid user!」が出て、間違ったパスワードを入力すれば「you are not a valid user」が出る。そういう状態になれば、準備の第2段階が完了している。

なお、パスワードの情報が格納されている cdwdoc-2023-001_challenge_passwd.txt の1行目は以下のようになっている。

user1:9834876dcfb05cb167a5c24953eba58c4ac89b1adf57f28f2f9d09af107ee8f0

「:」で区切られていて、1つ目のカラムはユーザー名で、2つ目のカラムがパスワードのハッシュ値になっているわけだ。「aaa」という文字列のSHA256サム値を確認してみよう。

$ printf aaa | openssl sha256
SHA256(stdin)= 9834876dcfb05cb167a5c24953eba58c4ac89b1adf57f28f2f9d09af107ee8f0

user1 については正しいパスワードが「aaa」であることは分かった。

ここであらためて、今回の出題の詳細がどのようなものかを伝えることができる。

問題とはつまり、user2 についてその正しいパスワードを知ることなく「you are a valid user!」と表示されるようにプログラムを混乱させてみよ、ということだ。

なお、パスワードをでたらめに入力したとしても以下のように延々と「you are not a valid user」と出続けるはずだ。

$ ./cdwdoc-2023-001_challenge.sh user2 aaa
error: you are not a valid user
$ ./cdwdoc-2023-001_challenge.sh user2 abcde
error: you are not a valid user
$ ./cdwdoc-2023-001_challenge.sh user2 grhgergregrgghrtehger
error: you are not a valid user

実行時には、打ち込むコマンドの最初の部分は

./cdwdoc-2023-001_challenge.sh user2 

というところまではまったく同じでなければならない。つまり、1つ目の引数は必ず "user2" という5文字だ。

他にも、以下の制約がある。

  • ディレクトリを移動してはならない。

  • シェル関数や alias や環境変数の類を定義してはならない。

  • いかなるファイルも削除したりリネームしたり内容を変更したり新規作成したりしてはならない。

  • いかなるファイルの権限も変更してはならない。

  • cdwdoc-2023-001_challenge.sh や cdwdoc-2023-001_challenge2.sh の内容を加工したプログラムを実行してはならない。

  • 外部サーバーの力を借りたり、Unixの基本的なツールセット以外のプログラムを使用してはならない。

  • Bash特有の機能を使ってはならない。ターミナルにおいてもそうだし、cdwdoc-2023-001_challenge.sh の一行目が「#!/bin/sh」ではなく「#!/bin/dash」だったり「#!/bin/ash」だったり「#!/bin/busybox sh」だったり「#!/bin/ksh」だったり「#!/bin/mksh」だったりしても、攻撃が成功するようにしなければならない(それぞれのシェルがシステムに存在してるなら)。

  • 通常のシェルではないような別のプログラムから cdwdoc-2023-001_challenge.sh を呼んではならない。あくまでもターミナルから実行し、普通のシェルスクリプトとして cdwdoc-2023-001_challenge.sh が解釈されるようにしなければならない。

  • 攻撃成功時、「you are a valid user!」という文字列は、あくまでも cdwdoc-2023-001_challenge.sh に出力させなければならない。

  • 攻撃成功時、cdwdoc-2023-001_challenge.sh の終了ステータスが 0 になるようにしなければならない。

「削除」や「リネーム」や「変更」や「新規作成」や「複数回実行」をしてはならないというのは最終的な解答の中でということであって、解答にたどり着くまでにいろいろ試行錯誤するのはかまわない。

もちろん、いろいろ試したあとも最終的な解答の際にはすべて元に戻さなければならない。

もっといろんな制約をつけないと解答(解法)が無数にあるような状態になってしまっているかもしれないが、あまりいろいろ書くとそれ自体がヒントになる可能性もありそうではあるので、このへんにしておく。

ところで、コードの一部を修正すればmacOSでも「ちゃんと解ける問題」として成立する可能性がある。例えば cdwdoc-2023-001_challenge2.sh の中の get_hash() の定義を、以下のように中身が一行だけのものに書き換えてしまう。

get_hash() {
  openssl sha256 | awk '{print $NF}'
}

筆者は店頭のデモ機のMacBook ProのmacOS Ventura 13.2(Build 22D49)でコソコソと確認しただけなので、本当にこれでいいかどうかはよく分からない。「立ち飲み」ならぬ「立ちデバッグ」である。

GNU coreutilsが入っているなど、すでに sha256sum コマンドが利用可能であれば特に修正しなくてもmacOSでそのまま動くかもしれない。

2023年3月現在においては、多少改変が必要になるかもしれないが基本的にはほとんどすべてのUnixにおいて解ける問題になっていると筆者は考えている。

実際のところ、ブラウザ上で動くLinuxのjor1kでもちゃんと解ける。

この記事では、前述の冒頭の出題のコードについて何度か直接的に言及しているが、複数箇所に分散している。その箇所は「_challenge」でページ内検索すれば見つかるはずである。

なお、筆者が想定している解法(攻略法)についてはこの記事の終盤のセクション「冒頭の出題の解答例」に書いてある。

 

根本原理について

 

以下、根本原理について解説する。

冒頭の出題(前のセクションで提示した出題)をノーヒントで攻略したい人はここから先は読まないように。

 

根本原理を短く説明すると、以下のようになる。

人間同士の営みにおいて、YESとNOは対等ではない。同じように、コンピュータにおいても true と false は対等ではない。

たったこれだけで、シェルスクリプトに詳しい人なら大きなヒントになるのではないだろうか。

いわばこれを「第一ヒント」にして、もう一度挑戦してみるのもいいかもしれない。

あるいは、筆者が想像だにしないような方法で攻略してしまった人も、この「第一ヒント」をもとに別の解を探してみるのもいいかもしれない。

なお、筆者が想定している方法で攻略に成功した人なら、なぜ上記の言い方がヒントといえるのかが分かるはずである。

 

起こっていることについての、もう少し現実的な解説

 

以下、原理についてより詳細に解説していく。

シェルの詳細な仕様を忘れてしまったという人にも、ある程度配慮して書いているつもりである。

前のセクションで提示した「第一ヒント」だけで冒頭の出題を攻略したい人はここから先は読まないように。

 

このALTLオーバーフロー(ALTL overflow)の原理は、筆者が2012年の後半か2013年の前半あたりに思いついたものである。

おそらく同じことを思いついた人は筆者以外にもいるだろうし、もうすでに別の名前がついているのかもしれない。

大筋としてはこれはプログラミングの堅牢性けんろうせいについての話題であり、セキュリティリスクにつながるようなケースはごく一部であると筆者は考えている。

また、運用・管理の視点というよりはコーディングスタイル寄りの話であり、システム構成の視点というよりは言語設計寄り・ライブラリ設計寄りの話である。そしてエンジニアリングあるいはコンピュータサイエンスとしては非常に根本的で哲学的なものをはらんでおり、人工言語のあり方としての普遍的な性質に関わる領域でありながら、数学の基礎論寄りの話ではない。

なお、筆者はこの件についての脆弱性の実例を見つけたことはない。実例というのはつまり、現実に稼働しているシステムやオープンソースのプロジェクトなどでこれが現実的に悪用可能(exploitable)な脆弱性になっているというような、そういう例のことである。

実際に脆弱性につながっているような例が必ずあるはずだという確信はあるものの、もし世界中のあらゆるコードを精査してどこにも見つからなかったとしても、筆者にとってはどうでもいいことである。

現実に存在している山ではなく、自分の頭の中だけに存在している仮想的な山を登ることのほうが、筆者にとっては重要なのである。

そういうわけで今回説明するALTLオーバーフローに関しては、2023年3月26日時点では純粋に理論だけがあって、過去の事例については一切知らないし自分で見つけた脆弱性も一切存在しないという状態である。

ちなみに筆者が去年(2022年)に報告したsedインジェクションについては、まず理論的にありうるということに気づいて攻撃パターンを理論的に「整備」してから自分で実例を探してみて、実際に報告・修正につながった。これが zgrep および xzgrep の CVE-2022-1271 と、HestiaCPの CVE-2022-1509 である。

sedインジェクション(sed injection)というのは、その名の通りSQLインジェクションのsed版を筆者がそう呼んでいるものである。SQLのコードを動的に生成する時に起こるのがSQLインジェクションだが、同じようにsedのコードを動的に生成する時(典型的にはシェルスクリプトの中に "s/$before/$after/" のようなパターンがある時)に起こるのがsedインジェクションである。だから呼称のあり方が「CRLFインジェクション」といったものよりも「SQLインジェクション」に近い。そういうことを含めての、「SQLインジェクションのsed版」なのである。

このsedインジェクションについては、sed やシェルスクリプトとは直接関係のない理論的なことについていろいろ考えている時に、その副産物としてsedインジェクションというものがありうるということに2013年の後半に気づいたのが発端である。

なお、筆者が「sedインジェクション」と呼んでいるような脆弱性としては、例えば2005年の時点ですでに CVE-2005-0758 (zgrep などの脆弱性)があったわけなのだが、筆者がsedインジェクションの可能性に気づいた時にはこの脆弱性の存在については知らなかった。そして結果的には CVE-2022-1271 というのは、CVE-2005-0758 に対応するために追加されたsedインジェクションが起こらないようにするためのセキュリティ対策のコードをすり抜ける方法があることを筆者が発見した、ということになるのだ。

今回提示するALTLオーバーフローについては、理論的な探索の際の副産物のようなものではなく、はじまりは偶然だ。

2012年後半か2013年前半に自作のシェルスクリプトを書く際に様々な検証をしていた時、「Argument list too long」というエラーが出ることがあるのに偶然気づいたのが発端である。その時点では単純に筆者はそのエラーの存在や ARG_MAX の存在を知らなかったために、「何なんだこれは」という驚きとともに色んな可能性を考えたわけだ。そしてそのうちに、セキュリティリスクにもなりうることに気づいたというわけである。

そう、ALTLオーバーフローの「ALTL」というのは「Argument list too long」の略である。

実際には「Argument list too long」のエラーを目にしたことはそれより過去にもあったはずではあるのだが、おそらくその頃はこのエラーは単にターミナルからシェルを利用している時のファイルの展開にまつわるものだ、くらいにしか思っていなかったのだろう。

今回この記事で説明しようとしているALTLオーバーフローというのは、局所的に起こっている現象だけに着目すればsedインジェクションとは何の関係もない。

しかし、sedインジェクションの対策をしようとして何らかの文字列のチェックや無害化(サニタイズ)のためのコードがある時に、ALTLオーバーフローを引き起こすことによってその対策をすり抜けることができるような場合が、理論的には存在しているのである。まあ、これはあくまでも、理論的にはということになるが。

さらにいえば、SQLインジェクションを含めたあらゆるインジェクション系の脆弱性はもちろん、ディレクトリトラバーサルなどを含めた様々な脆弱性の対策のためのコードを、ALTLオーバーフローによって無効化できる可能性があることになる。

では、この「Argument list too long」というエラーを実際に手元で起こす簡単な例を見てみよう。

ファイルを大量に生成してからファイル展開を利用する方法もあるが、ALTLオーバーフローにおいて重要なのは単一の巨大な文字列である。

そういうわけで、まずはUnixのシェル環境で巨大な文字列をどうやって手軽に生成するかを考える。

yes コマンドを利用する方法は以下のとおりである。この例では10文字だけである。

$ str="$(yes 'a' | head -10 | tr -d '\n')"
$ /bin/echo "$str" 
aaaaaaaaaa

「head -10」の数字の部分を変えれば、好きなサイズの文字列が生成できる。

yes を使わずに seq や jot や dd や printf でも似たことができるが、yes の結果を加工したほうが応用が効きやすい。次のセクション「シェルスクリプトにおける現実的な注意点」で実際に登場するが、2文字以上の文字列を連続させたい時に便利なのだ。

例えば「ABC,」という文字列を繰り返したい場合。

$ str="$(yes 'ABC,' | head -10 | tr -d '\n')"
$ /bin/echo "$str" 
ABC,ABC,ABC,ABC,ABC,ABC,ABC,ABC,ABC,ABC,

「../」という文字列を繰り返したい場合。

$ str="$(yes '../' | head -10 | tr -d '\n')"
$ /bin/echo "$str" 
../../../../../../../../../../

GNU coreutilsを前提にするなら、yes と head の組み合わせは「shuf -r」でも代用できる。ただし筆者はすぐに shuf のオプションの仕様を忘れてしまう。Solaris 11.4(2018年リリース)の場合は yes と head を組み合わせて巨大な文字列を生成するのは遅いので shuf を使うといいかもしれない。yes が SIGPIPE で終了するのが困る時にも shuf は使える。なお、shuf に -r オプションが登場したのはGNU coreutils 8.22(2013年リリース)である。

ところで、冗談や皮肉ではなく筆者は「ソースコードこそが第一級のドキュメント」であると考えている。コードというものはたいてい、人間とコンピュータと、その双方が「読める」ことを志向しているのだから。

だからこの記事の中で上記のようにコードの断片やコマンドの例が出て来たら、実際に実行して結果を確かめてみて、さらに部分的に変更して実行してみたりするなど、サーッと読み流すのではなく咀嚼そしゃくをしながら読むということを推奨したいのである。GUIのアプリケーションのインストール画面のキャプチャを貼り付けているのとはわけが違うのだ。

ではここで、実際に巨大な文字列を生成してエラーを起こしてみることにする。「head -10」のところを巨大な数に変更して生成した変数 str を、/bin/echo の引数として出力するのである。すると「Argument list too long」というエラーが出るはずである。

$ str="$(yes 'a' | head -999999 | tr -d '\n')"
$ /bin/echo "$str" | wc -c
bash: /bin/echo: Argument list too long
0

この例では、「Argument list too long」というエラーメッセージを出力しているのは bash である。bash が execve(2) などのシステムコールでサイズの制限に直面したため、このエラーメッセージを出力したのだ。そもそも /bin/echo は実行できなかったため、/bin/echo がエラーメッセージを出力しているわけではない。

「bash -c」の時の挙動でいいなら strace で簡易的に追跡できるかもしれない。なお、以下のようなものについては眺めるだけを推奨。

$ strace bash -c 'str=$(yes a | head -999999 | tr -d "\n") ; /bin/echo "$str" 2> /dev/null' 2>&1 | tail -18
rt_sigprocmask(SIG_SETMASK, [INT TERM CHLD], NULL, 8) = 0
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7ff1a1f2ea10) = 73005
rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
rt_sigprocmask(SIG_BLOCK, [CHLD], [], 8) = 0
rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=73005, si_uid=1000, si_status=126, si_utime=0, si_stime=0} ---
wait4(-1, [{WIFEXITED(s) && WEXITSTATUS(s) == 126}], WNOHANG, NULL) = 73005
wait4(-1, 0x7ffef70b3590, WNOHANG, NULL) = -1 ECHILD (No child processes)
rt_sigreturn({mask=[]})                 = 0
rt_sigprocmask(SIG_BLOCK, [CHLD], [], 8) = 0
rt_sigaction(SIGINT, {sa_handler=0x563c750b3dc0, sa_mask=[], sa_flags=SA_RESTORER, sa_restorer=0x7ff1a1f73520}, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=SA_RESTORER, sa_restorer=0x7ff1a1f73520}, 8) = 0
rt_sigaction(SIGINT, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=SA_RESTORER, sa_restorer=0x7ff1a1f73520}, {sa_handler=0x563c750b3dc0, sa_mask=[], sa_flags=SA_RESTORER, sa_restorer=0x7ff1a1f73520}, 8) = 0
ioctl(2, TIOCGWINSZ, 0x7ffef70b3c00)    = -1 ENOTTY (Inappropriate ioctl for device)
rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
rt_sigprocmask(SIG_BLOCK, [CHLD], [], 8) = 0
rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
exit_group(126)                         = ?
+++ exited with 126 +++

Solaris 11(SunOS 5.11)など、環境によっては999999回程度ではエラーが出ないかもしれない。

また、Solaris 11などのように「Argument list too long」ではなく「Arg list too long」だったりするかもしれないし、環境によっては「引数リストが長すぎます」や「引数のリストが長すぎます」のような日本語のエラーメッセージになるかもしれない。

伝統的には、Unixにおいて「Argument list too long」のエラーと関連が深いシステム構成変数(system configuration variable)は ARG_MAX である。また、マクロ定数としての ARG_MAX はカーネルのコンパイル時に変更可能である。

ただし、Linuxにおいてはカーネル2.6.23(2007年10月リリース)において ARG_MAX 周辺の挙動が大幅に変更されており、さらにカーネル2.6.25(2008年4月リリース)でも若干の変更があったようだ。

また、この記事で考えているようなものは攻撃のために1つの引数を意図的に巨大にしようとするのがメインなわけで、カーネル2.6.23以降のLinuxにおいてはコマンドライン全体のサイズよりも1つの引数の制限についてのマクロ定数 MAX_ARG_STRLEN が重要ということになるのかもしれない。

シェルスクリプトの書籍としては比較的最近のもの、例えば『UNIXシェルスクリプト マスターピース132』(2014年6月刊)の「032 大量のログファイルがあるディレクトリ内のファイルに一括したコマンドを実施する」のセクションにおいても、Linuxカーネル2.6.23およびそれ以降における ARG_MAX 周辺のことについては考慮していないような解説がされていたりするので要注意である。

Linuxにおけるこのあたりの事情については、筆者もあまり詳しくは知らない。以下のURLが参考になるかもしれない。

このページの内容は重要な情報なので、アーカイブサイトのURLもいくつか提示しておく。

Qiitaでシェルスクリプトに関する凄まじいボリュームの文章を継続的にポストし続けている人による2022年12月の以下の質問の投稿も参考になるかもしれない。

2014年のものだが、Stack Exchangeでの以下の回答にも MAX_ARG_STRLEN を含めた詳細な解説がある。

カーネル2.6.23で ARG_MAX 周辺の挙動が大幅に変わる前のLinuxカーネルを気軽に試すには、Knoppix 5.1.1(2007年1月リリースでカーネル2.6.19)のisoをVirtualBox上で起動してみるのがいいかもしれない。

$ openssl md5 KNOPPIX_V5.1.1CD-2007-01-04-EN.iso 
MD5(KNOPPIX_V5.1.1CD-2007-01-04-EN.iso)= 379e2f9712834c8cef3efa6912f30755

以下はKnoppix 5.1.1での ARG_MAX 周辺や libcのバージョンなど。こういった環境情報についても、とりあえずは「眺めるだけ」を推奨。

$ getconf ARG_MAX
131072
$ getconf _POSIX_ARG_MAX
131072
$ ulimit -s
8192
$ arch
i686
$ getconf GNU_LIBC_VERSION
glibc 2.3.6
$ ldd --version
ldd (GNU libc) 2.3.6
Copyright (C) 2005 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.
$ cat /proc/version
Linux version 2.6.19 (root@Knoppix) (gcc version 4.1.2 20061028 (prerelease) (Debian 4.1.1-19)) #7 SMP PREEMPT Sun Dec 17 22:01:07 CET 2006
$ dpkg -S /usr/bin/getconf
libc6: /usr/bin/getconf
$ dpkg -S /usr/bin/ldd
libc6: /usr/bin/ldd

2023年3月現在においては、ARG_MAX 周辺の挙動が大幅に変わったあとのLinuxカーネルでありつつglibc環境ではないようなものを気軽に試すにはAlpine Linuxが適しているかもしれない。「getconf ARG_MAX」はまるで昔のLinuxのように 131072 を出力する。

以下はAlpine Linux 3.17.1 Standard Edition(2022年リリース)の場合。

$ getconf ARG_MAX
131072
$ getconf _POSIX_ARG_MAX
4096
$ ulimit -s
8192
$ arch
x86_64
$ getconf GNU_LIBC_VERSION
getconf: GNU_LIBC_VERSION: unknown variable
$ ldd --version
musl libc (x86_64)
Version 1.2.3
Dynamic Program Loader
Usage: /lib/ld-musl-x86_64.so.1 [options] [--] pathname
$ cat /proc/version
Linux version 5.15.90-0-lts (buildozer@build-3-17-x86_64) (gcc (Alpine 12.2.1_git20220924-r4) 12.2.1 20220924, GNU ld (GNU Binutils) 2.39) #1-Alpine SMP Wed, 25 Jan 2023 08:18:30 +0000

以下はLubuntu 22.04.1(2022年リリース)の場合。

$ getconf ARG_MAX
2097152
$ getconf _POSIX_ARG_MAX
2097152
$ ulimit -s
8192
$ arch
x86_64
$ getconf GNU_LIBC_VERSION
glibc 2.35
$ ldd --version
ldd (Ubuntu GLIBC 2.35-0ubuntu3.1) 2.35
Copyright (C) 2022 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.
$ cat /proc/version
Linux version 5.15.0-66-generic (buildd@lcy02-amd64-076) (gcc (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0, GNU ld (GNU Binutils for Ubuntu) 2.38) #73-Ubuntu SMP Fri Feb 3 14:23:37 UTC 2023
$ dpkg -S /usr/bin/getconf
libc-bin: /usr/bin/getconf
$ dpkg -S /usr/bin/ldd
libc-bin: /usr/bin/ldd

Fedora 37 WorkstationやSlackware 15.0も含めて、モダンな64ビットのLinuxデスクトップ環境では、素直にインストールすればたいてい「getconf ARG_MAX」も「getconf _POSIX_ARG_MAX」も 2097152(20MiB)になる。

ちなみにTiny Core Linux 13.1のCorePlus版には getconf がなかったりする。

最近のLinux限定のこととして1つの引数の長さ制限の MAX_ARG_STRLEN のほうが重要かもしれないと述べたが、この MAX_ARG_STRLEN については、glibc環境で「getconf MAX_ARG_STRLEN」を実行しても「Unrecognized variable」と出る。

$ getconf MAX_ARG_STRLEN
getconf: Unrecognized variable `MAX_ARG_STRLEN'

/usr/include/linux/binfmts.h というインクルードファイルがある場合には、以下のようなマクロ定数 MAX_ARG_STRLEN の定義の行が見つかるかもしれない(そしてKnoppix 5.1.1のような古い環境では binfmts.h にこのような行はない代わりに MAX_ARG_PAGES についての行がある)。

$ grep -i 'define.*max_arg_strlen' /usr/include/linux/binfmts.h
#define MAX_ARG_STRLEN (PAGE_SIZE * 32)

2023年3月時点では、Ubuntuを含めたDebian系を使っていて libc6-dev や linux-libc-dev などのパッケージが入っておらず /usr/include/ 以下にインクルードファイルがない場合は、以下のようにするとマクロ定数 MAX_ARG_STRLEN が定義されているインクルードファイルが見つかりやすいかもしれない。

$ find /usr/src -type f | xargs grep -i max_arg_strlen 2> /dev/null

ところで、前述のように binfmts.h では MAX_ARG_STRLEN は「(PAGE_SIZE * 32)」と定義されている。

これはつまりページサイズの32倍。HugePagesの機能を使っているようなプロセスでない限り、Linuxで x86_64 アーキテクチャならページサイズは 4096(4KiB)である。

$ getconf PAGE_SIZE
4096
$ arch
x86_64

4096 の32倍ということは、131072(128KiB)。

$ awk 'BEGIN{ print 4096 * 32 }'
131072

1つの引数が131072バイト(128KiB)を超えるかどうかが重要なわけだ。

2023年3月26日時点での最新のLinuxカーネルのソースツリーの exec.c において、MAX_ARG_STRLEN による長さチェックは以下の箇所である。
https://github.com/torvalds/linux/blob/e1212e9b6f06016c62b1ee6fe7772293b90e695a/fs/exec.c#L292-L295

#ifdef により、この箇所はカーネルコンフィグレーションの CONFIG_MMU が有効の時のみコンパイルされる。

$ grep -i config_mmu= < /boot/config-$(uname -r)
CONFIG_MMU=y

ALTLオーバーフローを意図的に引き起こそうとする時には、こういったカーネルの挙動の詳細について正確に把握している必要はない。

ただし、最近のLinuxにおけるALTLオーバーフローについては、コマンドライン全体の長さ制限と1つの引数の長さ制限、この両方の制限が存在しているのだということを理解しておく必要はある。

2.6.23(2007年10月リリース)およびそれ以降のカーネルのLinuxでは、「/bin/echo "$str$str"」のように連結するとエラーになるのに「/bin/echo "$str" "$str"」のように複数の引数になるように分割すればエラーが出ない、ということが起こりやすくなっているわけだ。

$ str="$(yes 'a' | head -131071 | tr -d '\n')"
$ /bin/echo "$str" | wc -c
131072
$ /bin/echo "x$str" | wc -c
bash: /bin/echo: Argument list too long
0
$ /bin/echo "$str$str" | wc -c
bash: /bin/echo: Argument list too long
0
$ /bin/echo "$str" "$str" | wc -c
262144
$ /bin/echo "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" | wc -c
1966080
$ /bin/echo "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" | wc -c
bash: /bin/echo: Argument list too long
0
$ cat /proc/version
Linux version 5.15.0-66-generic (buildd@lcy02-amd64-076) (gcc (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0, GNU ld (GNU Binutils for Ubuntu) 2.38) #73-Ubuntu SMP Fri Feb 3 14:23:37 UTC 2023

上記の実行結果を見ても何が起きているのかよく理解できない状態のまま書籍やWeb上から情報を集めようとすると、余計に混乱するかもしれない。最近のLinuxでのことについては、まずは上記結果を熟読することを推奨したい。

なお、Knoppix 5.1.1(2007年1月リリース、カーネル2.6.19)のような古い環境では、上記のように引数を分割すればエラーが出にくくなるというようなことはなく、あくまでもコマンドライン全体のサイズが重要になる。

また2023年3月時点では、Linux以外のUnix全般において、コマンドライン全体のサイズが重要なのだと考えてよさそうだ。

制限が存在している以上は潜在的にはALTLオーバーフローを引き起こせる可能性があるため、どのOSがより安全かというような比較はあまり意味がない。シェルスクリプトの内容によって、ある場合には最近のLinuxのほうが攻撃が成功しやすいけれども、別の場合には最近のLinuxのほうが攻撃が成功しにくい、ということが起こる。

ところで、最近のLinuxでは「ulimit -s 16384」のように数字を指定して ulimit を実行すると、glibc環境では「getconf ARG_MAX」の値はあっさりと変わり、コマンドライン全体の長さ制限も変わる。ただし1つの引数の長さ制限が変わるわけではない。

$ str="$(yes 'a' | head -131071 | tr -d '\n')"
$ /bin/echo "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" | wc -c
bash: /bin/echo: Argument list too long
0
$ ulimit -s
8192
$ getconf ARG_MAX
2097152
$ ulimit -s 16384
$ ulimit -s
16384
$ getconf ARG_MAX
4194304
$ /bin/echo "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" "$str" | wc -c
2097152
$ /bin/echo "x$str" | wc -c
bash: /bin/echo: Argument list too long
0
$ cat /proc/version
Linux version 5.15.0-66-generic (buildd@lcy02-amd64-076) (gcc (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0, GNU ld (GNU Binutils for Ubuntu) 2.38) #73-Ubuntu SMP Fri Feb 3 14:23:37 UTC 2023

Alpine Linux 3.17.1 Standard Edition(2022年リリース)の場合は、「ulimit -s 16384」を実行しても「getconf ARG_MAX」は相変わらず 131072 を出力し、でもコマンドライン全体の長さ制限は確かに変わっている、という状態になるはずである。

ちなみに ulimit はシェルのビルトイン(組み込み)である。

ところで、このセクションではここまでずっと /bin/echo を使ってきた。

当然ながらここを echo に変更すれば、ビルトインの echo を使うのでたいていの環境ではエラーが出ないようになる。

$ str="$(yes 'a' | head -999999 | tr -d '\n')"
$ echo "$str" | wc -c
1000000

「wc -c」はバイト数のカウントである。正常にこの「wc -c」にデータが渡るとバイト数がカウントされて出力されるが、エラーになって wc に何もデータが渡らなかった場合は wc の出力は 0 になってしまうわけだ。

echo がビルトインかどうかは type コマンドによって確認できる。なお zsh のビルトインの which を除いて、たいていの which はシェルのビルトインかどうかを無視して通っているパスから探そうとする。

$ type echo
echo is a shell builtin
$ which echo
/usr/bin/echo

ちなみに、たいていの環境ではシェルのビルトインの機能である ${#str} という書き方によって文字数をカウントできる。

$ str="$(yes 'a' | head -999999 | tr -d '\n')"
$ echo "${#str}"
999999

echo は改行を付加するので、「echo "$str" | wc -c」の結果は str の中身のサイズ(つまり ${#str} の展開後)よりも1バイト多くなる場合がある。

なお、${#str} という書き方はバイト数ではなく文字数のカウントのため、マルチバイト文字がある場合は環境変数の影響を受けるかもしれない。

$ echo $LANG
ja_JP.UTF-8
$ str_hiragana=$(printf '\343\201\202')
$ echo "$str_hiragana"
あ
$ echo "${#str_hiragana}"
1
$ LANG=C
$ echo "${#str_hiragana}"
3
$ echo $BASH_VERSION
5.1.16(1)-release

さてここで、grep の終了ステータスについて考えてみよう。

$ echo abc | grep z > /dev/null ; echo $?
1                                                                                                                                                                
$ echo abc | grep a > /dev/null ; echo $?
0

見つからなければ1、見つかれば0。

これを利用して、if文での条件判定や「&&」「||」の短絡評価が使える。

$ echo abc | grep z > /dev/null && echo 'FOUND!'
$ echo abc | grep a > /dev/null && echo 'FOUND!'
FOUND!

この例では見つからなければ何も出力せず、見つかれば「FOUND!」を出力する。

では、先ほどのように「Argument list too long」が意図的に起こるようにするとどうなるか。

$ str="$(yes 'a' | head -999999 | tr -d '\n')"
$ /bin/echo "$str" | grep a > /dev/null ; echo $?
bash: /bin/echo: Argument list too long
1

終了ステータスは 1 になった。当然、以下のように短絡評価を利用した時も「FOUND!」は出ない。

$ str="$(yes 'a' | head -999999 | tr -d '\n')"
$ /bin/echo "$str" | grep a > /dev/null && echo 'FOUND!'
bash: /bin/echo: Argument list too long

冷静に考えると恐ろしいことが起こっている。これはつまり、シェル変数 str には999999個というものすごい量の「a」が含まれているはずだったにも関わらず、「grep a」による検出が失敗しているのである。

grep の終了ステータスを何らかの形で条件判定に利用しているコードには、分かりにくいバグが隠れているかもしれないわけだ。

もちろん、grep に限らずだ。エラーが出ているのは grep ではなく、その前の /bin/echo のところである。

そして、終了ステータスだけでなく出力が問題になることもある。例えば代入文だ。以下の例では変数 pickup への代入が失敗し、pickup が空文字列になってしまっている。

$ str="$(yes 'abc' | head -999999 | tr -d '\n')"
$ pickup="$(/bin/echo "$str" | tr -dc a)"
bash: /bin/echo: Argument list too long
$ echo "$pickup"

$ echo "${#pickup}"
0

ちなみに /bin/echo の実行ができずに「Argument list too long」のエラーが出た場合、その /bin/echo の終了ステータスはOSごとに違う。たいていは 126 になることが期待できる。

以下はLubuntu 22.04.1(2022年リリース)の場合。bash と dash(/bin/sh)両方で試している。

$ str="$(yes 'a' | head -999999 | tr -d '\n')"
$ /bin/echo "$str" > /dev/null
bash: /bin/echo: Argument list too long
$ echo $?
126
$ echo $BASH_VERSION
5.1.16(1)-release
$ /bin/sh
$ str="$(yes 'a' | head -999999 | tr -d '\n')"
$ /bin/echo "$str" > /dev/null
/bin/sh: 2: /bin/echo: Argument list too long
$ echo $?
126
$ cat /proc/version
Linux version 5.15.0-66-generic (buildd@lcy02-amd64-076) (gcc (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0, GNU ld (GNU Binutils for Ubuntu) 2.38) #73-Ubuntu SMP Fri Feb 3 14:23:37 UTC 2023

OpenBSD 7.2(2022年10月リリース)の /bin/sh では 126 ではなく 1 になるかもしれない。

$ str="$(yes 'a' | head -999999 | tr -d '\n')"
$ /bin/echo "$str" > /dev/null
/bin/sh: /bin/echo: Argument list too long
$ echo $?
1
$ uname -a
OpenBSD vm1.localdomain 7.2 GENERIC#728 amd64

Zsh では 127 になるかもしれない。

いずれにせよ、これらは /bin/echo の出力をパイプで grep に渡したあとは grep の終了ステータスが重要になる。

結局のところ、「真」と「偽」という非常に基本的かつ根本的な部分において、シェルスクリプトの場合は慎重にならざるをえないのである。

ところで、./configure スクリプトを自動生成するGNU Autoconfというツールがある。このGNU Autoconfのドキュメントの中にシェルスクリプトの移植性(互換性)についての解説があってこれがなかなかに内容が充実していて面白いのだが、ここの true コマンドについての記述が味わい深い。

心配しないでください.我々が知っている限りtrueには移植性があり ます.それにもかかわらず,常に組み込みコマンドというわけではなく(例えば Bash 1.x),移植性の高いシェルのコミュニティは,:の使用を好みが ちです.これには副作用があります.falseがtrueより移 植性が高いかどうか尋ねてみたときのAlexandre Oliva の回答です.

それらが存在しない場合,シェルは,falseに対しては正しく, trueに対しては正しくない,異常終了のステータスを生成するので, ある意味ではそのとおりです.

Autoconf: 10. 移植性のあるシェルプログラミング

この箇所は以下のURLで確認できる。

原文は以下の通りである。

Don’t worry: as far as we know true is portable. Nevertheless, it’s not always a builtin (e.g., Bash 1.x), and the portable shell community tends to prefer using :. This has a funny side effect: when asked whether false is more portable than true Alexandre Oliva answered:

In a sense, yes, because if it doesn’t exist, the shell will produce an exit status of failure, which is correct for false, but not for true.

GNU Autoconf 2.71 manual - 11 Portable Shell Programming

原文については、例えば以下のURLで確認できる。

2012年の後半だったか2013年の前半だったか、筆者はこの箇所を読んだ時に異様な感覚の中に引きずり込まれた。

まずこの true コマンドと false コマンドの非対称性の話題が強く印象に残っていたからこそ、「Argument list too long」というエラーの詳細を知った時に何か宿命的なものを感じた、というわけである。

前のセクション「根本原理について」の中で、シェルスクリプトに詳しい人だったら「true と false は対等ではない」という文章だけでも大きなヒントになると述べたのは、シェルスクリプトにおける堅牢性についてじっくり考えていた時期がある人であればどこかでこういった話題と出会ったことがあるのではないかと思ったからである。

ちなみに上記のドキュメント、false コマンドのところにはSolarisの /bin/false の終了ステータスは 1 ではなく 255 だと書かれており、これはこれで衝撃的に感じる人もいるかもしれない。実際に確認してみるとSolaris 11.4(2018年リリース)で確かにそうなる。

$ /bin/false
$ echo $?
255
$ uname -a
SunOS solaris 5.11 11.4.0.15.0 i86pc i386 i86pc

OpenIndiana 2022.10(2022年リリース)でもそうなる。

$ /bin/false
$ echo $?
255
$ uname -a
SunOS vm1 5.11 illumos-b8af4a8966 i86pc i386 i86pc

なお、このドキュメントの web.sfc.wide.ad.jp にある邦訳については、2023年3月時点で確認できる限りは古いバージョン(2.59)のものの翻訳しかないことに注意する必要がある。Autoconf 2.59というのは、ソフトウェアのリリースとしては2003年12月であり20年近くも前になる。

例えば $(commands) という書き方について、邦訳(バージョン2.59)のほうでは「残念ながら,まだ全体的にサポートされていません.」(原文では「Unfortunately it is not yet widely supported.」)とあるが、バージョン2.71の英語のドキュメントのほうでは「Although it is almost universally supported, unfortunately ...」(ほぼ例外なくサポートされてはいるものの、残念ながら…)と表現が変わっている。

該当箇所はそれぞれ以下のURL。

筆者がこの記事を執筆している2023年3月時点ではバージョン2.71のドキュメントが最新だが、以下のURLからもっと新しいものが入手できるかもしれない。

なお、バージョン2.59の頃の英語の原文は以下で読める。

ところで、ここで人間同士の営みというものに目を移してみよう。

人間同士の場合、「YESとNOは対等ではない」ということがいわれる。

こういった言い方は、通常は何らかの意味での力関係の差が意識されていることが多い。

たとえ力関係の差のようなものがほとんど関係ないような場面であったとしても、例えば何らかの疲れ切った人間の集団を前にして「この集団の中から歩ける人だけを抽出したい」と考えた時に、一人一人に対して物理的に口頭で問いかけをしていって明確に「歩けません」と回答があった人以外は全員歩けるはずだと本気で考えるのは馬鹿げている。

気絶していたりすれば何も回答できない可能性があるし、その場にいない(つまり「存在しない」)場合は回答が物理的に不可能である。また「問いかけ」という行為自体が途中で失敗したために「何も問いかけられなかった」と認識してしまう場合があるかもしれない。

肉体という実体があるから、これは当然のことだ。

純粋に論理的な世界における「真」と「偽」をコンピュータの世界で表現しようとした際には、その「真」と「偽」は何らかの形で実体を持つことになる。

論理的な世界と現実世界のギャップが、「真」と「偽」という最も根本的な部分において鋭く立ち現れてくるのである。

 

シェルスクリプトにおける現実的な注意点

シェルスクリプトをあくまでもスクリプト言語の一つとしてみなして、コーディングにおける現実的な注意点を述べていきたい。ただし網羅的なものではないことに注意してほしい。

まず「set -e」はあてにならない。これについてはGNU Autoconfのドキュメントを参照。

該当箇所は以下のURL。
https://www.gnu.org/savannah-checkouts/gnu/autoconf/manual/autoconf-2.71/autoconf.html#index-set
(web.sfc.wide.ad.jp にある邦訳のほうは古いバージョンの翻訳なので、「set -e」の詳細についての箇所はないことに注意)

また、FreeBSDのmanページでも巨大なスクリプトでは -e に依存しないよう推奨されている。

https://man.freebsd.org/cgi/man.cgi?query=sh&apropos=0&sektion=0&manpath=FreeBSD+13.1-RELEASE&arch=default&format=html

Bash限定ということなら、SIGPIPE がもたらす影響に気をつけていれば「set -e」や「set -o pipefail」はそれなりに有用かもしれない。これについては次のセクション「Bash限定の話題」で後述する。

実際には2023年3月現在の段階では、「set -e」はもう少しあてにしてもいい存在なのかもしれず、「Bash限定」という言い方は狭く設定しすぎなのかもしれないが、とりあえずこの記事では「Bash限定」という言い方をする。

なお、ここで「set -e」が「あてにならない」という時、シェルによって実装がまちまちで移植性(互換性)の観点からあてにならないということと、ALTLオーバーフローにおいてはif文などの条件判定や短絡評価が重要だからあてにできないということ、2つの側面がある。

「set -e」はシェルの言語としての性質上、if文などの条件判定や短絡評価の左側では発動しない。これについては「シェルスクリプト以外の言語におけるALTLオーバーフロー」のセクションで後述する。

さて、set に頼らずに堅牢にするためには、何よりもまずはシェル変数の使用そのものを減らすことができないか考えてみたほうが良さそうだ。

そもそもコマンドに引数を渡す機会がなければ、「Argument list too long」のエラーは起こらないのである。

自分のスクリプトはなるべくフィルタプログラムにするというのも1つの方法だ。

冒頭の出題の場合、スクリプトを分割して cdwdoc-2023-001_challenge2.sh を用意しているわけだが、分割するのなら片方は純粋なフィルタ、例えばパスワードを標準入力から受け取ってサム値を標準出力に出力するだけのようなものでも良さそうである。

そのフィルタプログラムの出力結果については変数に入れてもいいのかもしれない。あとはビルトイン(組み込み)の test (あるいは [ ... ] )で使うだけなのであれば。

また、変数の単純な出力についても考える必要がありそうだ。フィルタにするにしても、パイプラインの起点で変数の出力をしたいという場面は多そうだ。

つまり、/bin/echo はやめようということである。

でも正直なところ、どんな場合でも /bin/echo を排除できるのかどうかは筆者にはよく分からない。macOSでは /bin/echo を使うべきだと考える人もいる。職場のコーディング規約やプロジェクトの歴史的事情などで /bin/echo の使用を強制されることもあるかもしれない。

もし可能なのであれば、echo の代わりに printf で代替できないかというのを検討するのもいいかもしれない。

ただし printf を利用したコードも、たとえ現時点では完璧に見えるようなものであったとしても、もしかしたら将来的に様々なエッジケースに対応するためのコードが色んな人によってガンガン追加されていくうちにいつの間にかそのプロジェクトでは「printf」はシェル関数になっていて、その関数内では特定のパターンの時だけビルトイン(組み込み)でないコマンドを呼ぶようになっている……というような可能性もありえなくはない。

他にも、alias コマンドであったり、bash や zsh にはビルトインコマンドの無効化をすることができる enable コマンドのようなものがあり、これもやはりコードの中の「printf」の意味が将来的に変更される要因になる。

また、OpenBSDの /bin/sh は pdksh で、printf がビルトインではない。 同じくOpenBSDからフォークしたMirBSDの /bin/sh は mksh というシェルで、やはり printf がビルトインではない。

以下はOpenBSD 7.2(2022年10月リリース)で確認したもの。

$ str="$(yes 'a' | head -999999 | tr -d '\n')"
$ printf "$str" | wc -c
/bin/sh: printf: Argument list too long
       0
$ type printf
printf is /usr/bin/printf
$ uname -a
OpenBSD vm1.localdomain 7.2 GENERIC#728 amd64

移植性(互換性)を考慮しつつ将来的に意味が変わりにくいものを指向するなら、「<<」を利用したヒアドキュメント(here documents)がある。ただし複数行になる。

$ str="$(yes 'a' | head -999999 | tr -d '\n')"
$ /bin/echo "$str" | wc -c
bash: /bin/echo: Argument list too long
0
$ wc -c <<EOS
> $str
> EOS
1000000

パイプでつなごうとして奇妙なコードになることもあるかもしれない。

$ str="$(yes 'a' | head -999999 | tr -d '\n')"
$ { wc -c | sed 's/^[^0-9]*/_/' ; } <<EOS
> $str
> EOS
_1000000

書こうと思えばグルーピングで書ける、ということを知っておくのは安心材料にはなるかもしれない。

ヒアドキュメントの移植性の注意点については、GNU Autoconfのドキュメントも要確認である。

該当箇所はそれぞれ以下のURL。邦訳のほうは古いので情報が少ないことに注意。

ちなみに、『フルスクラッチから1日でCMSを作る シェルスクリプト高速開発手法入門』という本(第1版は2014年刊、なお筆者は第2版については未見)の第4章の index.2.cgi では、以下のような文字列の無害化(サニタイズ)の箇所がある。

page=$(tr -dc 'a-zA-Z0-9_' <<< "${QUERY_STRING:2}")

『フルスクラッチから1日でCMSを作る シェルスクリプト高速開発手法入門』

GitHub.com 上に置かれている書籍サンプルコードでは、以下の箇所である。
https://github.com/ryuichiueda/BashCMSBookCodes/blob/81eb97c4a40eb304365f8db40b1a07acc9ab11f3/bashcms.remote.chap4/index.2.cgi#L6

またindex.3.cgiおよびそれ以降でも、この書き方が踏襲とうしゅうされているようだ。

この書き方は、「set -e」をしていない場合であってもおそらく安全である。

「<<<」はヒアストリング(here strings)というものである。「ヒア文字列」と訳されることもある。Bashのマニュアルでは「ヒアドキュメントの一種」(A variant of here documents)と説明されている。bash や ksh93 においては、「<<<」を使うなら「Argument list too long」のエラーは出ないし、パイプでつないでいく時にも分かりやすいコードになる。

筆者にとっては間違いを指摘することよりも「安全宣言」のほうがはるかに緊張するが、ここで「安全」という言葉を使うのは他にも理由がある。

この箇所はif文の条件判定の中などではなく変数 page への代入でしかないため、もし仮にここで何か予想だにしないようなエラーが出たとしても page が空文字列になるだけの可能性が高いのだ。そして直後に page が空文字列だった場合の「page=top」の初期化があるので、基本的にはエラーが起こったら「top」という文字列で初期化されるはずだと期待していいことになる。

代入であればいつでもどんな時でも安全というわけではなく、この本のこの箇所の書き方であれば安全、というだけである。

例えば、もしも tr コマンドのオプションが「-dc」ではなく「-d」で、使用不可な文字だけを抽出するために「tr -d 'a-zA-Z0-9_'」というような書き方をしていて、「<<<」ではなく /bin/echo を使っているとまずい場合があるかもしれない。代入の箇所でこけると、実際には危険な文字が含まれているにも関わらず、危険な文字が存在しなかったように見えてしまう。

以下は、危険な文字を抽出できている場合。

$ str='aaaaaaaaaa!"#$%&'
$ echo "${#str}"
16
$ danger=$(/bin/echo "$str" | tr -d 'a-zA-Z0-9_')
$ echo "$danger"
!"#$%&
$ echo "${#danger}"
6

以下は、危険な文字をうまく抽出できていない場合。

$ str="$(yes 'a' | head -999999 | tr -d '\n')"'!"#$%&'
$ echo "${#str}"
1000005
$ danger=$(/bin/echo "$str" | tr -d 'a-zA-Z0-9_')
bash: /bin/echo: Argument list too long
$ echo "$danger"

$ echo "${#danger}"
0

この場合、変数 danger が空文字列かどうかによって安全かどうかを判定しようとすると、まずいことになるわけだ。

たとえBashであってもこれは「set -u」では検出できない。ただしBashやZshには「set -o pipefail」があり、これで検出することは可能である。これは次のセクション「Bash限定の話題」で後述する。

ちなみにヒアストリング(here strings)の「<<<」については、Bashでなら以下のような無茶なデータ量でも特に問題ない。

$ str="$(yes 'a-' | head -99999999 | tr -d '\n')"
$ wc -c <<< "$str"
199999999
$ tr -dc a-z <<< "$str" | wc -c
99999999
$ echo $BASH_VERSION
5.1.16(1)-release

ksh93(ksh93u+m)でも「<<<」は大丈夫。

$ ksh
$ str="$(yes 'a-' | head -99999999 | tr -d '\n')"
yes: standard output: Connection reset by peer
$ wc -c <<< "$str"
199999999
$ tr -dc a-z <<< "$str" | wc -c
99999999
$ echo $KSH_VERSION
Version AJM 93u+m/1.0.0-beta.2 2021-12-17

巨大な文字列生成のところで「yes 'a-' 2> /dev/null | ...」のようにしておかないと「Connection reset by peer」というエラーメッセージがあるかもしれないが、これは SIGPIPE シグナルによるものである。

もちろん、Zshにも「<<<」はある。

また、Solaris 11.4(2018年リリース)やOpenIndiana 2022.10(2022年リリース)の /bin/sh にも「<<<」はある。

$ wc -c <<< aaa
4
$ uname -a
SunOS solaris 5.11 11.4.0.15.0 i86pc i386 i86pc

ちなみにdashだと「<<<」は使えない。

$ dash
$ wc -c <<< aaa
dash: 1: Syntax error: redirection unexpected

BusyBox shも「<<<」は使えない。

$ busybox sh


BusyBox v1.30.1 (Ubuntu 1:1.30.1-7ubuntu3) built-in shell (ash)
Enter 'help' for a list of built-in commands.

~ $ wc -c <<< aaa
sh: syntax error: unexpected redirection

OpenBSD 7.2(2022年10月リリース)の /bin/sh(pdksh)も「<<<」は使えない。

$ wc -c <<< aaa
/bin/sh: syntax error: `< ' unexpected
$ uname -a
OpenBSD vm1.localdomain 7.2 GENERIC#728 amd64

FreeBSD 13.1-RELEASE(2022年リリース)の /bin/sh、GhostBSD 22.06.18(2022年リリース)の /bin/sh、NetBSD 9.3(2022年リリース)の /bin/sh、DragonFly BSD 6.4.0(2023年リリース)の /bin/sh などでも「<<<」は使えない。

ちなみにBashやZshで「set -o pipefail」をしている時は、よりヒアストリング(「<<<」)の価値は高まるかもしれない。これは次のセクション「Bash限定の話題」で後述する。

「<<」や「<<<」などのヒアドキュメントの類を使うというのは「Argument list too long」のようなエラーを起こさないという発想だが、そういうものとは別のベクトルの話として、こういったエラーが起こったとしても安全であるようにはできないものなのだろうか。

先ほどは変数への代入でしかないために仮にエラーになっても大きな問題にならないと述べたが、そういったこと以外で注目したいのは初期化のあり方についてである。

冒頭の出題におけるコードでは、まず初期化の段階で「安全側に倒す」ということをやっていない。

cdwdoc-2023-001_challenge.sh( https://gitlab.com/-/snippets/2494925 )には、以下のような初期化のコードがある。

user_is_valid=1

このあとに、正当なユーザーでは「ない」ということが確実になってから「user_is_valid=0」としている。

そうではなく、まず以下のように初期化をしておいたほうがよかったのではないだろうか。

user_is_valid=0

そしてこのあとに、絶対に、もう絶対に、この人は間違いなく正規のユーザーだから、ということが確実になった段階ではじめて「user_is_valid=1」としたほうがいいのではないだろうか。

初動がまずいのだ。最初からこういう方針でコードを書き始めていれば、正規のユーザーかどうかの条件判定のところで「Argument list too long」が出たとしてもif文の中に入らないため、正規のユーザーと誤認することはない。

たとえプログラマーが「Argument list too long」のエラーを知らなかったとしても、大きな問題とはならなかったかもしれないのである。

こういうことについては、静的解析ツールは指摘してくれないかもしれない。

このことに関連して、コンピュータの中でのことではなく、人間が機器を使って判定するような状況について考えてみよう。

例えば、体温が37.5℃以上の人を絶対に建物の中に入れたくないというような場合。

この時、「37.5℃以上が表示されたら、その人は建物の中に入れないようにしてください」という指示には曖昧さがあることになる。もし「37.5℃以上の人を絶対に建物の中に入れたくない」というのが最優先の方針であるならば、実際の手続きは「37.4℃あるいはそれ以下が表示された場合のみ、その人を建物の中に入れる」というようなものになるはずなのだ。なぜならば、体温を測定する機器が故障して何も表示しなくなるかもしれないからだ。

シェルスクリプトにおいては、たとえ未知のエラーが出たとしても安全なコードになるようにする方法は他にもある。

ここで、初期化をめぐる方針以外に「ガード節」(guard clause)との関係についても考えてみたい。

ガード節とは、例外的状況だった場合は関数の冒頭などでさっさと抜けるためのものだ。「アーリー・リターン」(early return)や「早期リターン」という言い方がされることもある。

昨今では宗教的な聖典として崇められることもあるらしい『リーダブルコード』という本(邦訳も原著も2012年刊)では「7.5 関数から早く返す」や「7.7 ネストを浅くする」のセクションで、このガード節について解説されている。また、ガード節とはややニュアンスが違うものとして、関数から早めに抜けることによってロジック全体の見通しをよくする例として「8.5 例: 複雑なロジックと格闘する」のセクションに興味深い例が載っている。

このガード節というものは通常、冒頭で何らかの形で条件判定がある。

if ...(some check)... ; then
  return 1
fi

...(some sort of sensitive process)...

シェルスクリプトの場合は、上記のような書き方は潜在的にはすべて危ういということになる。なぜなら「...(some check)...」の箇所でエラーが起こった場合、冒頭で抜けるはずのものが抜けずにそのまま本体のほうに処理が進んでしまうのである。

実際に動くコードで示そう。

cdwdoc-2023-001_sample_dir.sh ( https://gitlab.com/-/snippets/2487375 )

#!/bin/sh
# written by cleemy desu wayo / see [cdwdoc-2023-001] / Licensed under CC0 1.0

# too optimistic guard clause
if /bin/echo "x$1$2$3" | grep '[^a-z0-9/]' > /dev/null ; then
  echo "error: invalid dir"
  exit 1
fi

user_name="$1"
dir="$2"
option="$3"

realpath "/home/$user_name/$dir"
exit 0

通常はこのスクリプトは「/home/」で始まる文字列を出力するが、ALTLオーバーフローを利用するとこのスクリプトに「/root」という文字列を出力させることができる。

なお、このスクリプトは基本的には最近のLinux限定のものだ。カーネル2.6.23およびそれ以降が前提である。また、/usr/bin/realpath はGNU coreutilsのものであるか、もしくは最近のbusyboxへのsymlinkになっている必要がある。最近のbusyboxというより、最近のglibcにリンクしているbusyboxと言うべきかもしれない。

そういうわけで、例えばAlpine Linux 3.17.1 Standard Edition(2022年リリース)では筆者が意図したようには動作しない。

以下のように、realpath が巨大な文字列を受け取っても律儀に解釈するのが前提である。

$ str="$(yes '../' | head -43684 | tr -d '\n')"
$ wc -c <<< "${str}root"
131057
$ realpath "${str}root"
/root
$ realpath --version
realpath (GNU coreutils) 8.32
Copyright (C) 2020 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Written by Padraig Brady.

Knoppix 7.2.0(KNOPPIX_V7.2.0CD-2013-06-16-EN.iso、2013年リリース、カーネル3.9.6、GNU coreutilsは8.13)の場合では、最初にまず以下のようにして /usr/bin/realpath を作成しておく必要があるかもしれない。

$ sudo ln -s /bin/busybox /usr/bin/realpath

また、このようにしてもKnoppix 7.2.0の場合は攻撃成功時に「/root」ではなく「/UNIONFS/root」を出力するが、これは想定内である。

このスクリプトについても、Knoppix 9.1(2021年1月リリース)のCD版の英語版こそが筆者の意図した通りの振る舞いを見せる環境だ、と指定してもいいのかもしれない。ただし、Knoppix 9.1もやはり「/root」ではなく「/UNIONFS/root」を出力するのが「正解」である。

このセクションでこれから提示する実行結果については、特に明示されない限りはLubuntu 22.04.1でのものである。

さて、このスクリプトでは、ほぼ一目瞭然だとは思うが5行目から8行目がガード節になっている。

抜粋すると以下の箇所だ。

if /bin/echo "x$1$2$3" | grep '[^a-z0-9/]' > /dev/null ; then
  echo "error: invalid dir"
  exit 1
fi

このガード節では、$1 と $2 と $3 を連結したものを grep に渡して、もし怪しい文字が見つかったら、つまりアルファベット小文字や数字や「/」ではないものが見つかったら、if文の中に入って「exit 1」で抜ける。

このガード節によって、最後ほうにある realpath コマンドが変なものを出力したりしないように「ガード」しているわけだ。

なお、"x$1$2$3" のように先頭に「x」を挿入しているのは、$1 がハイフンで始まっていた時に echo が混乱しないようにという配慮である。/bin/echo 以外にも expr などにおけるこういった「配慮」がALTLオーバーフローにおいては命取りになることもあるが、この例においてはこの「x」よりも余分な $3 のほうが問題である。

このスクリプトの想定している使い方は以下のようなものだ。

$ ./cdwdoc-2023-001_sample_dir.sh "user1" "abc"
/home/user1/abc

なお、「user1」の部分は環境に合わせて変える必要がある。この例では、/home/user1 というディレクトリが存在しているのが前提だ。

さらに /home/user1/abc が存在している状態なら、2つ目のコマンドライン引数に「/」が含まれるケースをいろいろと試すことができる。

$ ls -l /home/user1/abc
total 0
$ ./cdwdoc-2023-001_sample_dir.sh "user1" "abc/"
/home/user1/abc
$ ./cdwdoc-2023-001_sample_dir.sh "user1" "abc////"
/home/user1/abc
$ ./cdwdoc-2023-001_sample_dir.sh "user1" "abc/def"
/home/user1/abc/def
$ ./cdwdoc-2023-001_sample_dir.sh "user1" "////abc////def////"
/home/user1/abc/def
$ ./cdwdoc-2023-001_sample_dir.sh "user1" "abc/def/../"
error: invalid dir

2つ目のコマンドライン引数は、「../」のようなものを含むことは許可されない。「error: invalid dir」というエラーメッセージを出力して終了する。

「.」という、[a-z0-9/] でない文字が含まれるためにガード節の中の「return 1」によってプログラムは無事に終了し、realpath コマンドのところまで処理が進まない。

このため、「/root」を表示させようとして2つ目のコマンドライン引数を「../../root」のようにしてもうまくいかない。

$ ./cdwdoc-2023-001_sample_dir.sh "user1" "../../root"
error: invalid dir

でも以下のように途方もない回数「../../../../../../」と繰り返すような文字列を渡し、さらに3つ目の引数を指定すると、「/root」を表示させることができてしまう。

$ str="$(yes '../' | head -43684 | tr -d '\n')"
$ ./cdwdoc-2023-001_sample_dir.sh "user1" "${str}root" "aaaaaaaaaaaaaaaa"
./cdwdoc-2023-001_sample_dir.sh: 4: /bin/echo: Argument list too long
/root
$ echo $?
0

「head -43684」の数字の部分や3つ目の引数の "aaaaaaaaaaaaaaaa" の数は環境によって変える必要があるかもしれない。/bin/echo の箇所では「Argument list too long」のエラーが起こるけれども最後の realpath コマンドではエラーが起こらない、というようなちょうど良い長さにする必要があるのだ。

さて、このスクリプト(cdwdoc-2023-001_sample_dir.sh)の場合は、「Argument list too long」のエラーが出ることがあるためにガード節が意図したように機能してないことが問題だった。

では、「Argument list too long」のようなものを含めて、何か想像だにしないようなエラーが出たような時にもガード節によって抜けるようにできないものだろうか。

if文では「if ! ...」のように否定形を使う、短絡評価でなるべく「||」を使うようにするというのはどうだろうか。

ただし、前述のGNU Autoconfのドキュメントによると、「if ! ...」のような書き方は互換性がないとのことである。

該当箇所はそれぞれ以下のURL。邦訳のほうは古いので情報が少ないことに注意。

というわけで、シェルスクリプトにおけるガード節は、移植性を意識するなら以下のように書くのが防御的(defensive)といえそうである。

if ...(some check)... ; then :; else
  return 1
fi

...(some sort of sensitive process)...

問題を修正した新しいバージョン cdwdoc-2023-001_sample_dir2.sh を考えてみる。

変更したのはif文のところだけである。

cdwdoc-2023-001_sample_dir2.sh ( https://gitlab.com/-/snippets/2487377 )

# maybe robust to ALTL overflow
if /bin/echo "x$1$2$3" | grep -v '[^a-z0-9/]' > /dev/null ; then :; else

さっき攻略できたのと同じ方法で攻撃してみよう。

$ str="$(yes '../' | head -43684 | tr -d '\n')"
$ ./cdwdoc-2023-001_sample_dir2.sh "user1" "${str}root" "aaaaaaaaaaaaaaaa"
./cdwdoc-2023-001_sample_dir2.sh: 4: /bin/echo: Argument list too long
error: invalid dir
$ echo $?
1

今度は「/root」は出力されていない。終了ステータスも 1 だ。

この新しいバージョン cdwdoc-2023-001_sample_dir2.sh の場合は、ガード節で「Argument list too long」のエラーが出たにも関わらず、ガード節の中の「exit 1」によって無事に抜けているのである。

/bin/echo を使っているにも関わらず、想定外のことが起こっても安全側に倒れるようになっているため、大きな問題にはならないというのがポイントだ。

ただしこの修正版は「grep -v '[^a-z0-9/]'」が二重の否定だから非直感的で分かりにくい。-v の時の grep の終了ステータスのあり方もあって、頭がこんがらがってきそうだ。

$ printf '' | grep a ; echo $?
1
$ printf '' | grep -v a ; echo $?
1

このため、-v は使わずに「grep '^[a-z0-9/]$'」としたくなる人もいるかもしれない。いやでもやっぱり正規表現の「*」は重くなりそうだ、だからここは「-v」を使いたいのだ、と思う人もいるかもしれない。

「; then :; else」なんて書き方はまさにバッドノウハウともいえるものだ。現実的には、Bash限定なら「if !」を積極的に使って可読性を高めたほうがいいし、そもそも /bin/echo ではなく「<<<」が使ったほうがいいだろう。

また、「そもそも」ということでいうなら、ifでも短絡評価でもなく case が使えないかというのを考えてみたほうがいいかもしれない。

GNU Autoconfのドキュメントでも、test コマンドの解説のところで case や expr の使用が推奨されている。古いバージョン(2.59)のほうには case はビルトイン(組み込み)なので速いという記載もあるが、それとは別の視点としてビルトインなのでALTLオーバーフローの影響を受けないと考えることもできそうだ。なお expr のほうについてはビルトインではない。

該当箇所はそれぞれ以下のURL。邦訳のほうは古いので微妙に内容が違うことに注意。

ただし、ここでは現実的なエンジニアリングとして妥当な書き方かどうかということとは別に、こんなに際どい要素がたくさんあるようにみえるにも関わらず実は安全であるというような、そういう書き方ができるということに着目したいのである。

ちなみに『リーダブルコード』の「7.2 if/else ブロックの並び順」(2012年刊の邦訳ではP.87)では以下のようにある。

条件は否定形よりも肯定形を使う。例えば、if (!debug) ではなく、if (debug) を使う。

『リーダブルコード』

そうなのだ。「if !」を積極的に使おう、などと推奨するのは上記の原則と真正面から「衝突」することになってしまうのである。

ちなみに、ガード節のif文で「if !」を積極的に使ったり短絡評価ではなるべく「||」を使うようにするというのは、必ずしもシェルスクリプト限定のバッドノウハウとはいえないものがある。これについてはのちのセクション「シェルスクリプト以外の言語におけるALTLオーバーフロー」であらためて述べる。

なお、ここまでで述べてきたことは、ガード節で抜けやすくするのが「安全側に倒す」というものになっているような状況が前提である。

もし危険なユーザーだったら最後にtrueを返す(シェルスクリプトの場合は「exit 0」や「return 0」で終了)というような関数の場合、ガード節で抜けにくくするのが「安全側に倒す」ものとなり、反転することに注意する必要がある。

そもそも新しい関数(あるいはコマンド)を用意しようという時には、ガード節で抜けやすくするのが「安全側に倒す」ものになるように統一するのが望ましいということになりそうだが、システム全体が複雑になってくるとそう簡単にはいかないかもしれない。

ところで、これまで考えてきたスクリプトはみな、自分に渡されたコマンドライン引数をそのまま使用していた。

ここで、テキストファイルから読み取ったデータを利用する場合にどうなるのか考えてみよう。それはつまり、あらかじめ攻撃者がテキストファイルの中に巨大な文字列を紛れ込ませることができるような場合である。

cdwdoc-2023-001_sample_dir3.sh ( https://gitlab.com/-/snippets/2488619 )

#!/bin/sh
# written by cleemy desu wayo / see [cdwdoc-2023-001] / Licensed under CC0 1.0

user_home_dir=$(grep "^$1"':' < passwd.txt | head -1 | awk -F: '{print $6}')

[ "dummy$user_home_dir" = "dummy" ] && exit 1

# too optimistic
if /bin/echo "dummy$user_home_dir" | grep '[^a-z0-9/]' > /dev/null ; then
  echo "error: invalid dir" >&2
  exit 1
fi

realpath "$user_home_dir"
exit 0

このコードはカレントディレクトリにある passwd.txt から文字列を抽出して、最後に realpath に渡している。渡す前に、文字列のチェックのコードがある。

cdwdoc-2023-001_sample_dir.sh などではコマンドライン引数を加工してから最後に realpath に渡していたわけだが、今回はテキストファイルから抽出したものを realpath に渡すようになっている。

if文のところでは攻撃が成功しやすいように「x」ではなく「dummy」となっているが、基本的には cdwdoc-2023-001_sample_dir.sh のif文と同じだ。

passwd.txt は /etc/passwd と似ているところがあり、1つ目のカラムがユーザー名で6つ目のカラムがディレクトリであるようなデータを想定している。

/etc/passwd をそのまま使うこともできる。

$ cp /etc/passwd passwd.txt
$ ./cdwdoc-2023-001_sample_dir3.sh root
/root
$ ./cdwdoc-2023-001_sample_dir3.sh games
/usr/games

これから示す例では、/home/user1 というディレクトリが存在しているのが前提である。

新規で passwd.txt を作り直して試してみる。まずは6つ目のカラムが「/home/user1」になっているもの。

$ echo 'user1:::::/home/user1:/bin/sh' > passwd.txt
$ ./cdwdoc-2023-001_sample_dir3.sh user1
/home/user1

では6つ目のカラムが「/home/user1/../../root」のようなものだった場合どうなるか。

$ echo 'user2:::::/home/user1/../../root:/bin/sh' > passwd.txt
$ ./cdwdoc-2023-001_sample_dir3.sh user2
error: invalid dir

ちゃんと不正なものと認識できている。ではこうするとどうなるか。

$ printf 'user3:::::/home/user1/' > passwd.txt
$ yes '../' | head -43684 | tr -d '\n' >> passwd.txt
$ printf 'root:/bin/sh\n' >> passwd.txt
$ ./cdwdoc-2023-001_sample_dir3.sh user3
./cdwdoc-2023-001_sample_dir3.sh: 9: /bin/echo: Argument list too long
/root

やはり「/root」を出力してしまった。

これまでの例で cdwdoc-2023-001_sample_dir.sh をスクリプト単体としてターミナルから試す際には、試そうとする人自身が引数の長さの制約の影響を受けるような状況ばかりだったわけだが、cdwdoc-2023-001_sample_dir3.sh の場合は攻撃者が passwd.txt に巨大な文字列を紛れ込ませる際にはそういう制約の影響を受けるとは限らない。

cdwdoc-2023-001_sample_dir.sh の場合はたとえ外部のプログラムから利用される場合でも、攻撃者はサイズについて3つの制約を受ける。

  1. cdwdoc-2023-001_sample_dir.sh が起動できる程度には小さい

  2. /bin/echo の箇所でこける程度には大きい

  3. realpath が起動できる程度には小さい

cdwdoc-2023-001_sample_dir3.sh の場合はこれ自体の起動のことは攻撃者は考える必要はない。制約は以下の2つだけだ。

  1. /bin/echo の箇所でこける程度には大きい

  2. realpath が起動できる程度には小さい

現実的には、cdwdoc-2023-001_sample_dir3.sh の場合は passwd.txt を更新するプログラムにおいてユーザーがいきなり「/root」のようなものを登録することはできないものの、「/home/user1/../../root」のようなものであれば登録できるようになっているというのが前提、ということになるだろう。例えば、ユーザーが登録しようとしている文字列の先頭が「/home/」で始まっているかどうかだけは厳密にチェックしているというように。

また、攻撃者がシンボリックリンクを作成したりはできないというのも前提になるだろう。realpath はシンボリックリンクをたどった結果を出力する。GNU coreutilsの realpath 限定ということでいいなら、「realpath -s」でシンボリックリンクをたどらないようにすることは可能ではある。

これはあくまでも例としてテキストファイルを利用しているが、SQLiteを利用したシェルスクリプトでは、攻撃者が攻撃対象のマシン上に巨大な文字列を保存できるようなルートが多数存在しているかもしれない。

また、攻撃者が攻撃対象のマシンに巨大な文字列を保存するという発想ではなく、例えばシェルスクリプトで書かれたクローラーがあるような場合に、脆弱なクローラーが攻撃用ページを読み込んだ瞬間にALTLオーバーフローが起こるようにする、というのも理論上は可能だ。

cdwdoc-2023-001_sample_dir3.sh の場合は、/bin/echo の箇所以外にも「grep "^$1"':'」という箇所にも要注目である。

user_home_dir=$(grep "^$1"':' < passwd.txt | head -1 | awk -F: '{print $6}')

$1 が巨大だった場合に grep の起動に失敗することがありうるわけだ。

ただし、この場合は grep の起動に失敗しても変数 user_home_dir が空になるだけで、攻撃者が悪意のあるデータを読み込ませたり出来るわけではない。

/bin/echo のような書き方は一切しないという場合でも、grep の引数を動的に変更したいというニーズはとても高い。2014年リリースのGNU grep 2.17では大幅な高速化に成功したり、最近ではRust製の ripgrep が話題だったりする。もし grep のようなものがシェルのビルトインとして存在していれば、と思うこともあるが、そういう発想はシェルの良さを損なうということになるかもしれない。

ただし、現状のシェルでも複雑な正規表現が必要ないなら前述のようにビルトインの case を使うという手はある。

なお、このセクションでの realpath を使ったサンプルは、改行を含む文字列が渡された場合のことについては考慮していないことに注意してほしい。

ガード節の中にもう一つ抜けるパターンを用意してそこで行数によって判定する場合は、行数が「1以外はすべて怪しい」とみなすロジックがいいということになる。エラーなどで異常終了した場合は行数が「0」になるかもしれず、「2以上なら怪しい」というロジックだとすり抜けてしまう可能性が出てくるのである。

$ str="$(yes 'a' | head -999999)"
$ line_num="$(/bin/echo "$str" | grep ^ | wc -l)"
bash: /bin/echo: Argument list too long
$ echo "$line_num"
0

ちなみに「grep ^」は末尾に改行がない場合に追加するためのものである。grepより少し遅いかもしれないが「awk 1」などでも同じ効果が得られる。それぞれ、移植性(互換性)の問題がどの程度大きいかは知らない。

GNU Autoconfのドキュメントでは、AIXの grep は長い行を「黙って切り捨てる」(silently truncates)とあるのは気になるところである。またSolaris 11.4の /usr/bin/awk は「awk 1」という書き方はシンタックスエラーで、結局「awk '/^/'」や「awk 1==1」や「awk '{print}'」が無難ということになるのかもしれない。そしてSolaris 11.4の /usr/bin/awk にも長い行をうまく処理できないという問題があるが、OpenIndiana 2022.10の /usr/bin/awk は長い行も特に問題はなく、「awk 1」も解釈できる。

なお、GNU Autoconfのドキュメントでの grep についての箇所は以下のURL。
https://www.gnu.org/savannah-checkouts/gnu/autoconf/manual/autoconf-2.71/autoconf.html#index-grep
(web.sfc.wide.ad.jp にある邦訳のほうは古いバージョンの翻訳なので、この箇所はないことに注意)

ところで、先ほど「たとえ外部のプログラムから利用される場合でも」という言い方をしたが、これはシェルスクリプトで例として示す際に誤解を招きやすいポイントである。

cdwdoc-2023-001_sample_dir.sh や cdwdoc-2023-001_sample_dir3.sh はスクリプト単体としてみれば単に realpath コマンドが正規化したディレクトリを出力してそこで終わりなだけだが、これらのスクリプトを外部から呼ぶプログラムがある時に脆弱性につながる可能性がある。その外部のプログラム(シェルスクリプトとは限らない)が、これらのスクリプトはディレクトリトラバーサルが起こらないようなチェックをうまくこなしてくれるはずだと全面的に信頼していれば、想定しないディレクトリにあるファイルへのアクセスが引き起こされる可能性がある。

また、cdwdoc-2023-001_sample_dir.sh をターミナルから試す際には巨大な文字列を直接コマンドライン引数として指定するわけだが、cdwdoc-2023-001_sample_dir.sh が外部のプログラムから呼ばれる場合は、その外部のプログラムはテキストファイルから抽出したデータを元に cdwdoc-2023-001_sample_dir.sh を起動しようとするかもしれないのである。

対話的にのみ使われることが前提になっているようなものを除き、基本的にすべてのシェルスクリプトは、潜在的にライブラリ関数のような存在になる可能性があることに注意する必要がある。独立性の高いプログラムは、対話的にのみ使われるコマンドやGUIアプリにおけるバグとは違うのである。

筆者が去年(2022年)に報告した CVE-2022-1271 は zgrep コマンドや xzgrep コマンドの脆弱性だが、この脆弱性に関連するものもそうでない一般的な使い方についても、Web上で「zgrep」などで検索すると zgrep をターミナルから対話的に利用するサンプルがたくさん見つかる。だがそもそも zgrep は外部のプログラムから呼ばれる可能性があり、その外部のプログラムはシェルスクリプトとは限らない。

CVE-2022-1509 のほうは HestiaCP というWebコンパネの脆弱性で、システム全体が多数のシェルスクリプトと多数のPHPプログラムで構成されている。筆者が報告したものについて、もしかしたらPHPの側で入念に文字列をチェックしてからシェルスクリプトを呼ぶようにすればWeb経由での攻撃は防げるようになっていた可能性もある。でもやはりこれは、シェルスクリプトの側で修正すべきなのだ。このようなシステムではシェルスクリプト群がライブラリとして機能しているわけで、シェルスクリプトの側を修正しなければライブラリのバグが残り続けることになる。

なお、cdwdoc-2023-001_sample_dir.sh や cdwdoc-2023-001_sample_dir3.sh の脆弱性を利用して実際に想定外のファイルにアクセスされた場合、こういうものは通常は「ディレクトリトラバーサルの脆弱性」ということになるのではないかと思う。

つまり、過去に見つかった脆弱性で「ディレクトリトラバーサルの脆弱性」として認識されているものの中には、攻撃の際に「ALTLオーバーフロー」を利用する必要があるような脆弱性が存在している可能性がある。

ディレクトリトラバーサルに限らず、OSコマンドインジェクションを含めたあらゆるインジェクション系の脆弱性においても同じことがいえる。

そもそも、ここで考えている「ALTLオーバーフロー」とは、「SQLインジェクション」とか「ディレクトリトラバーサル」といった一般的な脆弱性の呼称と同列のものというよりは、様々なセキュリティ対策をかいくぐるための手法の一つ、として捉えることもできるかもしれない。

ただしそれはALTLオーバーフローのある側面にすぎない。

冒頭の出題での cdwdoc-2023-001_challenge.sh のように、真偽判定の混乱が即、正当なユーザーかどうかの判断の混乱に直結している、というようなケースがやはり現実にあるのではないか、と筆者は考えているわけである。

 

Bash限定の話題

このセクションはBash限定の挙動に興味がなければ読み飛ばしてもらってもかまわない。

なお、冒頭の出題のBashバージョンを以下に置いてある。あまり入念にチェックはしていないが、このBashバージョンも基本的には同じ方法で攻略できるはずである。
https://gitlab.com/-/snippets/2502445

このBashバージョンでは、「set -e」「set -u」「set -o pipefail」「set -o posix」の4つが指定されている。こういうものを指定しただけでは、まずい書き方をしている時の誤動作を防ぐことはBashでもできないことが分かる。

「set -o posix」があることによってBashの機能がオフになってしまっているのでは、と思う人はこの行をコメントアウトして挙動を確かめてみてほしい。

さて、この記事ではこれまでにもBashについて触れてきた。

  • 「; then :; else」のようなハックではなく安心して「if !」利用可

  • ヒアストリング(「<<<」)がある

  • ビルトイン無効化の enable がある(「<<<」の価値が高まる)

  • Bashでなら「set -e」や「set -o pipefail」はそれなりに有用

  • 「set -o pipefail」をしている時は、「<<<」の価値が高まる

  • 代入時のエラーで空文字列になるのはBashであっても「set -u」では検出不可、ただし「set -o pipefail」で検出可能

これらの中の特に set 関連について、このセクションで述べていきたい。

なお、このセクションでの実行環境は以下である。

$ bash --version | head -1
GNU bash, version 5.1.16(1)-release (x86_64-pc-linux-gnu)
$ shuf --version | head -1
shuf (GNU coreutils) 8.32
$ grep --version | head -1
grep (GNU grep) 3.7
$ cat /proc/version
Linux version 5.15.0-66-generic (buildd@lcy02-amd64-076) (gcc (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0, GNU ld (GNU Binutils for Ubuntu) 2.38) #73-Ubuntu SMP Fri Feb 3 14:23:37 UTC 2023

131071 などの数字の部分は変更してSolaris 11.4(2018年リリース)でも試している。

$ bash --version | head -1
GNU bash, version 4.4.19(1)-release (x86_64-pc-solaris2.11)
$ shuf --version | head -1
shuf (GNU coreutils) 8.27
$ uname -a
SunOS solaris 5.11 11.4.0.15.0 i86pc i386 i86pc

「set -e」(set -o errexit)はエラー発生時(終了ステータスが 0 でないものを検出した時)にそこでスクリプト全体が終了するようにするためのものである。

$ cat settest.sh 
#!/usr/bin/env bash
set -e
echo aaa
false
echo bbb

$ ./settest.sh 
aaa

「set -e」はパイプの末尾のコマンドの終了ステータスが重要である。末尾が 0 で終了していれば、スクリプト全体の終了はしない。

$ cat settest2.sh 
#!/usr/bin/env bash
set -e
echo aaa
false | cat
echo bbb

$ ./settest2.sh 
aaa
bbb

また「set -e」は、短絡評価の場合も末尾が重要である。末尾が 0 で終了していれば、スクリプト全体の終了はしない。

$ cat settest3.sh
#!/usr/bin/env bash
set -e
echo aaa
false || false || false || echo xxx
echo bbb

$ ./settest3.sh 
aaa
xxx
bbb

また「set -e」は、if文など(while や until を含む)の条件の箇所では、0 以外で終了していてもスクリプト全体の終了はしない。

$ cat settest4.sh 
#!/usr/bin/env bash
set -e
echo aaa
if false; then
  echo xxx
fi
echo bbb

$ ./settest4.sh 
aaa
bbb

BashやZshには「set -o pipefail」がある。パイプの途中で 0 以外の終了ステータスで終了したものがあれば、その最後のものが伝播でんぱする。

$ set -o pipefail
$ true | true | true | true
$ echo $?
0
$ true | false | true | true
$ echo $?
1
$ true | ( exit 100 ) | true | true
$ echo $?
100
$ true | ( exit 10 ) | ( exit 20 ) | ( exit 30 ) | true | true
$ echo $?
30

pipefailの登場はBash3.0(2004年リリース)である。

シェルスクリプトにおける現実的な注意点」のセクションで realpath を使ったサンプルを提示したが、その中には「set -o pipefail」で解決できるものがある。

ただし、冒頭の出題のようなものは「set -e」や「set -o pipefail」では解決できない。

なお、「set -o pipefail」と「set -e」との組み合わせは、以下のようなちょっとした実験も失敗することになる。

cdwdoc-2023-001_bashtest.sh ( https://gitlab.com/-/snippets/2502302 )

#!/usr/bin/env bash
# written by cleemy desu wayo / see [cdwdoc-2023-001] / Licensed under CC0 1.0

set -e
set -o pipefail

echo '======== 1'
str="$(yes 'a' | head -131071 | tr -d '\n')"
echo "${#str}"
echo '======== 2'
/bin/echo "$str"     | wc -c && echo aaa
echo '======== 3'
/bin/echo "$str$str" | wc -c && echo bbb
echo '======== 4'
/bin/echo "$str$str" | wc -c
echo '======== 5'
/bin/echo "$str$str" > /dev/null
echo 'done.'

これを実行すると「======== 1」しか表示されない。

$ ./cdwdoc-2023-001_bashtest.sh 
======== 1

yes が SIGPIPE で終了しているせいだ。yes を使ったパイプラインの末尾に「|| true」を加えると、いきなりスクリプトが終了するということはなくなる。

cdwdoc-2023-001_bashtest2.sh ( https://gitlab.com/-/snippets/2502302 )
より抜粋。

str="$(yes 'a' | head -131071 | tr -d '\n' || true)"

これを実行すると、期待していた結果になった。「======== 4」は出力されるが「======== 5」は出力されない。

$ ./cdwdoc-2023-001_bashtest2.sh 
======== 1
131071
======== 2
131072
aaa
======== 3
./cdwdoc-2023-001_bashtest2.sh: line 14: /bin/echo: Argument list too long
0
======== 4
./cdwdoc-2023-001_bashtest2.sh: line 16: /bin/echo: Argument list too long
0

ちなみに「set -o pipefail」の行をコメントアウトすると、「bbb」や「======== 5」が出力されるはずである。

なお、「|| true」ではなくグルーピングを使って「yes 'a'」を「{ yes 'a' ;:; }」のようにするという方法もある。

「|| true」という書き方やグルーピングを使うのは若干トリッキーに見えるかもしれない。それに「|| true」はせっかくのエラー検出の機会を奪うかもしれない。また「set -e」「set -o pipefail」に加えて「set -o posix」をしていると「{ yes 'a' ;:; }」はいきなり終了することになるかもしれない。

というわけで、SIGPIPE が発生しない書き方を考えてみる。例えば、GNU coreutils 8.22およびそれ以降が前提なら、yes と head の組み合わせは shuf でも代用できる。

cdwdoc-2023-001_bashtest3.sh ( https://gitlab.com/-/snippets/2502302 )
より抜粋。

str="$(shuf -r -e a -n 131071 | tr -d '\n')"             # GNU coreutils 8.22 or later

先ほどと同じ結果になるはずである。

$ ./cdwdoc-2023-001_bashtest2.sh 2> /dev/null | sha256sum 
428432f4a11979167836aba9e6a62baf85fbab795711ec9d6293b9adc31e3a33  -
$ ./cdwdoc-2023-001_bashtest3.sh 2> /dev/null | sha256sum 
428432f4a11979167836aba9e6a62baf85fbab795711ec9d6293b9adc31e3a33  -

shuf 以外にも、seq や jot でも SIGPIPE が発生しないようにできる。

yes と head の組み合わせは SIGPIPE がほぼ確実に発生すると考えてよいため SIGPIPE がもたらす影響に気づきやすいが、厄介なのが巨大なデータの時だけ SIGPIPE が発生する grep や head である。

「set -o pipefail」をしている時は、パイプから受け取る grep では「-q」と 「> /dev/null」で違う挙動に見えることがあるかもしれない。

cdwdoc-2023-001_bashtest4.sh ( https://gitlab.com/-/snippets/2502302 )

#!/usr/bin/env bash
# written by cleemy desu wayo / see [cdwdoc-2023-001] / Licensed under CC0 1.0

str="$(yes 'a' | head -2999999)"

echo "$str" | grep -q a          && echo 'FOUND(1)'

set -o pipefail

echo "$str" | grep -q a          && echo 'FOUND(2)'
echo "$str" | grep a > /dev/null && echo 'FOUND(3)'
echo 'done.'

実行すると以下になる。

$ ./cdwdoc-2023-001_bashtest4.sh 
FOUND(1)
FOUND(3)
done.

上記のサンプルは「head -99999999」の数をもっと少なくすると「FOUND(2)」も出力される。

これはつまり、「set -o pipefail」をしている時というのは、小さいデータだけ扱っている時は問題ないが、大きいデータを扱い始めてから急に以前と挙動が変わって困惑する、ということが起こやすいわけだ。

ちなみに grep における「> /dev/null」は SIGPIPE を発行しないのだと考えていいのかどうかは筆者にはよく分からない。

「-q」と「> /dev/null」での振る舞いの違いは、GNU grep以外でも、FreeBSD 13.1-RELEASEやOpenBSD 7.2の /usr/bin/grep でもそうなる。

また、Solaris 11.4やOpenIndiana Hipster 2022.10には複数の grep があるが、以下の全てでそうなる。

  • /usr/bin/grep

  • /usr/xpg4/bin/grep

  • /usr/gnu/bin/grep

なお、上記のサンプルでは /bin/echo ではなくビルトインの echo を使っている。そして SIGPIPE が発行される時、ビルトインの echo や printf については「{ echo "$str" ;:; }」のようにグルーピングするだけでは解決しないようだ。これはZsh(Lubuntu 22.04.1の zsh 5.8.1 やGhostBSD 22.06.18の zsh 5.9)でも同様である。

こういうことがあるため、「set -o pipefail」をしている時は「<<<」の価値が高まるというわけである。

「set -u」についても考えてみる。

代入をしているところで「Argument list too long」などのエラーが出る場合、「set -u」では検出できず変数は空文字列になる。

cdwdoc-2023-001_bashtest5.sh ( https://gitlab.com/-/snippets/2502302 )

#!/usr/bin/env bash
# written by cleemy desu wayo / see [cdwdoc-2023-001] / Licensed under CC0 1.0

str="$(yes 'a' | head -2999999 | tr -d '\n')"
echo "${#str}"

echo ========

#set -e
set -u
set -o pipefail
#set -o posix

pickup="$(/bin/echo "${str}x" | tr -d a)"
echo $?
echo "$pickup"
echo "${#pickup}"

echo 'done.'

実行すると以下になる。

$ ./cdwdoc-2023-001_bashtest5.sh 
2999999
========
./cdwdoc-2023-001_bashtest5.sh: line 14: /bin/echo: Argument list too long
126

0
done.

「set -e」と「set -o pipefail」を組み合わせれば、上記は代入のところでスクリプトが終了するはずである。また「set -o pipefail」をしているが「set -e」はしないという場合、代入がうまくいかなかったことを終了ステータスによって検出するのは可能ではある。

代入で空文字列になることがセキュリティリスクになりうる場合については、このセクションにて後述する。

ところで、GNU Autoconfのドキュメントにもあるように、Bashであっても「set -e」は短絡評価の左側やif文の条件判定でシェル関数を呼んでいる時に予想と異なる動作になるかもしれない。

以下はいろいろと詰め込んだので、とりあえずは流し読みを推奨。

cdwdoc-2023-001_bashtest6.sh( https://gitlab.com/-/snippets/2502302 )

#!/usr/bin/env bash
# written by cleemy desu wayo / see [cdwdoc-2023-001] / Licensed under CC0 1.0
#
# see also: https://www.gnu.org/savannah-checkouts/gnu/autoconf/manual/autoconf-2.71/autoconf.html#index-set

str="$(yes 'a' | head -2999999 | tr -d '\n')"
echo "${#str}"

set -e
set -u
set -o pipefail
#set -o posix

echo '======== 1'
if /bin/echo "$str" | grep a > /dev/null ; then
  echo 'FOUND(1)'
fi

echo '======== 2'
/bin/echo "$str" | grep a > /dev/null && echo 'FOUND(2)'

doit() {
  echo '======== 3'
  /bin/echo "$str" | wc -c

  echo '======== 4'
  /bin/echo "$str" > /dev/null

  echo '======== 5'
  pickup="$(/bin/echo "${str}x" | tr -d a)"
  echo $?
  echo "$pickup"
  echo "${#pickup}"
}

doit || echo aaa
#doit && echo bbb  # maybe puts 'bbb'
#doit              # maybe 'set -e' works as expected

echo 'done.'

実行すると以下になる。

$ ./cdwdoc-2023-001_bashtest6.sh 
2999999
======== 1
./cdwdoc-2023-001_bashtest6.sh: line 15: /bin/echo: Argument list too long
======== 2
./cdwdoc-2023-001_bashtest6.sh: line 20: /bin/echo: Argument list too long
======== 3
./cdwdoc-2023-001_bashtest6.sh: line 24: /bin/echo: Argument list too long
0
======== 4
./cdwdoc-2023-001_bashtest6.sh: line 27: /bin/echo: Argument list too long
======== 5
./cdwdoc-2023-001_bashtest6.sh: line 30: /bin/echo: Argument list too long
126

0
done.

「set -e」と「set -u」と「set -o pipefail」の3つを on にしているが、if文での誤った判定、短絡評価での誤った判定、代入で空文字列になるパターン、すべてが防げていない。代入については、代入がうまくいかなかったことを終了ステータスで検出すること自体はできていている。

複雑な処理だからこそシェル関数の中に閉じ込めたいというニーズもありそうで、set に頼らない方法がないか検討しておくことには価値がありそうだ。

ところでBash限定の場合であれば、if文などの条件判定や短絡評価の左側ではなるべくビルトインの test (あるいは [ ... ])のみを使うようにするというのも良いかもしれない。「set -o pipefail」を利用し、いったん変数に代入するようにして、際どいものは必ず毎回代入がうまくいったのかどうかをチェックするという方針である。

この方針でも、パイプのどこかに grep があることによって空文字列になるのを正常な処理とみなす場合、「マッチしなかった」という報告で終了ステータスが 1 になる対策として「grep a」の代わりに「awk '/a/'」を使ったりグルーピングを使ったりするなどの工夫が必要かもしれない。

cdwdoc-2023-001_bashtest7.sh ( https://gitlab.com/-/snippets/2502302 )

#!/usr/bin/env bash
# written by cleemy desu wayo / see [cdwdoc-2023-001] / Licensed under CC0 1.0

set -o pipefail

str=$(seq 5 | grep 7         | cat)  ; echo $?
str=$(seq 5 | awk '/7/'      | cat)  ; echo $?
str=$(seq 5 | { grep 7 ;:; } | cat)  ; echo $?
str=$(seq 5 | ( grep 7 ;: )  | cat)  ; echo $?

実行すると以下のようになる。

$ ./cdwdoc-2023-001_bashtest7.sh 
1
0
0
0

Bashの PIPESTATUS は「set -o pipefail」が off の時でも使用できる。例えば以下のようにして、PIPESTATUS によって head の SIGPIPE の伝播でんぱ具合が確認できる。

$ set -o | grep pipefail
pipefail        off
$ seq 99999999 | cat | cat | cat | head -1
1
$ echo ${PIPESTATUS[@]}
141 141 141 141 0
$ seq 99999999 | cat | { cat ;:; } | cat | head -1
1
$ echo ${PIPESTATUS[@]}
141 141 0 141 0
$ { seq 99999999 | cat | cat | cat ;:; } | head -1
1
$ echo ${PIPESTATUS[@]}
0 0

小さいデータ量であれば SIGPIPE が送られる前にすべてがつつがなく終了していることが確認できるかもしれない。

$ seq 9 | cat | cat | cat | head -1
1
$ echo ${PIPESTATUS[@]}
0 0 0 0 0

代入などでコマンド置換を使う場合は、PIPESTATUS ではコマンド置換の中のパイプラインで起こったことを取得できない。「set -o pipefail」をしているなら、代入でのコマンド置換の中のエラーの検出自体は可能である。

$ str="$(seq 99999999 | cat | cat | cat | head -1)"
$ echo ${PIPESTATUS[@]}
0
$ set -o pipefail
$ str="$(seq 99999999 | cat | cat | cat | head -1)"
$ echo ${PIPESTATUS[@]}
141

POSIXの機能だけを使って PIPESTATUS に似たものを実現しようという試みについては、以下のものがある。

この改良版として、Qiitaの以下の記事(2014年公開)がある。

この改良版は『Windows/Mac/UNIX すべてで20年動くプログラムはどう書くべきか』(2016年刊)という本にも掲載されている。

また、これとは違う方向性での改良の案として、Qiitaの以下の記事(2020年公開)がある。

こちらは前述の「凄まじいボリュームの文章を継続的にポストし続けている人」と同じ人によるものだが、上記の記事はさほど文量が多くないのでそこについては安心してほしい。

ちなみに筆者は、これらのこの試みの有効性については確認していない。

ところで「set -o pipefail」を on にしていると head や「grep -q」が SIGPIPE を発行することに悩むことになる場合があるが、『「シェル芸」に効く! AWK処方箋』(2018年刊)という本のP.93では SIGPIPE 対策として head の代わりにAWKを使う例が紹介されている。

このAWKでの代用も含めて、基本的にパイプラインの末尾(というより SIGPIPE を発行するコマンドの箇所)を工夫するという方向性では処理が終わるのを待つことになりやすい。そして処理が終わるのを待つということは、yes や「tail -f」が SIGPIPE を受け取る対策には使えないということである。

SIGPIPE を受け取る側での「{ ... ;:; }」のようなグルーピングの場合、待つことがないというメリットがあり、yes や「tail -f」が SIGPIPE を受け取る対策にも使える。

ただし「{ ... ;:; }」という書き方ではうまくいかないことがある。例えば以下。

  • 「set -e」on かつ「set -o pipefail」on の時、「地の文」のパイプラインでの使用でスクリプト(シェル)が終了するのを止めることができない。

  • 「set -e」on かつ「set -o pipefail」on かつ「set -o posix」on の時、コマンド置換の中での使用でスクリプト(シェル)が終了するのを止めることができない。

そういうわけで、より汎用的なのは「{ ... ||:; }」という書き方である。

$ set -eo pipefail -o posix
$ set -o | grep -e errexit -e pipefail -e posix
errexit         on
pipefail        on
posix           on
$ { yes ||:; } | head -3
y
y
y
$ echo still alive
still alive
$ str="$({ yes ||:; } | head -3)"
$ echo ${PIPESTATUS[@]}
0

また前述のように、ビルトインの echo や printf については「{ ... ;:; }」や「{ ... ||:; }」のようなグルーピングは効かないかもしれないのだが、どうしても printf が必要な時など、もしかすると cat をかませることによってうまくいくことがあるかもしれない。

$ str="$(yes 'a' | head -2999999)"
$ { printf '%s\n' "$str" ||:; } | head -1
a
$ echo ${PIPESTATUS[@]}
141 0
$ set -eo pipefail -o posix
$ set -o | grep -e errexit -e pipefail -e posix
errexit         on
pipefail        on
posix           on
$ { printf '%s\n' "$str" | cat ||:; } | head -1
a
$ echo ${PIPESTATUS[@]}
0 0

ちなみに「{ ... ;:; }」や「{ ... ||:; }」は古いBashでも使える。以下はKnoppix 3.2(2003年リリース)の bash 2.05b.0(2002年リリース)で試したものである。この頃のBashには PIPESTATUS はあるが「set -o pipefail」はない。

knoppix@ttyp0[knoppix]$ seq 99999999 | head -1
1
knoppix@ttyp0[knoppix]$ echo ${PIPESTATUS[@]}
141 0
knoppix@ttyp0[knoppix]$ { seq 99999999 ;:; } | head -1
1
knoppix@ttyp0[knoppix]$ echo ${PIPESTATUS[@]}
0 0
knoppix@ttyp0[knoppix]$ set -o pipefail
bash: set: pipefail: invalid option name
knoppix@ttyp0[knoppix]$ echo $BASH_VERSION
2.05b.0(1)-release
knoppix@ttyp0[knoppix]$ uname -a
Linux Knoppix 2.4.21-xfs #1 SMP Fre Jul 25 00:06:47 CEST 2003 i686 GNU/Linux

当然ながら、「{ ... ;:; }」のようなグルーピングの使用は「Argument list too long」のような重要なエラーについても検出する機会を奪うため、黒魔術的な要素があることには留意する必要がある。

$ str="$(yes 'a' | head -999999 | tr -d '\n')"
$ seq 9 | cat | grep "$str" | cat | cat
bash: /usr/bin/grep: Argument list too long
$ echo ${PIPESTATUS[@]}
0 0 126 0 0
$ seq 9 | cat | { grep "$str" ;:; } | cat | cat
bash: /usr/bin/grep: Argument list too long
$ echo ${PIPESTATUS[@]}
0 0 0 0 0

 

BusyBoxという不思議な存在

コンピュータの歴史上においては、まだGPLのようなものが法的に拘束力を持つのか曖昧だった時期にGPL訴訟で勝利したことでも有名なBusyBox。

このセクションはBusyBoxに興味がなければ読み飛ばしてもらってもかまわない。

Ubuntuをはじめとして、Debian系では素直にインストールすれば最初から /bin/busybox が入っている。

Ubuntu 22.04.1、Lubuntu 22.04.1、Ubuntu 22.10、Knoppix 9.1などにおいては、busybox パッケージのほうではなく busybox-static パッケージによるスタティックバイナリの busybox が最初から入っているかもしれない。

$ dpkg -S /bin/busybox
busybox-static: /bin/busybox
$ ldd /bin/busybox
        not a dynamic executable
$ dpkg -l | grep busybox-static
ii  busybox-static                                1:1.30.1-7ubuntu3                       amd64        Standalone rescue shell with tons of builtin utilities

また、Fedora 37 Workstationで「sudo yum install busybox」あるいは「sudo dnf install busybox」でBusyBoxを入れると、こちらもスタティックバイナリの busybox が入る。

$ rpm -qf /usr/sbin/busybox
busybox-1.36.0-1.fc37.x86_64
$ ldd /usr/sbin/busybox
        not a dynamic executable

Debian系の busybox-static パッケージの busybox は、単にスタティックバイナリというだけではない。「busybox sh」で対話的にシェルを利用する中で例えば「type dirname」とタイプすると「dirname is dirname」という出力が得られる。

$ busybox sh


BusyBox v1.30.1 (Ubuntu 1:1.30.1-7ubuntu3) built-in shell (ash)
Enter 'help' for a list of built-in commands.

~ $ type dirname
dirname is dirname

strace によって「dirname a」を実行した時の様子を見ると、/usr/bin/dirname を実行した形跡がなく、execve(2) は1回だけだ。

$ strace busybox sh -c 'dirname a' 2>&1 | grep dirname
execve("/usr/bin/busybox", ["busybox", "sh", "-c", "dirname a"], 0x7fffb6bb65f8 /* 62 vars */) = 0
prctl(PR_SET_NAME, "dirname")           = 0

Debian 11.6で最初から入っている busybox パッケージのほうの busybox では以下のようになる。確かに execve(2) が2回ある。通っているパスから探索し、/usr/bin/dirname を実行している。

$ strace busybox sh -c 'dirname a' 2>&1 | grep dirname
execve("/usr/bin/busybox", ["busybox", "sh", "-c", "dirname a"], 0x7fffb8312538 /* 39 vars */) = 0
stat("/usr/local/bin/dirname", 0x7ffea1cd2f58) = -1 ENOENT (No such file or directory)
stat("/usr/bin/dirname", {st_mode=S_IFREG|0755, st_size=39712, ...}) = 0
execve("/usr/bin/dirname", ["dirname", "a"], 0x555706142a98 /* 39 vars */) = 0

Fedora 37 Workstationで「sudo yum install busybox」で入った busybox では以下のようになる。こちらも execve(2) が2回ある。

$ strace busybox sh -c 'dirname a' 2>&1 | grep dirname
execve("/usr/sbin/busybox", ["busybox", "sh", "-c", "dirname a"], 0x7fff64f6c388 /* 43 vars */) = 0
stat("/home/user1/.local/bin/dirname", 0x7fff19bbc880) = -1 ENOENT (No such file or directory)
stat("/home/user1/bin/dirname", 0x7fff19bbc880) = -1 ENOENT (No such file or directory)
stat("/usr/local/bin/dirname", 0x7fff19bbc880) = -1 ENOENT (No such file or directory)
stat("/usr/local/sbin/dirname", 0x7fff19bbc880) = -1 ENOENT (No such file or directory)
stat("/usr/bin/dirname", {st_mode=S_IFREG|0755, st_size=33264, ...}) = 0
execve("/usr/bin/dirname", ["dirname", "a"], 0x7ff71f1b6af8 /* 43 vars */) = 0

Debian系の busybox-static パッケージの busybox で dirname が「dirname is dirname」であるような場合、dirname はまるでビルトインであるかのように「Argument list too long」の制限が存在していないようにみえることがある。

$ busybox sh


BusyBox v1.30.1 (Ubuntu 1:1.30.1-7ubuntu3) built-in shell (ash)
Enter 'help' for a list of built-in commands.

~ $ str="$(yes 'aaa/' | head -999999 | tr -d '\n')"
~ $ dirname "$str" | wc -c
3999992
~ $ type dirname
dirname is dirname

そして最近のBusyBoxなら、expr が「expr is expr」であるような場合に、巨大な文字列の正規表現マッチングが事実上のビルトインとして利用できるかもしれない。

$ busybox sh


BusyBox v1.30.1 (Ubuntu 1:1.30.1-7ubuntu3) built-in shell (ash)
Enter 'help' for a list of built-in commands.

~ $ str="$(yes 'a' | head -9999999 | tr -d '\n')"
~ $ expr "$str" : 'a.*'
9999999
~ $ type expr
expr is expr

ただしビルトインのようにみえる場合でも、いつでも安心というわけではない。

grep が「grep is grep」な場合であっても、まるで外部コマンドのように「Argument list too long」が出るかもしれない。

$ busybox sh


BusyBox v1.30.1 (Ubuntu 1:1.30.1-7ubuntu3) built-in shell (ash)
Enter 'help' for a list of built-in commands.

~ $ str="$(yes 'a' | head -131071 | tr -d '\n')"
~ $ echo | grep "$str"
~ $ echo | grep "^$str"
sh: grep: Argument list too long
~ $ type grep
grep is grep

wget も、やはり外部コマンドのように「Argument list too long」が出る。

$ busybox sh


BusyBox v1.30.1 (Ubuntu 1:1.30.1-7ubuntu3) built-in shell (ash)
Enter 'help' for a list of built-in commands.

~ $ str="$(yes 'a' | head -999999 | tr -d '\n')"
~ $ wget "$str"
sh: wget: Argument list too long
~ $ type wget
wget is wget

また、巨大な変数を生成したあと、その変数とは無関係なはずのよく分からないタイミングで「Argument list too long」が出るようになることもある。

$ busybox sh


BusyBox v1.30.1 (Ubuntu 1:1.30.1-7ubuntu3) built-in shell (ash)
Enter 'help' for a list of built-in commands.

~ $ rm aaa.txt 2> /dev/null
~ $ echo aaa > aaa.txt
~ $ str="$(yes 'aaa/' | head -999999 | tr -d '\n')"
~ $ dirname "$str" > /dev/null
~ $ cat aaa.txt | wc -c
sh: cat: Argument list too long
sh: wc: Argument list too long

なお、上記と同じことをスクリプトとして実行するとBusyBoxは「Argument list too long」を出さない。

$ cat busyboxtest.sh 
#!/bin/busybox sh

rm aaa.txt 2> /dev/null
echo aaa > aaa.txt
str="$(yes 'aaa/' | head -999999 | tr -d '\n')"
dirname "$str" > /dev/null
cat aaa.txt | wc -c

$ ./busyboxtest.sh 
4
$ cat ./busyboxtest.sh | busybox sh
4
$ cat ./busyboxtest.sh | bash
bash: line 6: /usr/bin/dirname: Argument list too long
4

もしかしてスクリプトとして実行すれば grep や wget も「Argument list too long」を出さないのでは?などと考えてみても、これはやっぱり出るというじゃじゃ馬ぶり。

$ cat busyboxtest2.sh 
#!/bin/busybox sh

str="$(yes 'a' | head -999999 | tr -d '\n')"

echo | grep "$str"
wget "$str" -q -O - > /dev/null

$ ./busyboxtest2.sh 
./busyboxtest2.sh: line 5: grep: Argument list too long
./busyboxtest2.sh: line 6: wget: Argument list too long
$ cat ./busyboxtest2.sh | busybox sh
sh: grep: Argument list too long
sh: wget: Argument list too long

なお、Debian系の busybox-static パッケージの busybox では、grep や wget の際に /proc/self/exe で自分自身を起動していることが確認できる。

$ strace busybox sh -c 'dirname a' 2>&1 | grep exec
execve("/usr/bin/busybox", ["busybox", "sh", "-c", "dirname a"], 0x7ffdbe68b5c8 /* 63 vars */) = 0
$ strace busybox sh -c 'expr a : a' 2>&1 | grep exec
execve("/usr/bin/busybox", ["busybox", "sh", "-c", "expr a : a"], 0x7ffff5265818 /* 63 vars */) = 0
$ strace busybox sh -c 'grep a < /dev/null' 2>&1 | grep exec
execve("/usr/bin/busybox", ["busybox", "sh", "-c", "grep a < /dev/null"], 0x7ffca3063508 /* 63 vars */) = 0
execve("/proc/self/exe", ["grep", "a"], 0x235e3d8 /* 63 vars */) = 0
$ strace busybox sh -c 'wget http://localhost:8080/' 2>&1 | grep exec
execve("/usr/bin/busybox", ["busybox", "sh", "-c", "wget http://localhost:8080/"], 0x7ffc5ab2f948 /* 63 vars */) = 0
execve("/proc/self/exe", ["wget", "http://localhost:8080/"], 0x23ca3b8 /* 63 vars */) = 0

execve(2) に /proc/self/exe を渡すのではなく、clone(2) のこともあるようだ。

$ strace busybox sh -c 'grep a < /dev/null ; true' 2>&1 | grep -e exec -e clone
execve("/usr/bin/busybox", ["busybox", "sh", "-c", "grep a < /dev/null ; true"], 0x7ffec3802898 /* 63 vars */) = 0
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x125d690) = 66268
$ strace busybox sh -c 'wget http://localhost:8080/ ; true' 2>&1 | grep -e exec -e clone
execve("/usr/bin/busybox", ["busybox", "sh", "-c", "wget http://localhost:8080/ ; tr"...], 0x7ffc839579c8 /* 63 vars */) = 0
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x24ae690) = 66275

今回筆者が確認した busybox-static パッケージのバージョンはそれぞれ以下のとおりである。

  • Ubuntu 22.04.1およびLubuntu 22.04.1 : 1:1.30.1-7ubuntu3

  • Ubuntu 22.10 : 1:1.35.0-1ubuntu1

  • Knoppix 9.1 : 1:1.30.1-6+b1

 

シェルスクリプト以外の言語におけるALTLオーバーフロー

シェルスクリプト以外の言語のことも考えてみたい。そうすることによってしか見えてこないことがある。

だがその前に、近年シェルスクリプトをめぐる状況が大きく変化していることについて。

シェルはもともと特殊な存在であり、基本的にはオープンソースのエコシステムの一部と考えてよいが、オープンソース革命が起こる前に既成事実化してしまっていた。

1990年代後半のオープンソース革命は、Linuxの成功やPerlの成功と強く結びついている。

オープンソースおいては、2つの「5パーセント」が重要だと筆者は考えている。

1つ目の「5パーセント」はよくいわれる「5パーセントルール」、つまり5パーセントの有料版ユーザーが残り95パーセントの無料版ユーザーを支えているというものだ。

OSSの場合、特にそのままWebサービスとしてのサービス提供という形をとりにくいような「配布のみ」を前提にしたソフトウェアでは、そもそも「有料版」のようなものがない場合も多く、そうすると寄付をする人と寄付をしない人という分け方しかできないかもしれない。

それだと5パーセントではなく1パーセントくらいではないか、ともいえそうではあるが、大企業が寄付をしたりすることもある。その場合はその企業の従業員や、その企業のサービスやプロダクトにお金を払っている人々が間接的に支援をしていることになる。

もう1つの「5パーセント」は、有名なOSSやPDSのプロジェクトにおける、コードが書ける人々の中の「トップ5パーセント」だけを相手に開発を進められるというメリットである。

ものすごい数の資格を持っているけど実はコードが書けない「エンジニア」に無理やり仕事を割り当てる必要はないし、コードが書けない役員が政治的に決めた「新機能」をねじ込んでくるというようなことも基本的には起こりにくい。皮肉なことに、巨大でブランド力を持った企業にもこういったことが起こりやすい。

OSSの開発者が「私達はトップ5パーセントです!」などと自称することは基本的にはないが、営利企業は「うちの会社がいかにすごい技術者を揃えているか」をアピールするため、世間一般では逆のイメージを持たれてしまうことも多い。

有名なOSSやPDSでバグが発覚すると大きなニュースになったりするが、そもそも有名なOSSやPDSのコードというのはトップ5パーセントの人が書いた特殊なものと考えたほうがいいのである。

こういったニュースの裏では、決して表には出てこないような、想像を絶するほどひどいコードが日々生成され続けているのである。しかし表に出てこないコードというのは、まさに表に出てこないがゆえに、科学というまな板の上に乗せることは不可能である。

ソースコードが公開されているということは、いつでも誰でも誰の許可を得ることもなく検証できるという自由(フリー・プライスではなくフリーダム)があり、そのことはクオリティの向上や維持に貢献しやすいとされている。

いつでも誰でも検証できるということが、ソフトウェアとしてのクオリティとは違う側面から重要視されることもある。

オライリーから出ている『バイオインフォマティクスデータスキル――オープンソースツールを使ったロバストで再現性のある研究』(邦訳は2020年刊、原著は2015年刊)の第1章は、シェルスクリプトに、というよりもプログラミング自体にあまり興味がない人にとっても読み物的な面白さがあるのではないだろうか。

どのツールのどのバージョンをどんな風に利用してそういう結果が得られたのかということがドキュメントとして残されていないことが、研究結果の誤りを見逃してしまう要因になると同時に、他人が研究内容を検証する時の足かせにもなることが紹介されているのである。

バイオインフォマティクスは医療と重なる領域でもあるので、これはもはや社会問題になっているといってもいい。

この本の第1章の「1.7.7 データを読み取り専用として扱う」や「1.8.4 コードをドキュメントとして使用する」では、元のデータを直接変更するようなブラックボックス化された「Excelを使った作業」が科学研究にも大きな影を落としており、簡潔な記述が可能なシェルスクリプトのコードそのものをドキュメントとして利用したいというニーズがあることが分かる。元のデータは元のデータとして残しておき、シェルスクリプトのコードとそれに付随する環境情報によって、あとから誰が試しても同じ結果が出力されるようにしたいわけだ。

2016年には、遺伝子研究の3597件の発表済み論文を精査した結果、その約5分の1もの論文にExcelのような表計算ソフト特有のエラーの痕跡があったという調査が話題になったりもした。

『バイオインフォマティクスデータスキル』の「1.4 再現可能な研究」には以下のような箇所がある(P.9)。

残念ながら、ほとんどのシークエンシング実験は試験管の段階から再現するには費用がかかりすぎるために、私たちはますます“計算機内での再現”(in silico reproducibility)だけに頼ることが多くなっている。

『バイオインフォマティクスデータスキル』

さらに、以下のような箇所もある(P.9)。

よって、計算機内での再現を容易にし促進することは、優れた科学者としての責務なのだ。

『バイオインフォマティクスデータスキル』

そういうことが「責務」だととらえられることがあるというのは、開発現場のプログラマーを含めた多くの人々がイメージするような「生物学」からは、かなりかけ離れているのではないだろうか。

そしてサブタイトルにあるように、シェルスクリプトにロバストネス(堅牢性)が期待されているのである。

また2015年には同じくオライリーから『コマンドラインではじめるデータサイエンス――分析プロセスを自在に進めるテクニック』が刊行されている(第1版の原著は2014年刊)。こちらの本については、作者の2013年のブログ記事を1冊の本に膨らませようということからスタートしたようだ。そのブログ記事は以下で読める。

この本の「1.5.5 コマンドラインは普遍的」では、「TOP 500 Supercomputer」というサイトによるものとして、以下のような記述がある(P.10)。

トップ500に入るスーパーコンピュータの95%はGNU/Linuxを実行しているそうです。

『コマンドラインではじめるデータサイエンス』

良いことなのかどうかは分からないが、2023年3月現在はLinuxが他を駆逐してしまったようだ。以下のページで「Category」を「Operating system Family」にして統計を表示すると、ここ数年で「Linux」が100%になったことが分かる。

今となっては、スーパーコンピュータ = Linuxマシン、なのだ。

そしてこういった状況下で、バイオインフォマティクスやデータサイエンスにおいてBashやGNU coreutilsを研究に活用しようという動きがあるわけだ。

シェルスクリプトといえば「インフラ」や「システムスクリプト」だ、という連想になるのは1990年代後半のものであって、昔の状況からはまったく想像だにしない方面からの期待があるというわけなのである。

ちなみに『コマンドラインではじめるデータサイエンス』の邦訳は250ページ弱とコンパクトで、シェルスクリプトそのものについては『詳解シェルスクリプト』(邦訳は2006年刊、原著は2005年刊、原題は『Classic Shell Scripting』)を読むよう第4章にて推奨されている。

一方、『バイオインフォマティクスデータスキル』のほうは500ページを超えるボリュームで、バイオ方面やデータサイエンスに興味がない人にとっても示唆的なことがいろいろと書かれている。

そしてこの本では、シェルスクリプトで書けばロバストになる、などと主張しているわけではない。以下はP.424(第12章)より。

残念ながら、Bashは脆弱な言語であり、それをバイオインフォマティクスで安全に使用するためには、いくつかの奇妙な振る舞いに気をつける必要がある。

『バイオインフォマティクスデータスキル』

シェルスクリプトの利便性を活かしつつロバストであるためにはコツが必要ですよ、と言っているのである。

ちなみに上記の引用箇所は「set -e」や「set -u」や「set -o pipefail」についての解説の箇所である。そしてこれら3つを「最初の防波堤」と表現している。

ところで、再現性ということでいうと前述のようにKnoppixのisoイメージファイルのようなものは非常に強力ではあるが、残念ながらバイオインフォマティクスやデータサイエンスでは基本的なUnixツールセットだけを使おうという発想ではなさそうだ。バイオ向け・データサイエンス向けの様々な便利なツールがコマンドラインのツールとして提供されているため、それらの先進的なツールをGNU coreutilsのツール群と組み合わせて使おう、というのがメインのようである。

ちなみに『バイオインフォマティクスデータスキル』では遺伝子配列を対象にしたBioawkなんてものが紹介されていたりする。これはThe One True AWKを拡張してバイオ方面でよく使われるデータ形式を扱いやすくしたものである。

The One True AWKについては、去年(2022年)になってから設計者の1人であるブライアン・カーニハン自身の手によってUnicodeのサポートが書かれたりもした。あるいは「書かれつつある」というのが正確なところかもしれない。

テスト用のデータの中には「すべての善人のために」や「今がその時だ」などの日本語がある。
https://github.com/onetrueawk/awk/blob/d322b2b5fc16484affb09e86b044596a2e347853/testdir/T.utf

エリック・レイモンドの『The Art of UNIX Programming』(邦訳は2007年刊、原著は2003年刊)ではAWKを完全に「終わった存在」として扱ってしまっているが、これも基本的には1990年代後半の空気が反映されたものである。原文は以下のURLで読める。

そういえば、PerlやRubyはいろんな意味でAWKの影響を受けている。そしてRubyの作者はシェルスクリプトへの不満がRubyの設計に影響を与えていると示唆している。

PerlやRubyやPythonは良くも悪くも言語設計者の好みが濃厚かつ継続的に反映され続けてきたが、シェルについては言語設計者の視点があまり継続的には反映されないまま既成事実化してしまった。

2018年ごろからは、互換性を放棄したシェルのfish shellやElvishが話題になっており、言語設計者の視点が持ち込まれ始めているといえるのかもしれない。

ちなみに fish にも、バージョン3.1b1(2020年リリース)からはBashの PIPESTATUS に似た $pipestatus がある。そして「{ ... ;:; }」というグルーピングの書き方はできないが begin と end を使って似たことができるようだ。

以下はGhostBSD 22.06.18のfish 3.4.1で試したもの。以下は眺めるだけを推奨。

> jot 99999999 | cat | cat | cat | head -1
1
> echo $pipestatus
141 141 141 141 0
> jot 99999999 | cat | { cat ;:; } | cat | head -1
fish: Unknown command: '{ cat ;:; }'
fish: '{ ... }' is not supported for grouping commands. Please use 'begin; ...; end'
jot 99999999 | cat | { cat ;:; } | cat | head -1
                     ^
> jot 99999999 | cat | begin; cat ;:; end | cat | head -1
1
> echo $pipestatus
141 141 0 141 0
> echo $FISH_VERSION
3.4.1
> uname -a
FreeBSD vm1 13.1-STABLE FreeBSD 13.1-STABLE GENERIC amd64

fish では代入で「str=」のような形式ではなく set を使う。そしてfish 3.2.0(2021年リリース)からは set によって $pipestatus が上書きされないため、代入でコマンド置換を使用した時も、あとからコマンド置換の中のパイプラインで起こったことを取得できる。

> set str (jot 99999999 | cat | begin; cat ;:; end | cat | head -5)
> echo $pipestatus
141 141 0 141 0
> echo "$str"
1 2 3 4 5

筆者にも、互換性を放棄したシェルの構想が10年以上前からある。

そしてその構想はAWKの仕様と深い関連がある。

この記事で述べているようなものも含めた既存のシェルにおける弱点を克服したものという方向とはまた違うアイディアがあり、それを実現しようとすると結局シェルはAWKの機能を取り込まざるを得ないのである。

AWKの改良版の構想を練っていた時期もあったのだが、結局この「シェルがAWKの機能を取り込んでいるのか、AWKがシェル化しているのか」の区別がつかなくなることに気づいてわけが分からなくなった。

AWKを含めた広く使われているスクリプト言語と比較してみると、シェルの場合は何よりもまず、条件判定が特殊なことは明らかだ。

if文などや短絡評価では、終了ステータスを見て判定する。

そして終了ステータスが 0 の時だけがtruthy。0 以外はfalsy。

シェルなんだからそういうものなのだと言ってしまえばそれまでだが、意味論的には重大だ。

これはつまり、通常のif文や短絡評価がエラーハンドリングとしての性質を持ってしまっているのである。

grep のように、終了ステータスによってマッチした・マッチしなかったという情報を伝達しようとするコマンドが基本的なものとして存在していることがまず混乱の元だ。

そこに「set -o pipefail」を持ち込むと、grep の「マッチしなかった」という極めて日常的な「報告」と、SIGPIPE を受け取るというようなそこそこ日常的に発生するものと、「Argument list too long」のようなセキュリティの侵害を目的とした意図があるかもしれないような重大なエラーが同列になるという新しい問題が生まれる。

結局、ガード節のif文において「; then :; else」のようなものを使うとかガード節の短絡評価では && ではなく || を使うとか、外部プログラムが正常に終了しないかもしれないことを考慮して最初にまず正規のユーザーでは「ない」という初期化をするという発想とか、こういったものはすべて、通常の条件分岐の中にエラーハンドリングの発想を混ぜ込んでいるという解釈が可能なのだ。

しかしながら、こういったことは必ずしもシェルスクリプトに限定した話ともいえない。これは古くて新しい話題であり、こうすれば正解というすっきりした答えはおそらく存在しない。

「truthy」と「falsy」という言い方については、おそらく『JavaScript: The Good Parts』(第1版は原著も邦訳も2008年刊)がロングセラーとなって以降定着したものだろう。

JavaScriptではかなり原理主義的に「===」や「!==」を使って厳密な同値を比較すべき、と考える人が多くなった。そしてこの発想はPHPにも波及しているようだ。

Rubyの場合は nil と false だけがfalsyというシンプルなルールであり、またRubyではすべてがオブジェクトでありJavaScriptやPHPのような意味での「型」(プリミティブ型)は存在しないため、厳密な同値というような発想はあまりしない。

なおRubyでは「===」はメソッドであり、曖昧な判定に見えることもあるから「===」が厳密な同値判定比較であるJavaScriptやPHPとは反転しているように感じる人もいるかもしれない。

$ ruby -e 'p /a/ == "aaa"'
false
$ ruby -e 'p /a/ === "aaa"'
true

上記で実行されている「===」は Regexp#=== である。Regexp#=== についてはオフィシャルの日本語版ドキュメントでは以下。
https://docs.ruby-lang.org/ja/latest/method/Regexp/i/=3d=3d=3d.html

Rubyの作者自身は、型について「動的か静的か」という対比と「強い型付けか弱い型付けか」という2種類の対比があるという考え方のようである。つまり「動的で強い」「動的で弱い」「静的で強い」「静的で弱い」の4種類あり、Rubyは「動的で強い」型付けとしている。

基本的には、この発想は「Rubyではオブジェクトシステム ≒ 型システム」というのが前提といえそうだ。

Pythonの場合は厳密な同値判定が推奨されたりはしないようだが、特殊メソッド eq() がオーバーライドされる可能性を考慮して None かどうかの判定に「== None」ではなく「is None」を使うべき、というのはよくいわれる。

Pythonではそのことよりも、オブジェクトが特殊メソッド len() を持つ場合(つまりオブジェクトが len() に渡せる場合)にその len() の返り値が 0 だったらfalsyとなり、これを積極的に活用するのがPythonicパイソニックであるとされることのほうが個人的には気になる。

これはつまり「長さがゼロならそのオブジェクトはfalsyである」というのがPythonの世界観で、Pythonがよく使われる用途においてはうまくいっているようだ。

ここはPython以外の言語をすでにいろいろ知っている人にとってはかなり重要なポイントのはずだが、世にあふれる「Python入門書」では説明されないことがある。実際、筆者も2021年夏にPythonを勉強してみようと思った際にかなり驚いた点である。

この話題はPythonのオフィシャルの日本語版ドキュメントでは以下。
https://docs.python.org/ja/3/library/stdtypes.html#truth

また、Pythonオフィシャルのコーディング規約であるPEP8ペップエイトには以下のような箇所がある。

シーケンス (文字列, リスト, タプル) については、 空のシーケンスが False であることを利用しましょう。

PEP8 日本語版

PEP8の日本語版は以下のURLで読める。
https://pep8-ja.readthedocs.io/ja/latest/

len() が特別な意味を持つPythonは、オブジェクト指向としての純粋さを意識的に放棄しているともいえる。

さてここで、最近出た本の中で比較的評判の良いものをいくつか取り上げてみよう。

例えば『Effective Python 第2版』(邦訳は2020年刊、原著は2019年刊)という本では、PythonのプログラムをよりPythonicパイソニックにするためのコツが90項目挙げられている。

この中の「項目20 Noneを返すのではなく例外を送出する」では、None を返すことによってエラー発生を伝えたりすることがバグにつながりやすいことが紹介されている。解決策として多値たちを返すことによって通常の戻り値とエラー情報を分けること、さらに良い解決策として例外を送出することが推奨されている。

Javaのサンプルコードと共に悪いコードの例が解説されている『良いコード/悪いコードで学ぶ設計入門』(2022年刊)という本では、「12.6.3 エラーは戻り値で返さない、例外をスローすること」において、上記と類似した議論がある。

そして負の数値を返すことでエラー情報を伝えたりするのを「ダブルミーニング」としている。

また「9.7 例外の握り潰し」では例外を握りつぶすことを「極めて邪悪」と強い表現で非難し、「9.7.2 問題検出時にけたたましく叫ばせる」では不正な状態に対して寛容であることを「爆弾を持ってウロウロ歩き回るのと同じこと」と表現している。

C言語における「ダブルミーニング」については、例えば『CERT C コーディングスタンダード』で、エラーだった時に負の値で返したりすることについて ERR02-C で「推奨できない」としている。

高速性をウリにした言語の中では、例えばGo言語において、モダンなスクリプト言語が備えているようなエラーハンドリングの機構をあえて言語機能としては用意せずに多値たちを返すことによってエラーの情報を伝えることが推奨されているのがよく話題になったりする。

また個人的には、RustにおけるResult列挙型の存在や、Rustのかなり独特であるようにみえる機能の ? 演算子(question mark operator)も気になるところである。この ? 演算子は、シェルスクリプトで言うなら、0 でない終了ステータスを検出したら即座にその終了ステータスを使って関数から return (あるいはスクリプト自体を exit)という発想に近い。

Rubyに関する本では、『プロを目指す人のためのRuby入門[改訂2版]』(2021年刊)という本の「9.4.5 予期しない条件は異常終了させる」のセクションで、良くない例として case における「else節を用意しないパターン」について説明されている。

冒頭の出題の cdwdoc-2023-001_challenge2.sh の get_hash() などは、まさにこの「else節を用意しないパターン」といえそうだ。

get_hash() {
  if type sha256sum > /dev/null; then
    sha256sum | awk '{print $1}'
  elif type openssl > /dev/null; then
    openssl sha256 | awk '{print $2}'
  fi
}

これは case ではないが、if節の条件にもelif節の条件にも合致しないことがありうるのは一目瞭然である。つまり sha256sum コマンドも openssl コマンドもない環境だ。

そしてこの本の前述のセクションでも、else節がないことによって結果的に nil を返す状態になっていたり、練られていない安易なelse節になっていたりすることについての説明において、「時限爆弾」という表現がみられる。想定していない条件であれば、例外を発生させるべきであるというわけだ。

Rubyにおける適切なelse節を用意して例外を発生させるという方針は、シェルスクリプトの場合なら、適切なelse節を用意してスクリプトを 0 以外のステータスで終了させるという形で適用できそうではある。

しかし冒頭の出題の get_hash() の場合は、こういう改善をしようとしても、さらなる罠が待っているのである。

もし get_hash() に書き足して以下のようなelse節を用意したとする。

  else
    return 1

そして変数 user_input_hash への代入の直後に以下のように書き足したとする。

exit_status_tmp=$?
[ $exit_status_tmp -ne 0 ] && exit $exit_status_tmp

この変更により、get_hash() の終了ステータスを見て、異常があればスクリプト自体を exit することになるわけだ。

この変更は局所的に見ればまともな発想のようにみえるが、全体としてはおかしなことが起こる。cdwdoc-2023-001_challenge2.sh が 0 以外の終了ステータスで終了したことは、呼ぶ側の cdwdoc-2023-001_challenge.sh においては「正当なユーザーである」というフラグを「立てたままにする」ことになるのだ。だから変更後の get_hash() でelse節に入るような環境(sha256sum コマンドも openssl コマンドもない環境)では、どんなパスワードを入力しても正当なユーザーとして認識するプログラムになってしまう。

呼ぶ側の cdwdoc-2023-001_challenge.sh の仕様がおかしいため、cdwdoc-2023-001_challenge2.sh をまともにしようとあがいても、あまり良い結果にはならないのである。

前述のように、最初に以下のように「正当なユーザーである」と初期化しているのがまずいのだ。

user_is_valid=1

ところで、このあたりはかなり筆者の主観が入る領域になってくる話ではあるが、JavaやC言語の「ダブルミーニング」に比べれば、Pythonでエラー時に None を返すことやRubyでエラー時に nil を返すことはさほど邪悪とはいえないような気もする。

そしてRubyの場合は nil と false のみがfalsyというシンプルなルールのため、Pythonでエラー時に None を返すことに比べれば、Rubyでエラー時に nil を返すことのほうが邪悪度は低いかもしれない。

ただしこれも nil が返ることがあるのがコードで実質的に明示されている場合であって、case や複雑なif文でelse節がないことによって結果として nil が返るようになっているようなメソッドはやはり良くないといえそうだ。

ちなみにRubyにおけるシンプルな後置ifの場合、Rubyに慣れている人にとっては、条件に合致しない時に nil を返すというif文の性質を利用していると「明示されている」と感じることが多い。

$ ruby -e 'p ("a" if true)'
"a"
$ ruby -e 'p ("a" if false)'
nil

Rubyのコアライブラリでは、文字列を数値に変換する Kernel#Integer などが Ruby 2.6(2018年リリース)からオプションとして exception を受け付けるようになった。Kernel#Integer の場合、数値に変換できない時は exception が true なら例外を起こし、false なら nil を返す。デフォルトは true なので例外を起こす。

$ ruby -e 'p Integer("100")'
100
$ ruby -e 'p Integer("1_00")'
100
$ ruby -e 'p Integer("1_0_0")'
100
$ ruby -e 'p Integer("1_0_0_")'
-e:1:in `Integer': invalid value for Integer(): "1_0_0_" (ArgumentError)
        from -e:1:in `<main>'
$ ruby -e 'p Integer("1_0_0_", exception: false)'
nil

Rubyのコアライブラリでは、他にも Kernel.#system でRuby 2.6から exception が追加された。

こういった2.6での exception の導入について、以下のブログ記事でRubyの開発者サイドからの解説がある。

このブログ記事の中に、nil を返すのか例外を起こすのかについての箇所で以下のような記述がある。

その辺(なにをデフォルトに置くか)は言語デザインの妙なのかなと思います。

プロと読み解く Ruby 2.6 NEWS ファイル - クックパッド開発者ブログ

ちなみに Kernel.#system は外部コマンドを実行するためのものである。Kernel.#system のほうでは Kernel#Integer などとは逆で、exception はデフォルトで false である。つまり例外的状況で nil を返す。

以下は Kernel.#system で「echo a」を実行したもの。

$ ruby -e 'system("echo a")'
a

Kernel.#system も値を返す。終了ステータスが 0 なら true、それ以外の終了ステータスなら false である。

$ ruby -e 'p system("echo a")'
a
true
$ ruby -e 'p system("false")'
false
$ ruby -e 'p system("exit 0")'
true
$ ruby -e 'p system("exit 1")'
false

そして外部コマンドが実行できなければデフォルトでは Kernel.#system は nil を返す。

$ ruby -e 'p system("dummyabcabcabc")'
nil
$ ruby -e 'str = "a" * 999999 ; p system("echo " + str)'
nil

「exception: true」を指定すると、nil を返すのではなく例外を起こすようになる。これでエラーメッセージの「Argument list too long」と再会することになるわけだ。

$ ruby -e 'p system("dummyabcabcabc", exception: true)'
-e:1:in `system': No such file or directory - dummyabcabcabc (Errno::ENOENT)
        from -e:1:in `<main>'
$ ruby -e 'str = "a" * 999999 ; p system("echo " + str, exception: true)'
-e:1:in `system': Argument list too long - echo (Errno::E2BIG)
        from -e:1:in `<main>'

デフォルトでは nil を返すため、nil が返るかもしれないことを考慮せずに Kernel.#system が返す値を条件判定に使うとまずいことがあるかもしれない。

$ ruby -e 'p system("echo " + "a" * 9 + " | grep a > /dev/null")'
true
$ ruby -e 'p system("echo " + "b" * 9 + " | grep a > /dev/null")'
false
$ ruby -e 'p system("echo " + "a" * 999999 + " | grep a > /dev/null")'
nil

999999個というものすごい量の「a」が含まれているはずだったにも関わらず……がここでも起こるわけだ。

ちなみに、コマンドが実行できなければ nil なのだが、実行はできたけれども実行してみたら「Argument list too long」が出た、というような場合は false になることがあるかもしれない。

$ ruby -e 'str = "a" * 999999 ; p system("echo " + str + " > /dev/null")'
nil
$ ruby -e 'str = "a" * 99999 ; p system("str=" + str + " ; /bin/echo $str > /dev/null")'
true
$ ruby -e 'str = "a" * 99999 ; p system("str=" + str + " ; /bin/echo $str$str$str$str$str$str > /dev/null")'
sh: 1: /bin/echo: Argument list too long
false

これはつまり、Rubyが外部コマンドを直接実行するのではなく、Rubyがシェルを起動した場合だ。Kernel.#system は「;」のような文字があると直接の実行ではなくシェルにコマンドライン文字列を渡すようになる。シェル自体は起動できたが、シェルが指定されたコマンドラインを実行しようとすると「Argument list too long」が出たという場合は false なわけだ。

ちなみに実行結果を文字列として受け取りたい時は Kernel.#system ではなく Kernel.#` を使う。こちらはコマンドが実行できない時は例外が起こる。

$ ruby -e 'p `which sha256sum`'
"/usr/bin/sha256sum\n"
$ ruby -e 'p `type sha256sum`'
-e:1:in ``': No such file or directory - type (Errno::ENOENT)
        from -e:1:in `<main>'
$ ruby -e 'p `type sha256sum;`'
"sha256sum is /usr/bin/sha256sum\n"
$ ruby -e 'p `type sha256sum;`.upcase'
"SHA256SUM IS /USR/BIN/SHA256SUM\n"

type は通常はシェルのビルトインでしかないので「type」というコマンドを実行しようとすると例外が起こるかもしれないが、「;」を挿れてシェルに type を実行させるようにすると改行込みの文字列が返ってくるわけだ。

Kernel.#system が nil を返すかもしれないことを考慮していない場合などは特に、RubyでもALTLオーバーフローが起こりうることはとりあえず明確になった。

ただ、ここまでの例だと外部コマンドを起動しなければいいのでは、ということにもなりそうだ。

ここで、ALTLオーバーフローとは何なのか、という定義をそろそろはっきりさせたほうが良さそうである。

筆者が考えるALTLオーバーフローとは、以下のようなものである。

分散の要素が強い別のプログラムAに値Bを渡す時、そのBのデータ量が多すぎるためにAが正常に実行できないことがあるにも関わらず、Aを呼ぶ側でそのことを想定していないことで誤動作が起こる。

これが定義だ。

「分散の要素が強い」というのは程度問題だが、RubyはJavaやC++のようなオブジェクト指向に比べるともともとオブジェクトの分散の度合いが強い。JavaやC++はあくまでもモノリシックな構造物の構造化のためにオブジェクト指向から生まれた考え方を一部借用しているという感じだが、RubyはSmalltalkに近い。

Smalltalkそしてオブジェクト指向の生みの親であるアラン・ケイは、当初の構想としてオブジェクトは「biological cells and/or individual computers on a network」(ネットワーク上の生体細胞や個々のコンピュータ)のようなものと考えていたと語っている。

さて、ここからはRubyで実際に動くコードで示そうと思うが、ここではより「分散」をはっきりさせるたに、HTTPアクセスで外部APIのようなものを呼んでいる状況を考えてみたい。

以下が重要なポイントである。

  • HTTPアクセスにも、URLが長すぎるとサーバーが414エラーを返すという仕様上の制限がある

  • Rubyのプログラムが変更が加えられていくうちに、いつの間にか nil をうまく取り扱えていないものに変貌することがある

例によって、これから提示するサンプルは実際にあった事例を元にしているわけではなく、今回この記事のために筆者がでっち上げたものである。

また、このサンプルはこの記事の他のサンプルとは違って、読み手の想像力に委ねる部分が大きい。実質的にダミーのコードといえる部分もある。

そしてこのサンプルを静的な存在として解析してもあまり意味はない。最初は特に問題がなかったRubyのコードが、変更が加えられていくうちに徐々におかしなことになったというその変化の過程をイメージしてみてほしい。

なお、この記事の執筆時点(2023年3月26日時点)ではRuby(CRuby)の最新安定バージョンは3.2.1である。

前述の『リーダブルコード』では「if !」は使うべきでないとしていることは前述のとおりだが、Rubyの場合はもともと unless が存在しており、ガード節では unless の使用を推奨する人もいる。筆者もよく使う。特にガード節が短く一行で抜けるコードが並ぶようなものになっている場合、意図が明確になる。

def hoge(arg)
  return unless arg
  return unless arg.respond_to?(:length)
  return unless arg.length >= 5
  return unless arg.length <= MAX_QUERY_SIZE

  ...(some sort of sensitive process)...

arg が truthy であること(nil でも false でもないこと)が正常、arg が length メソッドを持つのが正常、長さは5以上が正常で MAX_QUERY_SIZE 以下が正常。

「return unless」のあとに来るのが、「このメソッドでは何を正常とみなすか」を規定するものとなる。

極めて明確。

ちなみにPythonの世界では len() は特別な意味を持つが、Rubyには特にそういう発想はなく、オブジェクト指向としての整った体系化が優先されている。

上記の4行で書かれたガード節、もし false が length メソッドを持っているかもしれないというような可能性を考慮しなくていいなら、Ruby 2.3以降の「&.」というぼっち演算子(safe navigation operator)を利用してほぼ同じことを1行で書けるかもしれない。

def hoge(arg)
  return unless (arg.respond_to?(:length)) && (arg&.length &.>= 5) && (arg&.length &.<= MAX_QUERY_SIZE)

  ...(some sort of sensitive process)...

ただし、意図は若干分かりにくくなった。

ではこの unless を利用したコードを、別の人が大急ぎで if に改めなければならなくなったとしよう。

とにかく急がなければならないというムードがあったため、以下のようなコードに変更されたとする。これはどうだろうか?

def hoge(arg)
  return if (! arg.respond_to?(:length)) || (arg&.length &.< 5) || (arg&.length &.> MAX_QUERY_SIZE)

  ...(some sort of sensitive process)...

一見すると似たようなものに見えるが、少し違う。nil が length メソッドを持っているような可能性についてはまあいいとして、「arg が nil ではないけどその arg が持つ length メソッドは nil を返す時がある」というような場合にすり抜けてしまうように思える。

以下の書き方の場合は、(cond A) (cond B) (cond C) のどれか一つでも nil があれば抜ける。

return unless (cond A) && (cond B) && (cond C)

だからこそ、ぼっち演算子にはそれなりに意味があった。

でも以下は違うのである。

return if (cond A) || (cond B) || (cond C)

ちなみにRubyでは nil に対しての「<」はエラーが起こることが期待できるが、nil に対しての「&.<」は nil を返す。

$ ruby -e 'p (nil < 5)'
-e:1:in `<main>': undefined method `<' for nil:NilClass (NoMethodError)
$ ruby -e 'p (nil &.< 5)'
nil
$ ruby --version
ruby 3.0.2p107 (2021-07-07 revision 0db68f0233) [x86_64-linux-gnu]

ただしRubyでは、モンキーパッチが可能ではある。つまり、やろうと思えば実行時に nil に「<」という特異メソッドを定義することも可能ではある。

$ ruby -e 'def nil.<(x) ; "maybe less than #{x}" ; end ; p (nil < 5)'
"maybe less than 5"

ぼっち演算子については、nilが伝播でんぱしていくことを利用してスッキリ書ける場合があるのは確かだが、むやみに多用して一行に詰め込もうとすると分かりにくいバグの温床になる。

実際に動くコードで示そう。

まず準備段階として、以下のように文字列の長さを返すだけの簡易的なWebサーバーを考える。

cdwdoc-2023-001_ruby1_webrick.rb ( https://gitlab.com/-/snippets/2519468 )

#!/usr/bin/env ruby
# written by cleemy desu wayo / see [cdwdoc-2023-001] / Licensed under CC0 1.0

require 'webrick'

websrv = WEBrick::HTTPServer.new({:Port => 8080})

websrv.mount_proc('/test') do |req, res|
  res.body = req.query['str'].to_s.length.to_s + "\n"
end

websrv.start

この中にぼっち演算子はない。API的にこれを呼ぶ側に登場する。

なお、ポート8080が使用中の場合は「:Port => 8080」の箇所を変更する必要があるかもしれない。

また、Rubyのgemで webrick があることが前提である。

$ gem list | grep -i webrick
webrick (1.7.0)

実行すると、待受の状態になる。

$ ./cdwdoc-2023-001_sample_webrick.rb 
[2023-02-02 23:28:27] INFO  WEBrick 1.7.0
[2023-02-02 23:28:27] INFO  ruby 3.0.2 (2021-07-07) [x86_64-linux-gnu]
[2023-02-02 23:28:27] INFO  WEBrick::HTTPServer#start: pid=22027 port=8080

終了する時は Ctrl + C でする。

実際にアクセスしてみると以下のようになる。

$ wget http://localhost:8080/test?str=aaa -q -O -
3
$ wget http://localhost:8080/test?str=aaaaaaaaaaaaaaaa -q -O -
16

当然、「a」の数が多すぎると「Argument list too long」のエラーで wget の起動自体に失敗する。

$ str="$(yes 'a' | head -999999 | tr -d '\n')"
$ wget "http://localhost:8080/test?str=$str" -q -O -
bash: /usr/bin/wget: Argument list too long

では、wget の起動には成功する程度の大きさに調整するとどうなるか。この場合はステータスコード「414」が返るのである。

$ str="$(yes 'a' | head -9999 | tr -d '\n')"
$ wget "http://localhost:8080/test?str=$str" -O - 2>&1 | grep -i error
2023-03-24 23:23:08 ERROR 414: Request-URI Too Large.

「Request-URI Too Large」という言い方は「Argument list too long」によく似ている。

これはWEBRick特有というわけではなく、RFCで規定されているものだ。2022年6月策定のRFC 9110では、単に「414 URI Too Long」として規定されている。

邦訳は、例えば以下のURLにある。

多くのWebサーバーではこの制限は設定によって変更することができる。

設定変更については、筆者の環境(Lubuntu 22.04.1に何も考えず apt-get 任せでRubyをインストール)では /usr/share/rubygems-integration/all/gems/webrick-1.7.0/lib/webrick/httprequest.rb というファイルの中の MAX_URI_LENGTH の定数を変更してから webrick を require しているスクリプトを再実行すると変更される。

GitHub.com 上のWEBRickのリポジトリでは、以下の箇所である。
https://github.com/ruby/webrick/blob/f87aec09fce09c142d2aa5108987338114327e4c/lib/webrick/httprequest.rb#L446

ここまで説明してきたのは「文字列の長さを返すだけの簡易的なWebサーバー」についてである。

では、この「文字列の長さを返すだけの簡易的なWebサーバー」を呼ぶようなスクリプトについて考えてみる。

以下はそのようなスクリプトであり、また前述の甘いガード節をそのまま利用したものである。

cdwdoc-2023-001_ruby2.rb ( https://gitlab.com/-/snippets/2519468 )

#!/usr/bin/env ruby
# written by cleemy desu wayo / see [cdwdoc-2023-001] / Licensed under CC0 1.0

require 'open-uri'

MAX_QUERY_SIZE = 30

def guard_test(arg)

  # weird and too optimistic guard clause
  return if (! arg.respond_to?(:length)) || (arg&.length &.< 5) || (arg&.length &.> MAX_QUERY_SIZE)

  puts "**** arg has been accepted in guard_test() [ arg.length = #{arg.length} ] ****"
end

class Hoge
  def initialize(str)
    @str = str
  end
  def highload?()
    (Time.now.strftime('%s').to_i % 10) <= 4
  end

  def length
    return @str.bytesize unless highload?

    begin
      URI.open("http://localhost:8080/test?str=#{@str}") do |f|
        f.first.chomp.to_i
      end
    rescue => e
      #p "ERROR: #{e}"
      nil
    end
  end
end

puts "---------" ; tmp = "a" * 4              ; puts 'String: "a" * 4'  ; guard_test(tmp)
puts "---------" ; tmp = "a" * 5              ; puts 'String: "a" * 5'  ; guard_test(tmp)
puts "---------" ; tmp = "a" * 6              ; puts 'String: "a" * 6'  ; guard_test(tmp)
puts "---------" ; tmp = Hoge.new("a" * 4)    ; puts 'Hoge: "a" * 4'    ; guard_test(tmp)
puts "---------" ; tmp = Hoge.new("a" * 5)    ; puts 'Hoge: "a" * 5'    ; guard_test(tmp)
puts "---------" ; tmp = Hoge.new("a" * 6)    ; puts 'Hoge: "a" * 6'    ; guard_test(tmp)
puts "---------" ; tmp = Hoge.new("a" * 29)   ; puts 'Hoge: "a" * 29'   ; guard_test(tmp)
puts "---------" ; tmp = Hoge.new("a" * 30)   ; puts 'Hoge: "a" * 30'   ; guard_test(tmp)
puts "---------" ; tmp = Hoge.new("a" * 31)   ; puts 'Hoge: "a" * 31'   ; guard_test(tmp)
puts "---------" ; tmp = Hoge.new("a" * 50)   ; puts 'Hoge: "a" * 50'   ; guard_test(tmp)
puts "---------" ; tmp = Hoge.new("a" * 9999) ; puts 'Hoge: "a" * 9999' ; guard_test(tmp)

Hoge というクラスの length メソッドの中で、先ほどの「文字列の長さを返すだけの簡易的なWebサーバー」を呼んでいるわけだ。

highload? は Hoge が持つべきメソッドなのか?とか length は何らかのキャッシュ的な機構を持つべきでは?とかいろいろあるが、そういうのは本題から外れるので適当に想像で埋めてほしい。

また、guard_test にはガード節と puts しかないが、これはこういうテストコードがあるということではなく、巨大な長さのオブジェクトを受け付けてしまうと何か非常にまずいことが起こる、というように置き換えて考えてほしい。

この「長さ」についても、実際のコードでは文字列の長さばかりを見ているが、実際は何か複雑な構造を持っているものであり、「長さ」を算出するのにそれなりの負荷がかかるという状況を想像してほしい。

重要なのは、システムが高負荷の時のみ(つまり highload? が true を返す時のみ)先ほどのAPI的な「文字列の長さを返すだけの簡易的なWebサーバー」にアクセスしていること、そして guard_test の中のガード節が、受け取ったオブジェクトの length メソッドが nil を返す場合にすり抜けるものになっていることだ。

ちなみに highload? メソッドは、実際に高負荷かどうかをチェックしているのではなく完全にダミーで、現在時刻の秒の1の位が0〜4の時が true だ。高負荷では「ない」ならば単に @str.bytesize の結果を return するが、高負荷なら処理を他に任せる(分散処理をする)というわけだ。

前述の「変更が加えられていくうちに徐々におかしなことになった」というのはつまり、最初は Hoge の length メソッドは nil を返すことがありえないはずだったものが、分散を前提にしてから nil を返すことがありうるようになった、というその「物語性」をイメージしてほしい、ということである。さらに、ガード節についても最初は length メソッドが nil を返す場合にガードできていた(あるいはエラーになっていた)はずのものが、いつの間にかすり抜けるものになっていた、という別の「物語」が組み合わさっているわけだ。

なお、この cdwdoc-2023-001_ruby2.rb を実行してみて、どう考えてもこの記事の解説の通り(つまり筆者の期待通り)に動いているようには見えない、という場合は「#p "ERROR: #{e}"」のコメントアウトの行のコメントアウトを外すと原因が分かるかもしれない。

この cdwdoc-2023-001_ruby2.rb を実行すると、実行結果がずらずらと表示される。ここでは最初の13行だけを掲載する。

$ ./cdwdoc-2023-001_ruby2.rb | head -13
---------
String: "a" * 4
---------                                                                                                                                                        
String: "a" * 5                                                                                                                                                  
**** arg has been accepted in guard_test() [ arg.length = 5 ] ****                                                                                               
---------
String: "a" * 6
**** arg has been accepted in guard_test() [ arg.length = 6 ] ****
---------
Hoge: "a" * 4
---------
Hoge: "a" * 5
**** arg has been accepted in guard_test() [ arg.length = 5 ] ****

guard_test では、受け取ったオブジェクトの length メソッドの結果が5以上30以下であれば受け付けるはずだ。

受け付けた場合は「**** arg has been accepted ...」と表示される。

最後のテストパターンは9999個の "a" で初期化した Hoge のインスタンスを guard_test に渡すものである。

現在時刻の秒の1の位が5〜9の時(高負荷でない時)に実行すると、実行結果の最後のほうは以下のようになる。

$ date ; ./cdwdoc-2023-001_ruby2.rb | tail -5
Sat Mar 25 04:33:37 JST 2023
Hoge: "a" * 31
---------
Hoge: "a" * 50
---------
Hoge: "a" * 9999

現在時刻の秒の1の位が0〜4の時(高負荷の時)に実行すると、実行結果の最後のほうは以下のようになる。

$ date ; ./cdwdoc-2023-001_ruby2.rb | tail -5
Sat Mar 25 04:33:42 JST 2023
---------
Hoge: "a" * 50
---------
Hoge: "a" * 9999
**** arg has been accepted in guard_test() [ arg.length =  ] ****

前述のALTLオーバーフローの定義をあらためて掲載する。

分散の要素が強い別のプログラムAに値Bを渡す時、そのBのデータ量が多すぎるためにAが正常に実行できないことがあるにも関わらず、Aを呼ぶ側でそのことを想定していないことで誤動作が起こる。

このRubyのサンプルの場合は、「分散」は外部コマンドという形ではなくHTTPアクセスで、「正常に実行できないことがある」は length が nil を返すことがあるということで、「呼ぶ側でそのことを想定していない」は length が nil を返す時にすり抜ける(甘いガード節)ということである。

ところで今回、Rubyのサンプルを実際に書いてみて気づいたことがある。HTTPの場合は GET ではなく POST で渡せば「414 URI Too Long」のエラーは起こらないわけだが、これはシェルスクリプトにおいてコマンドライン引数ではなく標準入出力で値の受け渡しをしたほうがいい、というのと類似しているのである。

 

スクリプト言語における防御的プログラミングをつきつめる

防御的プログラミング(defensive programming)について考えたい。

GitHub Security Labのブログに、PerlやPythonやPHPなどの言語インタプリタの脆弱性についての解説記事がある。2020年の夏に公開されたものである。

この記事の中に、Pythonの脆弱性の CVE-2014-1912 についての以下のような箇所がある。

But that’s exactly why this is an interesting case. Not because the vulnerability was widespread in the real world. It’s interesting because developers in memory managed languages tend to explicitly trust the language implementation to keep them safe. There is a cognitive dissonance that may occur when issues such as CVE-2014-1912 are present in the language.

Now you C me, now you don’t

以下、DeepL訳を参考にした拙訳。

でもそれこそが、このケースが興味深い理由なのです。現実世界にこの脆弱性が広く浸透していたからではありません。メモリ管理型言語の開発者は、言語処理系が自分たちの安全を守ってくれると明確に信頼する傾向があるがゆえに、興味深いのです。CVE-2014-1912 のような問題が言語に存在する場合、認知的不協和が起こる可能性があるのです。

また、以下のような箇所もある。

This teaches us the lesson that the assumption of safe memory management semantics, even in higher level languages that advertise memory safety, is never a given. When dealing with APIs that explicitly operate on statically sized mutable buffers, it never hurts to ensure that your sizes match your buffers, even in cases where it’s presumably safe to not do so by virtue of the language itself.

Now you C me, now you don’t

以下、DeepL訳を参考にした拙訳。

このことは、たとえメモリ安全性を標榜ひょうぼうしている高級言語であっても、安全なメモリ管理セマンティクスを仮定することは決して正しいことではないという教訓を私たちに教えてくれます。静的なサイズの変更可能なバッファを明示的に操作するAPIを扱う場合、たとえ言語そのものが備える効能によっておそらくそんなことをしなくても安全だろうと推定されるような場合であっても、データサイズがバッファサイズと釣り合うことを確認しておくことは決して無駄ではありません。

なおこの記事では、最初はフルエクスプロイト(full exploitation)が不可能だと思われたPerlの脆弱性 CVE-2005-3962 がしばらくしてからフルエクスプロイト可能なパターンが存在することが分かった件についての解説があるが、これについては以下のような2005年の記事もある。英語の原文はもう確認できないかもしれない。

さてここで、文字列の妥当性のチェックのために正規表現を使う場合を考えてみよう。

PHPなどにおいて、preg_match() などの関数は、strpos() や strlen() のようなシンプルな関数よりも複雑であり、未知の脆弱性が潜んでいる可能性が高いと考えることはそれなりに妥当といえる。

だからといって strpos() や strlen() が安全だと宣言したいわけではないのだが、少なくともバグハンティング的な観点からは preg_match() などの複雑な関数よりも strpos() や strlen() のような基本的な関数に脆弱性が見つかるほうがはるかにインパクトが大きいのは確かである。インパクトが大きいことが想定されるものには、多くの目が注がれやすい。

ちなみにRubyには String#start_with? や String#end_with? があり、Pythonには startswith や endswith があったが、PHPもPHP8からは str_starts_with() や str_ends_with() や、はたまた str_contains() などが登場しているようだ。

PHPなどにおいて、妥当性のチェックのために preg_match() に文字列が渡るよりも前に、まずサイズのチェックなどを含めて基本的な関数を使ってはじいておく、という方法は採れそうだ。それは例えば、ガード節に短いコードが並ぶような場合に、preg_match() を使って抜けるような箇所はガード節の最後のほうに配置し、先にサイズなどのシンプルなチェックで抜けるようにするという発想である。

2023年3月現在では、速度面ではなくセキュリティの観点でこういうコーディングをするのはかなりパラノイア的であるということになるのかもしれないが、未知の脆弱性まで含めて防御的であるようなコーディングとしてどのようなスタイルがありうるのか、と考えてみることもたまには有用かもしれない。ただしここで考える「有用」というのも、基本的には「宇宙線耐性」と似たような方向性のものでしかないとみなされても仕方がないのかもしれない。

「宇宙線耐性」というのは『あなたの知らない超絶技巧プログラミングの世界』(2015年刊)という本にあったもので、コードのどこか1バイトを削除しても削除前と全く同じ結果を出力するプログラムだ。

この本の「1-9-1 宇宙線耐性Quine」のセクションのコードは、以下のURLにある rquine.rb である。これは「radiation-hardened」(放射線耐性、宇宙線耐性)が施されつつ、さらにクワインであるようなプログラムである。適当に3箇所くらい消しても、運が良ければ完全に元通りに動いたりするのが恐ろしい。
https://github.com/mame/radiation-hardened-quine

クワインであるということは自分自身を出力するということだが、上記の例の場合はプログラムが損傷を受けたとしても実行すれば修復されたバージョンが出力されるわけで、自己修復的といえる。

ところで前述のGitHub Security Labのブログ記事は多くの言語の言語実装系がC言語などのメモリセーフでない言語で書かれていることに注目しているが、そういうものとはまったく違うものとして、Spotifyが2013年に公開した以下の記事はスリリングで面白い。

これはメモリセーフでない言語特有の脆弱性とは大きく異なっている。そしてSpotifyのコードにも、Spotifyが使用していたTwistedというライブラリにも、Pythonの標準ライブラリにも、それぞれにおいて特に深刻な脆弱性があったというわけではない。

Pythonのバージョンが2.4から2.5に上がることによってPythonの標準ライブラリの unicodedata の振る舞いが微妙に変わり、そのことによって文字列の「同一性」に大きく関わるTwistedのある関数の振る舞いが微妙に変わったために、Spotifyにおいて勝手に他人のアカウントのパスワードリセットができてしまう状態だったという、実際にあった事例だ。

ちなみに『バイオインフォマティクスデータスキル』の「1.4 再現可能な研究」には、以前とはまったく違う解析結果になって原因が分からず困惑していたら「Rパッケージのバージョン」が新しいバージョンになっていたことが原因だったという、この本の作者が実際に遭遇した事例が紹介されている。

さて。

この記事もそろそろ終わりに近づいていることではあるし、シェルスクリプトにおいてこういう微妙な振る舞いの違いがセキュリティリスクになりうる場合というのを実際のコードで示そう。

シェルスクリプトにおける現実的な注意点」のセクションの終盤で述べたように、シェルスクリプトでは各種コマンドやツールがライブラリ関数のような存在になる。

ここで、Solarisの /usr/bin/awk に存在している制限と、その制限の両義的な側面に着目したい。

Solarisの /usr/bin/awk は、2018年リリースのSolaris 11.4であっても、いろんな意味で「いにしえ」のAWKである。

$ echo aaa | /usr/bin/awk '{print toupper()}'
awk: syntax error near line 1
awk: illegal statement near line 1
$ echo aaa | /usr/bin/awk '{print toupper($1)}'
aaa
$ echo aaa | /usr/bin/nawk '{print toupper()}'
AAA
$ uname -a
SunOS solaris 5.11 11.4.0.15.0 i86pc i386 i86pc

そして今回着目したい制限とは、1つのレコードのデータ量だ。これを確認してみる。

$ yes 'a' | head -2559 | tr -d '\n' | wc -c
2559
$ yes 'a' | head -2559 | tr -d '\n' | /usr/bin/awk '{print}' | wc -c
2560
$ yes 'a' | head -2560 | tr -d '\n' | /usr/bin/awk '{print}' | wc -c
awk: record `aaaaaaaaaaaaaaaaaaaa...' too long
0
$ echo ${PIPESTATUS[@]}
141 0 0 2 0

1行が2559バイトまでなら特に問題ないが、2560バイトあるいはそれ以上の行(レコード)に遭遇すると /usr/bin/awk は処理を放棄して、終了ステータス 2 で終了してしまうのである。

エラーメッセージに「too long」とあるから「Argument list too long」と似ているようにも見える。でもAWKの起動に失敗しているわけではない。これはシェルではなくAWKが出力した「too long」だ。

以下のようにすると分かるが、2560バイト以上あるような行に遭遇するまでは、AWKはちゃんと処理するのである。

$ { echo aaa; echo bbbb; echo ccccc; yes 'd' | head -2560 | tr -d '\n' ; } | /usr/bin/awk '{ print length() }'
3
4
5
awk: record `dddddddddddddddddddd...' too long
 record number 3

Solarisには複数のAWKが入っているが、/usr/bin/nawk や /usr/xpg4/bin/awk や /usr/gnu/bin/awk (gawk)にはこの制限はない。

$ yes 'a' | head -2560 | tr -d '\n' | /usr/bin/nawk '{print length()}'
2560
$ yes 'a' | head -2560 | tr -d '\n' | /usr/xpg4/bin/awk '{print length()}'
2560
$ yes 'a' | head -2560 | tr -d '\n' | /usr/gnu/bin/awk '{print length()}'
2560

ただし /usr/xpg4/bin/awk には19999バイト以上の場合の「Record too long」の制限があるかもしれない。

$ yes 'a' | head -19998 | tr -d '\n' | /usr/xpg4/bin/awk '{print length()}'
19998
$ yes 'a' | head -19999 | tr -d '\n' | /usr/xpg4/bin/awk '{print length()}'
/usr/xpg4/bin/awk: line 0 (NR=1): Record too long (LIMIT: 19999 bytes)
$ echo ${PIPESTATUS[@]}
141 0 0 1

さて、ここで以下のようなスクリプトを用意してみよう。

cdwdoc-2023-001_solaris.sh ( https://gitlab.com/-/snippets/2516176 )

#!/bin/sh
# written by cleemy desu wayo / see [cdwdoc-2023-001] / Licensed under CC0 1.0

path=$(     printf '%s\n' "$1" | /usr/bin/awk -F: '{print $1}' )
line_num=$( printf '%s\n' "$path" | wc -l | sed 's/[^0-9]*//g' )

if [ "x$line_num" != 'x1' ]; then
  echo 'error: (multiple line)' >&2
  exit 1
fi

case $path in
  *..*) echo 'error: (".." was found)' >&2 ; exit 1;;
  /home/*) :;;
  *) echo 'error: (not start with "/home/")' >&2 ; exit 1;;
esac

printf '%s\n' "$1" | cut -d: -f1 >> requested_path_list.txt
exit 0

このスクリプトは、例えば「/home/aaa:abcde」のような文字列が引数として渡されると、「/home/aaa」を requested_path_list.txt というテキストファイルに追記する。

$ ./cdwdoc-2023-001_solaris.sh '/home/aaa:abcde'
$ tail requested_path_list.txt
/home/aaa

「:」を区切りとみなした時の1つ目のカラムが「/home/」で始まっていない場合、例えば「/home123/aaa:abcde」のようなものはエラーになり、テキストファイルには何も書き込まれない。

$ rm -f requested_path_list.txt
$ ./cdwdoc-2023-001_solaris.sh '/home123/aaa:abcde'
error: (not start with "/home/")
$ tail requested_path_list.txt
tail: cannot open input

また、「..」を含んでいたり改行を含んでいたりする場合もエラーになる。

$ ./cdwdoc-2023-001_solaris.sh '/home/aaa/../../root:abcde'
error: (".." was found)
$ ./cdwdoc-2023-001_solaris.sh '/home/aaa:abcde
> /home/bbb:xyz'
error: (multiple line)

「..」を含むかどうかのチェックには case を使っているため、この箇所でALTLオーバーフローが起こることはない。

しかし、このスクリプトにはいくつかおかしなところがある。

冒頭で変数 path を生成したはずだが、最後はこれを使用せずにまた $1 を cut で切り出したデータをテキストファイルに追記している。

また、変数 path を生成する時は /usr/bin/awk を使っているのに、最後には何故か cut を使っているのもポイントである。

ここで、以下のようにして生成した str2 を考えてみる。

$ str=$(yes 'a' | head -9999 | tr -d '\n')
$ str2=$(printf '/home/hoge:aaa\n/root:%s' "$str")

この str2 は複数行の文字列だ。1行目は「/home/hoge:aaa」だけだが2行目がそこそこ長いものになっている。

各行の先頭50文字を抽出すると以下のようになる。

$ echo "$str2" | sed 's/^\(.\{50\}\).*/\1/'
/home/hoge:aaa
/root:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

Solaris 11.4でこのように生成した str2 を引数として渡して cdwdoc-2023-001_solaris.sh を実行すると、以下のようになる。

$ rm -f requested_path_list.txt
$ ./cdwdoc-2023-001_solaris.sh "$str2"
awk: record `/root:aaaaaaaaaaaaaa...' too long
 record number 1
$ tail requested_path_list.txt
/home/hoge
/root

「/home/」で始まっていない「/root」という行が requested_path_list.txt に書き込まれてしまった。

Solaris 11.4では、以下の要素が組み合わさってこのようなことが起こるわけだ。

  • /usr/bin/awk の制限により、変数 path は1行目だけが抽出されたものになる

  • 変数 line_num は変数 path の行数のため、1 になる

  • 最後に変数 path を使用せずにもう一度 $1 を使用している

cdwdoc-2023-001_solaris.sh の /usr/bin/awk の箇所を /usr/bin/nawk などに変更してから実行したり、あるいは /usr/bin/awk が gawk や mawk であるような環境(つまりDebian系を含む多くのLinux)で実行すれば、変数 path が複数行になるためエラーになるはずである。

さて、これまで見てきたように cdwdoc-2023-001_solaris.sh のようなスクリプトの場合はSolarisの /usr/bin/awk の制限によってセキュリティリスクが増す例である。

以下は逆に、制限のおかげで守られるという例である。

cdwdoc-2023-001_solaris2.sh ( https://gitlab.com/-/snippets/2516176 )

#!/bin/sh
# written by cleemy desu wayo / see [cdwdoc-2023-001] / Licensed under CC0 1.0

user_home_dir=$( grep '^user4:' < passwd.txt | head -1 | /usr/bin/awk -F: '{print $6}')
user_shell=$(    grep '^user4:' < passwd.txt | head -1 | /usr/bin/awk -F: '{print $7}')

# too optimistic
if /bin/echo "x$user_home_dir$user_shell" | grep '[^a-z0-9/]' > /dev/null ; then
  echo "error: invalid data" >&2
  exit 1
fi

realpath "$user_home_dir" >> requested_path_list.txt
exit 0

このスクリプトは、「シェルスクリプトにおける現実的な注意点」のセクションの終盤に登場した cdwdoc-2023-001_sample_dir3.sh とよく似ている。

カレントディレクトリの passwd.txt から user4 のデータを読み取って、6つ目のカラムを抽出して requested_path_list.txt に追記する。

$ rm -f passwd.txt
$ rm -f requested_path_list.txt
$ echo 'user4:::::/home/user1:/bin/sh' >> passwd.txt
$ ./cdwdoc-2023-001_solaris2.sh
$ tail requested_path_list.txt
/home/user1

6つ目のカラムを「/home/user1/../../root」のようなものにしてもエラーになる。

$ rm -f passwd.txt
$ rm -f requested_path_list.txt
$ echo 'user4:::::/home/user1/../../root:/bin/sh' >> passwd.txt
$ ./cdwdoc-2023-001_solaris2.sh
error: invalid data
$ tail requested_path_list.txt
tail: cannot open 'requested_path_list.txt' for reading: No such file or directory

このスクリプトは先ほどの cdwdoc-2023-001_solaris.sh とは違って、6つ目のカラムが /home/ で始まっているかどうかのチェックはしない。

また、case によるチェックではなく /bin/echo と grep によって使用不可な文字列を検出している。だから /usr/bin/awk が gawk や mawk であるような環境(つまりDebian系を含む多くのLinux)で以下のようにすると、これまで他のセクションで説明してきたようなALTLオーバーフローを起こすことができる。以下は /home/user1 が存在しているのが前提である。

$ ls -l /home/user1
total 0
$ rm -f passwd.txt
$ rm -f requested_path_list.txt
$ printf 'user4:::::/home/user1/' >> passwd.txt
$ yes '../' | head -43684 | tr -d '\n' >> passwd.txt
$ printf 'root:/bin/sh' >> passwd.txt
$ yes 'h' | head -5000 | tr -d '\n' >> passwd.txt
$ echo >> passwd.txt
$ ./cdwdoc-2023-001_solaris2.sh
./cdwdoc-2023-001_solaris2.sh: 8: /bin/echo: Argument list too long
$ tail requested_path_list.txt
/root
$ uname -a
Linux poge1 5.15.0-69-generic #76-Ubuntu SMP Fri Mar 17 17:19:29 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux

「/root」という文字列がテキストファイルに書き込まれた。

例によって、Knoppix 9.1の場合は「/root」ではなく「/UNIONFS/root」になるはずである。

では、これと同じ方法をSolaris 11.4でも試してみる。以下は /export/home/user1 が存在していることと、realpath がGNU coreutilsのものであるのが前提である。

$ pwd
/export/home/user1
$ ls -l /export/home/user1 | head -1
total 4381
$ realpath --version | head -1
realpath (GNU coreutils) 8.27
$ rm -f passwd.txt
$ rm -f requested_path_list.txt
$ printf 'user4:::::/export/home/user1/' >> passwd.txt
$ yes '../' | head -698000 | tr -d '\n' >> passwd.txt
$ printf 'root:/bin/sh' >> passwd.txt
$ yes 'h' | head -5000 | tr -d '\n' >> passwd.txt
$ echo >> passwd.txt
$ ./cdwdoc-2023-001_solaris2.sh
awk: record `user4:::::/export/ho...' too long
awk: record `user4:::::/export/ho...' too long
realpath: '': No such file or directory
$ tail requested_path_list.txt
$ uname -a
SunOS solaris 5.11 11.4.0.15.0 i86pc i386 i86pc

Solaris 11.4では、「# too optimistic」の箇所で「Argument list too long」のエラーを起こそうとすると、/usr/bin/awk の制限のほうにも引っかかることになる。これにより、user_home_dir や user_shell は共に空文字列になるからテキストファイルには何も書き込まれないのである。

cdwdoc-2023-001_solaris2.sh の /usr/bin/awk の箇所を /usr/xpg4/bin/awk に変更してから cdwdoc-2023-001_solaris2.sh を再度実行しても、やはり20000バイト(1999バイトまで)の制限に引っかかるから守られているのが確認できるはずである。

でも /usr/bin/nawk や /usr/gnu/bin/awk などに変更してから再度実行した場合は、Solaris 11.4でもやはりALTLオーバーフローが起こって「/root」が書き込まれているのが確認できるはずである。

なお、これらの「再度実行」の際には、passwd.txt はすでに生成されているので作り直す必要はない。

2023年3月現在においては、生きているシステムでありながら /usr/bin/awk が「いにしえ」のものであるようなものを気軽に試すのは、VirtualBox上のSolaris 11.4が適している。

Solarisがどうなのかは分からないが、一般論として商用Unixは各種コマンドの微妙な振る舞いについて変更しないでほしいという要求がユーザー側からなされることが多いとされる。古いシェルスクリプトの振る舞いが微妙に変わるからである。

AWKは汎用的なプログラミング言語に片足を突っ込んでいるような存在ではあるが、ギリギリのところでDSLでもあり、そしてシェルスクリプトにおいてはライブラリ関数のような存在でもある。

たいていの言語において、前述のように複雑なものと単純なもので多少の差をつけたとしても、「ライブラリを信用しない」という方針そのものは本質的には堂々巡りの要素をはらんでいる。

例えば「できるだけ早い段階でサイズをチェックしておくのがいい」というのが理論上は正しいとしても、そのサイズのチェックには言語の基本機能あるいはライブラリを使うわけだ。

そう、そしてシェルスクリプトの場合においては、まさにそういう基本的なチェックに外部コマンドを使わなければならない状況がありうるというわけなのである。

 

冒頭の出題の解答例

 

というわけで、この記事の先頭にある出題の解答例。

シンプルな解答としては、単に巨大な文字列を渡せばいいだけ、ということになる。

すでに解説部分を読んだ人なら、「Argument list too long」のエラーについて知っているはずである。

以下はLubuntu 22.04.1での例である。

$ str="$(yes 'a' | head -131065 | tr -d '\n')"
$ ./cdwdoc-2023-001_challenge.sh user2 "$str"
./cdwdoc-2023-001_challenge.sh: 17: ./cdwdoc-2023-001_challenge2.sh: Argument list too long
you are a valid user! [ user name : "user2" ]
$ echo $?
0
$ cat /proc/version
Linux version 5.15.0-69-generic (buildd@lcy02-amd64-080) (gcc (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0, GNU ld (GNU Binutils for Ubuntu) 2.38) #76-Ubuntu SMP Fri Mar 17 17:19:29 UTC 2023

131065回「a」が繰り返されたものをパスワードとして渡すことによって、「you are a valid user!」を出力させることができたし終了ステータスも 0 になった。

なお、必要なデータ量は環境によって違う。ターミナルから cdwdoc-2023-001_challenge.sh が起動できる程度には小さくし、スクリプト内で cdwdoc-2023-001_challenge2.sh を実行しようとする箇所でエラーが起こる程度には大きくする、というのがポイントである。

Knoppix 7.2.0やKnoppix 9.1を含む現代的なLinuxでは、131059 以上 131071 以下がうまくいく範囲である。

Linux以外では、「getconf ARG_MAX」の出力が参考になるかもしれない。

ところで、この記事の「起こっていることについての、もう少し現実的な解説」のセクションの終盤などでは、true コマンドと false コマンドの非対称性について紹介した。

true が存在しない場合には true は期待に反してfalsyな終了ステータスになり、false が存在しない場合でも false は期待通りにfalsyな終了ステータスになる、という話だった。

では、もし冒頭の出題で cdwdoc-2023-001_challenge2.sh が存在しない状態だとどのような動作になるのだろうか。

実際に cdwdoc-2023-001_challenge2.sh のファイル名をリネームするなどして存在しない状態にしてしまうと、どんなパスワードを入力しても、あるいはパスワードをなしにしても、存在しないユーザーであっても、とにかく「you are a valid user!」を吐くようになる。

$ mv cdwdoc-2023-001_challenge2.sh cdwdoc-2023-001_challenge2_.sh
$ ./cdwdoc-2023-001_challenge.sh user1 a
./cdwdoc-2023-001_challenge.sh[17]: ./cdwdoc-2023-001_challenge2.sh: not found
you are a valid user! [ user name : "user1" ]
$ ./cdwdoc-2023-001_challenge.sh user1 abcdef
./cdwdoc-2023-001_challenge.sh[17]: ./cdwdoc-2023-001_challenge2.sh: not found
you are a valid user! [ user name : "user1" ]
$ ./cdwdoc-2023-001_challenge.sh user1
./cdwdoc-2023-001_challenge.sh[17]: ./cdwdoc-2023-001_challenge2.sh: not found
you are a valid user! [ user name : "user1" ]

将来的に自動アップデートのような機構が組み込まれ、その自動アップデートでファイル更新時にいったんファイルが削除されてから新規作成されるようなものであった場合、一瞬だけファイルが存在しない時間が存在することになるかもしれない。

自作のスクリプトについても既存のコマンドについても、もしも存在しない状態だとしたらシステム全体としてどんな動作になるだろうか、という可能性を考えてみることはシェルスクリプトの堅牢性を高めるうえで有用である。

もし、存在しない場合でもさほど危険な状態にはならないのだとしたら、おそらく「Argument list too long」のエラーが起こってもたいした問題とはならないのだ。

結局、すべてのコマンドは何か想像だにしないことでエラーになるかもしれないのである。

想像だにしないこととは、例えば「No space left on device」のようなものも含む。

/tmp が独立したファイルシステムになっているLinux(Knoppix 7.2.0やKnoppix 9.1を含む)では、「sort -R -S 1K」によって「No space left on device」のエラーが起こりやすいかもしれない。

$ seq 99999999 | sort -R -S 1K | head
sort: cannot create temporary file in '/tmp': No space left on device

上記を実行中に別のターミナルで以下のようにすると、みるみるうちに /tmp が浪費されていくのが確認できる。

$ while :; do du -sh /tmp 2>/dev/null ; sleep 0.5 ; done
290M    /tmp
389M    /tmp
491M    /tmp
599M    /tmp
713M    /tmp
835M    /tmp
961M    /tmp
1.1G    /tmp
1.3G    /tmp
1.4G    /tmp

ところで、この記事の「シェルスクリプトにおける現実的な注意点」のセクションでも説明したが、対話的なものを前提にしているわけではないすべてのシェルスクリプトは外部から呼ばれる可能性がある。そしてその外部のプログラムというのはシェルスクリプトとは限らない。

というわけで、冒頭の出題のコードについて、外部のプログラムから呼ばれる場合についても考えてみる。

例えば以下のようなRubyのコードが同じディレクトリにあったとする。

cdwdoc-2023-001_from_external.rb ( https://gitlab.com/-/snippets/2497177 )

#!/usr/bin/env ruby
# written by cleemy desu wayo / see [cdwdoc-2023-001] / Licensed under CC0 1.0

def user_authenticate(user, password)
  system('./cdwdoc-2023-001_challenge.sh', user, password, :err=>'/dev/null')
end

p user_authenticate('user1', 'aaa')
p user_authenticate('user1', 'aaaa')
p user_authenticate('user1', 'a' * 100)
p user_authenticate('user1', 'a' * 131071)  # change this line to your env
p user_authenticate('user1', 'a' * 9999999)

Kernel.#system を使って、カレントディレクトリの cdwdoc-2023-001_challenge.sh を実行しているわけだ。

これを実行するとこのようになる。

$ ./cdwdoc-2023-001_from_external.rb
true
false
false
true
nil

実行環境は以下。

$ ruby --version
ruby 3.0.2p107 (2021-07-07 revision 0db68f0233) [x86_64-linux-gnu]
$ cat /proc/version
Linux version 5.15.0-69-generic (buildd@lcy02-amd64-080) (gcc (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0, GNU ld (GNU Binutils for Ubuntu) 2.38) #76-Ubuntu SMP Fri Mar 17 17:19:29 UTC 2023

当然ながら、「'a' * 131071」としている箇所は環境によって変える必要がある。現代的なLinuxなら変更せずにそのまま使えるかもしれない。

Python版も示しておく。

cdwdoc-2023-001_from_external.py ( https://gitlab.com/-/snippets/2497177 )

#!/usr/bin/env python3
# written by cleemy desu wayo / see [cdwdoc-2023-001] / Licensed under CC0 1.0

import subprocess

def user_authenticate(user, password):
    command_args = ['./cdwdoc-2023-001_challenge.sh', user, password]
    try:
        process = subprocess.run(args=command_args, stderr=subprocess.DEVNULL)
        return process.returncode == 0
    except Exception:
        return None

print(user_authenticate('user1', 'aaa'))
print(user_authenticate('user1', 'aaaa'))
print(user_authenticate('user1', 'a' * 100))
print(user_authenticate('user1', 'a' * 131071))  # change this line to your env
print(user_authenticate('user1', 'a' * 9999999))

古いPython(Python 3.4 およびそれ以前)では一部を以下のように変更する必要があるかもしれない。

        exit_status = subprocess.call(args=command_args, stderr=open('/dev/null', 'w'))
        return exit_status == 0

これを実行するとこのようになる。

$ ./cdwdoc-2023-001_from_external.py
True
False
False
True
None

実行環境は以下。

$ python3 -V
Python 3.10.6
$ cat /proc/version
Linux version 5.15.0-69-generic (buildd@lcy02-amd64-080) (gcc (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0, GNU ld (GNU Binutils for Ubuntu) 2.38) #76-Ubuntu SMP Fri Mar 17 17:19:29 UTC 2023

このように、パスワードが正しいかどうかの判定をするシェルスクリプトを、シェルスクリプトでないプログラムから呼ぶようなシステムは実際に存在する。例えばオープンソースプロジェクトのHestiaCPというWebコンパネがある。

これは大部分がシェルスクリプトとPHPで書かれたもので、パスワードが正しいかどうかの判定をするシェルスクリプトをPHPの側から呼んでいるわけだ。

このHestiaCPでは、インストール後に /usr/local/hestia/bin/ 以下に大量のシェルスクリプトが置かれる。それらは「v-」で始まるファイル名で、単体としてもそれなりに機能する。フォーク元のVestaCPの場合は /usr/local/vesta/bin/ というディレクトリである。

これらの「v-」で始まるシェルスクリプトのうち、例えば v-check-user-password や v-check-user-hash が /etc/shadow を直接読むようなものになっている。

PHPの側からシェルスクリプト v-check-user-password や v-check-user-hash を呼んでいる箇所は例えば以下である。

なお2023年3月現在、HestiaCPは活発にバージョンアップがなされているが、フォーク元のVestaCPのほうはプロジェクトとしては「殆ど死んでいる」ようだ。

ところで、この記事の冒頭の出題における user2 の正しいパスワードは「h5_#r2iC&qD@A5F」である。特に意味はない。

user3 については、パスワードがないかもしれない。

「ないかもしれない」というのは、データベースの中の

636c65656d792064657375207761796f636c65656d792064657375207761796f

という文字列はハッシュ値ではないからだ。

いや、でももしかすると、実は何かのハッシュ値という可能性もあったりするのかもしれない。現時点では筆者には分からない。

とりあえずそれが分からない限りは、user3 については不正な方法でないと「you are a valid user!」と表示させることは難しいかもしれない。

 

※このすぐ上に冒頭の出題の解答例が記載されています

 

変更履歴

 

  • 2023-03-26 22:55 JST ごろ note.com にて初版公開

(※これ以降の変更はありません)

 

   とりあえず、まずは生活保護から脱出したいと考えております。