前回:https://www.demandosigno.study/entry/2023/07/20/071137で紹介したように、PHPの exec(), system() などの関数はOSコマンドを呼び出すことができます。多くはシェル経由でコマンドを起動しています。
----- 以下「安全なWebアプリケーションの作り方」p296~297より一部引用 -----
system("echo hello > a.txt"); PHPでのsystem関数の呼び出し
↓
/bin/sh -c echo hello > a.txt 実際に起動されているコマンド。/bin/sh 経由で呼び出されている。
シェル経由でコマンドを起動することにより、パイプ機能やリダイレクト機能などを利用しやすくなりますが、ここにOSコマンドインジェクションの原因があります。
シェルによる複数コマンド実行例。
$ echo aaa ; echo bbb # コマンドを続けて実行
aaa
bbb
$ echo ls | grep home # 第1のコマンドの出力を第2のコマンドの入力に
home
このように複数のコマンドを起動するための構文があるため、外部からパラメータを操作することにより元のコマンドに加えて別のコマンドを起動させられる場合があります。
----- 引用ここまで -----
proc_open 関数
今回は、BadTodoの「問い合わせ」画面を例に出します。以下のようになっており、項目を入力し「送信」すると問い合わせが送られます。



ここでEメール欄にtest@example.com;cat /etc/passwdと入力して送信してみます。
「メールアドレスの形式が不正です」となりました。バリデーションチェックが入っています。ソースコード common.php は下記です。
// RFC準拠のメールアドレス検証
function validEmailAddress($email) {
return preg_match(
'/\A(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){255,})~(中略)~(?>\.(?9)){3}))\])(?1)\z/isD',
$email
);
}
メールアドレスのローカルパートとドメイン部の構造を厳密にチェックしています。今回はOSコマンドインジェクションを試すため、このバリデーションチェックを外して試しました。
return preg_match( /*'/\A(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){255,})~(中略)~*/ //←コメントアウト '/(.*)/', //←追記 $email );
追記:通りすがりの @ockeghem さんより「メールアドレスのバリデーションは回避してOSコマンドインジェクションできますよ」コメントいただきました。
実は先日行った合同勉強会でも参加者の方に同様の指摘をしていただけておりまして、修正せねばと思いつつできておりませんでした。
その後、徳丸さんから『メールアドレスにはダブルクォーテーションも使える』というヒントもいただきまして、試してみた結果、次の入力値でバリデーションチェックを通過し、OSコマンドが実行されました。
"hoge; cat /etc/passwd;"@example.com
→詳細1
→詳細2
閑話休題
再送信します。バリデーションチェックがかからず問い合わせが受け付けられました。

しかし何も起こりません。受信メールを見ると /etc/passwd はアドレスの一つとして認識されているようです。

ソースコードは次の通り(inquerydo.php)
$process = proc_open("/usr/sbin/sendmail -i \"$email\"", $descriptorspec, $pipes);
$email の部分に入力された文字列test@example.com;cat /etc/passwdが入ると
$process = proc_open("/usr/sbin/sendmail -i \"test@example.com;cat /etc/passwd\"", $descriptorspec, $pipes);
となりますが、前後がエスケープされた "" で囲まれているためコマンドではなく、ひとまとまりの文字列として認識されています。
では入力文字列に " を加え test@example.com";cat /etc/passwd" とするとどうでしょう。


/etc/passwd が表示されました。先ほどとは違いメールアドレスとしては認識されていません。

入力が test@example.com";cat /etc/passwd" ですので次のようになり、
$process = proc_open("/usr/sbin/sendmail -i \"test@example.com";cat /etc/passwd"\"", $descriptorspec, $pipes);
前半の " を " で閉じ、後半の " も " で閉じているため ;cat /etc/passwd がコマンドとして認識されます。
上にも書きましたが"hoge; cat /etc/passwd;"@example.comで元々のコードのバリデーションチェックを通り、OSコマンドも実行されます。
""でくくられた quoted-string の形式であれば、加えて次のASCII文字を使用できる。
()<>[]:;@,.スペース
さらにquoted-string中では、\を前につけた quoted-pairの形式であれば、加えて次のASCII文字を使用できる。
\"
メールアドレス - Wikipedia
"hoge; cat /etc/passwd;"@example.com の形でバリデーションチェックが通り、
proc_open("/usr/sbin/sendmail -i \""hoge; cat /etc/passwd;"@example.com\"", $descriptorspec, $pipes);
となります。
(最短 ";cat /etc/passwd;"@a.b などでOKです。第一引数が空になるため確認メールは受信されません)
対策
OSコマンドインジェクションの対策として
・OSコマンド呼び出しを使わない実装方法を選択する → sendmail ではなく PHPのライブラリ mb_sendmail() を使うなど
・外部から入力された文字列をコマンドラインのパラメータに渡さない
・OSコマンドに渡すパラメータを安全な関数によりエスケープする
保険的対策として
・パラメータの検証 → 今回行われていたメールアドレスのバリデーションチェック
などがあります。
メールアドレスのバリデーションチェック箇所のメモ
// RFC準拠のメールアドレス検証
function validEmailAddress($email) {
return preg_match(
'/\A(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){255,})(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){65,}@)((?>(?>(?>((?>(?>(?>\x0D\x0A)?[\t ])+|(?>[\t ]*\x0D\x0A)?[\t ]+)?)(\((?>(?2)(?>[\x01-\x08\x0B\x0C\x0E-\'*-\[\]-\x7F]|\\\[\x00-\x7F]|(?3)))*(?2)\)))+(?2))|(?2))?)([!#-\'*+\/-9=?^-~-]+|"(?>(?2)(?>[\x01-\x08\x0B\x0C\x0E-!#-\[\]-\x7F]|\\\[\x00-\x7F]))*(?2)")(?>(?1)\.(?1)(?4))*(?1)@(?!(?1)[a-z0-9-]{64,})(?1)(?>([a-z0-9](?>[a-z0-9-]*[a-z0-9])?)(?>(?1)\.(?!(?1)[a-z0-9-]{64,})(?1)(?5)){0,126}|\[(?:(?>IPv6:(?>([a-f0-9]{1,4})(?>:(?6)){7}|(?!(?:.*[a-f0-9][:\]]){8,})((?6)(?>:(?6)){0,6})?::(?7)?))|(?>(?>IPv6:(?>(?6)(?>:(?6)){5}:|(?!(?:.*[a-f0-9]:){6,})(?8)?::(?>((?6)(?>:(?6)){0,4}):)?))?(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?>\.(?9)){3}))\])(?1)\z/isD',
$email
);
}
RFC 5321 - Simple Mail Transfer Protocol 日本語訳
RFC 5322 - Internet Message Format 日本語訳
- ユーザー名またはローカル部分の最大全長は64オクテット
- ドメイン名の合計の最大長は255オクテット
- 最大合計長は256オクテット
By Gemini.(引用文字列の一部をGeminiが勝手に書き換えたため当方で修正)
\A文字列の先頭にマッチします。
(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){255,})ローカル部(@より前の部分)が255文字を超える場合にマッチしないことを保証する否定先読みです。(?1) はこの正規表現全体を再帰的に参照しています。これはコメントや引用符で囲まれた部分を考慮に入れています。
(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){65,}@)@ より前の部分が64文字を超える場合にマッチしないことを保証する否定先読みです。
((?>(?>(?>((?>(?>(?>\x0D\x0A)?[\t ])+|(?>[\t ]*\x0D\x0A)?[\t ]+)?)(\((?>(?2)(?>[\x01-\x08\x0B\x0C\x0E-\'*-\[\]-\x7F]|\\\[\x00-\x7F]|(?3)))*(?2)\)))+(?2))|(?2))?)ローカル部の複雑な構造を定義しています。空白文字、コメント、引用符で囲まれた文字列などを考慮に入れています。
([!#-\'*+\/-9=?^-~-]+|"(?>(?2)(?>[\x01-\x08\x0B\x0C\x0E-!#-\[\]-\x7F]|\\\[\x00-\x7F]))*(?2)")ローカル部の主要な文字(英数字、記号、引用符で囲まれた文字列)にマッチします。
(?>(?1)\.(?1)(?4))*ドットで区切られたローカル部の繰り返しにマッチします。
(?1)@@ 記号にマッチします。
(?!(?1)[a-z0-9-]{64,})ドメイン部が64文字を超える場合にマッチしないことを保証する否定先読みです。
(?1)(?>([a-z0-9](?>[a-z0-9-]*[a-z0-9])?)(?>(?1)\.(?!(?1)[a-z0-9-]{64,})(?1)(?5)){0,126}|\[(?:(?>IPv6:(?>([a-f0-9]{1,4})(?>:(?6)){7}|(?!(?:.*[a-f0-9][:\]]){8,})((?6)(?>:(?6)){0,6})?::(?7)?))|(?>(?>IPv6:(?>(?6)(?>:(?6)){5}:|(?!(?:.*[a-f0-9]:){6,})(?8)?::(?>((?6)(?>:(?6)){0,4}):)?))?(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?>\.(?9)){3}))\])ドメイン部の構造を定義しています。通常のドメイン名(例:example.com)または角括弧で囲まれたIPアドレス(IPv4またはIPv6)にマッチします。
(?1)\z文字列の末尾にマッチします。
/isD
i大文字・小文字を区別しないマッチングを行います。
sドット (.) が改行文字にもマッチするようにします。
D文字列の末尾に改行文字がある場合にマッチしないようにします(\z と似ていますが、\z は改行文字があってもマッチします)。
以下は ChatGPT の解説(こちらの方が理解しやすかったです)
\A ├── Negative Lookahead: 全体の長さが255文字以上でないことを確認 ├── Negative Lookahead: ローカル部が65文字以上でないことを確認 ├── ローカル部の検証 │ ├── コメントや空白の処理 │ ├── ドットアトム形式または引用符付き文字列の検証 │ └── ドットで区切られたローカル部の検証 ├── @記号 ├── ドメイン部の検証 │ ├── コメントや空白の処理 │ ├── FQDN(完全修飾ドメイン名)の検証 │ │ ├── ラベルの検証(英数字で始まり、英数字で終わる) │ │ └── ラベルの長さが64文字未満であることを確認 │ └── IPリテラルの検証 │ ├── IPv6アドレスの検証 │ └── IPv4アドレスの検証 └── コメントや空白の処理 \z
各部分の詳細
ローカル部の検証
ローカル部は、メールアドレスの「@」記号の前の部分で、以下の形式を許容しています:
- ドットアトム形式:英数字および特定の記号を含む文字列をドットで区切った形式
- 引用符付き文字列:特殊文字や空白を含む場合、引用符で囲まれた形式
- コメントや空白の処理:RFC 5322では、コメントや空白が許容されており、それらを適切に処理する
ドメイン部の検証
ドメイン部は、メールアドレスの「@」記号の後の部分で、以下の形式を許容しています:
- FQDN(完全修飾ドメイン名):ドットで区切られたラベルの集合。各ラベルは英数字で始まり、英数字で終わり、長さは64文字未満
- IPリテラル:角括弧で囲まれたIPアドレス。IPv6およびIPv4の形式を許容
- コメントや空白の処理:RFC 5322では、コメントや空白が許容されており、それらを適切に処理する
具体的な例
以下は、上記の正規表現が許容するメールアドレスの例です:
simple@example.com
"very.unusual.@.unusual.com"@example.com
user@[192.168.1.1]
user@[IPv6:2001:db8::1]
次回:やられアプリ BadTodo - 9 Server Side Code Injection - PHP Code Injection - demandosigno