Недавно я опробовал различные методы эксплуатации уязвимости ядра Linux CVE-2022-3910. После успешного написания эксплойта, который использовал DirtyCred для повышения локальных привилегий. Я задумался, можно ли изменить мой код, чтобы облегчить выход из контейнера, вместо этого перезаписав /proc/sys/kernel/modprobe. В этой статье рассмотрим как выйти из контейнера с использованием DirtyCred.
Введение в соответствующие компоненты io_uring
Давайте сосредоточимся на компонентах, соответствующих этой конкретной уязвимости.
Фиксированные файлы можно зарегистрировать, передав массив дескрипторов файлов в функцию io_uring_register(). Альтернативно, io_uring может быть проинструктирован зарегистрировать его напрямую (без необходимости предварительного захвата дескриптора файла программой пользовательского пространства с помощью какого-либо другого системного вызова), используя различные функции, такие как io_uring_prep_openat_direct(). Впоследствии на них можно будет ссылаться в будущих SQE, установив флаг IOSQE_FIXED_FILE и передав индекс фиксированного файла в массиве зарегистрированных файлов вместо фактического дескриптора файла.
Исправленные файлы
Фиксированные файлы или прямые дескрипторы можно рассматривать как файловые дескрипторы, специфичные для io_uring. io_uring поддерживает ссылку на любые зарегистрированные файлы, чтобы уменьшить дополнительные накладные расходы, связанные с разрешением файловых дескрипторов для каждой операции с ними; эта ссылка освобождается только тогда, когда исправленные файлы не зарегистрированы или экземпляр io_uring удаляется.
Фиксированные файлы можно зарегистрировать, передав массив дескрипторов файлов в функцию io_uring_register(). Альтернативно, io_uring может быть проинструктирован зарегистрировать его напрямую (без необходимости предварительного захвата дескриптора файла программой пользовательского пространства с помощью какого-либо другого системного вызова), используя различные функции, такие как io_uring_prep_openat_direct(). Впоследствии на них можно будет ссылаться в будущих SQE, установив флаг IOSQE_FIXED_FILE и передав индекс фиксированного файла в массиве зарегистрированных файлов вместо фактического дескриптора файла.
Звонковые сообщения
io_uring поддерживает передачу сообщений между кольцами с помощью io_uring_prep_msg_ring(). Более конкретно, согласно странице руководства, эта операция создает CQE в целевом кольце с параметрами res и user_data, установленными в заданные пользователем значения.
Как отмечалось здесь, эту функцию можно использовать для пробуждения спящих задач, ожидающих звонка, или просто для передачи произвольной информации.
Уязвимость
CVE-2022-3910 — это неправильное обновление счетчика ссылок в функции io_msg_ring(). Исходный файл находится здесь, но соответствующий фрагмент кода показан ниже:
int io_msg_ring(struct io_kiocb *req, unsigned int issue_flags)
{
struct io_msg *msg = io_kiocb_to_cmd(req, struct io_msg);
int ret;
ret = -EBADFD;
if (!io_is_uring_fops(req->file))
goto done;
switch (msg->cmd) {
case IORING_MSG_DATA:
ret = io_msg_ring_data(req);
break; case IORING_MSG_SEND_FD: ret = io_msg_send_fd(req, issue_flags); break;
default:
ret = -EINVAL;
break;
}
done:
if (ret < 0)
req_set_fail(req);
io_req_set_res(req, ret, 0);
/* put file to avoid an attempt to IOPOLL the req */
io_put_file(req->file); req->file = NULL
; return IOU_OK;
}
Подсказку об уязвимости можно найти в сообщении о фиксации патча:
Обычно функция передачи сообщений io_uring ожидает файловый дескриптор, соответствующий другому экземпляру io_uring. Если мы передаем ссылку на что-либо еще, она просто удаляется при вызове io_put_file() и возвращается ошибка.
Если мы передаем фиксированный файл, io_put_file() все равно вызывается. Но такое поведение на самом деле неверно! Мы не получили дополнительную ссылку на файл, поэтому нам не нужно было уменьшать счетчик ссылок.
Непосредственные последствия
io_put_file() — это просто оболочка fput(). Вы можете найти его исходный код здесь, но достаточно следующего понимания:
void fput(struct file *file)
{
if (atomic_long_dec_and_test(&file->f_count)) {
// free the file struct
}
}
Другими словами, многократно активируя уязвимость до тех пор, пока счетчик ссылок не упадет до 0, мы можем освободить связанную файловую структуру, в то время как io_uring продолжает удерживать ссылку на нее. Это представляет собой использование после освобождения.
Вот код, показывающий, как мы можем это сделать:
struct io_uring r;
io_uring_queue_init(8, &r, 0);
int target = open(TARGET_PATH, O_RDWR | O_CREAT | O_TRUNC, 0644);
// Register target file as fixed file.
if (io_uring_register_files(&r, &target, 1) < 0) {
perror("[-] io_uring_register_files");
}
struct io_uring_sqe * sqe;
// Refcount is currently 2
// (Check by by setting a breakpoint in io_msg_ring())
for (int i=0; i<2; i++) {
sqe = io_uring_get_sqe(&r); io_uring
_prep_msg_ring(sqe, 0, 0, 0, 0);
sqe->flags |= IOSQE_FIXED_FILE;
io_uring_submit(&r);
io_uring_wait_cqe(&r, &cqe);
io_uring_cqe_seen(&r, cqe);
}
// Refcount should now be 0, file struct should be freed.
Моя первоначальная попытка взлома использовала несколько спреев кросс-кэша и в конечном итоге перезаписала деструктор структуры sk_buff (а не распределение данных sk_buff->data, поскольку его минимальный размер слишком велик), чтобы получить контроль над выполнением.
Этот «стандартный» эксплойт не является основной темой этой статьи, но если вам интересно, вы можете посмотреть код здесь. Мне не удалось найти в Интернете другую статью, в которой sk_buff использовался бы так же, как и я, поэтому я решил, что об этом стоит упомянуть вкратце.
DirtyCred
Попробуем написать другой код, используя вместо этого DirtyCred.
DirtyCred — это атака только на данные, нацеленная на структуры файлов и кредитов. Оригинальные слайды объясняют концепцию более четко, чем я, поэтому, если вы не знакомы с этой техникой, я предлагаю сначала прочитать это. Особое значение имеет раздел «Атака на учетные данные открытого файла», именно его мы и будем использовать.
Файловая структура
Как следует из названия, файловая структура представляет собой открытый файл и выделяется в кэше filp при каждом открытии файла. Каждая файловая структура отслеживает свой собственный счетчик ссылок, который можно изменить с помощью таких операций, как dup() и close(). Когда счетчик ссылок достигает нуля, структура освобождается.
Некоторые важные члены этой структуры показаны ниже:
struct file {
// ...
const struct file_operations * f_op; /* 40 8 */
// ...
atomic_long_t f_count; /* 56 8*/
// ...fmode_t f_mode; /* 68 4 */
// ...
/* size: 232, cachelines: 4, members: 20 */
} __attribute__((__aligned__(8)));
Давайте кратко рассмотрим каждый из них:
-
- f_op — это указатель на таблицу функций, которая определяет, какой обработчик вызывается при запросе операции над файлом. Например, это ext4_file_operations для всех файлов, находящихся в файловой системе ext4.
- f_count хранит счетчик ссылок для файла.
- f_mode хранит режим доступа к файлу. Сюда входят такие флаги, как разрешено ли нам читать или писать в него.
Примечание: когда мы открываем() один и тот же файл несколько раз, выделяется несколько файловых структур. Для сравнения, когда мы вызываем dup() для файлового дескриптора, счетчик ссылок существующей файловой структуры увеличивается, и новые выделения не выполняются.
Анализ кода
Теперь попробуем понять, как именно работает DirtyCred. Предположим, мы открыли файл A с режимом доступа O_RDWR и пытаемся в него записать. В конечном итоге это вызывает вызов vfs_write(), как показано ниже:
Предположим, что после завершения проверок разрешений, но до начала фактической записи, нам удалось освободить файловую структуру файла A и создать новую, соответствующую другому файлу B, который мы открыли с режимом доступа O_RDONLY. Режим доступа больше проверяться не будет, поэтому запись будет производиться в файл B, хотя нам этого делать нельзя.
Традиционный DirtyCred: таргетинг на файлы ext4
В типичном приложении DirtyCred файлы A и B находятся в файловой системе ext4. В этом сценарии запись в конечном итоге обрабатывается ext4_buffered_write_iter():
Чтобы избежать проблем, возникающих из-за одновременной записи нескольких задач в один и тот же файл, операция записи заключена в мьютекс. Другими словами, только один процесс может писать в файл в любой момент времени. Это позволяет нам стабилизировать эксплойт с помощью идеи, показанной на диаграмме ниже
Когда поток A выполняет медленную запись в файл A, он захватывает блокировку соответствующего индексного дескриптора. Это предотвращает попадание потока B в критическую область между [A] и [B]. Мы могли бы использовать этот период ожидания, чтобы заменить файловую структуру файла A на структуру файла B. Когда поток A снимает блокировку индексного дескриптора, поток B захватывает ее и продолжает запись не в тот файл.
Повышение местных привилегий
Нетрудно понять, как такой примитив позволит нам добиться локального повышения привилегий. Одной из возможностей было бы добавить нового пользователя с правами root, указав /etc/passwd. Но я применил другой подход, вместо этого нацеливаясь на /sbin/modprobe.
Когда мы пытаемся выполнить файл с неизвестным магическим заголовком, ядро вызывает двоичный файл, на который указывает глобальная переменная ядра modprobe_path, с привилегиями root и из корневого пространства имен. По умолчанию это /sbin/modprobe.
Поэтому я перезаписал /sbin/modprobe с помощью следующего сценария оболочки:
Когда я попытался выполнить файл с неверным магическим заголовком, ядро выполнило приведенный выше сценарий, создав копию setuid /bin/sh. Теперь у нас есть корневая оболочка.
Кроличья нора
Было мнение что данный подход не будет работать в контейнерной среде, поскольку /sbin/modprobe не будет доступен из пространства имен контейнера. Вместо этого, можем ли мы напрямую указать переменную modprobe_path через /proc/sys/kernel/modprobe.
Файловая система /proc и вы
/proc — это псевдофайловая система, которая «действует как интерфейс для внутренних структур данных ядра». В частности, подкаталог /proc/sys позволяет нам изменять значения различных параметров ядра, просто записывая их, как если бы они были файлом.
В качестве соответствующего примера, /proc/sys/kernel/modprobe напрямую связан с глобальной переменной ядра modprobe_path, и запись в этот «файл» соответственно изменит значение modprobe_path.
Важно: мы не можем ничего писать в /proc/sys/*, если мы не root. Но это не большая проблема, потому что мы можем просто использовать традиционный DirtyCred для локального повышения привилегий, предварительно нацеливаясь на /etc/passwd.
Должно быть ясно, что эти файловые операции требуют специальных функций-обработчиков. файловые структуры, связанные с /proc/sys/* «файлами», имеют f_op, установленный в proc_sys_file_operations.
Это создает проблему, поскольку использованный ранее метод блокировки индексного дескриптора основан на предположении, что ext4_buffered_write_iter() все еще может успешно записывать в целевой файл. На самом деле попытка сделать это с файлом /proc/sys/* приведет к неопределенному поведению, которое обычно приводит к возврату кода ошибки.
Вместо этого нам придется поменять файловые структуры до того, как будет разрешен вызов обработчика записи, а это означает, что у нас есть следующее окно гонки:
Это довольно мало. Можем ли мы улучшить наши шансы?
Новая цель: aio_write().
Подсистема AIO ядра (не путать с POSIX AIO) — это несколько устаревший асинхронный интерфейс ввода-вывода, который можно рассматривать как предшественник io_uring. Билли указал мне на функцию aio_write(), которая будет вызываться, если мы запросим системный вызов записи через интерфейс AIO ядра:
aio_setup_rw() копирует iovecs из пользовательской среды с помощью copy_from_user(). Более того, он находится в нашем окне гонки (после проверки разрешений, но до разрешения обработчика записи). Таким образом, если у нас есть доступ к userfaultfd или FUSE, мы можем постоянно выигрывать гонку, позволяя нам перенаправить операцию записи в /proc/sys/kernel/modprobe.
Но ждать. Зачем кому-то включать FUSE или обработку ошибок страницы ядра для userfaultfd внутри контейнера? Печальная правда заключается в том, что условия, необходимые для использования вышеупомянутого метода, слишком строги, чтобы его можно было использовать в обычном реальном сценарии эксплуатации.
Примечание: технически, даже если обработка ошибок страницы ядра userfaultfd отключена, мы все равно можем использовать ее, если у нас есть возможность CAP_SYS_PTRACE (фактическая проверка здесь). Однако в целом у нас вряд ли это будет даже в качестве корня контейнера.
Ошибка медленной страницы спешит на помощь
Давайте подумаем о роли, которую играют userfaultfd и FUSE в нашей технике эксплойта. Когда ядро обнаруживает ошибку страницы при попытке скопировать данные из пользовательской среды:
- userfaultfd заставляет неисправный поток ядра приостанавливаться до тех пор, пока мы не обработаем ошибку страницы из области пользователя.
- Наш собственный обработчик чтения FUSE вызывается, когда ядро пытается загрузить в память страницу с ошибкой.
В обоих случаях мы можем просто остановить поток ядра при вызове copy_from_user() до тех пор, пока не закончим другие задачи, например распыление кучи. Но возможно ли сделать так, чтобы ошибка страницы занимала так много времени, чтобы мы могли завершить распыление кучи за это время?
После того, как я провел несколько попыток, экспериментируя с различными идеями, которые не сработали слишком хорошо,было предложено адаптировать этот метод, чтобы значительно увеличить задержку, создаваемую ошибкой страницы.
shmem_fault() содержит полезный комментарий, объясняющий, почему это так:
/* * Trinity finds that probing a hole which tmpfs is punching can
* prevent the hole-punch from ever completing: which in turn
* locks writers out with its hold on i_rwsem. So refrain from
* faulting pages into the hole while it's being punched. Although
* shmem_undo_range() does remove the additions, it may be unable to
* keep up, as each new page needs its own unmap_mapping_range() call,
* and the i_mmap tree grows ever slower to scan if new vmas are added.
*
* It does not matter if we sometimes reach this check just before the
* hole-punch begins, so that one fault then races with the punch:
* we just need to make racing faults a rare case.
*
* The implementation below would be much simpler if we just used a
* standard mutex or completion: but we cannot take i_rwsem in fault,
* and bloating every shmem inode for this unlikely case would be sad.
*/
Собираем все это вместе
Вкратце, наш план атаки выглядит следующим образом:
- Откройте какой-нибудь случайный файл A с режимом доступа O_RDWR. Ядро выделит соответствующую файловую структуру.
- Используя уязвимость, многократно уменьшайте счетчик ссылок файловой структуры файла A до тех пор, пока он не достигнет опустошения. Это освобождает его, хотя таблица дескрипторов файлов все еще содержит ссылку на него.
Примечание: это необходимо, потому что fget() (который будет вызываться, когда мы позже отправим запрос AIO) приведет к остановке ядра, если он будет вызван для файловой структуры с refcount 0. Код-нарушитель находится здесь (проверьте расширение макроса get_file_rcu).
3. Создайте и получите файловый дескриптор для временного файла B с помощью memfd_create(). Выделите для него большой объем памяти с помощью Fallocate().
4. Подготовьте запрос AIO, используя буфер, расположенный за границей страницы. Вторая страница должна поддерживаться файлом B и еще не находиться в памяти.
5. (CPU 1, thread X): вызовите Fallocate() для файла B с режимом FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE.
6. (CPU 1, thread Y): отправьте запрос AIO. Это вызывает ошибку страницы для страницы, поддерживаемой файлом B. Пока выполняется дырокол, поток Y помещает себя в очередь ожидания, приостанавливая выполнение до тех пор, пока поток X не завершится.
7. (CPU 0, thread Z): пока поток Y остановлен, повторно вызывайте open() в /proc/sys/kernel/modprobe, чтобы заполнить кучу соответствующими файловыми структурами, перезаписывая файловую структуру файла A структурой /proc/sys/kernel/modprobe.
8. Поток Y возобновляет выполнение, и запись выполняется в /proc/sys/kernel/modprobe. Исходный код эксплойта можно найти здесь.
Тестирование на реальных контейнерах
Как только все это было сделано, я приступил к использованию эксплойта на некоторых тестовых контейнерах, которые я установил на уязвимом образе Ubuntu Kinetic. Это НЕ использовалось ядро версии v6.0-rc5, но в коде, имеющем отношение к эксплойту, изменений практически не было, так что это не должно быть проблемой.
Примечание. Точнее, я использовал этот образ, а затем вручную понизил версию ядра до уязвимой версии (ubuntu 5.19.0-21-generic).
Чтобы продемонстрировать успешный выход из контейнера, не усложняя ситуацию, я выбрал простую полезную нагрузку, которая создает файл в системе хоста (вне контейнера):
path=$(sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /proc/mounts)
echo "container escape" > /home/amarok/c
Стандартный Docker-контейнер
Команда: sudo docker run -it --rm ubuntu bash
Мой эксплойт не сработал против первой тестовой цели. Вместо этого я получил Permission denied.
Как оказалось, после вызова aio_setup_rw(), rw_verify_area() вызывает функцию-перехватчик безопасности. По умолчанию контейнеры Docker запускаются с ограниченным профилем AppArmor, поэтому дополнительная проверка разрешений в aa_file_perm() завершается неудачно, что приводит к возврату aio_write() без фактического выполнения операции записи.
Docker-контейнер с apparmor=unconfined
Команда: sudo docker run -it --rm --security-opt apparmor=unconfined ubuntu bash
Однако если контейнер Docker запускается с параметром apparmor=unconfined, aa_file_perm() завершает работу раньше, чем произойдет фактическая проверка разрешений, что позволяет нашему эксплойту пройти нормально.
Этот сценарий не очень полезен, поскольку маловероятно, что кто-то приложит все усилия, чтобы отключить AppArmor в развернутом контейнере Docker.
Стандартный контейнер
Команда: sudo ctr run -t --rm docker.io/library/ubuntu:latest bash
Если вместо этого мы запустим контейнер с помощью клиента командной строки ctr, который работает непосредственно поверх API контейнера, эксплойт также будет работать нормально. Мы можем использовать эту технику для выхода из готовых контейнеров-контейнеров. Это гораздо более реалистичный вариант использования этой техники.
Заключение
Обратите внимание, что такое использование контейнеров для запуска веб-серверов или других типов служб лучше всего подходит только для целей разработки или тестирования. Все из-за того, что сервисы активны только во время работы контейнера. Выход из контейнера останавливает все работающие службы или любые внесенные вами изменения.