knqyf263's blog

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

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と判定されるケースとか、報告者に直接質問して得た細かい諸々も書きたかったのですがグッとこらえました。そもそも報告者はどうやってこの脆弱性見つけたの?とか細かい話はまとめて別の記事に書きます。