knqyf263's blog

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

Dirty Pipe(CVE-2022-0847)の発見経緯が面白かった

最初に断っておくと今回は万人向けの記事ではないです。面白かったので自分が忘れないようにまとめているだけです。

本記事の位置付け

Dirty Pipe(CVE-2022-0847)三部作の最後です。ダークナイト三部作で言うとダークナイト ライジングにあたります。ダーティとダークって似てませんか。

  1. spliceを使って高速・省メモリでGzipからZIPを作る
  2. 20分で分かるDirty Pipe(CVE-2022-0847)
  3. Dirty Pipe(CVE-2022-0847)の発見経緯が面白かった(本記事)

上の1, 2を前提知識として要求します。二作品目であるダークナイトが一番面白いところも同じですね(?)

ダークナイトと比べたダークナイト ライジングの評価を思い出しながらこの記事を読んでもらえると優しい気持ちになれると思います。

はじめに

以前Linuxカーネル脆弱性であるDirty Pipe(CVE-2022-0847)の解説記事を書きました。

knqyf263.hatenablog.com

この記事はなるべく概要を掴んでもらおうと思って書いた記事で、本当は書きたかったことをかなり省略しています。特に報告者のブログに書いてあった発見経緯は個人的に面白かったのですが、その辺はまるっと省きました。今回はその辺を全部書き記しておこうという趣旨です。本家ブログの内容そのままならそっち読めばいいとなるのですが、理解するのに少し時間がかかったのと不明点を報告者にメールして教えてもらったので自分の言葉でまとめ直しておきます。頭を整理するために図も書いています。

発見経緯

以下が報告者によるブログです。自分が間違ったことを言っている可能性もあるので正確な内容はこちらをご確認ください。

dirtypipe.cm4all.com

CRCのエラー

報告者の会社ではHTTPのアクセスログを提供しているのですが、顧客からgzipファイルが壊れているという報告があったそうです。サーバを見ると確かにCRCエラーが起きているファイルがありました。その時は原因も分からなかったため手動でCRCを直して終わりにしたそうです。

しかし、数ヶ月経ち再び同じ問題が報告されます。ファイルの中身は正しいのにファイルの最後にあるCRCだけが壊れている状態です。CRC-32はgzipファイルにおいてはtrailerと呼ばれるフッタに含まれているのですが、その部分が壊れていました。図にすると以下です。

この問題は繰り返し起きたため報告者は調査することにしました。

HTTPアクセスログ

ここで少し脱線し、まずシステム構成を見てみます。このサービスでは各WebサーバがHTTPリクエストのログをUDPでログサーバに送っています。

そしてこれらを夜間に日付ごとにまとめてzlibで圧縮します。ここでzlibと言っているのですが、zlibフォーマットのことだとするとgzipとはヘッダなどのフォーマットが異なるので、文脈的にはzlibプログラムを使ってgzipに圧縮したという意味かなと勝手に推測しています。ここは細かい話なので聞かなかったですしあまり関係ないので気にしなくて良いです。

その結果、以下のように日付単位のgzipファイルが作成されます。実際にはさらにWebサーバ単位のようですが重要ではないので省略します。2022/01/01から2022/01/31までのログは以下のようになります。ちなみに上で壊れていたと報告があったのはこれら日付単位のgzipファイルのうちの1つです。例えば2022年1月31日のアクセスログである 20220131.gzip が壊れるなど。

さらに、このサービスは一ヶ月単位でのログファイルのダウンロードを提供しています。もし2022/01のログファイルをまとめて1ファイルとしてダウンロードしたい場合に 20220101.gzip から 20220131.gzip を一旦全て解凍してさらに1ファイルに圧縮するのはCPUもメモリも食いますし大変です。そこでどうしているかというと、複数gzipファイルを連結してもgzipファイルとして有効であるという性質を利用し、ただ各gzipファイルを連結しています。

$ cat 20220101.log.gz 20220102.log.gz 20220103.log.gz > 202201.log.gz

以下のようになります。 202201.gzip は単に 20220101.gzip から 20220131.gzip をくっつけただけになっています。

しかしWindowsユーザはgzipファイルを解凍できません(標準機能ではという意味だと思います)。そこでWindowsユーザでも解凍可能なZIPファイルとして1ヶ月ごとのアクセスログを提供しています。gzipファイルは単に日付ごとのgzipファイルを連結すれば済んだが、ZIPファイルはどうするのか?という疑問が湧きますが、その点について以前ブログを書きました。

knqyf263.hatenablog.com

一言で言うとgzipファイルもheaderとtrailerさえ外してしまえばDeflate圧縮したファイルなので、ZIPのヘッダとフッタさえうまくつけてしまえばgzipファイルの解凍不要でZIPが生成可能という点に着目してzero-copyでZIPファイルの生成を実現しています。以下はgzipの図ですが、真ん中の圧縮部分だけ取り出します。

なおZIPのフッタはセントラルディレクトリと呼ばれています。

壊れたgzipのtrailerを見てみる

gzipファイルの末尾は正しくは以下のようになります。

000005f0  81 d6 94 39 8a 05 b0 ed  e9 c0 fd 07 00 00 ff ff
00000600  03 00 9c 12 0b f5 f7 4a  00 00

特に最後の8バイトがtrailerで、0xf50b129cCRC-32で0x00004af7が非圧縮時のファイルサイズです。今回だと0x00004af7 = 19191 bytesになります。

壊れているログファイルは以下のようでした。

000005f0  81 d6 94 39 8a 05 b0 ed  e9 c0 fd 07 00 00 ff ff
00000600  03 00 50 4b 01 02 1e 03  14 00

ファイルサイズは0x0014031e = 1.3 MBになってしまい(実際は上に書いたように19KB)、CRC-32も0x02014b50となり間違っています。そこで壊れたファイルを複数比較して調査したところ、驚くべきことに全てのtrailerが同じ値になっていました。通常単にCRC-32が壊れている場合は様々な値を取りうるため、たまたま計算が間違ったわけではなさそうなことが分かります。

調査した結果、この8バイトはZIPファイルのセントラルディレクトリエントリのヘッダであることが分かりました。分からんと諦めずに調査してZIPファイルのヘッダっぽいとたどり着くのがまず凄いですね。法則性があると分かれば突き止めるのがそこまで難しいとは思わないですが、何の手がかりもない状態だと諦めてしまいそうです。

