Продолжаем разбираться в работе лайвпатчей для ядра Linux. В примере из первой части этой статьи мы загрузили лайвпатч и он каким-то магическим образом настроил все так, чтобы ядро Linux использовало не свою функцию nf_tproxy_laddr4()
, а ее исправленный вариант.
Давайте теперь посмотрим, что стоит за этой магией, а после этого разберемся, как все это использовать в продакшене.
В этой части статьи будет и несколько вопросов-заданий для читателя. Ответы и подсказки — в конце.
Ftrace и перенаправление функций
Значительную часть грязной работы при активации и деактивации лайвпатчей выполняет в ядре Linux подсистема Ftrace. На первый взгляд это может показаться странным: Ftrace — набор механизмов для сбора информации о ядре во время его работы. Какое отношение это имеет к лайвпатчам? Но Ftrace можно использовать и для других целей.
Статические (заданные в исходном коде ядра) трейспойнты позволяют не только собирать данные и выводить их в userspace. В Android, например, они используются и для vendor hooks, позволяя вендорскому модулю ядра влиять на то, какие проверки делаются при открытии файлов, как выбираются частоты CPU в подсистеме cpufreq, и на многое другое. Но для нашей темы более важен динамический Ftrace (CONFIG_DYNAMIC_FTRACE=y
). Его можно включить в работающей системе для любой функции, где это не запрещено явно атрибутами (notrace, noinstr) или параметрами компилятора.
Динамический Ftrace позволяет получать информацию об аргументах и возвращаемом значении функции, о времени ее работы и не только. Если ядро собрано с CONFIG_KPROBES_ON_FTRACE=y
, то этот же механизм используется, когда нужно поставить kernel probe (kprobe) на начало функции. Это нередко работает быстрее, чем обычная реализация kprobe с использованием breakpoints. eBPF также во многом полагается на Ftrace.
Но есть у динамического Ftrace и такая малоизвестная возможность, как перенаправление функций (function redirection). При этом используется API, описанный в документации по ядру Linux. Посмотрим, как все это работает на практике.
На x86-64 происходит следующее. Ядро Ubuntu 24.04 собрано с CONFIG_DYNAMIC_FTRACE=y
, и в начале функции nf_tproxy_laddr4()
при сборке автоматически зарезервировано 5 байт — достаточно, чтобы можно было вписать туда инструкцию call для вызова функции-переходника. В рантайме, пока Ftrace для nf_tproxy_laddr4()
не включен, она начинается так:

Если включить Ftrace для этой функции, то пятибайтный NOP заменяется на вызов функции-переходника:

Переходник может быть разным для разных функций. В данном случае он условно назван ftrace_regs_caller_for_nf_tproxy_laddr4()
. Ядро Linux может генерировать такие переходники прямо во время работы системы, используя как шаблон код ftrace_caller()
, ftrace_regs_caller()
и некоторых других функций. Их основные задачи — сохранить значения регистров, вызвать требуемый обработчик (Ftrace handler), восстановить значения регистров.
Когда требуется отключить динамический Ftrace для этой функции, Linux снова превращает вызов ftrace_regs_caller_for_nf_tproxy_laddr4()
в пятибайтный NOP.
Linux во время работы может вписать в код каждой функции-переходника, какой именно обработчик там нужно вызвать. Если же ядро собрано с CONFIG_DYNAMIC_FTRACE_WITH_CALL_OPS=y
(ARM, PowerPC), то функция-переходник может быть и общей для всех. Но при этом перед каждой функцией, где поддерживается Ftrace, будет храниться адрес структуры ftrace_ops
, откуда и будет взят адрес обработчика в данном случае.
Если ядро собрано с CONFIG_DYNAMIC_FTRACE_WITH_REGS=y
, функция-переходник сохраняет значения всех регистров. Если с CONFIG_DYNAMIC_FTRACE_WITH_ARGS=y
— сохраняются, как минимум, те регистры, в которых могут передаваться аргументы функций, а также stack pointer и регистр с адресом возврата, если есть. Для лайвпатчинга подойдет и то, и другое.
Важно, что Ftrace handler не просто получает структуру со значениями регистров (struct ftrace_regs
). Handler может менять и эти значения, и содержимое стека для интересующей нас функции. Что это дает?
Вот как обычно отрабатывает Ftrace: функция original_function()
вызывает функцию-переходник, та сохраняет регистры, вызывает Ftrace handler, восстанавливает регистры и возвращает управление по адресу, который хранится в заданном месте на стеке (x86) или в регистре (ARM, RISC-V и пр.)

Но кто мешает в Ftrace handler поменять этот адрес возврата? Правильно, никто! Значение нужного регистра в struct ftrace_regs
или содержимое нужной ячейки стека обработчик может изменить так, чтобы переходник потом вернул управление не в original_function()
, а в другую функцию, new_function()
:

Тогда получится, что оставшаяся часть original_function()
выполняться не будет, а вместо этого выполнится new_function()
. Последняя, отработав, вернет управление туда, куда его вернула бы original_function()
.
Получается не замена функций как таковая (код original_function()
никуда не делся), а, действительно, что-то похожее на «перенаправление».
Ровно на этом механизме лайвпатчинг и строится. Как минимум, тот вариант, который реализован в mainline-ядре Linux.
Если Ftrace для функции использовать нельзя, то и «перенаправить» ее таким способом не получится. kpatch-build
при сборке лайвпатчей проверяет, поддерживается ли Ftrace для функций, которые надо пропатчить. Если нет, — выдает ошибку: function [...] has no fentry/mcount call, unable to patch.
NOP, CALL, stop_machine()
Вообще, легко говорить: «Заменяем в рантайме инструкцию NOP в коде на вызов функции, а когда надо, — вызов функции обратно на NOP». На самом деле, это очень нетривиальные операции.
Допустим, у нас в системе два или более CPU (точнее, два или более CPU core, harts и так далее), которые могут одновременно выполнять код ядра Linux. У Ftrace есть два пути:
На время, пока один CPU меняет код ядра Linux, запретить всем остальным выполнять этот код, например, с помощью stop_machine().
Менять этот код очень аккуратно, чтобы каждый CPU все время видел его в каком-то разумном состоянии и мог его выполнять параллельно с заменой.
Вопреки своему названию, stop_machine()
не останавливает работу CPU и не отключает питание в системе. Если грубо, stop_machine()
заставляет все CPU, кроме одного, вызвать специальную функцию ядра Linux и ждать там, пока оставшийся CPU выполняет указанные действия — в нашем случае меняет NOP на вызов ftrace_caller*
или наоборот.
Если в системе много CPU (или много ядер у CPU), то stop_machine()
может оказаться дорогой операцией, так как работа всех процессов в системе при этом приостанавливается на некоторое время. Понятно, что у разработчиков ядра есть желание избавиться от использования stop_machine()
для Ftrace.
На x86 пошли по второму пути: менять код аккуратно, так, чтобы каждый CPU на каждом шаге видел этот код в каком-то разумном состоянии. Здесь очень помогает то, что на х86 есть однобайтная breakpoint-инструкция, int3 (opcode: 0xcc). Замена одного байта в коде — атомарная операция: каждый CPU увидит этот код либо в состоянии до замены байта, либо после, но ни в каком ином.
Допустим, надо заменить пятибайтный NOP на вызов ftrace_*caller()
. Это делается в три этапа:
сначала первый байт NOP меняется на инструкцию int3,
после этого в оставшиеся 4 байта пишется то, что должно быть в инструкции call,
наконец, первый байт меняется на opcode инструкции call, 0xe8.

После каждого из этих шагов делается sync_core()
на всех CPU, чтобы в кэше инструкций было правильное содержимое для этих адресов в коде.
Если во время такой замены кода какой-то из CPU попытается этот код выполнить, то увидит одну из трех инструкций:
nopl 0x0(%rax,%rax,1)
— Ftrace handler не будет вызван для функции, это нестрашно.call ftrace_caller*
— тоже нормально: Ftrace отработает для этой функции.int3
— тут будет breakpoint trap, обработчик которого передаст в итоге управление наftrace_caller*()
, так, как если бы в функции уже стояла инструкцияcall ftrace_caller*
.
Подробнее можно посмотреть по коду text_poke_bp_batch() и poke_int3_handler() в ядре Linux.
В такой схеме не требуется останавливать выполнение кода на этих CPU-ядрах на существенное время. То есть, на x86 в этом плане все хорошо.
ARM64 тоже справляется с включением-выключением Ftrace для функции без stop_machine()
. Там это делается заменой одной 4-байтной инструкции NOP на инструкцию BL и обратно — это атомарные операции, если начало функции выровнено по 4-байтной границе.

Подробнее смотрите в комментарии к ftrace_init_nop() в коде ядра Linux, а также в описании реализации FTRACE_WITH_REGS для ARM.
А что на RISC-V?
Для вызова функции здесь необходимы две 4-байтные инструкции, AUIPC + JALR. Заменить две 4-байтных инструкции NOP на AUIPC + JALR или обратно так, чтобы ничего не сломать, — задача нетривиальная.
Стоит учесть, что механизм замены инструкций должен правильно работать при разных вариантах preemption в ядре Linux, в том числе и при full preemption (CONFIG_PREEMPT=y
). Thread, выполняющийся в таком ядре, можно прервать и вытеснить с CPU в любом месте, где это явно не запрещено. Правда, в серверных системах мне до сих пор все-таки чаще попадалось CONFIG_PREEMPT_VOLUNTARY=y
и иногда CONFIG_PREEMPT_NONE=y
, но механизм замены инструкций в идеале не должен быть привязан к одним серверным системам. Да и Ftrace не только для лайвпатчинга используется, в конце концов.
Вопрос №1.
Допустим, ядро Linux для RISC-V собрано с full preemption, код этого ядра использует только 4-байтные инструкции, и эти инструкции выровнены по 4-байтной границе. Запись такой инструкции в код — атомарная операция. Будем включать Ftrace для функции, не используя stop_machine(), а просто вписывая AUIPC+JALR вместо NOP+NOP и инвалидируя после этого соответствующую часть кэшей инструкций для всех ядер CPU . Какие проблемы могут при этом возникнуть?
В ядре для RISC-V вплоть до версии 6.15 включительно Ftrace использует stop_machine()
, чтобы безопасно менять NOP на AUIPC+JALR и обратно:

