[Перевод] Разыменование нуля в Linux – возможность эксплойта
Уже достаточно давно баги, связанные с неправильным разыменованием нуля, были излюбленной целью эксплойта при попытках вторжения в ядро. Ещё в те времена, когда ядро могло без ограничений обращаться к пользовательскому пространству в памяти, а программы из пользовательского пространства могли отображаться на нулевую страницу, имелось множество лёгких способов эксплуатировать такие баги. Правда, со временем стали применяться современные техники предотвращения эксплойтов, в частности, SMEP и SMAP, а также вошла в обиход программа mmap_min_addr
, не позволяющая непривилегированным программам отображать содержимое на нижние адреса. Поэтому баги, связанные с разыменованием нуля, в современных версиях ядра, как правило, не считаются особо опасными. В этом посте проиллюстрировано, как сделать эксплойт, позволяющий усомниться в безобидности этих багов. Недооценка их важности с точки зрения безопасности может дорого вам обойтись.
❯ Обзор oops-ов в ядре
В настоящее время, когда ядро Linux инициирует разыменование нуля в контексте процесса, генерируется oops — это особая сущность, отличающаяся от паники ядра. Паника происходит, когда ядро обнаруживает, что не существует способа безопасно продолжить выполнение программы — следовательно, всякое выполнение следует остановить. Но ядро в случае oops не останавливает всего выполнения задач –, а вместо этого пытается максимально качественно восстановиться и продолжить выполнение. Здесь задача подразумевает выброс всего актуального стека ядра и переход непосредственно к make_task_dead
, который и вызывает do_exit
. Также ядро опубликует лог отказа в dmesg и трассировку обратных вызовов ядра, по которой можно будет восстановить, в каком именно состоянии находилось ядро на момент oops. Может показаться странно, что такие манипуляции реализуются в случае, когда явно была повреждена память. Но смысл таких действий в том, чтобы упростить обнаружение и логирование багов ядра. Это делается в соответствии с философией, что отлаживать рабочую систему гораздо легче, чем мёртвую.
У такого подхода к восстановлению после oops есть неприятный побочный эффект: ядро оказывается не в состоянии произвести сопутствующую очистку, которая произошла бы на типичном пути восстановления после ошибочного системного вызова. Таким образом, все блокировки, действовавшие на момент oops, остаются активны, все значения счётчиков ссылок являются актуальными, вся память, которая была временно выделена, остаётся выделена, т.д. Но процесс, спровоцировавший oops, а также связанные с этим процессом стек ядра, структура задач, производные члены и т.д. зачастую бывают высвобождены. Это означает, что, в зависимости от точных обстоятельств, в которых случился oops, может оказаться, что никакой утечки информации и не было. Этот момент приобретает особую важность в контексте эксплойта, о котором мы поговорим ниже.
❯ Ошибки при подсчёте ссылок: обзор
Ошибки при работе со счётчиками ссылок — очень хорошо известная и часто эксплуатируемая проблема. В случаях, когда программа некорректно убавляет количество ссылок, это может спровоцировать классический примитив UAF (использование после высвобождения). Случаи, когда программа некорректно не снижает счёт ссылок, тогда как его следовало снизить (происходит утечка ссылки), также часто поддаются эксплойту. Если злоумышленнику удастся добиться многократного неверного увеличения счётчика ссылок, то возможно, что, постаравшись, можно вызвать переполнение такого счётчика. В результате программа утратит даже отдалённое представление о том, сколько именно ссылок указывает на данный объект. В таком случае злоумышленник сможет уничтожить объект, если после переполнения увеличит значение счётчика ссылок, а затем снова сбросит счётчик до нуля, оставляя при этом действующие ссылки на связанную с объектом память. Перед такими переполнениями особенно уязвимы 32-разрядные счётчики ссылок. Правда, важно отметить, что при каждом увеличении числа ссылок физическая память не выделяется или почти не выделяется. Выделить даже единственный байт — операция очень затратная, если её приходится выполнить 2^32 раза.
❯ Пример бага с разыменованием нуля
Когда из-за oops в ядре задача бесцеремонно завершается, продолжают удерживаться все значения счётчиков ссылок, которые в ней использовались. При этом после выхода из задачи уже может быть высвобождена вся память, которая была с ней ассоциирована. Рассмотрим пример: совершенно случайный баг, который мне недавно довелось обнаружить.
static int show_smaps_rollup(struct seq_file *m, void *v)
{
struct proc_maps_private *priv = m->private;
struct mem_size_stats mss;
struct mm_struct *mm;
struct vm_area_struct *vma;
unsigned long last_vma_end = 0;
int ret = 0;
priv->task = get_proc_task(priv->inode); // взята ссылка на задачу
if (!priv->task)
return -ESRCH;
mm = priv->mm; //Когда нет vma, mm->mmap равно NULL
if (!mm || !mmget_not_zero(mm)) { // взята ссылка на mm
ret = -ESRCH;
goto out_put_task;
}
memset(&mss, 0, sizeof(mss));
ret = mmap_read_lock_killable(mm); //взята блокировка на чтение mmap
if (ret)
goto out_put_mm;
hold_task_mempolicy(priv);
for (vma = priv->mm->mmap; vma; vma = vma->vm_next) {
smap_gather_stats(vma, &mss);
last_vma_end = vma->vm_end;
}
show_vma_header_prefix(m, priv->mm->mmap->vm_start,last_vma_end, 0, 0, 0, 0); //здесь из-за разыменования mmap случается oops ядра
seq_pad(m, ' ');
seq_puts(m, "[rollup]\n");
__show_smap(m, &mss, true);
release_task_mempolicy(priv);
mmap_read_unlock(mm);
out_put_mm:
mmput(mm);
out_put_task:
put_task_struct(priv->task);
priv->task = NULL;
return ret;
}
Этот файл предназначен для того, чтобы просто вывести подборку статистики об использовании заданного процесса. Тем не менее, этот багрепорт выявляет классический и в остальном безобидный баг с разыменованием нуля, оказавшийся в данной функции. В случае с задачей, которая вообще не отображалась на VMA, член mm_struct mmap
этой задачи будет равен NULL. Следовательно, обращение к priv->mm->mmap->vm_start
вызывает разыменование нуля и сопутствующий данной ситуации oops ядра. Этот баг можно спровоцировать, просто прочитав /proc/[pid]/smaps_rollup
в задаче, не имеющей VMA (создать стабильную задачу такого рода можно при помощи ptrace
):
Этот oops ядра будет означать, что произойдут следующие события:
- В ассоциированном
struct file
произойдёт утечка счётчика ссылок, еслиfdget
произвёл подсчёт ссылок (мы попытаемся обеспечить, чтобы в дальнейшем такого не происходило, и убедимся в этом). - У ассоциированного
seq_file
внутриstruct file
есть мьютекс, который окажется в вечной блокировке (любые последующие операции read/write/lseek и т.д. навсегда зависнут). - В структуре
task struct
, ассоциированной с файломsmaps_rollup
, произойдёт утечка подсчёта ссылок. - Произойдёт утечка подсчёта ссылок в связанном с задачей
mm_struct
множествеmm_users
. - Блокировка mmap, связанная с
mm_struct
, окажется навсегда зафиксирована (любые последующие попытки получить блокировку для записи навсегда зависнут).
Каждая из этих ситуаций сопряжена с нечаянно возникающими побочными эффектами, приводящими к возникновению багов в поведении, но не всеми этими багами злоумышленник может воспользоваться. Перманентные блокировки, возникающие в случаях 2 и 5, только осложняют эксплойт. Ситуация 1 не допускает эксплойта, поскольку мы не можем повторно организовать утечку структуры и количества ссылок, если не возьмём такой мьютекс, который никогда не будет разблокирован. Условие 3 также не поддаётся эксплойту, так как структура задачи использует безопасный вариант refcount_t
, не допускающий переполнения ядра. Итак, остаётся условие 4.
В счётчике ссылок mm_users
всё равно используется подверженный переполнению atomic_t
, а поскольку мы неопределённое количество раз берём блокировку на чтение, сопутствующий mmap_read_lock
не мешает нам вновь увеличить значение счётчика ссылок. Здесь нам придётся обойти пару важных препятствий, чтобы наладить многократную утечку из этого счётчика:
- Невозможно инициировать этот системный вызов из задачи, список vma в которой пуст — иными словами, нельзя вызвать
read
из/proc/self/smaps_rollup
. Такой процесс не позволяет запросто выполнять многократные системные вызовы, поскольку не располагает отображённой виртуальной памятью. Эту преграду мы обходим, считываяsmaps_rollup
из другого процесса. - Нам придётся каждый раз повторно открывать файл
smaps_rollup
, так как любые последующие операции считывания, которые мы будем производить с экземпляромsmaps_rollup
, в котором мы уже спровоцировали oops, намертво запрут блокировку мьютекса на локальномseq_file
, и эта блокировка продлится вечно. Также нам потребуется разрушить получающийся в результатеstruct file
(методом close) уже после того, как сгенерируем oops, чтобы предотвратить недопустимое использование памяти. - Если мы будем всякий раз обращаться к mm через один и тот же идентификатор процесса, то затронем счётчик ссылок max, работающий с
task struct
, прежде, чем вызовем переполнение счётчика ссылокmm_users
. Следовательно, нам потребуется создать две отдельные задачи, совместно использующие один и тот же mm, и сбалансировать те oops-ы, которые генерируем в пределах обеих задач. Так мы обеспечим, что относящиеся к задачам счётчики ссылок будут расти вполовину медленнее, чем счётчик ссылокmm_users
. Это делается при помощи флагаCLONE_VM
дляclone
. - Постараемся не открывать и не читать файл
smaps_rollup
из такой задачи, которая пользуется при работе разделяемой таблицей дескрипторов, так как в противном случае счётчик ссылок утечёт в самstruct file
. Это несложно сделать: всего лишь ничего не считывайте из файла многопоточным процессом.
Подытожим, какова будет наша окончательная стратегия по организации утечки путём переполнения счётчика ссылок:
- Процесс A порождает процесс B.
- Процесс B выполняет
PTRACE_TRACEME
, так что, когда он закончится ошибкойsegfault
при возврате изmunmap
, он никуда не денется (а войдёт в состояние, в котором остановлена трассировка). - Процесс B при помощи
CLONE_VM | CLONE_PTRACE
клонирует ещё один процесс, C. - Процесс B применяет
munmap
ко всему адресному пространству своей виртуальной памяти — при этом также прекращая отображение адресного пространства процесса C в виртуальной памяти. - Процесс A порождает новые дочерние процессы D и E, которые будут обращаться к
smaps_rollup
файлов B|C соответственно. - (D|E) открывает файл
smaps_rollup
(B|C) и выполняет считывание, в результате которого происходит oops, и из-за этого (D|E) умирают. Соответственно, по разу на каждыйopps
у счётчика ссылокmm_users
будет происходить приращение, сопровождающееся утечкой. - Процесс A возвращается к шагу 5 и повторяет последующие шаги, и так ~2^32 раз.
Вышеописанную стратегию можно переориентировать на параллельное выполнение (на уровне процессов, а не потоков, см. препятствие 4) и повысить производительность. В таких серверных конфигурациях, где записи логов ядра выводятся в консоль последовательного ввода, на генерацию 2^32 oops-ов ядра требуется более 2 лет. Правда, на машине с тривиальным Kali Linux, работающим через графический интерфейс, демонстрационное доказательство работоспособности выполняется всего примерно за 8 дней! Когда выполнение завершится, счётчик ссылок mm_users
уже должен успеть переполниться и установиться в ноль, даже притом, что этот mm
сейчас используется множеством процессов, и на него по-прежнему может быть направлена ссылка из файловой системы proc
.
❯ Эксплойт
Как только счётчик ссылок mm_users
установлен в ноль, спровоцировать неопределённое поведение и повреждение памяти должно быть совсем просто. Инициировав mmget
и mmput
(это очень легко сделать, ещё раз открыв файл smaps_rollup
), мы должны быть в состоянии высвободить весь mm, что приведёт к ситуации UAF (использование после высвобождения):
static inline void __mmput(struct mm_struct *mm)
{
VM_BUG_ON(atomic_read(&mm->mm_users));
uprobe_clear_state(mm);
exit_aio(mm);
ksm_exit(mm);
khugepaged_exit(mm);
exit_mmap(mm);
mm_put_huge_zero_page(mm);
set_mm_exe_file(mm, NULL);
if (!list_empty(&mm->mmlist)) {
spin_lock(&mmlist_lock);
list_del(&mm->mmlist);
spin_unlock(&mmlist_lock);
}
if (mm->binfmt)
module_put(mm->binfmt->module);
lru_gen_del_mm(mm);
mmdrop(mm);
}
К сожалению, поскольку 64591e8605 ("mm: protect free_pgtables with mmap_lock write lock in exit_mmap”)
, функция exit_mmap
безусловно принимает блокировку mmap
в режиме записи. Поскольку этот mmap_lock
, относящийся к mm
, многократно перманентн заблокирован на чтение, любые вызовы __mmput
проявятся как перманентная взаимная блокировка внутри exit_mmap
.
Правда, прежде, чем этот вызов окажется в перманентной мёртвой блокировке, он успеет вызвать несколько других функций:
- uprobe_clear_state
- exit_aio
- ksm_exit
- khugepaged_exit
Кроме того, мы можем вызвать __mmput
в этом mm одновременно из нескольких задач. При этом мы заставим каждую из них инициировать mmget/mmput
в mm
, что сгенерирует нерегулярные условия гонки. При нормальных условиях выполнения программы не должно существовать возможности одновременно запустить множественные __mmput
в одном и том же mm
(гораздо менее конкурентные), поскольку __mmput
должен вызываться только в последней и единственной операции понижения счётчика ссылок, в ходе которой этот счётчик становится равен нулю. Однако после переполнения счётчика ссылок все mmget/mmput в mm, на который по-прежнему стоят ссылки, инициируют __mmput
. Всё потому, что каждый mmput
, понижающий счётчик ссылок до нуля, считает, что именно он единственный отвечает за высвобождение соответствующего mm (несмотря на то, что причиной обнуления показанного выше счётчика ссылок был соответствующий mmget
).
Этот примитив __mmput
, склонный к гонкам, распространяет такое поведение и на всех, кого вызывает. Воспользоваться этой уязвимостью было бы удобно в exit_aio
, чтобы получить:
void exit_aio(struct mm_struct *mm)
{
struct kioctx_table *table = rcu_dereference_raw(mm->ioctx_table);
struct ctx_rq_wait wait;
int i, skipped;
if (!table)
return;
atomic_set(&wait.count, table->nr);
init_completion(&wait.comp);
skipped = 0;
for (i = 0; i < table->nr; ++i) {
struct kioctx *ctx =
rcu_dereference_protected(table->table[i], true);
if (!ctx) {
skipped++;
continue;
}
ctx->mmap_size = 0;
kill_ioctx(mm, ctx, &wait);
}
if (!atomic_sub_and_test(skipped, &wait.count)) {
/* Дождаться, пока в этом контексте будут выполнены все операции ввода/вывода. */
wait_for_completion(&wait.comp);
}
RCU_INIT_POINTER(mm->ioctx_table, NULL);
kfree(table);
}
Притом, что вызываемая функция kill_ioctx
пишется именно таким образом как раз, чтобы не допустить повреждения памяти из-за конкурентного выполнения (контракт aio, в частности, позволяет вызывать kill_ioctx
конкурентным образом), exit_aio
сам по себе таких гарантий не даёт. Два конкурентных вызова exit_aio
в одной и той же структуре mm struct
могут впоследствии привести к двойному высвобождению объекта mm->ioctx_table
, который выхватывается в самом начале функции, а освобождается только в самом конце. Это окно для возникновения гонки можно существенно расширить, создав много контекстов aio с целью замедлить высвобождающий цикл, работающий во внутреннем контексте exit_aio
. Успешный эксплойт приведёт к следующему багу ядра, указывающему, что произошло двойное высвобождение:
Обратите внимание: при выходе на путь exit_aio
из __mmput
спровоцированная гонка приведёт к вечной взаимной блокировке как минимум двух процессов, когда впоследствии эти процессы попытаются взять блокировку на запись mmap
. Правда, с точки зрения эксплойта это не имеет значения, так как ранее, до взаимной блокировки, уже случился примитив повреждения памяти. При эксплойте результирующего примитива, вероятно, будем иметь дело с гонкой за возвращение высвобожденной памяти между двумя актами высвобождения объекта mm->ioctx_table
. В таком случае нам на руку возникающее при возвращении высвобожденной памяти условие UAF. Несомненно, такое осуществимо, хотя я и не буду здесь развёрнуто это доказывать.
❯ Заключение
Притом, что сам баг с разыменованием нуля был исправлен в октябре 2022, более важной доработкой было лимитирование предельного количества oops — теперь, если произойдёт слишком много oops, возникает паника ядра. Притом, что этот патч уже добавлен в главную ветку, важно отметить, что распределённые ядра также должны наследовать этот лимит oops и привносить его обратно в версии, рассчитанные на долговременную поддержку — если мы не хотим, чтобы в будущем такие баги с разыменованием нуля превратились в полноценную угрозу безопасности. Даже в самом оптимистичном сценарии исследователям-безопасникам было бы крайне полезно тщательно изучить побочные эффекты других багов, которые будут выявляться и на первый взгляд казаться безобидными. Нужно удостовериться, что резкий останов при выполнении кода ядра, вызванный возникающими в ядре oops, не приведёт к формированию других существенных опасных примитивов.