50 4b 01 02 1e 03 14 00

0x504b = PK が各ZIPヘッダの始まりを意味し、特に0x0102はセントラルディレクトリのヘッダを意味します。この辺の詳細は上のブログに書いてあるので見てください。他にも0x0304はローカルファイルヘッダという他のZIPヘッダを意味したりします。

en.wikipedia.org

しかしZIPのセントラルディレクトリファイルヘッダは最低でも46バイトあるのに、壊れたファイルには8バイトだけ含まれ、あとは含まれていません。そして壊れたファイルの末尾に毎回ZIPのヘッダが書き込まれるというのは偶然とは考えられません。そこで圧縮のために使っているzlibやその他ライブラリを確認したとのことですが、このようなヘッダを書き込む処理は見つけられなかったそうです。ブログではサラッと言ってますが、まずここが凄くない?と思いました。関連するライブラリのコード読んでZIPのヘッダを生成しないことを確かめるだけでも相当な労力だと感じました。

1つだけZIPヘッダを書き込む処理に心当たりがあったそうです。上で説明したように1ヶ月単位でログファイルを提供する機能です。ですが、ここでは単にgzipファイルを読み込んでZIPファイルを生成するだけです。この処理はgzipファイルを生成するユーザと異なり、read権限はあるもののwrite権限は持っていません。つまりこの処理がgzipファイルに書き込んでしまうなどということは起こりうるはずがないです。

壊れたファイルの法則性

ハードディスク全体をスキャンした壊れたファイルを探したところ、各月の最終日のファイルが壊れている傾向があることを突き止めました。つまり2021/11/30のログだったり2021/12/31のログです。

ハードウェアの故障やRAMの故障を疑ったようですが、明らかに法則性があることからハードウェア関連の問題ではないと考えました。

月次ログファイルの生成

やはり月次のログファイルをZIPにする処理が怪しいと考えます。しかし再びになりますがこのプロセスは各gzipファイルをreadしているだけです。write権限は持っていないですし、そもそもread処理しか行いません。以下は各gzipファイルのheader/trailerを外し中身のDeflate圧縮したファイルのみを取り出している処理を図示したものです。実際には各ファイルの中身の前後にZIPのheaderなどがつくのですが、分かりやすさのため図では省略しています。詳細はgzipからzipを生成する方法のブログを参照してください。

図にあるように 202201.zip を作る際に最後にZIPセントラルディレクトリヘッダを書き込みますが、 20220131.gzip には一切触りません。読み込んだだけです。

ですが他の可能性を潰していった結果、ZIPのセントラルディレクトリファイルヘッダに関連するような怪しい処理はこのプロセスしかありません。ここで先程の法則性を思い出すと月の最後のファイルが壊れる傾向にありました。月次のログアーカイブ生成処理は月の初めのgzipファイルから読み取り、最後の日付のgzipファイルが最後に処理されます。つまり2022/01の月次アーカイブであれば、20220101.gzipから処理され20220131.gzipが最後に処理されます。上の図でも20220131.gzipの圧縮部分とZIPセントラルディレクトリヘッダが隣接しているのが分かります。

ダークナイトを読んでくれた方は隣接しているということからどのようにしてこの破損が起きたか既に分かったと思います。

Linuxカーネルのバグの可能性

ありとあらゆる可能性を潰していった結果、残った可能性はLinuxカーネルのバグでした。Linuxカーネルは安定しておりデータ破損の原因をLinuxカーネルに求めるのは通常難しいですが、今回に限ってはそれ以外考えられないと思ったそうです。

そこでバグの再現のため簡単な2つのCプログラムを書きました。1つ目(writer)は単にAAAAAをファイルに出力するプログラムです。

#include <unistd.h>
int main(int argc, char **argv) {
  for (;;) write(1, "AAAAA", 5);
}
// ./writer >foo

そして2つ目(splicer)はファイルを splice() を使ってパイプに書き込み、さらにBBBBBという文字列をパイプに書き込みます。spliceって何なの?という人はspliceについて書いたブログを参照ください。

#define _GNU_SOURCE
#include <unistd.h>
#include <fcntl.h>
int main(int argc, char **argv) {
  for (;;) {
    splice(0, 0, 1, 0, 2, 0);
    write(1, "BBBBB", 5);
  }
}
// ./splicer <foo |cat >/dev/null

良い感じに図にできなかったのですが、一応こんなイメージです。

splicerはfooというファイルを splice() を使って読み出しパイプに書き込んでいますが、あくまで読み込み処理であってfooには一切書き込みません。そしてあとは"BBBBB"という文字列をパイプに書き込んでいますが、これもfooというファイルとは何の関係もない処理です。ですが、このプログラムを実行するとファイルfooにBBBBBの文字列が現れ始めました。つまりこれでLinuxカーネルのバグであることが判明しました。splicerがfooへの書き込み権限がない場合でもBBBBBがfooに書き込まれます。

バグ混入の歴史

細かいのでスキップしても良いです。書くか悩んだのですが一応簡単に書いておきます。

git bisectを使って調べたところ以下のコミットからこの挙動をするようになったことが分かりました。

github.com

パイプに書き込むだけで何でファイルに書き込まれてしまうの?という点については 20分で分かるDirty Pipe(CVE-2022-0847) - knqyf263's blog を参照してください。以下はこちらの内容を理解済みの前提で書いています。

起こってはいけないパイプ内でのマージが起きてしまうというのが問題なわけですが、歴史について補足すると元々は struct pipe_buf_operationscan_merge というflagを持っており、 splice() システムコールを導入した以下のコミットで can_merge=0 (つまりマージできない)がセットされました。

github.com

その後 can_merge が廃止されたと思いきや(pipe: stop using ->can_merge · torvalds/linux@01e7187 · GitHub)、結局 PIPE_BUF_FLAG_CAN_MERGE が導入されたり( pipe: merge anon_pipe_buf*_ops · torvalds/linux@f6dd975 · GitHub)と紆余曲折あったようです。Linuxカーネルの開発でもこんな風に実装がコロコロ変わるんだなと思うと面白いわけですが、結局何が問題だったのでしょうか?PIPE_BUF_FLAG_CAN_MERGE が導入される数年前、pipe_buffer を作る関数が作られました( new iov_iter flavour: pipe-backed · torvalds/linux@241699c · GitHub)。この際、pipe_buffer のflagsの初期化を忘れてしまい、任意のflagsでページキャッシュ参照を作れるようになっていました。これ自体バグではあるものの、この時は大したflagが存在していなかったので問題なかったのですが、その後上述したように PIPE_BUF_FLAG_CAN_MERGE が導入されこのバグは今回の脆弱性へと繋がりました。