Тут при включении и выключении Ftrace для функции (а потому и при работе с лайвпатчами) пока еще приходится останавливать все процессы в системе на некоторое время.
Возможно, уже в версии ядра 6.16 stop_machine()
не будет использоваться в Ftrace для RISC-V. Эта серия патчей также должна решить и потенциальные проблемы при замене инструкций в ядрах, собранных с CONFIG_PREEMPT=y
.
Идея здесь такая: AUIPC с нужными аргументами для каждой функции вписать заранее, а при включении-выключении Ftrace менять только NOP на JALR и обратно. Если начало функции выровнено по 4-байтной границе, такая замена должна быть атомарной операцией.

В таком варианте, правда, становится сложнее задавать для функции кастомный «переходник», ftrace_caller*
, что бывает полезно, например, для работы eBPF. При включении-выключении Ftrace для этой функции инструкция AUIPC больше не меняется, поэтому теперь нельзя задать произвольный адрес для «переходника». Играть можно только операндом инструкции JALR. Как эти трудности обходятся, описано в этом обсуждении Ftrace для RISC-V.
Ограничения
Теперь, когда стало ясно, как с помощью Ftrace лайвпатчи «заменяют» функции ядра на новые, проще понять ограничения этой технологии.
Лайвпатчить напрямую можно только функции. Если нужно изменить в рантайме какие-то данные, какое-то состояние в ядре Linux, то просто так это сделать нельзя. Нужно в каждом случае разбираться отдельно, кем в ядре эти данные могут использоваться в данный момент и как их безопасно поменять.
В некоторых ситуациях могут помочь pre/post-patch callbacks. Они дают возможность выполнить ваш код при активации и деактивации лайвпатча.
Нельзя лайвпатчить функции, с которыми не может работать Ftrace. Тут нужно искать обходные пути.
Нельзя лайвпатчить
__init
-функции ядра и модулей, а такжеprobe
-функции в драйверах.
Теоретически можно подменить такие функции, но на практике это, как правило, бессмысленно. К моменту активации лайвпатча init-функции ядра уже отработали, так же, как и init
-функции загруженных модулей. Ошибки в этих функциях уже, вероятно, проявились, и лайвпатчем их не исправить. Разве что, можно попытаться как-то компенсировать эффект от них. Если же модуль с ошибкой еще не загружен на момент применения лайвпатча, то, вероятно, проще пересобрать этот модуль, исправив там __init
-функцию, а не лайвпатчить его.
Для probe-функций соображения те же. Если соответствующие устройства уже опознаны ядром и связаны с драйверами к моменту активации лайвпатча, то probe-функции из этих драйверов уже отработали. Правда, если делать unbind и потом снова bind для устройств, то probe будет вызываться и позже, но это все-таки частный случай. Здесь стоит разобраться, можно ли как-то защититься от ошибок в probe-функции, не трогая саму эту функцию.
Kprobes и eBPF probes, поставленные в данную функцию, не сработают, если эту функцию лайвпатч заменит на новую. Логично, так как, кроме Ftrace-пролога, код функции не будет выполняться.
Нельзя применять лайвпатчи к функциям, которые в данный момент выполняются.
Об этом уже говорилось в первой части статьи. Теперь, думаю, понятно, почему нельзя. Перенаправление с помощью Ftrace делается в самом начале функции. Если мы так заменим
foo()
наfoo_patched()
, то ядра CPU, выполнявшие на тот момент старый код foo(), так и продолжат его выполнять.Но разве это плохо?
Допустим, из
foo()
вызываетсяbar()
и патчатся обе эти функции. Тогда может оказаться, что из старого вариантаfoo()
вызовется в итоге новый вариантbar()
. Получится несогласованность, которая может привести к неприятным (и трудноотлаживаемым) последствиям. Аналогичные проблемы могут быть, еслиfoo()
вызывает себя рекурсивно, хотя рекурсивные вызовы в ядре Linux все-таки встречаются нечасто.Анализировать заранее для каждого лайвпатча, может ли возникнуть такая несогласованность и чем она грозит — слишком затратно, да и ошибиться тут легко. Проще проверять при активации и деактивации лайвпатча call stack процессов, чтобы увидеть, выполняются ли в тот момент такие-то функции.
Таким образом, применение лайвпатча может и не пройти, если он меняет те части ядра Linux, которые работают постоянно или очень часто. Чем больше функций меняет лайвпатч, тем больше вероятность таких сценариев.
Кстати, это одна из основных причин, почему лайвпатчи плохо подходят для обновлений ядра, где меняется много функций, например, для перехода от v6.6.40 к v6.6.70 или к v6.12.x. Вторая причина в том, что, как уже говорилось выше, не каждое изменение в исходном коде ядра можно превратить в лайвпатч.
Подробнее об этих и других ограничениях можно также узнать из документации по лайвпатчам.
Livepatching для x86, ARM и RISC-V
Лайвпатчи на х86-64 используются в продакшене уже несколько лет (RedHat, SUSE, Canonical и др.). На этой архитектуре поддерживается все необходимое:
динамический Ftrace c перенаправлением функций, не требующий
stop_machine()
;reliable stacktraces;
хорошо отлаженные инструменты для сборки лайвпатчей.
Для PowerPC и s390x все это тоже есть.
ARM64: для версии ядра 6.15 перенаправление функций без stop_machine()
уже реализовано, а остальное (reliable stacktraces и инструменты) ожидается в обозримом будущем.
А вот для RISC-V пока все в самом начале пути.
Пример лайвпатча из mainline-ядра 6.12 мне удалось запустить. Правда, ядро пришлось немного доработать по аналогии с x86:
добавить
TIF_PATCH_PENDING
и_TIF_PATCH_PENDING
в соответствующих местах вarch/riscv/include/asm/thread_info.h;
в
arch/riscv/Kconfig
включитьHAVE_LIVEPATCH
и добавитьsource kernel/livepatch/Kconfig
;включить в конфиге ядра
CONFIG_LIVEPATCH и CONFIG_SAMPLE_LIVEPATCH
.
Reliable stack traces и поддержки RISC-V в инструментах сборки лайвпатчей на данный момент нет. Надеюсь, это изменится в будущем.
Пока же активация и деактивация лайвпатчей проходят медленнее, чем могли бы: каждый процесс переключается на новый код только при выходе из ядра и в klp_update_patch_state(
). Ядру обычно приходится ждать 15 секунд после начала активации лайвпатча, затем отправлять процессам сигнал, чтобы они вышли из ядра и их можно было бы переключить на пропатченные функции.

