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. ファイルはリサイズできない
    • パイプバッファのページ管理がページキャッシュにどのぐらいデータが追加されたことを伝えないため

まとめ

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