
CVE-2023-3269: Vulnerabilidad de escalada de privilegios en el kernel de Linux
(Exploit verificado por GitHub-CI)
Se encontró una falla en el manejo de la expansión de la pila en el kernel de Linux 6.1 hasta 6.4, también conocida como "Stack Rot". El árbol maple, responsable de gestionar las áreas de memoria virtual, puede someterse a un reemplazo de nodos sin adquirir correctamente el bloqueo de escritura MM, lo que provoca problemas de use-after-free. Un usuario local sin privilegios podría utilizar esta falla para comprometer el kernel y escalar sus privilegios.
Dado que StackRot es una vulnerabilidad del kernel de Linux que se encuentra en el subsistema de gestión de memoria, afecta a casi todas las configuraciones del kernel y requiere capacidades mínimas para activarse. Sin embargo, cabe señalar que los nodos maple se liberan mediante callbacks de RCU, lo que retrasa la desasignación real de memoria hasta después del período de gracia de RCU. En consecuencia, explotar esta vulnerabilidad se considera un desafío.
Hasta donde yo sé, actualmente no hay exploits disponibles públicamente dirigidos a errores de use-after-free por RCU (UAFBR). Esta es la primera vez que se demuestra que los errores UAFBR son explotables, incluso sin la presencia de las opciones CONFIG_PREEMPT o CONFIG_SLAB_MERGE_DEFAULT. Cabe destacar que este exploit se ha demostrado con éxito en el entorno proporcionado por Google kCTF VRP (bzImage_upstream_6.1.25, config).
La vulnerabilidad StackRot ha estado presente en el kernel de Linux desde la versión 6.1, cuando la estructura del árbol VMA se cambió de árboles rojo-negro a árboles maple.
Siempre que se utiliza la llamada al sistema mmap() para establecer una asignación
de memoria, el kernel genera una estructura llamada vm_area_struct para representar
el área de memoria virtual (VMA) correspondiente. Esta estructura almacena diversa
información, incluidas banderas, propiedades y otros detalles pertinentes relacionados
con la asignación.```c
struct vm_area_struct {
long unsigned int vm_start; /* 0 8 /
long unsigned int vm_end; / 8 8 /
struct mm_struct * vm_mm; / 16 8 /
pgprot_t vm_page_prot; / 24 8 /
long unsigned int vm_flags; / 32 8 /
union {
struct {
struct rb_node rb attribute((aligned(8))); / 40 24 /
/ --- cacheline 1 boundary (64 bytes) --- /
long unsigned int rb_subtree_last; / 64 8 /
} attribute((aligned(8))) shared attribute((aligned(8))); / 40 32 /
struct anon_vma_name * anon_name; / 40 8 /
} attribute((aligned(8))); / 40 32 /
/ --- cacheline 1 boundary (64 bytes) was 8 bytes ago --- /
struct list_head anon_vma_chain; / 72 16 /
struct anon_vma * anon_vma; / 88 8 /
const struct vm_operations_struct * vm_ops; / 96 8 /
long unsigned int vm_pgoff; / 104 8 /
struct file * vm_file; / 112 8 /
void * vm_private_data; / 120 8 /
/ --- cacheline 2 boundary (128 bytes) --- /
atomic_long_t swap_readahead_info; / 128 8 /
struct vm_userfaultfd_ctx vm_userfaultfd_ctx; / 136 0 */
/* size: 136, cachelines: 3, members: 14 */
/* forced alignments: 1 */
/* last cacheline: 8 bytes */
} attribute((aligned(8)));
Posteriormente, cuando el kernel encuentra fallos de página u otras llamadas al sistema relacionadas con la memoria, necesita una búsqueda rápida del VMA basada únicamente en la dirección. Anteriormente, los VMA se gestionaban mediante árboles rojo-negro. Sin embargo, a partir de la versión 6.1 del kernel de Linux, se produjo la migración a los árboles maple. Los [árboles maple][mt] son estructuras de datos B-tree seguras para RCU, optimizadas para almacenar rangos no superpuestos. No obstante, su naturaleza intrincada añade complejidad al código base e introduce la vulnerabilidad StackRot.
[mt]: https://docs.kernel.org/6.4/core-api/maple_tree.html
En esencia, un árbol maple está compuesto por nodos maple. Si bien la estructura del árbol puede ser compleja, es importante señalar que esta complejidad no tiene nada que ver con el error StackRot. Por lo tanto, a lo largo de este artículo se asume que el árbol maple consta de un solo nodo, es decir, el nodo raíz.
Este nodo raíz puede contener hasta 16 intervalos. Estos intervalos pueden representar un hueco o apuntar a un VMA. Como los huecos también cuentan como intervalos, todos los intervalos están conectados secuencialmente, lo que da lugar a la necesidad de solo 15 puntos finales, también conocidos como pivotes, dentro de la estructura del nodo. Tenga en cuenta que el punto final más a la izquierda y el punto final más a la derecha se omiten, ya que se pueden recuperar del nodo padre.```c
struct maple_range_64 {
struct maple_pnode * parent; /* 0 8 */
long unsigned int pivot[15]; /* 8 120 */
/* --- cacheline 2 boundary (128 bytes) --- */
union {
void * slot[16]; /* 128 128 */
struct {
void * pad[15]; /* 128 120 */
/* --- cacheline 3 boundary (192 bytes) was 56 bytes ago --- */
struct maple_metadata meta; /* 248 2 */
}; /* 128 128 */
}; /* 128 128 */
/* size: 256, cachelines: 4, members: 3 */
};
La estructura maple_range_64, como se muestra arriba, representa un nodo maple. Además
de los pivotes, los slots se utilizan para referirse a la estructura VMA cuando
el nodo funciona como nodo hoja, o a otros nodos maple cuando el nodo
funciona como nodo interior. Si un intervalo corresponde a un hueco, el slot
simplemente contendrá un valor NULL. La disposición de los puntos de pivote y los slots se
puede visualizar como se ilustra a continuación:```
Slots -> | 0 | 1 | 2 | ... | 12 | 13 | 14 | 15 |
┬ ┬ ┬ ┬ ┬ ┬ ┬ ┬ ┬
│ │ │ │ │ │ │ │ └─ Implied maximum
│ │ │ │ │ │ │ └─ Pivot 14
│ │ │ │ │ │ └─ Pivot 13
│ │ │ │ │ └─ Pivot 12
│ │ │ │ └─ Pivot 11
│ │ │ └─ Pivot 2
│ │ └─ Pivot 1
│ └─ Pivot 0
└─ Implied minimum
En cuanto a la modificación concurrente, el maple tree impone una
restricción específica: los escritores deben mantener un bloqueo exclusivo (*Rule W*). En
el caso del árbol VMA, el bloqueo exclusivo corresponde al bloqueo de escritura de MM.
En cuanto a los lectores, hay dos opciones disponibles. La primera opción implica mantener
el bloqueo de lectura de MM (*Rule A1*), lo que hace que el escritor sea bloqueado por el
bloqueo de lectura-escritura de MM. Alternativamente, la segunda opción es entrar en la
sección crítica de RCU (*Rule A2*). Al hacerlo, el escritor no es bloqueado, y
los lectores pueden continuar sus operaciones ya que el maple tree es seguro para RCU. Mientras que
la mayoría de los accesos VMA existentes optan por la primera opción (es decir, Rule A1), Rule A2 se
emplea en algunos escenarios críticos de rendimiento, como fallos de página sin bloqueo.
Sin embargo, hay un aspecto adicional que requiere especial atención,
que se relaciona con la expansión de la pila. La pila representa un área de memoria que está
mapeada con la bandera MAP_GROWSDOWN, lo que indica una expansión automática cuando se
accede a una dirección por debajo de la región. En tales casos, la dirección inicial de la
VMA correspondiente se ajusta, así como el intervalo asociado dentro del
maple tree. Notablemente, estos ajustes se realizan sin mantener el bloqueo de escritura
de MM.```c
static inline
void do_user_addr_fault(struct pt_regs *regs,
unsigned long error_code,
unsigned long address)
{
// ...
if (unlikely(!mmap_read_trylock(mm))) {
// ...
}
// ...
if (unlikely(expand_stack(vma, address))) {
// ...
}
// ...
}
Normalmente, existe un espacio entre el VMA de la pila y su VMA adyacente, ya que el kernel impone un stack guard. En este escenario, al expandir la pila, solo se necesita actualizar el valor pivote del nodo maple, un proceso que puede realizarse de forma atómica. Sin embargo, si el VMA adyacente también posee la bandera MAP_GROWSDOWN, no se impone ningún stack guard.```c int expand_downwards(struct vm_area_struct *vma, unsigned long address) { // ...
if (prev) {
if (!(prev->vm_flags & VM_GROWSDOWN) &&
vma_is_accessible(prev) &&
(address - prev->vm_end < stack_guard_gap))
return -ENOMEM;
}
// ...
}
Como resultado, la expansión de la pila puede eliminar el espacio. En tales situaciones, el
intervalo de espacio dentro del nodo maple debe eliminarse. Como el árbol maple es
seguro para RCU, sobrescribir el nodo in situ no es posible. En su lugar, se crea un nuevo nodo,
lo que desencadena el reemplazo del nodo, y el nodo antiguo se destruye posteriormente
mediante una devolución de llamada de RCU.```c
static inline void mas_wr_modify(struct ma_wr_state *wr_mas)
{
// ...
if ((wr_mas->offset_end - mas->offset <= 1) &&
mas_wr_slot_store(wr_mas)) // <-- in-place update
return;
else if (mas_wr_node_store(wr_mas)) // <-- node replacement
return;
// ...
}
El callback de RCU se invoca solo después de que todas las secciones críticas de RCU preexistentes hayan concluido. Sin embargo, el problema surge al acceder a las VMAs, ya que solo se mantiene el bloqueo de lectura de MM, y no entra en la sección crítica de RCU (según la Regla A1). En consecuencia, en teoría, el callback podría invocarse en cualquier momento, resultando en la liberación del antiguo nodo maple. Sin embargo, los punteros al antiguo nodo pueden ya haber sido obtenidos, lo que conduce a un error de use-after-free al intentar acceder posteriormente a él.
El backtrace donde se produce el use-after-free (UAF) se muestra a continuación:```
mm_read_lock() mm_read_lock() expand_stack() find_vma_prev() expand_downwards() mas_walk() mas_store_prealloc() mas_state_walk() mas_wr_story_entry() mas_start() mas_wr_modify() mas_root() mas_wr_node_store() node = rcu_dereference_check() mas_replace() [ The node pointer is recorded ] mas_free() ma_free_rcu() call_rcu(&mt_free_rcu) [ The node is dead ] mm_read_unlock()
[ Wait for the next RCU grace period.. ] rcu_do_batch() mas_prev() mt_free_rcu() mas_prev_entry() kmem_cache_free() mas_prev_nentry() [ The node is freed ] mas_slot() mt_slot() rcu_dereference_check(node->..) [ UAF occurs here ] mm_read_unlock()
## Fix
Informé de esta vulnerabilidad al equipo de seguridad del kernel de Linux el 15 de junio.
A continuación, Linus Torvalds lideró el proceso para abordar este fallo.
Dada su complejidad, se necesitaron casi dos semanas para desarrollar un conjunto de parches que
contara con consenso.
El 28 de junio, durante la ventana de fusión del kernel de Linux 6.5, la solución se incorporó
al árbol de Linus. Linus proporcionó un [mensaje de fusión completo][fix] para
explicar la serie de parches desde una perspectiva técnica.
[fix]: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=9471f1f2f50282b9e8f59198ec6bb738b4ccc009
Estos parches se trasladaron posteriormente a los kernels estables ([6.1.37][6.1],
[6.3.11][6.3] y [6.4.1][6.4]), resolviendo efectivamente el fallo "Stack Rot" el
1 de julio.
[6.1]: https://lore.kernel.org/stable/2023070133-create-stainless-9a8c@gregkh/T/
[6.3]: https://lore.kernel.org/stable/2023070146-endearing-bounding-d21a@gregkh/T/
[6.4]: https://lore.kernel.org/stable/2023070140-eldercare-landlord-133c@gregkh/T/
## Exploit
El exploit se centra principalmente en el desafío Google kCTF, concretamente cuando
ni CONFIG_PREEMPT ni CONFIG_SLAB_MERGE_DEFAULT están establecidos. Para explotar
StackRot, la tarea más importante es localizar una iteración de VMA que cumpla
los siguientes criterios:
1. El momento de la iteración se puede controlar. Este control nos permite asegurar
que el período de gracia de RCU concluya durante la iteración de VMA.
2. La iteración recupera información específica de la estructura VMA y
devuelve la información al espacio de usuario. Esta característica nos permite
explotar la vulnerabilidad UAF del nodo maple para filtrar algunas direcciones
del kernel.
3. La iteración invoca ciertos punteros a función en la estructura VMA. Esta
capacidad particular nos permite explotar el UAF del nodo maple para
controlar el contador de programa (PC) en modo kernel.
La iteración de VMA elegida es la iteración responsable de generar el
contenido de `/proc/[pid]/maps`. Las siguientes secciones mostrarán cómo esta
iteración satisface los criterios anteriores.
### Paso 0: De UAFBR a UAF
Durante cualquier iteración de VMA, la referencia al nodo raíz del árbol VMA se
obtiene, y la iteración avanza a través de sus ranuras. Así, al provocar
la expansión de la pila en otro hilo en una CPU separada durante la iteración de VMA,
el reemplazo del nodo puede iniciarse de manera concurrente. En este punto, acceder
al nodo antiguo se considera una situación de use-after-free-by-RCU (UAFBR). Sin embargo,
los problemas reales surgen solo cuando el nodo antiguo se libera de verdad, lo cual ocurre en el
callback de RCU.
Esto plantea dos desafíos: (i) determinar cuándo se libera el nodo antiguo y
(ii) asegurar que la iteración de VMA no se complete antes de que el nodo antiguo sea
liberado.
La primera cuestión es relativamente sencilla. En el kernel, se puede utilizar la
función `synchronize_rcu()` para esperar hasta que el período de gracia de RCU
concluya, asegurando que se hayan invocado todas las devoluciones de llamada de RCU
preexistentes. En el espacio de usuario, se pueden utilizar llamadas al sistema que en última instancia
llamen a `synchronize_rcu()` con el mismo propósito. Por lo tanto, cuando dichas llamadas al sistema terminan, se
sabe que el nodo antiguo ha sido liberado. En particular, hay una llamada al sistema,
`membarrier(MEMBARRIER_CMD_GLOBAL, 0, -1)`, que invoca únicamente
`synchronize_rcu()`.```c
SYSCALL_DEFINE3(membarrier, int, cmd, unsigned int, flags, int, cpu_id)
{
// ...
switch (cmd) {
// ...
case MEMBARRIER_CMD_GLOBAL:
/* MEMBARRIER_CMD_GLOBAL is not compatible with nohz_full. */
if (tick_nohz_full_enabled())
return -EINVAL;
if (num_online_cpus() > 1)
synchronize_rcu();
return 0;
// ...
}
}
La segunda cuestión requiere una consideración más profunda. Existen varias soluciones posibles:
jiffies_till_first_fqs (que por defecto es de varios jiffies), se
enviará una interrupción entre procesadores (IPI) a la CPU víctima y se
provocará un adelantamiento voluntario. En el caso de la iteración de VMA, el
adelantamiento voluntario puede hacer que el periodo de gracia de RCU concluya y libere el nodo maple,
convirtiendo efectivamente UAFBR en un auténtico escenario de use-after-free (UAF).Una observación significativa es que durante la iteración de VMA para
/proc/[pid]/maps, se genera la ruta completa del archivo para las regiones de memoria
mapeadas por archivo. Aunque el nombre del directorio suele estar restringido a un máximo de
255 caracteres, no hay limitación en la profundidad del directorio. Esto significa que
al crear un archivo con una profundidad de directorio extremadamente grande y establecer un
mapeo de memoria para este archivo, el acceso a /proc/[pid]/maps puede tardar una
cantidad considerable de tiempo durante la iteración de VMA. En consecuencia, esta
duración extendida permite la posibilidad de concluir el periodo de gracia de RCU
y adquirir la primitiva de UAF.```c
static void
show_map_vma(struct seq_file *m, struct vm_area_struct *vma)
{
// ...
/*
* Print the dentry name for named mappings, and a
* special [heap] marker for the heap:
*/
if (file) {
seq_pad(m, ' ');
/*
* If user named this anon shared memory via
* prctl(PR_SET_VMA ..., use the provided name.
*/
if (anon_name)
seq_printf(m, "[anon_shmem:%s]", anon_name->name);
else
seq_file_path(m, file, "\n");
goto done;
}
// ...
}
Este paso se ilustra en la siguiente figura:

### Paso 1: De UAF de slab a UAF de página
Ahora que el UAF está funcionando dentro de un slab. Si CONFIG_SLAB_MERGE_DEFAULT está
habilitado y el slab de nodos maple se fusiona con kmalloc-256, el contenido
dentro del nodo antiguo puede controlarse asignando una nueva estructura desde
kmalloc-256 y rellenándola con datos de espacio de usuario. Sin embargo, si
CONFIG_SLAB_MERGE_DEFAULT no está definido, se requiere un enfoque alternativo. En
este caso, es necesario devolver la página del nodo liberado al asignador de
páginas, permitiendo que el nodo antiguo sea controlado asignando una nueva página y
rellenándola en consecuencia.
Recuerde que el árbol VMA solo contendrá un nodo. Por lo tanto, al utilizar
`fork()`/`clone()`, se generan múltiples árboles VMA y una cantidad igual de nodos maple.
Suponiendo que un slab abarca M nodos maple, y se retiene un nodo de cada M
nodos mientras todos los demás nodos se liberan mediante `exit()`, los nodos restantes
se convierten en los únicos nodos dentro de sus respectivos slabs. Inicialmente, estos
slabs residen en la lista parcial de la CPU. Cuando la lista parcial alcanza su
capacidad, los slabs se vacían de vuelta a la lista parcial del nodo NUMA
correspondiente.
Si el último nodo maple dentro de un slab se libera, el slab queda vacío. Si este
slab reside en la lista parcial de un nodo NUMA, y la lista parcial de ese
nodo NUMA en particular ya está en su capacidad máxima, la página se devuelve
inmediatamente al asignador de páginas. En consecuencia, el UAF de slab se transforma en un
escenario de UAF de página. El contenido dentro de la página liberada puede manipularse
enviando datos mediante `msgsnd()`, que asigna objetos elásticos y directamente
los rellena con los datos de usuario proporcionados.```c
static void __slab_free(struct kmem_cache *s, struct slab *slab,
void *head, void *tail, int cnt,
unsigned long addr)
{
// ...
if (unlikely(!new.inuse && n->nr_partial >= s->min_partial))
goto slab_empty;
// ...
return;
slab_empty:
// ...
discard_slab(s, slab);
}
El número de nodos maple por slab, M, depende del número de CPUs. La implementación del exploit considera una situación con dos CPUs y por lo tanto asume 16 como el valor de M, como se ilustra en la siguiente figura:

Al obtener el control del nodo maple, se vuelve posible manipular las
direcciones de los VMAs subsiguientes que serán iterados más adelante. Como la
iteración objetivo está destinada a generar /proc/self/maps, cierta información del VMA,
como las direcciones de inicio y fin, que residen dentro de la estructura VMA, se
devuelve al espacio de usuario.
Sin embargo, surge un desafío: la dirección de una estructura VMA en el nodo maple
solo puede establecerse adecuadamente si ya se conocen algunas direcciones. Afortunadamente,
CVE-2023-0597 sirve directamente para este propósito. Según CVE-2023-0597, la
dirección de cpu_entry_area no está aleatorizada. Aunque esta vulnerabilidad ha
sido parcheada en Linux 6.2, no se ha retroportado a kernels estables anteriores
al momento de escribir este artículo. En consecuencia, al sobrescribir la dirección de la estructura VMA
con la de la última entrada IDT, la entrada que contiene la dirección
de asm_sysvec_spurious_apic_interrupt se filtra directamente, revelando así
las direcciones base del código del kernel y de los datos del kernel.

El método discutido anteriormente puede usarse de forma recurrente para exponer
incrementalmente más direcciones de la sección de datos del kernel. Por ejemplo, el
puntero init_task.tasks.prev dentro de la sección de datos apunta a la
estructura task_struct de la tarea más recientemente creada, que sin duda
está asignada en el heap.