Лайвпатчинг «под ключ»
Итак, допустим, вы хотите использовать лайвпатчи в продакшене. По моему опыту, готовить и поддерживать лайвпатчи хотя и не совсем просто, но и не настолько сложно, как об этом обычно пишут крупные вендоры вроде RedHat.
Потребуется:
хорошее знание языка C;
умение разбираться в коде ядра Linux;
знание типичных подводных камней в лайвпатчинге и того, как их обходить.
Думаю, у читателей, которые интересуются лайвпатчингом, первое и второе уже есть или скоро будет. А о подводных камнях сейчас поговорим.
Допустим, есть исправление для ядра Linux, которое нужно превратить в лайвпатч. Процесс работы над лайвпатч-обновлением в целом выглядит так:

Анализ и доработка исходного патча (source patch)
Это самый интересный и, вероятно, самый сложный этап.
Не всегда можно взять исходный патч как есть, скормить его инструментам сборки, получить лайвпатч-модуль для этой версии ядра, загрузить его на целевых системах и рассчитывать, что все просто заработает.
«Нельзя просто так пойти в Мордор» (C) Боромир, LOTR.
Сначала исходный патч нужно тщательно проанализировать. Это самый сложный этап. Стоит понять, что именно делает этот патч, и задать себе основной вопрос: «Что будет с системой, если туда принести вот такие изменения во время ее работы?»
Стоит разобраться:
Какие функции меняет исходный патч? Как они связаны? Какое поведение эти функции ждут друг от друга? Например, если нужно в одну функцию добавить вызов lock, а в другую —
unlock
для того жеmutex
илиspinlock
, надо позаботиться о том, чтобы не выполнилсяunlock
без вызоваlock
и наоборот.Когда вызываются эти функции, в каких ситуациях? Вспомним, например, что напрямую патчить
__init
-код и probe-функции нельзя.Может ли какая-то из функций, которые патчим, вызываться часто или работать постоянно в каком-то из
kernel threads
? От этого зависит, получится ли применить такой лайвпатч.Меняются ли структуры данных, глобальные переменные и тому подобное?
Меняется ли семантика данных или функций?
Связи между функциями
Рассмотрим модельный пример. Допустим, в ядре есть структура some_struct для которой предусмотрен reference counting с использованием kref.
struct some_struct {
...
struct kref ref;
};
Экземпляры struct some_struct
создаются динамически и затем инициализируются:
struct some_struct some_obj;
...
some_obj = kzalloc(sizeof(some_obj), ...);
...
kref_init(&some_obj->ref);
После такой инициализации счетчик ссылок для *some_obj
равен 1.
Если какой-то другой компонент ядра хочет использовать some_obj,
он должен увеличить счетчик ссылок для него: kref_get(&ref)
. Когда объект больше не нужен, надо вызвать kref_put()
или аналогичную функцию: kref_put(&ref, delete_some_obj)
. Эта функция уменьшит счетчик ссылок для some_obj
на 1 и, если он станет равным 0, вызовет delete_some_obj()
для удаления объекта.
Допустим, в каком-то из компонентов ядра *some_obj
используется, начиная с момента, когда отрабатывает некоторая функция start_activity()
, и до момента, когда выполняется stop_activity()
. Также предположим, что между вызовами start_activity()
и stop_activity()
может проходить достаточно много времени. Например, эта «активность» может начинаться и заканчиваться по командам из userspace или от другой подсистемы в ядре.
static int start_activity(...)
{
... // start using some_obj
}
static int stop_activity(...)
{
... // stop using some_obj
}
int handle_command(int cmd, ...)
{
...
if (cmd == START_ACTIVITY) {
err = start_activity(..., some_obj);
} else if (cmd == STOP_ACTIVITY) {
err = stop_activity(..., some_obj);
}
...
}
Предположим, авторы этого кода забыли вызвать kref_get/kref_put
для *some_obj
. Давайте попробуем это сделать патчем:
static int start_activity(...)
{
+ kref_get(&some_obj->ref);
... // start using some_obj
}
static int stop_activity(...)
{
... // stop using some_obj
+ kref_put(&some_obj->ref, delete_some_obj);
}
Все просто, не правда ли? Если теперь возьмем этот простой патч, соберем из него лайвпатч, попробуем применить в рантайме — все же будет работать, правда?
Нет, не будет! И вот почему.
Как говорилось выше, между вызовами start_activity()
и stop_activity()
проходит некоторое время. лайвпатчи могут загружаться и выгружаться когда угодно. А если окажется так, что в ядре отработает старая функция start_activity()
, затем загрузится лайвпатч, и после этого отработает уже новая stop_activity()
?
static int start_activity(...) // старый вариант
{
... // start using some_obj
}
static int stop_activity(...) // новый вариант
{
... // stop using some_obj
kref_put(&some_obj->ref, delete_some_obj);
}
Получим дисбаланс для счетчика ссылок: kref_get()
не вызывалась, а kref_put()
будет вызвана. *some_obj
будет удален раньше времени — получим, вероятно, use-after-free. Хорошо еще, если ядро упадет при этом быстро и будет понятно, из-за чего. Иначе получим еще один трудноотлаживаемый баг.
Получается, нельзя такой исходный патч брать как есть. Как же быть? Здесь могут помочь так называемые shadow-переменные.
Лайвпатчи могут «прицепить» один или более блоков данных к существующим структурам в памяти ядра Linux, не меняя самих этих структур.