要約すると最初は小さいバグだったのが、その後の修正によって致命的になったということで合わせ技一本という感じがします。それぞれのコミットをした開発者も異なります。これはLinuxカーネルのような大きなソフトウェアだと難しい問題であると感じます。自分が直接修正していないところでそんな問題が起きるとは、という感じじゃないでしょうか。

ログ破損の原因

以上のことを把握した上でログ破損の原因を考えると理解できます。どうやってgzipファイルからZIPファイルを作っているかを思い出します。まずZIPのローカルファイルヘッダをパイプに書き込み、次に splice()gzipの圧縮部分のみをパイプに書き込みます。"圧縮部分のみ"というのがポイントなのですが、それは後述します。その後、data descriptorをパイプに書き込み1ファイル分の処理が完了します。そうすると以下のようにパイプに直接writeした場合の pipe_buffer はマージ可能で splice() した場合の pipe_buffer はマージ不可になります。

これを続けていくとリングバッファが一周します。この際、たまたま過去の pipe_buffer のflagsがマージ可能にセットされていた場合、本来マージしてはいけない splice() による pipe_buffer もマージ可能な状態になってしまいます。

2022/01/31のgzipファイル(20220131.gzip)のページキャッシュへの参照がある状態でZIPのセントラルディレクトリヘッダをパイプに書き込もうとすると誤って20220131.gzipに書き込まれてしまいます。

これだけ聞くともっと頻繁にファイルの破損が起きるのではないかと感じる人もいるかも知れませんが、その理由も説明されています。上で20220131.gzipに書き込まれると言いましたが、実際にはページキャッシュに書き込まれるだけでファイルには書き戻されません。ファイルに書き込まれるためにはそのページがdirtyであるとマークを付けられる必要がありますが、今回は正規の処理ではない方法で書き込まれているためdirty扱いにならず、ページがキャッシュから開放されたりrebootなどするとページキャッシュへの変更は消えます。

つまり偶然他のプロセスによってdirtyな変更が行われている場合のみ、このページキャッシュへの変更はファイルに書き戻され永続化されます。このdirtyな処理が行われる可能性が低いため、ファイルの破損確率は低くなっていました。ただしメモリ内ではもっと頻繁に破損していたと思われます。

実はこのdirtyな処理がどういうものなのか想像ができず報告者に質問しました。というのも20220131.gzipへの追記が発生した場合は末尾の破損にならないですし、ログの日次アーカイブファイルという性質上、作成後に更新がされるとも考えにくいですし、どのような処理が行われるのか想像できなかったためです。そこで頂いた回答としては、20220131.gzipがページキャッシュ上に書き込まれてからディスク上のファイルに書き込まれるまでの間に月次ログアーカイブの生成リクエストが来た場合には書き戻しが起こりうるというものでした。確かにdirtyになってから書き戻されるまではデフォルトだとvm.dirty_expire_centisecs=500で5秒程度あるので、20220131.gzipが作られてから5秒以内にこの破損が起きれば元からdirtyページになっているので書き戻される可能性がありそうです。

そしてもう一つの疑問も同時に解消されました。それは、リングバッファが一周してからはずっとこの問題が起きるはずで、ZIPセントラルディレクトリヘッダでなくても月の途中のgzipファイルもdata descriptorが書き込まれてしまうことがあるんじゃないの?というものです。以下の例のように2021/01/15のgzipファイルがspliceされて、そのあとにdata descriptor書いたら上書きしちゃわない?という。

ここからは自分の推測ですが、この破損は普通に起きていたのではないかと思います。ただし、上のようにページをdirtyにしてくれる処理がまず起きないので書き戻されなかった。2022/01の月次アーカイブを2022/02/01 00:00:01 とかにリクエストした場合、2022/01/31のgzipファイルは出来たてほやほやの可能性があり、dirtyになり得ます。しかし、2022/01/15は大分前に作られたファイルで誰もdirtyにしてくれないので書き戻されない。

8バイトの謎

また、何で破損が毎回8バイトなの?というのも理解できます。実際にはページに全てのZIPヘッダが書き込まれます。しかし正当な書き込みではないためファイルのサイズは増えません。ですが今回の処理では最後の8バイトはspliceする必要がなかったため元から最後8バイト以外をspliceでページキャッシュに載せていました。最後8バイト不要だったのは、gzipファイルからZIPファイルを作る際にgzipのtrailerが不要なためです。これは不安だったので報告者の方に質問したのですが、trailerの8バイトで合ってると確認してくださいました。

ファイルの最後までspliceしていたらこの破損は起きなかったわけで、なかなかピンポイントのバグでした。splice()でファイルからパイプに書き込みつつ、かつファイル全体をsplice()せず、パイプバッファを良い感じにマージ可能な状態にしつつ、という条件が揃わなければ再現しません。よく見つかったなと思いますし、きちんと原因追求するプロセスも面白かったです。

PoCの制約

三部作を読んでくれた人はもう理解していると思いますが、報告者のブログにもあるようにこの脆弱性を使った攻撃にはいくつかの制約があります。何かうまく訳せないので気になる人は本家ブログを見てください。

  1. ファイルへのread権限があること
    • これは splice() は正規の処理として行う必要があるため、read権限がないとsplice()が失敗してしまうためです。
  2. オフセットをページの境界線には指定できない
    • ページ上に最低1バイトは splice() で載せる必要があるため
    • 例えばオフセットを0バイトにしようとしてもマージ元のファイルがページキャッシュ上に載らない
  3. 書き込みはページの境界を超えられない
    • ページが一杯になったら別のパイプバッファ用のページが作られてしまうため
  4. ファイルはリサイズできない
    • パイプバッファのページ管理がページキャッシュにどのぐらいデータが追加されたことを伝えないため

まとめ

ダークナイト ライジングも結構好きです

セキュリティツールの評価は難しい

前から思ってたことをちょっと書かずにいられなくなったのでポエムを書きました。

背景

お前誰だよってなるかもしれないので書いておくと、Trivyという脆弱性スキャナーのメンテナをやっています。

