
Эксплойт proof-of-concept и подробный анализ уязвимости повышения привилегий в ядре Linux CVE-2022-0847 (Dirty Pipe), включая среду Docker для тестирования и отладки.
[toc]
Статья впервые опубликована в официальном аккаунте Huawei Security; это блог-версия (более полная).
Ссылка на оригинал: https://mp.weixin.qq.com/s/6VhWBOzJ7uu80nzFxe5jpg
Идентификатор уязвимости: CVE-2022-0847 (псевдоним: Dirty Pipe / «грязная труба»)
Поражённый продукт: ядро Linux — системный вызов splice
Затронутые версии: начиная с Linux 5.8, где появился коммит f6dd975583bd, исправлено в 5.16.11, 5.15.25, 5.10.102
Ущерб: позволяет записать не более одной страницы в любой читаемый файл (этого достаточно) и локально повысить привилегии.
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) напрямую используется как страницаbufканалаpipe. При этом вводится неинициализированная переменная — из-за этой ошибки страница файлового кэша в дальнейшем обрабатывается как обычная страница кэшаpipe, и в неё можно «дописать» данные, изменив её содержимое. Однако в этой ситуации ядро не помечает страницу кэша как «грязную», поэтому она не будет сброшена на диск в ближайшее время (до следующей перезагрузки и т.п.). В течение этого времени все обращения к файлу используют изменённую страницу файлового кэша, что даёт возможность «в течение короткого времени писать произвольные данные в любой читаемый файл». Это позволяет локально повысить привилегии.
Судя по патчу, уязвимость находится в функции copy_page_to_iter_pipe: был добавлен код инициализации buf->flags. Это классическая ошибка неинициализированной переменной.

Вызов copy_page_to_iter_pipe происходит внутри системного вызова splice. Функция splice (системный вызов) передаёт содержимое файла в канал методом «нулевого копирования». Это производительнее, чем традиционная передача содержимого файла в канал. Подробнее об этом ниже.
Для начала, раз уж уязвимость называют «грязной трубой», стоит разобраться с каналом (pipe). pipe — это коммуникационный механизм, предоставляемый ядром; он создаётся функциями pipe/pipe2 и возвращает два файловых дескриптора: один для отправки данных, другой для их приёма, как два конца трубы. Подробности использования расписывать не буду.

Кратко о реализации в ядре: обычно пространство кэша pipe имеет общий размер 65536 байт и управляется страницами — всего 16 страниц (по 4096 байт). Страницы не обязательно непрерывны: они управляются через массив и образуют кольцевой список. Поддерживаются два указателя списка: один для записи (pipe->head), другой для чтения (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]pipe 缓存不为空,则尝试是否能从当前最后一页"接着"写
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]设置flag,默认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;
}
pipe не пуст (head == tail означает пустой канал), значит в нём есть непрочитанные данные. Берётся указатель head, то есть адрес самой свежей страницы для записи, и проверяются len, offset этой страницы (чтобы найти конец данных). Затем предпринимается попытка дописать данные в текущую страницу.PIPE_BUF_FLAG_CAN_MERGE; если его нет, дописывать в текущую страницу нельзя. Также проверяется, что после дописывания к существующим данным общая длина не превышает одну страницу (то есть запись не пересекает границу страницы). Если пересекает — дописать нельзя.alloc_page выделяет новую страницу.buf->flag по умолчанию устанавливается в PIPE_BUF_FLAG_CAN_MERGE, потому что по умолчанию странице разрешено дописывание.Ключ к эксплуатации — неинициализированный флаг PIPE_BUF_FLAG_CAN_MERGE, оставшийся в splice. Именно он определяет, можно ли дописать данные в «незакрытую» страницу pipe.
Как уже упоминалось, pipe использует 16 страниц в качестве кольцевого кэша. Метод нулевого копирования в splice состоит в том, что страница файлового кэша напрямую подставляется вместо страницы кэша pipe (указатель страницы в pipe меняется на страницу файлового кэша).
