Dirty Pipeはpipe_bufferの未初期化フラグでページキャッシュを書き換えた
目次
TL;DR
対象 Linux 5.8以降のDirty Pipe未修正カーネル。NVD上では5.10.102、5.15.25、5.16.11未満の該当系列が対象
原因 splice() でページキャッシュ由来のページをpipe bufferへ入れる経路で、再利用された pipe_buffer.flags が初期化されず、PIPE_BUF_FLAG_CAN_MERGE が残った
確認 uname -r だけでなく、ディストリビューションのカーネルパッケージとCVE-2022-0847のバックポート状況を見る。コンテナイメージではなくホストカーネルを見る
Dirty Pipe(CVE-2022-0847)は、pipe_buffer.flags の未初期化1ビットだけで、読み取り専用ファイルのページキャッシュを書き換えるバグだ。
影響はLinux 5.8以降の未修正カーネル。
2022年に修正済みのCVEだが、2026年の Copy Fail、Dirty Frag、Fragnesia、DirtyDecrypt と入口の構造が同じだ。
共通の挙動は、ディスク上のファイルを直接書き換えず、RAM上のページキャッシュを汚すこと。
Dirty Pipeはその中で、ネットワークも暗号サブシステムも経由しない。
splice() でページキャッシュ参照をpipe bufferへ入れる経路と、pipe buffer側に残った古いフラグだけでカーネルが書き込みを通した。
発見はgzipログのZIPヘッダー混入から
Dirty PipeはMax KellermannがCM4allのログ配信で見つけたバグだ。
入り口は「日次gzipログの末尾が壊れている」という顧客報告で、原典の The Dirty Pipe Vulnerability に当時の調査ログが残っている。
最初は破損したgzipのCRCチェックだけが落ちる現象で、何ヶ月も再現が安定しなかった。
ハードウェア起因を疑ったが、別のホストでも同じ位置でCRCが合わなくなる。
壊れたバイト列を眺めるうちに、gzipフッターの直前にZIPのlocal file header(50 4B 03 04 ...)が混ざるパターンが見えてきた。
CM4allのログ配信は、splice経由でファイルからpipeへ、そこからHTTPレスポンス用のソケットへゼロコピーで流していた。
別の顧客がアップロードしたZIPファイルの断片が、なぜかgzipログの末尾に書き込まれている。
共有しているのはディスクではなく、Linuxカーネル側のページキャッシュとpipe bufferだった。
調べると、splice() でページキャッシュ由来のページをpipe bufferへ入れた後、そのpipe bufferの古い PIPE_BUF_FLAG_CAN_MERGE ビットが残っていた。
次の write() が、ページキャッシュ上のページに「追記」されていた。
顧客のgzipファイルに別顧客のZIPデータが混ざる経路がここにできていた。
このバグは最初「ログが壊れた」という形で見つかった。
権限昇格に使えると分かるのはあとで、最初の症状はデータ破壊だった。
壊れたのは読み取り専用ファイルではなくpipe bufferの状態
Linuxはファイル内容をページキャッシュとしてRAM上に保持する。
read()、mmap()、execve() は、そのキャッシュされたページを見る。
通常、読み取り専用ファイルのページへユーザー権限の書き込みが混ざってはいけない。
Dirty Pipeでは、ファイルのページキャッシュがpipe bufferから参照されたあと、そのpipe bufferが「まだ追記してよい」と見なされた。
判断に使われたのが PIPE_BUF_FLAG_CAN_MERGE だ。
匿名pipeの普通のページなら、直前の書き込みに続けて同じページへ追記できる。
ページキャッシュ由来のページでは、その扱いにしてはいけない。
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};
flags が問題のフィールドだった。
copy_page_to_iter_pipe() が新しいpipe bufferを作るとき、本来はここを明示的に消す。
buf->ops = &page_cache_pipe_buf_ops;
buf->flags = 0;
get_page(page);
buf->page = page;
buf->offset = offset;
buf->len = bytes;
修正前は buf->flags = 0; が抜けていた。
pipeはリングバッファなので、struct pipe_buffer のスロットは再利用される。
前に匿名pipeページとして使われたときの PIPE_BUF_FLAG_CAN_MERGE が残り、次にページキャッシュ参照として使われたときにも同じフラグが生きた。
page_cache_pipe_buf_ops と anon_pipe_buf_ops の差
pipe bufferは pipe_buf_operations でページの扱いを切り替えている。
| ops | ページの出どころ | 期待される扱い |
|---|---|---|
anon_pipe_buf_ops | 匿名pipeへの write() で確保した一時ページ | 末尾に追記してマージしてよい |
page_cache_pipe_buf_ops | splice() で渡されたファイルのページキャッシュ | 参照だけで、追記してはいけない |
匿名pipeのページは「同じpipeへ書いている誰かの作業用バッファ」なので、PIPE_BUF_FLAG_CAN_MERGE を立てて末尾追記で性能を稼いでいた。
ページキャッシュ由来のページは「ファイル本体のRAM上コピー」なので、追記すると別ファイルの中身が書き換わる。
スロットを匿名 → ページキャッシュへ切り替えるとき、ops は正しく差し替わっていた。
ただ書き込み側のコードは ops ではなく flags の CAN_MERGE を見て分岐していたため、flags のクリア漏れだけで誤判定が成立した。
2016年の初期化漏れが2020年のフラグ追加で攻撃経路になった
Dirty Pipeは、単一コミットだけで急に危険になったわけではない。
2016年の 241699cd72a8 でpipe-backedな iov_iter が入り、copy_page_to_iter_pipe() が追加された。
この時点で flags の初期化漏れはあった。
ただ、当時の既存フラグでは深刻な書き換え経路につながらなかった。
2020年の f6dd975583bd で、匿名pipe bufferのマージ判定が PIPE_BUF_FLAG_CAN_MERGE へ寄った。
この時点で、古い初期化漏れが「ページキャッシュ由来のページにも追記できる」と誤解される状態を作った。
NVDのCPE設定でも、Linux kernel 5.8以降の該当系列がCVE-2022-0847の対象に含まれている。
flowchart TD
A["pipeを作る"] --> B["匿名pipe bufferに<br/>CAN_MERGEが付く"]
B --> C["pipeを空にする"]
C --> D["同じpipe_bufferスロットが<br/>再利用される"]
D --> E["spliceで可読ファイルの<br/>ページキャッシュを参照"]
E --> F["flagsが消えていない"]
F --> G["writeがページキャッシュへ<br/>追記扱いで入る"]
2026年組は入口にネットワーク暗号処理を挟んでいた。
Copy Failは AF_ALG、Dirty FragはESPとRxRPC、FragnesiaはESP-in-TCP、DirtyDecryptはRxGKが入口だった。
Dirty Pipeはこれらを経由せず、pipeと splice() の状態遷移だけでページキャッシュを書き換える。
Dirty COWより扱いやすかった理由
Dirty PipeはDirty COW(CVE-2016-5195)とよく比較される。
どちらも「本来書けないはずのファイル由来データを書ける」タイプだが、攻撃の性質は違う。
| 観点 | Dirty COW | Dirty Pipe |
|---|---|---|
| 主な仕組み | Copy-on-Writeの競合 | pipe bufferの古いフラグ |
| タイミング競争 | ある | ない |
| 書き込み先 | ファイル由来のメモリ | ページキャッシュ |
| 代表的な制約 | レースの安定化 | ページ境界と読み取り権限 |
レース安定化が不要な代わりに、書き込みできる形は限定される。
何が書けて何が書けないか
Dirty Pipeの書き込みプリミティブには4つの制約がある。
- 対象ファイルを読み取れること。
open(O_RDONLY)が通る権限が要る - 書き込み開始位置はページ境界そのものにはできない。
splice()の前に1バイトでも読ませてpipe bufferにoffset > 0を作る - 書き込みはページ境界をまたげない。1ページ4KB単位までの上書き
- ファイルサイズは伸ばせない。既存バイトの上書きだけ
裏返すと、「全ユーザーに読み取りが許可されていて、特定ページの一部バイトを書き換えるだけで攻撃が成立するファイル」が標的になる。
原典で示されたパターンが2つある。
setuid-rootバイナリの書き換え。
/usr/bin/su や /usr/bin/sudo のページキャッシュを汚し、ELFヘッダーの後ろの命令列をシェル起動コードへ差し替える。
次にこのバイナリを execve() した別ユーザーは、root権限で改変済みコードを実行する。
ディスク上のバイナリは無傷で、sha256sum も一致する。
/etc/passwd のrootハッシュ書き換え。
rootのパスワードハッシュ部分(13文字程度)を空文字へ書き換え、su - でパスワードなしでrootに上がる。
「既存バイトの上書き」「ページ境界内」「読み取り可能」の条件をすべて満たす標的だった。
ページキャッシュ上の改変は、ページがdirty扱いになっていなければディスクに書き戻されない。
CM4allの原典も、再起動やキャッシュ破棄で元に戻ると説明している。
裏返すと、サーバーが動いている間は侵害が成立する。
ファイルハッシュ検証は通るので、検出にはホストカーネル側のアクセス監査やkprobeレベルの観測が要る。
Copy FailやFragnesiaでも同じで、ディスク上のファイルハッシュは正常だが、実行時に読むキャッシュだけが汚れている。
2026年に読み直すと確認範囲が変わる
Dirty Pipe自体は2022年に修正済みだ。
原典ではLinux 5.16.11、5.15.25、5.10.102で修正されたと書かれている。
NVDも、5.8以上5.10.102未満、5.15以上5.15.25未満、5.16以上5.16.11未満の範囲を対象に含めている。
ただし、実際のサーバー確認では上流バージョン番号だけを見ない。
ディストリビューションは修正をバックポートする。
古いバージョン文字列でもCVE修正が入っていることがあるし、カーネルパッケージを更新しても再起動していなければ、実行中のカーネルは古いままだ。
確認はこのあたりになる。
uname -r
cat /etc/os-release
Debian/Ubuntu系なら、インストール済みカーネルとchangelogを見る。
apt list --installed 2>/dev/null | grep linux-image
apt changelog "linux-image-$(uname -r)" | grep -i CVE-2022-0847 -A 5
RHEL/Fedora系なら、パッケージchangelogを追う。
rpm -q kernel
rpm -q --changelog kernel | grep -i CVE-2022-0847 -A 5
コンテナでは、イメージ内のディストリビューションよりホストカーネルを見る。
コンテナは自分専用のLinuxカーネルを持たない。
CIランナー、共有開発サーバー、Kubernetes workerのように他人のコードを同じホストカーネルで動かす場所では、ローカル権限昇格がそのままホスト側へ届く。
具体的な確認パターンはこのあたり。
# Dockerホスト
docker info | grep -i "kernel version"
# Kubernetesクラスター(nodeごとに違うことがある)
kubectl get nodes -o wide
# self-hosted GitHub Actions runner(hosted runnerは公式が更新済み)
uname -r
# コンテナ内から見たカーネルはホストと同じ
docker run --rm alpine uname -r
Kubernetesは各nodeが別カーネルを持つことがある。
一部のnodeだけ古いカーネルで動いていて、そこにpodがスケジュールされた瞬間にDirty Pipeの対象になる、というケースがある。
kubectl get nodes -o wide でnode全体のカーネルバージョンを並べて確認する。
GitHub Actions hosted runnerは公式が修正済みのUbuntu LTSを使うため、CVE-2022-0847の対象外。
self-hosted runnerは自分で管理しているホストカーネルが対象になる。
ビルダーがdockerコンテナの中で動いていても、カーネルはホストのものを使う点が同じ。
修正後も残ったのはバグクラスのほう
Dirty Pipeの直接の修正は小さい。
新しいpipe bufferを作るときに flags を初期化する。
その1行で、この経路の PIPE_BUF_FLAG_CAN_MERGE 持ち越しは止まった。
ただ、2026年に出ているCopy Fail系を見ると、残った問題は「ページキャッシュ由来のページをどのサブシステムへ渡すか」にある。
splice() やゼロコピー経路は、コピーを減らして性能を稼ぐ。
そのかわり、受け取った側が「これは自分の作業用ページではなく、ファイルのキャッシュかもしれない」と扱えないと、別の入口で同じパターンの脆弱性が再発する。
Dirty Pipeはpipe bufferの古いフラグ。
Copy Failは AF_ALG のin-place暗号処理。
Dirty FragとFragnesiaは sk_buff のfragとESP処理。
DirtyDecryptはRxRPC/RxGKの復号経路。