github.com

とある有名な方による以下のツイートがありました。

簡単に言うとTrivyがAlpine Linuxで検知するべき脆弱性を検知していない(false negative)という話です。つまりバグっているという指摘なわけですが、結論から言うと実際には彼の勘違いでTrivyのバグではありませんでした。正直GitHubに来るfalse positive/negativeに関するIssueの9割近くは報告者の勘違いなので(一般論ではなくTrivyの場合です)まずはちゃんと原因を把握しようと思ったのですが、その前にすでにAlpineのセキュリティチームの方が反論してくれていました。

こうやって開発者である自分以外の方が反論してくれるの嬉しいなと思う一方で(元ツイートは言葉が強かったので削除されたようです)、知名度のある方ですらセキュリティツールの評価をするのは難しいなと改めて思ったので、なぜ難しいと思うのかについてまとめます。上のツイートをきっかけに突然思い立って書いてるので何か書き忘れているところがあると思います。その場合は追記します。

問題

一応説明しやすいように自分の開発しているスキャナーを例として使っていますが、ログ解析だったりファイアウォールだったり他のセキュリティツールでも当てはまることは多いと思います。開発者の立場で書いていますが、以下の内容はセキュリティツールを評価する立場の時に役立つかもしれないということで書いています。

あと、評価をより一層難しくしている原因としてDevSecOpsなどが進みセキュリティツールの評価をセキュリティ組織以外が行うケースが増えたこともあるのではないかと考えています。以前は既に経験や知識のあるセキュリティエンジニアが評価を行っていたが、今ではDevやOps側の人間が行うため経験や知識が不足している可能性があると推測しています。

検知している方が正しいように見えがち

これはツールの比較をする時に起こりがちです。実際、脆弱性スキャナーのメンテナを始めてから数え切れない程のツール比較の報告をもらっています。つい先週も別のツールではLog4Shellを検知するのにTrivyは検知しないんだけどバグってんじゃないの?という報告をもらいました。実際にはそのツールの実装がバグっており、"検知しない"が正解でした。それを証明するためにそのツールのデバッグまでしており、何で無償で他のツールのバグ直さないといけないんだ...と思いましたが、ここで重要なのは検知しないツールのほうが間違っているように見られがちということです。

上で紹介したツイートの件も同じです。別のスキャナーでは検知されたため、それを正と信じTrivyの間違いだと思い込みました。"known issues in Alpine images"と、完全に既知のものだと思い込んでいます。検知したスキャナーも検知していないスキャナーも立場は同じはずです。結果が違うのであればどちらが正しいのかを考えるべきなのですが、検知している方が正しく見えてしまうという罠です。

数え切れないほどそういう報告を見てきたのでその一例ではあるのですが、自分は個人的にこの方を以前から色々なOSSのメンテナーをしている人として知っていて、ツイートも見ていたのでそういう人でもこの罠に引っかかるんだなというのはちょっと驚きました。あと自分も詳細を知りたかったのでTwitterでリプライ送ったのですがスルーされて悲しかったです。

Alpineのセキュリティチームもこういう誤った報告に頭を悩ませているようです。自分も毎日そういった報告を受けているので気持ちが痛いほどよく分かります(ECRもTrivyにならないかな...)

もちろん正しい報告もありますし、そういった報告によって改善されてきているのでバグ報告は大歓迎なのですが、一度「本当に検知している方が正しいのだろうか?」という視点を持ってもらえると良いかと思います。

ちなみに間違いは誰にでもあるので、今回の件を批判したいという意図は全く無いです。教訓として

  • やっぱりこの罠には陥りやすいよね
  • バグだと思い込んで公で発信する前に一度立ち止まって確認すると良いよね

という2点があるというだけです。「あれ、これバグかな?」となったらIssueを起票してバグであることを確かめてから発信してもらえると誤解を招かなくて良いなと思います。

条件を揃えるのが難しい

複数スキャナーを比較する際の別の問題として、条件を揃えるのが難しいというのがあります。一例として、スキャナーによってデフォルトのオプションが異なります。例えばとあるスキャナーはCriticalな脆弱性のみデフォルトで表示するかもしれません。一方で他のスキャナーは低い深刻度の脆弱性も全て表示するかもしれません。そうすると、何となく後者のほうがたくさん検知しているため良さそうに見えます。実際には最初のスキャナーもオプションを付けて低い深刻度の脆弱性を表示したら総数は同じになるにもかかわらずです。

Trivyでは上の理由から悩ましい葛藤があります。現在、デフォルトではパッチがない脆弱性であっても表示するようになっています。これは組織によってはパッチがないものでも設定変更やファイアウォールで対応したいかもしれませんし、ゼロデイのような場合はパッチが来る前であっても検知してほしい場合があるためです。ですが、実際にはパッチがなければactionableではないため非表示にしたいという組織が多いです。そういった理由から本当は非表示をデフォルトにしたかったのですが、そうなると表示がデフォルトのスキャナーに比べて検知数が少なく見えます。もちろんちゃんとした人なら分かってくれるのですが、世の中はそこの判断が出来ない人が非常に多いです。誰かが条件を揃えずに総数を比較したブログを公開するとそれに従って使うツールが選ばれます。

この判断は真の意味でユーザに寄り添っていなくて本当に辛いところなのですが、会社として開発している以上は多くの人に使ってもらう必要があり、泣く泣くデフォルト表示の実装になっています。ツールが無名だった頃は血の涙を流しながらやっていましたが、既にある程度知名度を獲得したので将来的には変えたいと思っています。

環境の再現が難しい

複数ツールを比較する時に、そのツールを使った環境を揃えるのが難しい場合があります。例えばネットワークトラフィックに対して異常検知するIDSのようなツールの場合は比較するツールそれぞれに全く同じトラフィックを流さないといけませんが、その量が膨大だったりしたら保存も大変ですし実環境でしか検知されない問題だったら実環境でどう流すのかを考えなければなりません。ネットワーク機器の設定が間違っていて一部のパケットだけうまく一部のツールにだけ転送されていないといったことも起きます。

ホストマシン上で何かしらのセキュリティツールを実行する場合は、そのホストの状態が変わってしまうことで検知結果が変わってしまうこともあります。さっきまでポートが空いていたのに閉じてしまったり、新しくプロセスが立ち上がったり、状態は刻一刻と変化していきます。

