
CVE-2023-3269: Уязвимость повышения привилегий в ядре Linux
В обработке расширения стека в ядре Linux версий с 6.1 по 6.4 была обнаружена ошибка, также известная как «Stack Rot». Дерево maple, отвечающее за управление виртуальными областями памяти, может выполнять замену узлов без надлежащего получения блокировки записи MM, что приводит к проблемам use-after-free. Непривилегированный локальный пользователь может использовать эту ошибку для компрометации ядра и повышения своих привилегий.
Поскольку StackRot — это уязвимость ядра Linux, обнаруженная в подсистеме управления памятью, она затрагивает почти все конфигурации ядра и требует минимальных привилегий для срабатывания. Однако следует отметить, что узлы maple освобождаются с помощью RCU-колбэков, что задерживает фактическое освобождение памяти до завершения периода ожидания RCU. Следовательно, эксплуатация этой уязвимости считается сложной.
Насколько мне известно, в настоящее время не существует публично доступных эксплойтов, нацеленных на ошибки use-after-free-by-RCU (UAFBR). Это первый случай, когда ошибки UAFBR были признаны эксплуатируемыми даже без наличия параметров CONFIG_PREEMPT или CONFIG_SLAB_MERGE_DEFAULT. Примечательно, что этот эксплойт был успешно продемонстрирован в среде, предоставленной [Google kCTF VRP][ctf] ([bzImage_upstream_6.1.25][img], [config][cfg]).
Уязвимость StackRot присутствует в ядре Linux начиная с версии 6.1, когда структура дерева VMA была изменена с красно-чёрных деревьев на деревья maple.
Всякий раз, когда системный вызов mmap() используется для создания отображения памяти,
ядро создаёт структуру vm_area_struct, представляющую соответствующую
область виртуальной памяти (VMA). Эта структура хранит различные
сведения, включая флаги, свойства и другие важные детали, относящиеся к
отображению.```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)));
Впоследствии, когда ядро сталкивается со сбоями страниц или другими системными
вызовами, связанными с памятью, ему требуется быстрый поиск VMA только по адресу.
Ранее VMA управлялись с помощью красно-чёрных деревьев. Однако начиная с
версии ядра Linux 6.1 произошёл переход на кленовые деревья. [Кленовые
деревья][mt] — это RCU-безопасные структуры данных B-дерева, оптимизированные для хранения
непересекающихся диапазонов. Тем не менее их сложное устройство добавляет сложность
кодовой базе и порождает уязвимость StackRot.
[mt]: https://docs.kernel.org/6.4/core-api/maple_tree.html
По своей сути кленовое дерево состоит из кленовых узлов. Хотя структура дерева
может быть сложной, важно отметить, что эта сложность никак не связана
с багом StackRot. Поэтому в этой статье предполагается, что
кленовое дерево состоит только из одного узла, то есть корневого узла.
Этот корневой узел может содержать до 16 интервалов. Эти интервалы могут либо
представлять собой промежуток, либо указывать на VMA. Поскольку промежутки также считаются
интервалами, все интервалы соединены последовательно, поэтому в структуре узла
требуется только 15 конечных точек, также известных как pivots. Обратите внимание, что
самая левая и самая правая конечные точки опущены, поскольку их можно
получить из родительского узла.```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 */
};
Структура maple_range_64, как показано выше, представляет узел maple. В дополнение к поворотным точкам (pivots), слоты используются для ссылки на структуру VMA, когда узел функционирует как листовой узел, или на другие узлы maple, когда узел функционирует как внутренний узел. Если интервал соответствует промежутку (gap), слот просто содержит значение NULL. Расположение поворотных точек и слотов можно визуализировать, как показано ниже:```
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
Что касается конкурентной модификации, кленовое дерево накладывает определённое ограничение: писатели должны удерживать эксклюзивную блокировку (*правило W*). В случае дерева VMA эксклюзивная блокировка соответствует блокировке записи MM. Что касается читателей, доступны два варианта. Первый вариант предполагает удержание блокировки чтения MM (*правило A1*), в результате чего писатель блокируется блокировкой чтения-записи MM. Второй вариант — войти в критическую секцию RCU (*правило A2*). В этом случае писатель не блокируется, а читатели могут продолжать свои операции, поскольку кленовое дерево является RCU-безопасным. Хотя большинство существующих обращений к VMA выбирают первый вариант (т.е. правило A1), правило A2 применяется в немногих критически важных для производительности сценариях, таких как страничные ошибки без блокировок.
Однако есть ещё один аспект, требующий особого внимания, который касается расширения стека. Стек представляет собой область памяти, отображаемую с флагом MAP_GROWSDOWN, что указывает на автоматическое расширение при обращении к адресу ниже этой области. В таких случаях корректируется стартовый адрес соответствующего VMA, а также связанный интервал внутри кленового дерева. Примечательно, что эти корректировки выполняются без удержания блокировки записи 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))) {
// ...
}
// ...
}
Обычно между стековым VMA и соседним VMA существует промежуток, поскольку ядро применяет защиту стека. В этом сценарии при расширении стека необходимо обновлять только значение pivot в узле maple, что можно выполнить атомарно. Однако если соседний VMA также имеет флаг MAP_GROWSDOWN, защита стека не применяется.```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;
}
// ...
}
В результате расширение стека может устранить разрыв. В таких ситуациях
интервал разрыва внутри узла maple-дерева должен быть удалён. Поскольку maple-дерево
является RCU-безопасным, перезапись узла на месте невозможна. Вместо этого создаётся
новый узел, что запускает замену узла, а старый узел впоследствии
уничтожается с помощью 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;
// ...
}
Обратный вызов RCU вызывается только после того, как завершились все ранее существовавшие критические секции RCU. Однако проблема возникает при доступе к VMA, поскольку удерживается только блокировка чтения MM, и она не входит в критическую секцию RCU (согласно Правилу A1). Следовательно, теоретически обратный вызов может быть вызван в любой момент, что приведёт к освобождению старого maple node. Однако указатели на старый узел могли быть уже получены, что приводит к ошибке use-after-free при последующем обращении к нему.
Трассировка стека, где возникает use-after-free (UAF), показана ниже:```
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
Я сообщил об этой уязвимости команде безопасности ядра Linux 15 июня.
После этого процесс устранения этой ошибки возглавил Линус Торвальдс.
Учитывая её сложность, потребовалось почти две недели, чтобы разработать набор патчей,
который получил консенсус.
28 июня, во время окна слияния для ядра Linux 6.5, исправление было влито
в дерево Линуса. Линус предоставил [подробное сообщение о слиянии][fix], чтобы
разъяснить серию патчей с технической точки зрения.
[fix]: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=9471f1f2f50282b9e8f59198ec6bb738b4ccc009
Эти патчи впоследствии были перенесены в стабильные ядра ([6.1.37][6.1],
[6.3.11][6.3] и [6.4.1][6.4]), что фактически устранило ошибку "Stack Rot"
1 июля.
[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/
## Эксплойт
Эксплойт в первую очередь ориентирован на задачу Google kCTF, в частности когда
не заданы ни CONFIG_PREEMPT, ни CONFIG_SLAB_MERGE_DEFAULT. Чтобы эксплуатировать
StackRot, самая важная задача — найти итерацию VMA, которая удовлетворяет
следующим критериям:
1. Временем выполнения итерации можно управлять. Этот контроль позволяет нам
гарантировать, что период ожидания RCU завершится во время итерации VMA.
2. Итерация извлекает конкретную информацию из структуры VMA и
возвращает эту информацию в пользовательское пространство. Эта возможность
позволяет нам эксплуатировать UAF-уязвимость узла maple для утечки некоторых
адресов ядра.
3. Итерация вызывает определённые указатели на функции в структуре VMA. Эта
конкретная возможность позволяет нам использовать UAF узла maple для
управления программным счётчиком (PC) в режиме ядра.
Выбранная итерация VMA — это итерация, отвечающая за формирование содержимого
`/proc/[pid]/maps`. В следующих разделах будет показано, как эта итерация
удовлетворяет указанным выше критериям.
### Шаг 0: от UAFBR к UAF
Во время любой итерации VMA получается ссылка на корневой узел дерева VMA,
и итерация проходит по его слотам. Таким образом, вызывая расширение стека
в другом потоке на отдельном CPU во время итерации VMA, можно одновременно
инициировать замену узла. В этот момент доступ к старому узлу считается
ситуацией use-after-free-by-RCU (UAFBR). Однако реальные проблемы возникают
только тогда, когда старый узел действительно освобождается, что происходит
в callback-функции RCU.
Это создаёт две проблемы: (i) определение момента, когда старый узел
освобождается, и (ii) обеспечение того, чтобы итерация VMA не завершилась
до освобождения старого узла.
Первый вопрос решается относительно просто. В ядре функцию
`synchronize_rcu()` можно использовать для ожидания завершения периода ожидания
RCU, что гарантирует вызов всех ранее существовавших callback-функций RCU.
В пользовательском пространстве для той же цели можно использовать системные
вызовы, которые в конечном итоге вызывают `synchronize_rcu()`. Таким образом,
когда такие системные вызовы завершаются, известно, что старый узел уже
освобождён. Примечательно, что существует системный вызов
`membarrier(MEMBARRIER_CMD_GLOBAL, 0, -1)`, который только лишь вызывает
`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;
// ...
}
}
Второй вопрос требует дальнейшего рассмотрения. Несколько потенциальных решений выглядят следующим образом:
jiffies_till_first_fqs (по умолчанию — несколько jiffies),
межпроцессорное прерывание (IPI) будет отправлено на ЦП жертвы и
вызовет добровольное вытеснение. В случае итерации VMA добровольное
вытеснение может привести к завершению периода ожидания RCU и освобождению узла maple,
фактически превращая UAFBR в настоящий сценарий использования после освобождения (UAF).Одно важное наблюдение заключается в том, что во время итерации VMA для
/proc/[pid]/maps генерируется полный путь к файлу для областей памяти,
отображаемых файлами. Хотя имя каталога обычно ограничено максимумом
255 символов, ограничения на глубину каталога нет. Это означает, что
создав файл с чрезвычайно большой глубиной каталога и установив
отображение этого файла в память, доступ к /proc/[pid]/maps может потребовать
значительного времени во время итерации VMA. Следовательно, эта
увеличенная продолжительность позволяет завершить период ожидания RCU
и получить примитив 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;
}
// ...
}
Этот шаг показан на следующем рисунке:

### Шаг 1: от slab UAF к page UAF
Теперь, когда UAF работает в пределах slab. Если CONFIG_SLAB_MERGE_DEFAULT включён и slab узлов maple объединяется с kmalloc-256, содержимым старого узла можно управлять, выделив новую структуру из kmalloc-256 и заполнив её данными пользовательского пространства. Однако, если CONFIG_SLAB_MERGE_DEFAULT не установлен, требуется альтернативный подход. В этом случае нужно вернуть страницу освобождённого узла аллокатору страниц, что позволит управлять старым узлом, выделив новую страницу и заполнив её соответствующим образом.
Напомним, что дерево VMA будет содержать только один узел. Следовательно, используя `fork()`/`clone()`, можно создать несколько деревьев VMA и такое же количество узлов maple. Предположим, что один slab вмещает M узлов maple, при этом сохраняется один узел из M, а все остальные узлы освобождаются через `exit()`; тогда оставшиеся узлы становятся единственными узлами в своих соответствующих slab'ах. Изначально эти slab'и находятся в partial-списке CPU. Когда partial-список достигает своей ёмкости, slab'и сбрасываются обратно в partial-список соответствующего NUMA-узла.
Если последний узел maple внутри slab освобождается, slab становится пустым. Если этот slab находится в partial-списке NUMA-узла, а partial-список этого конкретного NUMA-узла уже достиг максимальной ёмкости, страница немедленно возвращается аллокатору страниц. Следовательно, сценарий slab UAF превращается в сценарий page UAF. Содержимым освобождённой страницы можно управлять, отправив некоторые данные через `msgsnd()`, который выделяет эластичные объекты и напрямую заполняет их предоставленными пользовательскими данными.```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);
}
Количество узлов maple на slab, M, зависит от числа CPU. В реализации эксплойта рассматривается ситуация с двумя CPU, и поэтому в качестве значения M предполагается 16, как показано на следующем рисунке:

Получив контроль над узлом maple, становится возможным манипулировать
адресами последующих VMA, которые будут позже обходиться итератором. Так как
целевой обход направлен на формирование /proc/self/maps, определённая информация
о VMA, такая как начальный и конечный адреса, находящиеся в структуре VMA,
возвращается в пространство пользователя.
Однако возникает проблема: адрес структуры VMA в узле maple
может быть корректно установлен только в том случае, если некоторые адреса уже известны.
К счастью, CVE-2023-0597 напрямую служит этой цели. Согласно CVE-2023-0597,
адрес cpu_entry_area не рандомизирован. Хотя эта уязвимость
была исправлена в Linux 6.2, на момент написания она не была перенесена
в более ранние стабильные ядра. Следовательно, перезаписав адрес структуры VMA
адресом последней записи IDT, запись, содержащая адрес
asm_sysvec_spurious_apic_interrupt, напрямую утекает, тем самым раскрывая
базовые адреса кода ядра и данных ядра.

Описанный выше метод можно использовать многократно, чтобы постепенно раскрывать
всё больше адресов из секции данных ядра. Например,
указатель init_task.tasks.prev в секции данных ведёт на структуру
task_struct самой последней созданной задачи, которая, без сомнения,
выделена в куче.

Когда все вновь созданные задачи завершаются, их структуры task_struct
впоследствии освобождаются. Если количество таких задач достаточно
велико, соответствующие страницы могут быть возвращены обратно в page allocator.
Это позволяет перераспределить эти страницы и заполнить их
данными пользователя. Однако следует помнить, что освобождённые страницы обычно принадлежат
списку per-cpu page (PCP). Для страниц, присутствующих в списке PCP, они могут быть
перераспределены исключительно в том же порядке страниц. Следовательно, простое
отображение новых страниц в пространство пользователя, которое требует только страницы порядка 0
от page allocator, не достигнет поставленных целей.
Тем не менее, системный вызов msgsnd запрашивает блоки памяти через kmalloc и заполняет эти блоки данными, определёнными пользователем. Когда кэш kmalloc исчерпан, он запрашивает страницы у page allocator определённого порядка. Если размер сообщения точно подобран, будет получен именно нужный порядок. Таким образом, страница, адрес которой был ранее утёк, будет перераспределена. В результате становится возможным получить страницу с известным адресом и управляемыми пользователем данными.
Теперь можно подделать структуру VMA на странице с известным адресом и
управлять указателем функции vma->vm_ops->name. Следующий шаг — поиск
подходящих гаджетов для выхода из контейнера и получения 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;
}
// ...
}

Конструкции гаджетов выглядят следующим образом:
1. Перестановка стека: `movq %rbx, %rsi; movq %rbp, %rdi; call
__x86_indirect_thunk_r13` -> `pushq %rsi; jmp 46(%rsi)` -> `popq %rsp; ret`
-> `popq %rsp; ret`, где %rdi, %rbx и %r13 _изначально_ указывают на
данные, контролируемые пользователем.
2. Получение root-привилегий: `popq %rdi; ret` -> `prepare_kernel_cred` -> `popq
%rdi; ret` -> `movq %rax, (%rdi); ret`, где %rdi _теперь_ указывает на
вершину стека; `popq %rdi; ret` -> `commit_creds`, что фактически выполняет
`commit_creds(prepare_kernel_cred(&init_task))`.
3. Побег из контейнеров: `popq %rdi; ret` -> `find_task_by_vpid` -> `popq %rdi;
ret` -> `movq %rax, (%rdi); ret`, где %rdi _теперь_ указывает на вершину стека;
`popq %rdi; ret` -> `popq %rsi; ret` -> `switch_task_namespaces`,
что фактически выполняет `switch_task_namespaces(find_task_by_vpid(1),
&init_nsproxy)`.
4. Разблокировка mm: `popq %rax; ret` -> `movq %rbp, %rdi; call
__x86_indirect_thunk_rax`, где %rbp указывает на исходный seq_file;
`popq %rax; ret` -> `m_stop`, что фактически выполняет `m_stop(seq_file, ..)`.
5. Возврат в пользовательское пространство: используйте `swapgs_restore_regs_and_return_to_usermode` и
вызовите `execve()`, чтобы получить шелл.
Наконец, используйте `nsenter --mount=/proc/1/ns/mnt` для восстановления пространства имён монтирования
и получите флаг через `cat /flag/flag`.
### Исходный код
Полный исходный код эксплойта доступен [здесь](https://github.com/lrh2000/stackrot/blob/HEAD/exp). Для получения дополнительных сведений обратитесь к
его README-файлу.