
Использование после освобождения в Netfilter nf_tables при обработке пакетных запросов CVE-2023-32233

Затронутый код происходит из официального ядра Linux с https://kernel.org/ и является частью компонента Netfilter nf_tables (net/netfilter/nf_tables_api.c).
Netfilter nf_tables позволяет обновлять свою конфигурацию как атомарную операцию. При использовании этой функции клиенты пользовательского режима отправляют пакетные запросы, содержащие список базовых операций. Netfilter nf_tables затем обрабатывает все операции в рамках пакета как единую транзакцию. При обработке пакета Netfilter nf_tables проверяет обновления состояния конфигурации, чтобы убедиться, что каждая последующая базовая операция допустима, и это также учитывает обновления состояния от всех предыдущих операций в рамках пакета. Однако текущая реализация проверки является недостаточной.
В нашем конкретном сценарии мы начинаем с конфигурации Netfilter nf_tables,
которая содержит nft_rule с выражением lookup на анонимном nft_set, и где
анонимный nft_set содержит некоторые элементы. Затем мы отправляем пакетный запрос,
содержащий следующие две базовые операции:
NFT_MSG_DELRULE для удаления nft_rule.lookup и
анонимный nft_set.NFT_MSG_DELSETELEM для удаления любого из элементов
удалённого анонимного nft_set.Текущая версия Netfilter nf_tables принимает указанный выше пакетный запрос.
Затем она вызывает nf_tables_commit_release(), которая добавляет освобождённые ресурсы в
nf_tables_destroy_list. nf_tables_destroy_list затем обрабатывается
nf_tables_trans_destroy_work(), которая сначала освобождает ресурсы, связанные с
операцией NFT_MSG_DELRULE, вызывая:
nft_commit_release()
nf_tables_rule_destroy()
nf_tables_expr_destroy()
expr->ops->destroy() что указывает на nft_lookup_destroy()
nf_tables_destroy_set()
nft_set_destroy()
kvfree() которая освобождает память, используемую `nft_set`
перед обработкой операции NFT_MSG_DELSETELEM, где ссылка на
освобождённый nft_set получается через nft_trans_elem_set() во время
следующих вызовов:
nft_commit_release()
nf_tables_set_elem_destroy()
nft_set_elem_ext()
В nft_set_elem_ext() выше, местоположение памяти освобождённого
nft_set используется для определения местоположения nft_set_ext:
static inline struct nft_set_ext *nft_set_elem_ext(const struct nft_set *set,
void *elem)
{
return elem + set->ops->elemsize;
}
для последующих операций. Поэтому, когда значение set->ops->elemsize
оказывается повреждённым, некоторое непредвиденное место в памяти может быть интерпретировано как
список nft_expr, подлежащих уничтожению:
static void nf_tables_set_elem_destroy(const struct nft_ctx *ctx,
const struct nft_set *set, void *elem)
{
struct nft_set_ext *ext = nft_set_elem_ext(set, elem);
if (nft_set_ext_exists(ext, NFT_SET_EXT_EXPRESSIONS))
nft_set_elem_expr_destroy(ctx, nft_set_ext_expr(ext));
Эксплуатация вышеуказанной уязвимости требует выигрыша состояния гонки с nf_tables_trans_destroy_work(), которая выполняется из фонового рабочего потока ядра Linux. Это, по-видимому, усложняет практическую эксплуатацию даже до рассмотрения существующих средств смягчения, таких как укрепление аллокатора slab ядра, рандомизация адресного пространства ядра (KASLR) и, особенно, целостность потока управления (Control-Flow Integrity). Однако приложенный PoC доказывает, что всё ещё возможно добиться достаточно надёжной эксплуатации на практике.
Чтобы использовать уязвимость, нам нужно изменить содержимое памяти
из nft_set после его освобождения в nf_tables_rule_destroy(), но
до того, как он будет использован в nf_tables_set_elem_destroy(). Оба вызова
nf_tables_rule_destroy() и nf_tables_set_elem_destroy() выполняются в рамках
одного вызова nf_tables_trans_destroy_work(), который выполняется из
фонового рабочего потока ядра Linux. Кроме того, освобождённый
фрагмент памяти обычно доступен для повторного использования только на том же ядре CPU.
При гонке с nf_tables_trans_destroy_work() мы увеличиваем свои шансы,
добавляя контролируемую задержку для фонового рабочего потока между вызовами
nf_tables_rule_destroy() и nf_tables_set_elem_destroy(). Для этого мы вставляем
дополнительную операцию по уничтожению другого nft_set, содержащего большое
количество элементов. Кроме того, мы держим все остальные ядра CPU занятыми, так
что фоновый рабочий поток, скорее всего, будет запланирован на определённом ядре
CPU, и мы можем попытаться выделить новую структуру на том же ядре CPU
сразу после того, как он освободит nft_set в nf_tables_rule_destroy(). Наша цель —
выделить новый nft_set другого типа, чтобы повторно использовать местоположение памяти
nft_set, освобождённого в nf_tables_rule_destroy().
Новый тип nft_set выбирается так, чтобы использовать другое значение для
set->ops->elemsize. Поэтому, когда фоновый рабочий поток наконец вызывает
nf_tables_set_elem_destroy() для обработки операции NFT_MSG_DELSETELEM, он
интерпретирует свой аргумент elem неправильно, так что
повреждённый nft_set_ext *ext оказывается на несколько байт после правильного местоположения. Это означает, что
определённое пользователем поле данных исходного nft_set_ext теперь интерпретируется
как заголовки, что приводит к путанице типов.
Один из способов злоупотребить этой путаницей типов — подделать
заголовки повреждённого nft_set_ext со значениями смещений таким образом, чтобы
nf_tables_set_elem_destroy() интерпретировала содержимое любых смежных блоков памяти
как список nft_expr для уничтожения через следующие вызовы:
nft_set_elem_expr_destroy()
__nft_set_elem_expr_destroy()
nf_tables_expr_destroy()
expr->ops->destroy()
На этом этапе эксплуатации у нас ещё нет деталей макета памяти
ядра. Поэтому невозможно сформировать абсолютные адреса указателей.
Однако при создании повреждённых заголовков nft_set_ext мы всё ещё можем использовать
смещения вне диапазона, так что expr->ops->destroy() вызывается на определённых
валидных nft_expr в смежных блоках памяти.
Для этого мы распыляем выражения nft_log с контролируемым NFTA_LOG_PREFIX.
Затем nft_log->prefix освобождается функцией nft_log_destroy(), когда
expr->ops->destroy() вызывается:
static void nft_log_destroy(const struct nft_ctx *ctx,
const struct nft_expr *expr)
{
struct nft_log *priv = nft_expr_priv(expr);
struct nf_loginfo *li = &priv->loginfo;
if (priv->prefix != nft_log_null_prefix)
kfree(priv->prefix);
Обратите внимание, что мы всё ещё можем получить доступ к этой памяти и даже снова освободить её
через другую ссылку из распылённого выражения nft_log.
Кроме того, мы можем контролировать размер nft_log->prefix, так что он
может быть выделен из любого из slab-кэшей kmalloc-{8, ..., 192}. Наконец,
упомянутая память интерпретируется ядром как строка символов, поэтому
нет необходимости беспокоиться о повреждениях при наложении разных объектов поверх неё.
По сути, это конец игры.
Одно неудобство: любые нулевые символы завершают nft_log->prefix, поэтому
мы не можем читать дальше нулевых байтов при утечке содержимого памяти. Это решается
на следующем шаге, где мы выделяем nft_object->udata для повторного использования
фрагмента памяти nft_log->prefix и уничтожаем выражение nft_log. Это
освобождает память nft_object->udata, но теперь мы всё ещё можем использовать
висящий указатель nft_object->udata для утечки содержимого памяти без
ограничений на нулевые байты.
В поисках подходящих структур для следующих шагов мы остановились на
nft_expr, выделяемом из nft_dynset_new(). Они находятся в тех же slab-кэшах, что и
nft_log->prefix и nft_object->udata. Кроме того, мы имеем разумный
контроль над размером выделения, так что позже мы могли бы легко переключаться
между slab-кэшами разного размера, если потребуется.
Чтобы использовать эти структуры, мы создаём фильтр пакетов с выражением nft_dynset.
И когда мы отправляем любые пакеты через интерфейс loopback,
выражение nft_dynset вызывает nft_dynset_new() для создания новых элементов для
связанного nft_set. Созданные элементы являются stateful-выражениями следующих
типов:
nft_counter для получения местоположения модуля nf_tables.ko в памяти ядра.
Структура включает указатель на nft_counter_ops в модуле ядра
nf_tables.ko. Мы раскрываем этот указатель, читая nft_object->udata.
nft_quota для произвольного чтения и записи памяти.
Мы можем многократно освобождать и перераспределять nft_object->udata, чтобы изменить
указатель nft_quota->consumed. Затем мы выполняем операцию NFT_MSG_GETSETELEM,
которая вызывает nft_quota_do_dump() для чтения содержимого указанной
памяти и передаёт результат в атрибуте NFTA_QUOTA_CONSUMED
в ответе. Что касается записи, мы просто отправляем пакеты через
интерфейс loopback, где nft_quota_do_eval() вызывает:
static inline bool nft_overquota(struct nft_quota *priv,
const struct sk_buff *skb)
{
return atomic64_add_return(skb->len, priv->consumed) >=
Мы используем вышеуказанное произвольное чтение памяти, чтобы получить базовый адрес ядра. Затем мы приступаем к изменению подстроки "sbin" в пути "/sbin/modprobe", чтобы она была заменена на "/tmp". Полученный путь "//tmp/modprobe" затем используется ядром для запуска процесса с правами root, где мы контролируем содержимое файла.
Обратите внимание, что мы не предпринимали намеренных усилий для обхода целостности потока управления (Control-Flow Integrity). Однако для каждого из шагов эксплуатации мы сознательно выбрали наиболее гибкие и наиболее надёжные примитивы. Оказалось, что наш выбор каким-то образом избежал всех примитивов, которые потенциально могли бы быть заблокированы целостностью потока управления. Теперь нам интересно подтвердить тестированием, что полученный эксплойт действительно работает против систем с защитой целостности потока управления.
для изменения nft_quota->consumed.