同じ環境で検証したつもりが実は微妙に異なっていて、それが異なる結果を生んでいたということはよくあります。

こちらは@m_mizutaniさんのツイートをきっかけに過去の辛さを思い出したので追記しました。

検知数が多い方が良さそうに見える

これは上の話とも関連しますが、検知数が多いほうが良さそうに見えます。この辺は以前も少しブログで触れたのですが、脆弱性検知においては完全に正しく検知できないという状況が存在します。その場合にノイズにならないようにということで検知しないようにするのか、過検知を含むかもしれないけど検知してしまうのか、というのは未だに自分も答えを持っていません。こういったトレードオフは(セキュリティに限らずですが)よく出てきます。

ただし、評価の面から言うと検知数が多いほうがよく見えてしまいます。そうすると例えfalse positveを多く含んだとしても検知数を増やすほうが評価されてしまう問題が起きます。もちろん言い分としてはfalse negativeを減らせるということになります。しかしfalse positveが100件増えてfalse negativeが1件減らせるとなれば正解率(Accuracy)を考えると明らかに好ましくありません。好ましくないというのは自分の主観であってそこの判断は組織がするべきですが、言いたいことはそこまで考えてツールを評価する人は稀ということです。

もちろん1件でも見落として致命的な問題につながったら困るからAccuracyは低くても構わないと考える人もいるかも知れませんが、セキュリティツールが頻繁にアラートをあげてかつそのほとんどが嘘だった場合やがて誰も信じなくなります。そう考えるとオプションとして過検知多めがあっても良いですが、個人的にはデフォルトオプションではアラートは正確であってほしいと思います。そういう判断軸でTrivyは実装しているのですが、数が少なく見えるから多く検知できるように実装してくれという圧を受けることは多いです。

こういったトレードオフに対してツールがどういうアプローチを取っているのか、というのは上の「条件を揃えるのが難しい」とも通ずる話ですが、正しく理解して判断するのは難しいです。

余談ですが、一方でスキャン対象の場合は検知数が少ないほうが正義になりがちです。もちろん少ないほうが良いのですが内訳は考えたほうが良いです。例えば脆弱性スキャンの例で言うとDebianとAlpineの脆弱性数の比較というのがよく行われ、Alpineの方が少ないからAlpineの方が良いと評価されたりします。ですが実際にはDebianの方ではパッチ未提供のものもセキュリティアドバイザリを出していて、Alpineでは出していません。つまりセキュリティアドバイザリの質としてはDebianの方が優れているということになります。にもかかわらずそこまでは踏み込まず単純に数で比較されてしまい、自分の作ったツールによって誤った判断がされているのを悲しい気持ちで見ています。

正解かどうかの判断が難しい

例えば脆弱性が検知された時に、これって検知されるべきなの?という判断はかなり知識が要求されます。そのため他のツールとの比較に走ってしまう気持ちはわかります。例えばRed Hatなどのディストリビューションではbackport fixが存在し、上流の修正バージョンとは異なります。そのためupstreamのバージョンと比較してしまい、自分のバージョンは古いのに検知されない!というバグ報告を受けることは多いです。

また、バージョンを比較するというのも実際には容易ではなくディストリビューションによってロジックが異なります。人間が目で見て判断が難しい場合も存在します。

他にも推移的依存がどう解決されるのか?なども難しいです。例えばAがCの1.0に依存していてBがCの2.0に依存している場合にnpmではバージョンはどう解決されるの?とかまで考えて日頃npmを使っている人は多くないと思います。これはもちろんパッケージマネージャによって実装が異なります。

npm.github.io

もっと言うとテスト用のライブラリの脆弱性は本番にデプロイされないという理由で検知しないようにしてたりするのですが、この辺りも何で検知されないんだと言われることが多いです。

つまり、アラートが出た時にそれが本当に正しいのかを判断することがユーザ側にできないことがあります。その逆も然りでこれは検知されるべきだ!というバグ報告もあります。上で述べたようにDevSecOpsやシフトレフトが進みセキュリティツールを使うのは必ずしもセキュリティに詳しい人間ではないため、この辺は本当に難しいなと思います。

また、この評価の難しさから検知数の大小で比較されてしまう問題に繋がっている気がします。

カバー範囲の正確な見極めが難しい

これまた脆弱性スキャナーの例なのですが、例えばJava対応をしている時に「JARに対応しています」という表面的な文言だけで判断してしまうことがあります。ですが実際にはUber JARには対応してるの?とかShaded JARには対応してるの?とか詳細まで踏み込んで比較する必要があります。しかし、関連技術を深く理解していないとそういった比較は行なえません。

Log4Shellの時に書かれた比較ブログで良い例があるので共有しておきます。これも単純化してしまうと「全てのスキャナーがLog4Shellを検知可能です」ということになります。

www.rezilion.com

@58_158_177_102さんのツイートを見てその辺りを思い出したので追記しました。

検知されないほうが嬉しい

これはツールを比較せずに単体で評価する場合の話ですが、基本的にはみんなセキュリティアラートとか見たくないです。つまり、検知されないと安全だと信じ込んでしまいます。

アラートが出た場合はまだ正当性を確認するためのアクションを起こせますが、アラートが出ない場合は「おや、本当は検知されるべきなのに」と気付くのは非常に難しいです。これもまた正しく評価されていないことになります。検知されてない問題があるかもしれないから正しく評価できてないなと気付けた場合でも、とりあえず比較するかーとなって今度は上の問題に繋がります。

まとめ

セキュリティツールの評価は非常に難しいです。特にこうすると良いという案はなくて難しいよね〜と言ってるだけなのでポエムです。ただ、誤った評価をする前に上のように誤解しやすいポイントを思い出してもらえると違う視点で見ることが出来るかもしれません。

日々「お前のツールバグってるぞ!」と言われ続け「いえ正しいです」と返すのは結構精神的に来るものがあるのですが、その中に改善に繋がるものもあるのでIssue上げるのやめろとか言うつもりはないですしむしろ歓迎です。ただ、「こっちのツールで検知したのにお前のツールバグってるぞ!」と思い込んで主張するよりは「こっちのツールでは検知したけどこれってどっちが正しいの?」みたいなスタンスのほうが嬉しいな〜〜〜〜〜と思ったり思わなかったりです。

20分で分かるDirty Pipe(CVE-2022-0847)

