image

Уже достаточно давно баги, связанные с неправильным разыменованием нуля, были излюбленной целью эксплойта при попытках вторжения в ядро. Ещё в те времена, когда ядро могло без ограничений обращаться к пользовательскому пространству в памяти, а программы из пользовательского пространства могли отображаться на нулевую страницу, имелось множество лёгких способов эксплуатировать такие баги. Правда, со временем стали применяться современные техники предотвращения эксплойтов, в частности, 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):

image

Этот oops ядра будет означать, что произойдут следующие события:

  1. В ассоциированном struct file произойдёт утечка счётчика ссылок, если fdget произвёл подсчёт ссылок (мы попытаемся обеспечить, чтобы в дальнейшем такого не происходило, и убедимся в этом).
  2. У ассоциированного seq_file внутри struct file есть мьютекс, который окажется в вечной блокировке (любые последующие операции read/write/lseek и т.д. навсегда зависнут).
  3. В структуре task struct, ассоциированной с файлом smaps_rollup, произойдёт утечка подсчёта ссылок.
  4. Произойдёт утечка подсчёта ссылок в связанном с задачей mm_struct множестве mm_users.
  5. Блокировка mmap, связанная с mm_struct, окажется навсегда зафиксирована (любые последующие попытки получить блокировку для записи навсегда зависнут).

Каждая из этих ситуаций сопряжена с нечаянно возникающими побочными эффектами, приводящими к возникновению багов в поведении, но не всеми этими багами злоумышленник может воспользоваться. Перманентные блокировки, возникающие в случаях 2 и 5, только осложняют эксплойт. Ситуация 1 не допускает эксплойта, поскольку мы не можем повторно организовать утечку структуры и количества ссылок, если не возьмём такой мьютекс, который никогда не будет разблокирован. Условие 3 также не поддаётся эксплойту, так как структура задачи использует безопасный вариант refcount_t, не допускающий переполнения ядра. Итак, остаётся условие 4.

В счётчике ссылок mm_users всё равно используется подверженный переполнению atomic_t, а поскольку мы неопределённое количество раз берём блокировку на чтение, сопутствующий mmap_read_lock не мешает нам вновь увеличить значение счётчика ссылок. Здесь нам придётся обойти пару важных препятствий, чтобы наладить многократную утечку из этого счётчика:

  1. Невозможно инициировать этот системный вызов из задачи, список vma в которой пуст – иными словами, нельзя вызвать read из /proc/self/smaps_rollup. Такой процесс не позволяет запросто выполнять многократные системные вызовы, поскольку не располагает отображённой виртуальной памятью. Эту преграду мы обходим, считывая smaps_rollup из другого процесса.
  2. Нам придётся каждый раз повторно открывать файл smaps_rollup, так как любые последующие операции считывания, которые мы будем производить с экземпляром smaps_rollup, в котором мы уже спровоцировали oops, намертво запрут блокировку мьютекса на локальном seq_file, и эта блокировка продлится вечно. Также нам потребуется разрушить получающийся в результате struct file (методом close) уже после того, как сгенерируем oops, чтобы предотвратить недопустимое использование памяти.
  3. Если мы будем всякий раз обращаться к mm через один и тот же идентификатор процесса, то затронем счётчик ссылок max, работающий с task struct, прежде, чем вызовем переполнение счётчика ссылок mm_users. Следовательно, нам потребуется создать две отдельные задачи, совместно использующие один и тот же mm, и сбалансировать те oops-ы, которые генерируем в пределах обеих задач. Так мы обеспечим, что относящиеся к задачам счётчики ссылок будут расти вполовину медленнее, чем счётчик ссылок mm_users. Это делается при помощи флага CLONE_VM для clone.
  4. Постараемся не открывать и не читать файл smaps_rollup из такой задачи, которая пользуется при работе разделяемой таблицей дескрипторов, так как в противном случае счётчик ссылок утечёт в сам struct file. Это несложно сделать: всего лишь ничего не считывайте из файла многопоточным процессом.

