
[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 */