極限まで詳細を省けば何とか20分で雰囲気だけでも伝えられるんじゃないかと思って書きました。書き終えてから見返したら多分無理なので誇大広告となったことを深くお詫び申し上げます。

背景

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

dirtypipe.cm4all.com

Linuxのパイプに関する脆弱性なのですが、仕組みは意外とシンプルでぎりぎりブログでも伝わるかもしれないと思ったので自分の理解を書きました。あといつも細かく書きすぎて長くなるので、今回は雰囲気だけでも伝わるようにとにかく説明を簡略化し、ふわっとした概要だけでも理解してもらえるように頑張りました。その結果、若干正確性に欠ける部分があるかもしれませんがお許しください。細かい部分はまた別の記事でまとめます。もっと正確に細かく書きたいという衝動を抑え、とにかく20分以内に「あーそういうことね完全に理解した(わかってない)」というところまで持っていくことを目指しています。

なお、自分は特にLinuxカーネル開発者でもないですし、一部は簡略化のせいとかではなく普通に間違っている可能性もあります。その点は念頭に置いて読んでください。また、何か間違いがあれば修正するのでLinuxカーネルに詳しい方たちからのご指摘もお待ちしております。

概要

脆弱性の影響

まず、この脆弱性はread-onlyのファイルに対して書き込みができてしまうというものです。例えばrootしか書き込みできない /root/.ssh/authorized_keys だったり /etc/passwd だったりを一般ユーザから変更することでrootになりすますことが出来ます。つまり権限昇格の脆弱性ということになります。

PoCは上のブログで公開されているのですが、Linuxカーネルのバージョンが5.8以降でかつ修正バージョンである5.16.11, 5.15.25, 5.10.102以前の場合は簡単に刺さります。昔Dirty Cow(CVE-2016-5195)という似た脆弱性がありましたが、Dirty Pipeはレースコンディションを引き起こす必要もなくシビアなタイミングを求められたり運に左右されるものではないため、遥かに攻撃成功率が高いです。

$ ./exploit /etc/passwd 1 ootz:

上のPoCをビルドして、ファイル名とオフセットを指定してその後に書き込みたいデータを指定するだけで、指定したオフセットからデータを書き込んでくれます。上の場合だと /etc/passwd のrootユーザのパスワードを消し去っています。ファイルの長さは変えられないためrootユーザをrootzに変えていますがidは依然として0です。この攻撃の後に su rootz とするとスーパーユーザになり攻撃は完了です。

read-onlyな任意のファイルの上書きが出来るので、攻撃のパスは多数あります。

ページキャッシュやsplice

最初に初歩的な説明をしますが、Linuxカーネル脆弱性が気になるような人は知っている話なので飛ばして良いです。

Linuxにおけるメモリ管理のおさらいですが、CPUによるメモリ管理の最小単位はページと呼ばれています。通常は4KBです。アプリケーションがメモリを要求した場合、必要なサイズ分のページが確保されます。ファイルの入出力も同様にページです。ファイルからデータを読み込みたい場合、カーネルは最初に4KB単位でディスクからカーネルのメモリ上にコピーします。これはページキャッシュと呼ばれています。そして次にユーザ空間にコピーされます。キャッシュと呼ばれていることから分かるように、次に同じファイルにアクセスした際に不要なディスクI/Oを避けるためにページキャッシュはしばらくの間残ります。カーネルが不要と判断したり別の用途に使う場合に開放されます。次回以降のファイルアクセスはメモリから読み込まれるので高速になります。書き込みの場合は一定のタイミングでディスクに同期されます。mmapなどは今回の説明の本筋から離れるので説明しません。

次に、 splice() というシステムコールについて説明します。これはデータ転送をしたい場合の片方がパイプだった時にユーザ空間を介さず直接ファイルやソケットやパイプにカーネルがデータを転送してくれるものです。zero-copyと呼ばれるやつです。詳しくはぐぐったら色々出てくるので割愛しますが、入力側がパイプでも良いし出力がパイプでも良いですし両方パイプでも良いです。とにかく片方はパイプである必要があります。似たシステムコールとして sendfile とかもありますが、その辺もググってください。

splice() については前回の記事でも触れているのであわせてご覧ください。

knqyf263.hatenablog.com

パイプ

パイプは知ってると思いますがプロセス間の入出力を繋げる仕組みです。以下のようなやつです。

$ command1 | command2

以下で単にパイプと言うときは無名パイプの前提で話しています。ソースコードを見ましたが名前付きパイプの場合はちょっと違う雰囲気だったので、以下の説明が名前付きパイプにも当てはまるかどうかは分からないです。

パイプでは片方がデータを送り、片方がそのデータを受け取ります。これは内部的にはリングバッファで実装されています。リングバッファも調べれば出てくるので説明しませんが、一時的にデータを貯めておくバッファ領域の終端と先端が連結されていて循環的に利用できるものです。内部の実装的には pipe_buffer というstructで実装されています。 pipe_inode_infobufs というフィールドを持ちリングバッファを管理しています。

一部古いですが分かりやすい図があったので引用しておきます。

f:id:knqyf263:20220310225307p:plain
https://lwn.net/Articles/118052/ より引用

pipe_buffer はページへの参照を持っていることが分かります。このパイプバッファはデフォルトで16個あります。pipe_buffer は1ページに対応していることを考えると16ページと言えます。ページ1つが4KBなのでパイプは64KBまでデータをバッファに保持できます。パイプから読み出されれば開放されていきますが、開放される前にそれを超える分を書き込むとブロックされます。パイプに初めてデータが書き込まれるとページが一つ確保され、 pipe_buffer はそのページへの参照を持ちます。

仮に最初1バイト書き込み、次にまた1バイトを書き込むとページはどのように確保されるのでしょうか?上で引用したブログに面白いことが書いてありました。

lwn.net

元々パイプバッファはページ1つのみだったようで、それでは4KBしかなくてブロックされやすいし色々と捗らないということでLinusLinux 2.6.11でリングバッファに書き直したようです。その際、一度確保したページは使い回されず逐一ページを確保する実装になっていました。つまり1バイト書き込んだあと1バイト書くとページがもう一つ確保されます。1バイトずつ16回書き込むだけで16ページを使い切ってしまいます。最悪の場合は16バイトでパイプバッファが詰まります。