Подытожим, какова будет наша окончательная стратегия по организации утечки путём переполнения счётчика ссылок:

  1. Процесс A порождает процесс B.
  2. Процесс B выполняет PTRACE_TRACEME, так что, когда он закончится ошибкой segfault при возврате из munmap, он никуда не денется (а войдёт в состояние, в котором остановлена трассировка).
  3. Процесс B при помощи CLONE_VM | CLONE_PTRACE клонирует ещё один процесс, C.
  4. Процесс B применяет munmap ко всему адресному пространству своей виртуальной памяти – при этом также прекращая отображение адресного пространства процесса C в виртуальной памяти.
  5. Процесс A порождает новые дочерние процессы D и E, которые будут обращаться к smaps_rollup файлов B|C соответственно.
  6. (D|E) открывает файл smaps_rollup (B|C) и выполняет считывание, в результате которого происходит oops, и из-за этого (D|E) умирают. Соответственно, по разу на каждый opps у счётчика ссылок mm_users будет происходить приращение, сопровождающееся утечкой.
  7. Процесс 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.

Правда, прежде, чем этот вызов окажется в перманентной мёртвой блокировке, он успеет вызвать несколько других функций:

  1. uprobe_clear_state
  2. exit_aio
  3. ksm_exit
  4. khugepaged_exit

Кроме того, мы можем вызвать __mmput в этом mm одновременно из нескольких задач. При этом мы заставим каждую из них инициировать mmget/mmput в mm, что сгенерирует нерегулярные условия гонки. При нормальных условиях выполнения программы не должно существовать возможности одновременно запустить множественные __mmput в одном и том же mm (гораздо менее конкурентные), поскольку __mmput должен вызываться только в последней и единственной операции понижения счётчика ссылок, в ходе которой этот счётчик становится равен нулю. Однако после переполнения счётчика ссылок все mmget/mmput в mm, на который по-прежнему стоят ссылки, инициируют __mmput. Всё потому, что каждый mmput, понижающий счётчик ссылок до нуля, считает, что именно он единственный отвечает за высвобождение соответствующего mm (несмотря на то, что причиной обнуления показанного выше счётчика ссылок был соответствующий mmget).

image

Этот примитив __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. Успешный эксплойт приведёт к следующему багу ядра, указывающему, что произошло двойное высвобождение:

image

Обратите внимание: при выходе на путь exit_aio из __mmput спровоцированная гонка приведёт к вечной взаимной блокировке как минимум двух процессов, когда впоследствии эти процессы попытаются взять блокировку на запись mmap. Правда, с точки зрения эксплойта это не имеет значения, так как ранее, до взаимной блокировки, уже случился примитив повреждения памяти. При эксплойте результирующего примитива, вероятно, будем иметь дело с гонкой за возвращение высвобожденной памяти между двумя актами высвобождения объекта mm->ioctx_table. В таком случае нам на руку возникающее при возвращении высвобожденной памяти условие UAF. Несомненно, такое осуществимо, хотя я и не буду здесь развёрнуто это доказывать.

Заключение


Притом, что сам баг с разыменованием нуля был исправлен в октябре 2022, более важной доработкой было лимитирование предельного количества oops – теперь, если произойдёт слишком много oops, возникает паника ядра. Притом, что этот патч уже добавлен в главную ветку, важно отметить, что распределённые ядра также должны наследовать этот лимит oops и привносить его обратно в версии, рассчитанные на долговременную поддержку – если мы не хотим, чтобы в будущем такие баги с разыменованием нуля превратились в полноценную угрозу безопасности. Даже в самом оптимистичном сценарии исследователям-безопасникам было бы крайне полезно тщательно изучить побочные эффекты других багов, которые будут выявляться и на первый взгляд казаться безобидными. Нужно удостовериться, что резкий останов при выполнении кода ядра, вызванный возникающими в ядре oops, не приведёт к формированию других существенных опасных примитивов.

Комментарии (2)


  1. screwer
    07.06.2023 15:02
    +3

    Перевод отвратительного качества


    1. Firsto
      07.06.2023 15:02
      +3

      Разыменование нуля ведёт к панике ядра. ¯\_(ツ)_/¯