Cuando todas las tareas recién creadas terminan, sus estructuras task_struct
serán posteriormente desasignadas. Si la cantidad de estas tareas es
lo suficientemente grande, las páginas correspondientes pueden devolverse al asignador de páginas.
Esto permite la posibilidad de reasignar estas páginas y llenarlas con
datos de usuario. Sin embargo, tenga en cuenta que las páginas liberadas generalmente pertenecen a
la lista de páginas por CPU (PCP). Para las páginas presentes en la lista PCP, solo
se pueden reasignar en el mismo orden de página. En consecuencia, mapear únicamente
páginas nuevas en el espacio de usuario, lo que requiere solo páginas de orden 0 del asignador
de páginas, no cumplirá los objetivos.
No obstante, la llamada al sistema msgsnd solicitará fragmentos de memoria mediante kmalloc y poblará estos fragmentos con datos definidos por el usuario. Cuando la caché de kmalloc se agota, requerirá páginas del asignador de páginas en un orden específico. Si el tamaño del mensaje se ajusta con precisión, el orden exacto será el deseado. Así, la página cuya dirección fue previamente filtrada será reasignada. Como resultado, es posible obtener una página con una dirección conocida y datos manipulados por el usuario.
Ahora es posible falsificar la estructura VMA en la página de dirección conocida y
controlar el puntero de función vma->vm_ops->name. El siguiente paso implica
encontrar gadgets adecuados para escapar de los contenedores y adquirir privilegios de root.```c
static void
show_map_vma(struct seq_file *m, struct vm_area_struct *vma)
{
// ...
if (vma->vm_ops && vma->vm_ops->name) {
name = vma->vm_ops->name(vma);
if (name)
goto done;
}
// ...
}

Las construcciones de gadgets son las siguientes:
1. Pivote de pila: `movq %rbx, %rsi; movq %rbp, %rdi; call
__x86_indirect_thunk_r13` -> `pushq %rsi; jmp 46(%rsi)` -> `popq %rsp; ret`
-> `popq %rsp; ret`, donde %rdi, %rbx y %r13 _inicialmente_ apuntan a
datos controlables por el usuario.
2. Obtener privilegios de root: `popq %rdi; ret` -> `prepare_kernel_cred` -> `popq
%rdi; ret` -> `movq %rax, (%rdi); ret`, donde %rdi _ahora_ apunta a la
cima de la pila; `popq %rdi; ret` -> `commit_creds`, ejecutando efectivamente
`commit_creds(prepare_kernel_cred(&init_task))`.
3. Escapar de contenedores: `popq %rdi; ret` -> `find_task_by_vpid` -> `popq %rdi;
ret` -> `movq %rax, (%rdi); ret`, donde %rdi _ahora_ apunta a la cima de la pila;
`popq %rdi; ret` -> `popq %rsi; ret` -> `switch_task_namespaces`,
realizando efectivamente `switch_task_namespaces(find_task_by_vpid(1),
&init_nsproxy)`.
4. Desbloquear mm: `popq %rax; ret` -> `movq %rbp, %rdi; call
__x86_indirect_thunk_rax`, donde %rbp apunta al seq_file original;
`popq %rax; ret` -> `m_stop`, ejecutando efectivamente `m_stop(seq_file, ..)`.
5. Volver al espacio de usuario: usa `swapgs_restore_regs_and_return_to_usermode`, y
llama a `execve()` para obtener la shell.
Finalmente, usando `nsenter --mount=/proc/1/ns/mnt` para restaurar el namespace de montaje
y obtener la flag mediante `cat /flag/flag`.
### Código fuente
El código fuente completo del exploit está disponible [aquí](https://github.com/lrh2000/stackrot/blob/HEAD/exp). Para más detalles, consulta
su archivo README.