言葉だと分かりにくいのでイメージ図を書きましたが、絵を書くセンスが無いので逆に分かりにくいかもしれません。せっかく16個もバッファがあるのに各バッファは1バイトずつしか埋められてないという図です。正確にはバッファはページへの参照なので、4KBもあるページのうち1バイトしか使われていません。16個書くの面倒だったので6個になってますが脳内で16個だと思ってください。

f:id:knqyf263:20220311080206p:plain

1バイトずつ書き込む場合の指摘に対して、Linusは「そんなことをするべきではないし、そういうことをする人間はパフォーマンスを期待するべきではない」と主張していたようですが、後々一般的な用途で問題となるケースが見つかったようで各ページを使い切るように実装し直したそうです。

改善後のイメージは以下です。1バイトずつ書き込んでも最初の pipe_buffer が参照するページに足されていきます。

f:id:knqyf263:20220311080509p:plain

そして4KB使い切ったら次のページが確保されてそちらが使われていきます。つまり今のLinuxでは同じページが使い回されるということです。別の言い方をするとページに対してあとから書き込まれたデータがマージされていくためメモリ使用量の観点で効率的です。ここまでは妥当な改善かなと思いますし特に難しくはないと思います。

では splice() を使ってパイプにデータを入れた場合はどうなるか見てみます。例としてファイルからパイプに転送する場合を考えてみます。この場合、カーネルはまずデータをページキャッシュに読み込みます。そして pipe_buffer からそのページキャッシュ内のページを参照するようにします。こうすることでユーザ空間へのコピーなしにパイプへファイルのデータを転送することが出来ます。

一応自分のイメージを図にしてみました。これはファイルのデータをパイプ経由で直接ソケットに転送している例です。splice() を発行するとカーネルがファイルをページキャッシュに読み込み、パイプバッファはそのページへの参照を保持します。パイプからデータを読み込む側も splice() を使うことで直接そのページを参照するため、ユーザ空間にコピーせずページキャッシュへの参照を引き回すだけでデータを転送できます。

f:id:knqyf263:20220311075414p:plain

上の図だと急に登場人物が増えて分かりにくいとは思いますが、正直あまり脆弱性の理解の上では必要ないので忘れてもらっても大丈夫です。中でもパイプバッファだけに注目すると以下になります。ディスクからページキャッシュにデータが読み込まれて、そのページを参照しています。ページキャッシュから再度通常のページにコピーして参照するといったことはしていないので効率的です。

f:id:knqyf263:20220311094507p:plain

この時、パイプに単にデータを連続して入れた場合とは異なり、splice()の後にパイプにデータを入れても splice() によって作られたページは更新されません。そのページはページキャッシュによって管理されているものでパイプによって管理されているものではないためです。例えば

  1. パイプにデータを入れる
  2. splice()を使ってファイルからパイプにデータを入れる
  3. パイプにデータを入れる

の順番で処理を行う場合を考えます。その場合のイメージ図は以下です。

f:id:knqyf263:20220310231906p:plain

1つめのページ(page 1)を使い切っていないのに splice() によって新たな pipe_buffer が作られページキャッシュが参照されます。そしてその後に追加されるデータはページキャッシュにはマージされず新たなページ(page 2)に足されます。

などと偉そうに言いましたが、2つめのページが作られるのかは少し自信がないです。データの順番は保つ必要があるので普通に実装するとこうなるはずですが、Linuxがめっちゃ省メモリを頑張っている場合は splice() 後に足したデータが1ページ目にマージされる可能性も0ではないのかなと思いました。pipe_buffer のフィールド的にそのような実装にはなっていなさそうですが。

linux/pipe_fs_i.h at v5.8 · torvalds/linux · GitHub

ですがここではそれはあまり問題ではなくて、あとからパイプに書き込んだデータは splice() によって確保されたページには絶対にマージされないということが重要です。ページキャッシュに書き込まれたデータはファイルに書き戻されてしまうため、勝手にページキャッシュに書き込んだら大問題です。

重要なことなのでもう一度言いますが、パイプに書き込んだデータをページキャッシュにマージしてはいけません。新しくページを作ってそこに入れるべきです。もしマージをしてしまうとただパイプにデータを入れただけなのにファイルが更新されるという珍現象が起きます。

ということで既に分かったと思いますが、Linuxカーネルがパイプに書き込まれたデータを間違ってページキャッシュにマージしてしまうというのが今回の脆弱性です。その結果、 splice() でただreadされただけのファイルがカーネルによって編集されてしまいます。任意のファイルが編集可能なわけではなくて、正規の手順で splice() を発行するためにreadの権限は少なくとも持っている必要があります。

マージの可否

マージされてはいけないものがマージされてしまうというのが本質なわけですが、ではどうやってそのパイプバッファにマージするべきかどうかを判断しているのでしょうか?それは、 pipe_buffer の中に flags というフィールドがあり、そちらを使っています。

linux/pipe_fs_i.h at v5.8 · torvalds/linux · GitHub

ちなみに1つ目の page がページへの参照を保持しています。この flags の中に PIPE_BUF_FLAG_CAN_MERGE のビットがセットされているとカーネルはマージ可能と判断します。

f:id:knqyf263:20220311095434p:plain

普通にパイプにデータを入れていくと上で説明したとおりページはマージ可能になります。PIPE_BUF_FLAG_CAN_MERGE だと少し長いので下図では can_merge=true と表現しています(実際カーネルの過去の実装ではcan_mergeのフィールドを持ってた)。ページにデータがマージされていくので、4KB使い切ったら次のページが作られ...という流れでパイプバッファは使われていきます。

f:id:knqyf263:20220311083512p:plain

リングバッファなので一周したら再び先頭に戻ります。処理としては以下の辺りです。

linux/iov_iter.c at v5.8 · torvalds/linux · GitHub

i_head & p_mask をしてるので、一周したら最初の pipe_buffer が再利用されます。 p_mask は少し上の実装を見れば分かりますがリングバッファのサイズです。もちろん読み込まれたあとじゃないと再利用はされません。読み込みが終わっていない場合はブロックします。しかしこの1つ目の pipe_buffer は使用済みのものなので、新たに正しいページへの参照などを入れてあげる必要があります。

buf->ops = &page_cache_pipe_buf_ops;
get_page(page);
buf->page = page;
buf->offset = offset;
buf->len = bytes;

この辺の処理です。確かに新たなページへの参照などを書き込んでいます。

