CVE-2022-0847(Dirty Pipe)について

Abstract[1]

Linuxカーネルにバージョン5.8から存在し、任意の読み取り専用ファイル(のページキャッシュ)を上書き可能な脆弱性です。 非特権プロセスがrootプロセスにコード注入可能なため、権限昇格につながります。 Dirty COW (CVE-2016-5195)に似た脆弱性ですが、exploitがより簡単です。 Linuxカーネル5.16.11, 5.15.25, 5.10.102で修正されています。早急にアップデートすべきです。

Description[4]

Linuxカーネル内の関数copy_page_to_iter_pipe()及びpush_pipe()内で、確保したpipe_buffer構造体のflagsメンバが適切に初期化されずに古い値のまま残る欠陥が見つかりました。 非特権ユーザがこの欠陥を使って、読み取り専用ファイルに対応したページキャッシュに書き込むことで権限昇格が可能になります。

脆弱性の原因

文献[2]が詳しいです。その後で文献[3]を読めば、本家の状況を理解することができると思います。 脆弱性が生まれるのに複数の要因が重なっています。すぐに思いつくのは以下のとおりですが、探せばまだあるかもしれません:

  • パイプにリングバッファを採用し、循環して利用していること
  • マージ機構により、同一ページに追加していく実装であること
  • splice()で読み込んだページキャッシュを、リングバッファから直接参照していること(zero-copy)
  • 問題の関数が導入された当初から初期化漏れがあったこと
  • ポインタ比較をやめてPIPE_BUF_FLAG_CAN_MERGEフラグを導入したこと

脆弱性の修正コード[5]

問題のファイルはカーネルlib/iov_iter.cです。 関数copy_page_to_iter_pipe()及びpush_pipe()で新しいpipe_bufferをアロケートした後にflagsメンバのフラグクリアが抜けており、これを補っています。

diff --git a/lib/iov_iter.c b/lib/iov_iter.c
index b0e0acdf96c15..6dd5330f7a995 100644
--- a/lib/iov_iter.c
+++ b/lib/iov_iter.c
@@ -414,6 +414,7 @@ static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t by
        return 0;
 
    buf->ops = &page_cache_pipe_buf_ops;
+   buf->flags = 0;
    get_page(page);
    buf->page = page;
    buf->offset = offset;
@@ -577,6 +578,7 @@ static size_t push_pipe(struct iov_iter *i, size_t size,
            break;
 
        buf->ops = &default_pipe_buf_ops;
+       buf->flags = 0;
        buf->page = page;
        buf->offset = 0;
        buf->len = min_t(ssize_t, left, PAGE_SIZE);

まず関数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;

    if (unlikely(bytes > i->count))
        bytes = i->count;

    if (unlikely(!bytes))
        return 0;

    if (!sanity(i))
        return 0;

    off = i->iov_offset;
    buf = &pipe->bufs[i_head & p_mask];
    if (off) {
        if (offset == off && buf->page == page) {
            /* merge with the last one */
            buf->len += bytes;
            i->iov_offset += bytes;
            goto out;
        }
        i_head++;
        buf = &pipe->bufs[i_head & p_mask];
    }
    if (pipe_full(i_head, p_tail, pipe->max_usage))
        return 0;

    buf->ops = &page_cache_pipe_buf_ops;
    buf->flags = 0; /* フラグ PIPE_BUF_FLAG_CAN_MERGE がセットされていてもクリアする */
    get_page(page);
    buf->page = page;
    buf->offset = offset;
    buf->len = bytes;

    pipe->head = i_head + 1;
    i->iov_offset = offset + bytes;
    i->head = i_head;
out:
    i->count -= bytes;
    return bytes;
}

次に関数push_pipe()です。