При этом пропатченные start_activity()
и stop_activity()
могут выглядеть так:
static int start_activity(...)
{
+ void patch_data;
...
+ patch_data = klp_shadow_get_or_alloc(some_obj, 42 / ID /, ...);
+ if (patch_data)
+ kref_get(&some_obj->ref);
// start using some_obj
}
static int stop_activity(...)
{
+ void patch_data;
...
// stop using some_obj
+ patch_data = klp_shadow_get(some_obj, 42 /* ID */);
+ if (patch_data) // работаем по-новому только в этом случае
+ kref_put(&some_obj->kref, delete_some_obj);
...
}
klp_shadow_get_or_alloc()
в start_activity()
проверяет, есть ли для объекта some_obj
shadow-данные с ID 42 (у каждого shadow-блока данных для объекта свой ID). Если таких shadow-данных пока нет, klp_shadow_get_or_alloc()
их пытается создать и связать с some_obj
. Если shadow данные с указанным ID уже были или их удалось создать, функция возвращает их адрес.
В нашем случае содержимое такого блока shadow-данных не используется, важен сам факт его наличия. Если для *some_obj
есть shadow-данные с ID 42, значит, для него вызывалась новая start_activity(), так как эти shadow-данные создаются только там.
Это и проверяется в stop_activity()
: klp_shadow_get(some_obj, 42)
выдаст ненулевой указатель, только если соответствующие shadow-данные для *some_obj
есть. А если есть, значит, вызывалась новая start_activity()
, которая выполнила kref_get()
. Только в этом случае нужно в stop_activity()
делать kref_put()
.
Созданные shadow-данные не удаляются автоматически. Их стоит удалять при удалении *some_obj
в delete_some_obj()
и в unpatch callback, если на момент выгрузки лайвпатча какие-то из них еще не будут удалены.
Таким образом, в данном модельном примере нужно доработать исходный патч, чтобы исправление безопасно было применять в рантайме.
Вопрос №2.
Допустим лайвпатч с такими shadow-данными был загружен до вызова
start_activity()
, но его деактивируют и выгрузят в интервале между завершением этой функции и началомstop_activity()
. Какие проблемы могут возникнуть в таком случае и как их обойти?
В этом модельном примере упор делается на баланс kref_get()
и kref_put()
. Аналогичные ситуации возможны с любыми парными операциями: lock/unlock
, alloc/free
и т.д.
Как говорилось выше, самый важный вопрос, который надо себе задать при анализе исходного патча:
«Что будет с системой, если туда принести вот такие изменения во время ее работы?»
Функции, где не поддерживается Ftrace
Как вы уже знаете, такие функции напрямую лайвпатчить нельзя. А как быть, если очень надо?
Один из способов обойти это ограничение — сделать исправленную копию нужной функции и изменить те функции, которые ее вызывают, чтобы они использовали новый вариант. При этом достаточно поправить те ее вызовы, которые происходят в интересующих нас сценариях, а не вообще все.
Например, в ядре 6.12 и в более старых версиях нельзя лайвпатчить функции, исходный код которых лежит в lib/*.c
, т.к. поддержка Ftrace выключена для них в lib/Makefile
:
ccflags-remove-$(CONFIG_FUNCTION_TRACER) += $(CC_FLAGS_FTRACE)
Примечание
Хотя Ftrace выключен для lib/*.c
, но в подкаталогах из lib/ Ftrace
может быть и включен. Например, функцию zlib_deflate_workspacesize()
из lib/zlib_deflate/deflate.c
, возможно, и получится пропатчить напрямую. Но для кода из lib/*.c
приходится искать обходные пути.
Допустим, нам нужно изменить функцию idr_alloc_u32()
из lib/idr.c
, но баг, который мы там правим, приносит вред только тогда, когда idr_alloc_u32()
вызывается из p9_fid_create()
. Тогда достаточно сделать так:
// lib/idr.c:
// Исходный вариант idr_alloc_u32() оставляем без изменений.
int idr_alloc_u32(...)
{
...
}
// Рядом с исходным добавляем исправленный вариант функции.
+int patched_idr_alloc_u32(...)
+{
+ ...
+}
// net/9p/client.c
+int patched_idr_alloc_u32(...);
// Меняем вызов старой idr_alloc_u32() на вызов исправленного ее варианта
// в p9_fid_create(), благо в p9_fid_create() Ftrace поддерживается.
static struct p9_fid *p9_fid_create(...)
{
...
- ret = idr_alloc_u32(...);
+ ret = patched_idr_alloc_u32(...);
...
}
Готово.
Такой подход хорошо работает, если функций, вызывающих данную, немного, и для них Ftrace поддерживается. Но если данная функция вызывается во многих местах в ядре Linux, лайвпатч может стать слишком громоздким.
Вопрос №3.
Допустим, нужно исправить лайвпатчем функцию
assoc_array_iterate()
изlib/assoc_array.c
в ядре 6.12. Напрямую Ftrace для этой функции не поддерживается. Как быть?
Работа с данными
Вот пример из моей практики. В 2020 году мой бывший коллега Василий Аверин проанализировал падения ядра на некоторых из клиентских систем и выяснил, что причиной была ошибка в одном из компонентов netfilter
. Вот исправление для этой ошибки, оно вошло в stable-варианты ядра Linux:
commit 396ba2fc4f27ef6c44bbc0098bfddf4da76dc4c9
Author: Vasily Averin <vasily.averin@linux.dev>
Date: Tue Jun 9 10:53:22 2020 +0300
netfilter: nf_conntrack_h323: lost .data_len definition for Q.931/ipv6
--- a/net/netfilter/nf_conntrack_h323_main.c
+++ b/net/netfilter/nf_conntrack_h323_main.c
@@ -1225,6 +1225,7 @@ static struct nf_conntrack_helper nf_conntrack_helper_q931[] __read_mostly = {
{
.name = "Q.931",
.me = THIS_MODULE,
+ .data_len = sizeof(struct nf_ct_h323_master),
.tuple.src.l3num = AF_INET6,
.tuple.src.u.tcp.port = cpu_to_be16(Q931_PORT),
.tuple.dst.protonum = IPPROTO_TCP,
Проблема очень неприятная, поскольку она теоретически давала возможность организовать remote denial-of-service: при обработке определенных пакетов, полученных по сети, ядро Linux либо падало, либо портило данные. (CVE-2020-14305, CVSS 3: 8.1, high).
До исправления размер дополнительного блока данных (.data_len) в nf_conntrack_helper не был задан явно и оказывался нулевым. В результате место под него не выделялось. А когда ядро Linux пыталось в этот блок данных что-то записать, это, понятно, портило чужие данные в памяти.
Вопрос №4.
Почему в том виде, как оно есть, исправление для этого бага не подходит для лайвпатчинга? Как можно было бы это обойти?
Еще несколько примеров, где нужно дорабатывать исходный патч, можно найти в Patch Author Guide.
Сборка лайвпатча
В первой части статьи уже рассматривалась процедура сборки лайвпатча. Вспомним, как она выполняется и поговорим о том, что до сих пор оставалось за кадром.
Для каждой из нужных нам версий ядра Linux готовим отдельный лайвпатч-модуль.
Как правило, удобнее использовать накопительные (cumulative) лайвпатч-обновления, о которых говорилось в первой части. То есть — комбинируем все исходные патчи для этой версии ядра в один, скажем, v1.patch
.
Затем выполняем сборку, например, с помощью kpatch tools:
kpatch-build \
-s <kernel source tree> \
-c <kernel config file> \
-v <vmlinux with debug info> \
v1.patch
Если kpatch-build выявит, что из такого исходного патча лайвпатч сделать нельзя, например, если патчатся __init-функции или функции, где не поддерживается Ftrace, и так далее, то будет сообщение об ошибке.
Стоит обратить внимание на список новых и измененных на бинарном уровне функций, который выводит kpatch-build. В примере с исправлением для nf_tproxy_laddr4()
на бинарном уровне изменилась только одна эта функция:
kpatch-build:
Extracting new and modified ELF sections
nf_tproxy_ipv4.o: changed function: nf_tproxy_laddr4
Patched objects: net/ipv4/netfilter/nf_tproxy_ipv4.ko
Если в списке измененных будут функции, которые вы там не ожидали увидеть, нужно разбираться. Возможно, часть функций изменилась из-за__LINE__
в коде. Возможно, — из-за того, что исходный патч коснулся inline-функций. А может быть, компилятор решил по-другому оптимизировать функции в пропатченном ядре.
Последний вариант самый неприятный. Тут стоит постараться переделать патч как-то так, чтобы исходное и пропатченное ядро компилятор оптимизировал сходным образом. Например, если он начал по-другому выполнять inline функции — попробовать noinline и так далее. Не всегда это возможно, но здесь, к сожалению, общих рекомендаций нет.
Если сборка пройдет успешно, будет создан модуль ядра livepatch-v1.ko
.
Зачем нужны инструменты сборки лайвпатчей
У вас мог возникнуть такой вопрос. Лайвпатчи — это модули ядра. В ядре Linux есть примеры лайвпатчей. Они собираются как обычные модули, без каких-либо дополнительных инструментов и без повторных сборок ядра, которые делает, например, kpatch-build
.
Зачем тогда нужен kpatch-build
и аналогичные инструменты?
В примерах из ядра список новых и измененных на бинарном уровне функций указывается вручную, в коде самих примеров. Но там меняется совсем немного функций. А как быть, если ваш патч изменяет на уровне исходного кода 10-20-100 функций, какие-то из которых компилятор решает инлайнить или еще как-то оптимизировать? Например, компилятор может создать несколько копий одной и той же функции, оптимизировав их для разных случаев - как выявить, какие функции изменятся при этом на бинарном уровне? kpatch tools и другие инструменты как раз решают этот вопрос.
Допустим, компилятор в исходном ядре создал оптимизированный вариант функции
foo()
и назвал его foo.isra.45. А в пропатченном ядре он этот вариантfoo()
назвал foo.isra.47. Формально это разные функции, но фактически — одна и та же, просто под разными именами. kpatch-build автоматически выявляет многие такие случаи.
Вспомним, что лайвпатч — это модуль ядра. А модули ядра не могут вызывать функции из ядра и других модулей, которые теми не экспортируются. Допустим, мы патчим функцию
foo()
из ядра, которая вызывает функциюbar()
, но ядро не экспортируетbar()
. Мы поместили исправленный кодfoo()
в лайвпатч-модуль, загружаем этот модуль... И если не принять меры, он просто не загрузится, т.к. функцияbar()
для него недоступна!
Как быть? kpatch tools автоматически формируют список таких зависимостей и сохраняют его как relocations специального вида в лайвпатч-модуле. Linux при загрузке лайвпатча использует эту информацию и выполнит связывание новой функции foo()
с bar(
) из ядра.
Тестирование
Тестировать лайвпатчи, как и любые другие обновления ПО, надо обязательно. Думаю, это ни для кого не новость.
Лайвпатч-обновления собираются отдельно для каждой поддерживаемой версии ядра, и тестировать их надо для каждой такой версии ядра.
Если у вас есть какие-то штатные тесты обновлений ПО, есть смысл прогнать их и для систем с лайвпатчами тоже. Стоит также проверить следующее, по возможности.
Если есть способ воспроизвести баги, которые лайвпатч исправляет, полезно проверить, что после его применения эти баги больше не проявляются. Тут можно узнать много интересного: например, — что какой-то из сбоев происходит до загрузки и активации лайвпатчей и просто так в рантайме его не исправить. Здесь, вероятно, придется вернуться к анализу и доработке source-патча.
Применение / отключение / update / downgrade лайвпатчей, в том числе, когда система находится под нагрузкой. Здесь может выявиться, что лайвпатч удается активировать не всегда, так как какие-то из измененных функций ядра работают часто или постоянно. Также тут можно отловить ошибки, связанные с передачей состояния между разными версиями лайвпатчей при обновлении, например, — с управлением shadow-переменными и так далее.
Выпуск лайвпатчей и поддержка после выпуска
Не знаю, есть ли сейчас универсальный способ доставки лайвпатчей клиентам или развертывания таких обновлений на ваших собственных системах. Нередко лайвпатчи поставляются в RPM- или DEB-пакетах, в зависимости от дистрибутива Linux, и хранятся в репозиториях пакетов, как и другие виды обновлений ПО. Но это необязательно.
А вот что ясно точно: не стоит отправлять лайвпатч-обновление на все целевые системы сразу. Лучше поэтапно: сначала развернуть его на некоторых из систем, понаблюдать, не будет ли сбоев ядра, отказов при активации патчей, а также проблем с производительностью и других подозрительных ситуаций. Если за заданное время проблем не выявится — развернуть лайвпатч-обновление на следующей группе систем и так далее.
Поддержка лайвпатчей после их выпуска тоже должна быть, как и для других обновлений ПО. По возможности стоит мониторить системы, где развернуты лайвпатчи, аналогично тому, что делали при выпуске: не будет ли сбоев ядра и тому подобного. Думаю, для читателей, ответственных за выпуск обновлений ПО, здесь нет ничего нового.
При грамотном анализе исходных патчей и результатов их сборки, правильно организованном тестировании, выпуске и последующем мониторинге вероятность, что лайвпатчи приведут к каким-то проблемам, будет минимальной.
Ответы на вопросы
Вопрос №1
(RISC-V, full preemption). Какие проблемы могут возникнуть, если заменять
NOP+NOP
наAUIPC+JALR
безstop_machine()
или аналогов?
Допустим, thread начал выполнять соответствующую функцию, успел выполнить первый NOP, затем был вытеснен с CPU. После этого для функции включили Ftrace, заменили NOP+NOP на AUIPC+JALR, добились, чтобы эти изменения попали в кэш инструкций на всех CPU. Через какое-то время вытесненный thread продолжит работу - и выполнит инструкцию JALR.
NOP AUIPC
--- (preempted here) ----- (resumed here)
NOP JALR
В результате — попытается передать управление по неправильному адресу, так как инструкция AUIPC, где задаются старшие биты адреса, не выполнялась. В лучшем случае, ядро упадет сразу, в худшем — после, в каком-нибудь неожиданном месте. Получим баг, который анализировать и отлаживать будет очень тяжело.
Вопрос №2.
Допустим, в модельном примере с
kref
лайвпатч c shadow-данными был загружен до вызоваstart_activity()
, но его деактивируют и выгрузят в интервале между завершением этой функции и началомstop_activity(
). Какие проблемы могут возникнуть в таком случае и как их обойти?
Здесь есть два варианта.
1. Лайвпатч выгружается, так как его заменяют на более новую его версию, где есть аналогичный фикс для start_activity()
и stop_activity()
.
При подготовке лайвпатчей действует такое правило: каждая новая версия берет на себя ответственность за shadow-переменные, созданные в предыдущих версиях. Если в новой версии лайвпатча функция stop_activity()
тоже проверяет наличие нужной shadow-переменной и делает kref_put()
для объекта, то все в порядке, проблем нет.
2. Лайвпатч выгружается совсем, или заменяется на более старую версию или на более новую, но без фикса для start_activity()
и stop_activity()
.
Вызовется новый вариант start_activity()
и увеличит счетчик ссылок для объекта на единицу. Затем лайвпатч будет выгружен и, если ничего дополнительного в нем не делать, то потом вызовется уже тот вариант stop_activity()
, который kref_put()
не делает. В итоге получим объект, который не будет удален никогда: его счетчик ссылок навсегда останется положительным.
Чтобы предотвратить такие утечки ресурсов, можно, например, в unpatch callback перед удалением shadow-данных делать и kref_put()
для объекта. Тогда баланс для счетчика ссылок будет восстановлен.
Вопрос №3.
Как быть, если в ядре 6.12 нужно лайвпатчить функцию
assoc_array_iterate()
, для которой Ftrace не поддерживается?
В ядре 6.12 assoc_array_iterate()
вызывается только в security/keys/keyring.c
, в трех местах:
keyring_read(),
keyring_gc(),
search_keyring()
.
Для keyring_read()
и keyring_gc()
Ftrace поддерживается.
search_keyring()
может инлайниться в search_nested_keyrings()
, для которой Ftrace тоже поддерживается.
Таким образом, можно сделать исправленный вариант assoc_array_iterate()
в lib/assoc_array.c
, скажем, assoc_array_iterate_patched_v1()
, и подправить keyring_read()
, keyring_gc()
и search_keyring()
так, чтобы они вызывали этот вариант функции.
Вопрос №4.
Почему в том виде, как оно есть, исправление для CVE-2020-14305 не подходит для лайвпатчинга? Как можно было бы это обойти?
Исходный патч правит инициализацию структуры nf_conntrack_helper
. Но лайвпатчи могут только заменять одни функции на другие. Плюс, к моменту активации лайвпатча соответствующие структуры nf_conntrack_helper
уже проинициализированы и в .data_len
там нули.
Когда мы с коллегами готовили лайвпатч для этого исправления, то было два варианта:
При загрузке лайвпатча найти в памяти эти структуры
nf_conntrack_helper
и поправить.data_len
в них.Поправить те места в ядре, где
.data_len
используется, чтобы там бралось правильное значение.
В итоге выбрали второй путь.
Понятно, что ни один из этих вариантов не поможет, если неправильное значение .data_len
(то есть 0) уже было использовано, а данные в памяти уже были испорчены. Но, так как в нашем случае лайвпатчи на клиентских системах загружались заведомо до начала работы по соответствующим сетевым протоколам, правки .data_len
оказалось достаточно.
Заключение
Теперь вы знаете, что такое лайвпатчинг, зачем эта технология нужна и когда может быть выгодно ее использовать. Понимаете, как лайвпатчи устроены, что у них под капотом, какие у этой технологии есть ограничения и как некоторые из них можно обойти. Знаете, в каком состоянии сейчас поддержка лайвпатчей для x86-64, ARM64 и RISC-V.
Надеюсь, вы убедились, что работать с лайвпатчами, хотя и не очень просто, но и не настолько сложно, как может показаться на первый взгляд. Если вы хорошо владеете языком C и умеете разбираться в коде ядра Linux, то, думаю, в 90% случаев легко справитесь с подготовкой лайвпатчей. А в оставшихся 10%, надеюсь, вам помогут примеры из этой статьи, а также Patch Author Guide и документация по лайвпатчингу.
Так что пробуйте!
Благодарности
Я бы хотел сказать огромное спасибо мои коллегам Александру Солдатову и Матвею Быстрину за ценные советы и комментарии. Отдельная благодарность — редакторскому коллективу, особенно Екатерине Кобзевой за помощь с подготовкой этой статьи.
Its_ryder
Хоть и я не пользуюсь Linux, на удивления я почти все понял...
euspectre Автор
Это здорово! Мне как раз хотелось как-то попонятнее объяснить все эти вещи: как Ftrace работает и пр.