f:id:knqyf263:20220311084609p:plain

ここでよく見ると flags の初期化を忘れていることに気付きます。つまり使用済みのページ1のときの flags の値が残ったままになります。この初期化忘れが深刻な脆弱性につながっています。修正は当然 flags を初期化するだけなので一行です。

buf->flags = 0;

もしこの flagsPIPE_BUF_FLAG_CAN_MERGE がセットされていた場合、本来はマージできないはずのページを can_merge=true にすることが出来てしまいます。

先程 splice() を使った例ではマージしてはならないという話をしました。ではここで splice() を使うとどうなるか?初期化を忘れた flags が残ってしまい、ページキャッシュへの参照にも関わらず can_merge=true な状態になってしまいます。

f:id:knqyf263:20220311085150p:plain

この状態でパイプに foo を書き込むと、本来ページキャッシュにはマージできないので新たなページに書き込まれるはずが、誤ってページキャッシュに書き込まれてしまいます。もちろんそのページが4KBを使い切っておらずfooを書き込む余裕がある場合に限ります。

f:id:knqyf263:20220311085418p:plain

このページキャッシュは他のプロセスがそのファイルを読み込もうとしたら使われるので、末尾にfooと入ったデータが返されます。上でページキャッシュに書き込むとディスクに同期されると言いましたが、このようにして追加されたデータは明らかに通常の方法ではない方法でページキャッシュに追加されているのでページがdirtyと判断されず、実際にはディスクには書き戻されません。ページキャッシュに書き込みがあるとdirtyというマークが付けられ、そのあとまとめてdirtyなページをディスクに書き戻します。

そのため、例えば再起動したりページキャッシュが開放されたりするとfooは消え去ります。ですが、それまでは別プロセスはディスクを読みに行かずページキャッシュのデータを使うため、fooはあるものとして扱われます。以下の図でディスク上のファイルにはfooは書き込まれていないことに注目してください。

f:id:knqyf263:20220311085825p:plain

ということで無事にread-onlyのファイルに指定した値を書き込むことに(永続化はされないけど)成功しました。偶然別のプロセスがそのページをdirtyにしてくれるとfooも一緒に永続化されます。

splice() はオフセットやファイルから読み込むサイズを指定できるので、うまく指定することでデータの書き込む位置も制御できます。つまり splice() で先頭から10バイト目までページキャッシュに乗せておけばfooは11バイト目から書き込まれます。

f:id:knqyf263:20220311100734p:plain

もっと言うとカーネルがファイル位置との対応付けをやってくれるので10バイト目"だけ" splice() でページキャッシュに乗せれば十分です。後続のデータは11バイト目以降として扱われます。

下準備

パイプの中の flagsPIPE_BUF_FLAG_CAN_MERGE ビットを事前に全て立てておく必要があります。これは簡単で4KBのデータを16回パイプに入れるだけです。何度も説明していますが普通にパイプにデータを入れえると can_merge=true になります。PoCでは以下の部分です。一応パイプバッファが16個じゃない場合もあるので、ちゃんと fnctl でパイプのサイズを最初に計算しています。

const unsigned pipe_size = fcntl(p[1], F_GETPIPE_SZ);
static char buffer[4096];

/* fill the pipe completely; each pipe_buffer will now have
   the PIPE_BUF_FLAG_CAN_MERGE flag */
for (unsigned r = pipe_size; r > 0;) {
    unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
    write(p[1], buffer, n);
    r -= n;
}

ただ入れるだけだとパイプが詰まってしまうので、パイプからデータを読み込みます。このデータは特に使わないので捨てます。

/* drain the pipe, freeing all pipe_buffer instances (but
   leaving the flags initialized) */
for (unsigned r = pipe_size; r > 0;) {
    unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
    read(p[0], buffer, n);
    r -= n;
}

準備はこれだけです。これでリングを一周したので、次に使うバッファは自動的に PIPE_BUF_FLAG_CAN_MERGE が誤ってセットされた状態になります。あとは splice() を呼んでファイルからページキャッシュにデータをロードして、

ssize_t nbytes = splice(fd, &offset, p[1], NULL, 1, 0);

データをパイプに書き込むと

nbytes = write(p[1], data, data_size);

ページは新たに作成されずページキャッシュにマージされ、あたかもファイルが上書きされたかのような状態になります。

攻撃手順

改めてまとめると以下です。

  1. まずはパイプバッファのリングをデータで満たす
    • この時、通常のページなので PIPE_BUF_FLAG_CAN_MERGE がセットされる
  2. パイプからデータを全て読み出す
    • パイプは空になったが、各 pipe_bufferflags には PIPE_BUF_FLAG_CAN_MERGE がセットされたまま
  3. splice() を使ってファイルをページキャッシュに読み込みつつ pipe_buffer からそのページを参照する
    • この時、flagsPIPE_BUF_FLAG_CAN_MERGE が存在してはいけないが、初期化を忘れているため上の1でセットした flags がそのまま残る
  4. パイプに適当なデータを書き込む
    • 新たなページが作成され pipe_buffer はそちらを参照するべきだが、3で作った pipe_buffer はマージ可能と言っているため参照先であるページキャッシュにデータを書き込む
  5. 別プロセスがこのファイルを読み込もうとした場合、改ざんされたページキャッシュが参照されるため攻撃者の入れたデータがファイルの中身として使われる

f:id:knqyf263:20220311101903p:plain

この説明を読んでからPoCを読むと簡単に感じるのではないでしょうか(願望)。元のブログを読んでもらえば分かると思いますが、元のブログに比べると図を入れたことでかなり分かりやすくなったのでないかと思います(願望)。

この辺まで理解していれば自分でPoCを書くのも容易だなということで自分でも書きました。オリジナルのCによるPoCをGoに移植しただけですが、なるべくGoで提供されているAPI経由で出来るように挑戦しました。試しましたがちゃんとGoでも刺さります。

github.com

まとめ

ということで駆け足で説明しました。本当はページの境界を超えるようなデータは書き込めないとか、たまたまページがdirtyと判定されるケースとか、報告者に直接質問して得た細かい諸々も書きたかったのですがグッとこらえました。そもそも報告者はどうやってこの脆弱性見つけたの?とか細かい話はまとめて別の記事に書きます。

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にやらせようというタイトルにしようか悩みましたが思いっきり人力だし意味不明なのでやめました。