極限まで詳細を省けば何とか20分で雰囲気だけでも伝えられるんじゃないかと思って書きました。書き終えてから見返したら多分無理なので誇大広告となったことを深くお詫び申し上げます。
背景
先日Dirty PipeというLinuxカーネルの脆弱性が公表されました。
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()
については前回の記事でも触れているのであわせてご覧ください。
パイプ
パイプは知ってると思いますがプロセス間の入出力を繋げる仕組みです。以下のようなやつです。
$ command1 | command2
以下で単にパイプと言うときは無名パイプの前提で話しています。ソースコードを見ましたが名前付きパイプの場合はちょっと違う雰囲気だったので、以下の説明が名前付きパイプにも当てはまるかどうかは分からないです。
パイプでは片方がデータを送り、片方がそのデータを受け取ります。これは内部的にはリングバッファで実装されています。リングバッファも調べれば出てくるので説明しませんが、一時的にデータを貯めておくバッファ領域の終端と先端が連結されていて循環的に利用できるものです。内部の実装的には pipe_buffer というstructで実装されています。 pipe_inode_info
が bufs というフィールドを持ちリングバッファを管理しています。
一部古いですが分かりやすい図があったので引用しておきます。
各 pipe_buffer
はページへの参照を持っていることが分かります。このパイプバッファはデフォルトで16個あります。pipe_buffer
は1ページに対応していることを考えると16ページと言えます。ページ1つが4KBなのでパイプは64KBまでデータをバッファに保持できます。パイプから読み出されれば開放されていきますが、開放される前にそれを超える分を書き込むとブロックされます。パイプに初めてデータが書き込まれるとページが一つ確保され、 pipe_buffer
はそのページへの参照を持ちます。
仮に最初1バイト書き込み、次にまた1バイトを書き込むとページはどのように確保されるのでしょうか?上で引用したブログに面白いことが書いてありました。
元々パイプバッファはページ1つのみだったようで、それでは4KBしかなくてブロックされやすいし色々と捗らないということでLinusがLinux 2.6.11でリングバッファに書き直したようです。その際、一度確保したページは使い回されず逐一ページを確保する実装になっていました。つまり1バイト書き込んだあと1バイト書くとページがもう一つ確保されます。1バイトずつ16回書き込むだけで16ページを使い切ってしまいます。最悪の場合は16バイトでパイプバッファが詰まります。
言葉だと分かりにくいのでイメージ図を書きましたが、絵を書くセンスが無いので逆に分かりにくいかもしれません。せっかく16個もバッファがあるのに各バッファは1バイトずつしか埋められてないという図です。正確にはバッファはページへの参照なので、4KBもあるページのうち1バイトしか使われていません。16個書くの面倒だったので6個になってますが脳内で16個だと思ってください。
1バイトずつ書き込む場合の指摘に対して、Linusは「そんなことをするべきではないし、そういうことをする人間はパフォーマンスを期待するべきではない」と主張していたようですが、後々一般的な用途で問題となるケースが見つかったようで各ページを使い切るように実装し直したそうです。
改善後のイメージは以下です。1バイトずつ書き込んでも最初の pipe_buffer
が参照するページに足されていきます。
そして4KB使い切ったら次のページが確保されてそちらが使われていきます。つまり今のLinuxでは同じページが使い回されるということです。別の言い方をするとページに対してあとから書き込まれたデータがマージされていくためメモリ使用量の観点で効率的です。ここまでは妥当な改善かなと思いますし特に難しくはないと思います。
では splice()
を使ってパイプにデータを入れた場合はどうなるか見てみます。例としてファイルからパイプに転送する場合を考えてみます。この場合、カーネルはまずデータをページキャッシュに読み込みます。そして pipe_buffer
からそのページキャッシュ内のページを参照するようにします。こうすることでユーザ空間へのコピーなしにパイプへファイルのデータを転送することが出来ます。
一応自分のイメージを図にしてみました。これはファイルのデータをパイプ経由で直接ソケットに転送している例です。splice()
を発行するとカーネルがファイルをページキャッシュに読み込み、パイプバッファはそのページへの参照を保持します。パイプからデータを読み込む側も splice()
を使うことで直接そのページを参照するため、ユーザ空間にコピーせずページキャッシュへの参照を引き回すだけでデータを転送できます。
上の図だと急に登場人物が増えて分かりにくいとは思いますが、正直あまり脆弱性の理解の上では必要ないので忘れてもらっても大丈夫です。中でもパイプバッファだけに注目すると以下になります。ディスクからページキャッシュにデータが読み込まれて、そのページを参照しています。ページキャッシュから再度通常のページにコピーして参照するといったことはしていないので効率的です。
この時、パイプに単にデータを連続して入れた場合とは異なり、splice()
の後にパイプにデータを入れても splice()
によって作られたページは更新されません。そのページはページキャッシュによって管理されているものでパイプによって管理されているものではないためです。例えば
- パイプにデータを入れる
- splice()を使ってファイルからパイプにデータを入れる
- パイプにデータを入れる
の順番で処理を行う場合を考えます。その場合のイメージ図は以下です。
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
のビットがセットされているとカーネルはマージ可能と判断します。
普通にパイプにデータを入れていくと上で説明したとおりページはマージ可能になります。PIPE_BUF_FLAG_CAN_MERGE
だと少し長いので下図では can_merge=true
と表現しています(実際カーネルの過去の実装ではcan_mergeのフィールドを持ってた)。ページにデータがマージされていくので、4KB使い切ったら次のページが作られ...という流れでパイプバッファは使われていきます。
リングバッファなので一周したら再び先頭に戻ります。処理としては以下の辺りです。
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;
この辺の処理です。確かに新たなページへの参照などを書き込んでいます。
ここでよく見ると flags
の初期化を忘れていることに気付きます。つまり使用済みのページ1のときの flags
の値が残ったままになります。この初期化忘れが深刻な脆弱性につながっています。修正は当然 flags
を初期化するだけなので一行です。
buf->flags = 0;
もしこの flags
に PIPE_BUF_FLAG_CAN_MERGE
がセットされていた場合、本来はマージできないはずのページを can_merge=true
にすることが出来てしまいます。
先程 splice()
を使った例ではマージしてはならないという話をしました。ではここで splice()
を使うとどうなるか?初期化を忘れた flags
が残ってしまい、ページキャッシュへの参照にも関わらず can_merge=true
な状態になってしまいます。
この状態でパイプに foo
を書き込むと、本来ページキャッシュにはマージできないので新たなページに書き込まれるはずが、誤ってページキャッシュに書き込まれてしまいます。もちろんそのページが4KBを使い切っておらずfooを書き込む余裕がある場合に限ります。
このページキャッシュは他のプロセスがそのファイルを読み込もうとしたら使われるので、末尾にfooと入ったデータが返されます。上でページキャッシュに書き込むとディスクに同期されると言いましたが、このようにして追加されたデータは明らかに通常の方法ではない方法でページキャッシュに追加されているのでページがdirtyと判断されず、実際にはディスクには書き戻されません。ページキャッシュに書き込みがあるとdirtyというマークが付けられ、そのあとまとめてdirtyなページをディスクに書き戻します。
そのため、例えば再起動したりページキャッシュが開放されたりするとfooは消え去ります。ですが、それまでは別プロセスはディスクを読みに行かずページキャッシュのデータを使うため、fooはあるものとして扱われます。以下の図でディスク上のファイルにはfooは書き込まれていないことに注目してください。
ということで無事にread-onlyのファイルに指定した値を書き込むことに(永続化はされないけど)成功しました。偶然別のプロセスがそのページをdirtyにしてくれるとfooも一緒に永続化されます。
splice()
はオフセットやファイルから読み込むサイズを指定できるので、うまく指定することでデータの書き込む位置も制御できます。つまり splice()
で先頭から10バイト目までページキャッシュに乗せておけばfooは11バイト目から書き込まれます。
もっと言うとカーネルがファイル位置との対応付けをやってくれるので10バイト目"だけ" splice()
でページキャッシュに乗せれば十分です。後続のデータは11バイト目以降として扱われます。
下準備
パイプの中の flags
の PIPE_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);
ページは新たに作成されずページキャッシュにマージされ、あたかもファイルが上書きされたかのような状態になります。
攻撃手順
改めてまとめると以下です。
- まずはパイプバッファのリングをデータで満たす
- この時、通常のページなので
PIPE_BUF_FLAG_CAN_MERGE
がセットされる
- この時、通常のページなので
- パイプからデータを全て読み出す
- パイプは空になったが、各
pipe_buffer
のflags
にはPIPE_BUF_FLAG_CAN_MERGE
がセットされたまま
- パイプは空になったが、各
splice()
を使ってファイルをページキャッシュに読み込みつつpipe_buffer
からそのページを参照する- この時、
flags
にPIPE_BUF_FLAG_CAN_MERGE
が存在してはいけないが、初期化を忘れているため上の1でセットしたflags
がそのまま残る
- この時、
- パイプに適当なデータを書き込む
- 新たなページが作成され
pipe_buffer
はそちらを参照するべきだが、3で作ったpipe_buffer
はマージ可能と言っているため参照先であるページキャッシュにデータを書き込む
- 新たなページが作成され
- 別プロセスがこのファイルを読み込もうとした場合、改ざんされたページキャッシュが参照されるため攻撃者の入れたデータがファイルの中身として使われる
この説明を読んでからPoCを読むと簡単に感じるのではないでしょうか(願望)。元のブログを読んでもらえば分かると思いますが、元のブログに比べると図を入れたことでかなり分かりやすくなったのでないかと思います(願望)。
この辺まで理解していれば自分でPoCを書くのも容易だなということで自分でも書きました。オリジナルのCによるPoCをGoに移植しただけですが、なるべくGoで提供されているAPI経由で出来るように挑戦しました。試しましたがちゃんとGoでも刺さります。
まとめ
ということで駆け足で説明しました。本当はページの境界を超えるようなデータは書き込めないとか、たまたまページがdirtyと判定されるケースとか、報告者に直接質問して得た細かい諸々も書きたかったのですがグッとこらえました。そもそも報告者はどうやってこの脆弱性見つけたの?とか細かい話はまとめて別の記事に書きます。