static size_t push_pipe(struct iov_iter *i, size_t size,
            int *iter_headp, size_t *offp)
{
    struct pipe_inode_info *pipe = i->pipe;
    unsigned int p_tail = pipe->tail;
    unsigned int p_mask = pipe->ring_size - 1;
    unsigned int iter_head;
    size_t off;
    ssize_t left;

    if (unlikely(size > i->count))
        size = i->count;
    if (unlikely(!size))
        return 0;

    left = size;
    data_start(i, &iter_head, &off);
    *iter_headp = iter_head;
    *offp = off;
    if (off) {
        left -= PAGE_SIZE - off;
        if (left <= 0) {
            pipe->bufs[iter_head & p_mask].len += size;
            return size;
        }
        pipe->bufs[iter_head & p_mask].len = PAGE_SIZE;
        iter_head++;
    }
    while (!pipe_full(iter_head, p_tail, pipe->max_usage)) {
        struct pipe_buffer *buf = &pipe->bufs[iter_head & p_mask];
        struct page *page = alloc_page(GFP_USER);
        if (!page)
            break;

        buf->ops = &default_pipe_buf_ops;
        buf->flags = 0; /* フラグ PIPE_BUF_FLAG_CAN_MERGE がセットされていてもクリアする */
        buf->page = page;
        buf->offset = 0;
        buf->len = min_t(ssize_t, left, PAGE_SIZE);
        left -= buf->len;
        iter_head++;
        pipe->head = iter_head;

        if (left == 0)
            return size;
    }
    return size - left;
}

exploitが成功する条件[1]

条件はかなり緩く、攻撃者がPoC相当のコードを走らせることができたら、簡単にroot権限を取ることができます。早急にアップデートすべきです。 例えば/etc/passwdのページキャッシュを書き換えた直後にsuコマンドを打つことでroot権限を奪われてしまいます。

攻撃対象ファイルに書き込むプロセスも(非特権プロセスで書き込むことすら)不要ですし、タイミング条件もありませんし、ほとんど任意の場所に任意のデータを書き込むことができます。 制限としては以下のとおりです:

  • 攻撃対象ファイルの読み取りパーミッションがあること(ページキャッシュを読み込んでパイプにsplice()するために必要)
  • オフセットがページ境界にないこと(少なくとも1バイトはパイプにsplice()する必要がある)
  • 書き込む内容がページ境界をまたがないこと(あぶれた部分のために新しいバッファページが作られるため)
  • 攻撃対象ファイルはリサイズできない(パイプのページ管理バグを利用するので書き込みサイズが分からない)

カーネルからはページキャッシュが常に書き込み可能なうえパイプへの書き込みにはパーミッションチェックが無いため、攻撃にはファイルの書き込みパーミッションすら不要でイミュータブルなファイルでも攻撃対象になり得ます。

ただし、これはページキャッシュを書き換える脆弱性なので、ページキャッシュが更新された(dirtyである)とカーネルが判定しなければ永続化しません。 逆に言えば、再起動したりカーネルがメモリ不足などの理由でページキャッシュを捨ててしまえば(reclaim)痕跡が残らなくなるので、注意が必要です。

感想

本家[1]は、不具合の報告から端を発して、ユーザーランドそしてカーネルへと原因追及が進み、かなり重大な権限昇格の脆弱性を発見するまでの過程が記されています。 その途中で、ファイルの破損箇所のバイト列を検証したりgit bisectを活用して原因コミットを絞り込んだりと非常に参考になりました。 さらにカーネルチームにパッチを送り、どのような過程で重大な脆弱性が生まれたのかまで調査するなど、しっかりと取り組んだ感じがします。

参考文献

  1. The Dirty Pipe Vulnerability — The Dirty Pipe Vulnerability documentation - 本家
  2. 20分で分かるDirty Pipe(CVE-2022-0847) - knqyf263's blog - 詳しい解説
  3. spliceを使って高速・省メモリでGzipからZIPを作る - knqyf263's blog - 文献[2]を読んだ後の落穂拾いに
  4. CVE - CVE-2022-0847 - CVE
  5. kernel/git/torvalds/linux.git - Linux kernel source tree - 脆弱性の修正コミット
  6. CVE-2022-0847 - DSA-5092-1