VoidLink адаптировался под каждое обновление — четыре поколения за несколько лет.

VoidLink оказался не просто еще одним Linux-фреймворком для скрытого управления зараженными системами. Утечка с исходниками, готовыми бинарными файлами и скриптами развертывания показала куда более тяжелую картину: внутри проекта развивали полноценную линейку руткитов для ядра Linux, которые подгоняли под разные поколения ядер и под реальные целевые системы. По составу дампа видно, что речь шла не о разовом эксперименте. В каталоге лежали версии для разных веток ядра, готовые .ko-модули под конкретные сборки, отдельные заголовки BTF и последовательные поколения кода. Значит, разработчики не просто писали руткит, а собирали, тестировали и дорабатывали его в боевых условиях.
Check Point раньше описывала VoidLink как модульный фреймворк командного управления на Zig с более чем 30 плагинами, поддержкой облачных сред и несколькими техниками маскировки, включая LD_PRELOAD, загружаемые модули ядра и eBPF. Новый дамп раскрыл самую опасную часть всей системы: подсистему скрытности на уровне ядра. По содержимому файлов видно, что разработчики не ограничились одним методом. Они собрали гибридную архитектуру, где классический загружаемый модуль ядра работал в паре с eBPF-программой. Такое сочетание в реальных Linux-руткитах встречается редко.
Главный модуль маскировался под vl_stealth, а в части версий еще и под amd_mem_encrypt. Выбор второго имени выглядит расчетливым. В Linux действительно существует легитимный модуль amd_mem_encrypt, связанный с поддержкой Secure Memory Encryption и Secure Encrypted Virtualization на платформах AMD. Если вредоносный модуль копирует его метаданные, поверхностная проверка через modinfo уже не выглядит надежной. В файле прямо показано, как руткит подменяет сведения о себе:
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Advanced Micro Devices, Inc.");
MODULE_DESCRIPTION("AMD Memory Encryption Support");
MODULE_VERSION("3.0");
Такой прием особенно удобен в облачных средах и виртуальных машинах, где модули, связанные с AMD, никого не удивляют. В пятой версии разработчики пошли еще дальше и спрятали само имя модуля через простую XOR-обфускацию, чтобы оно не всплывало при банальном поиске строк по бинарному файлу:
static char obf_modname[] = {
'a'^ICMP_KEY, 'm'^ICMP_KEY, 'd'^ICMP_KEY, '_'^ICMP_KEY,
'm'^ICMP_KEY, 'e'^ICMP_KEY, 'm'^ICMP_KEY, '_'^ICMP_KEY,
'e'^ICMP_KEY, 'n'^ICMP_KEY, 'c'^ICMP_KEY, 'r'^ICMP_KEY,
'y'^ICMP_KEY, 'p'^ICMP_KEY, 't'^ICMP_KEY, 0
};
static void decrypt_string(char *dst, const char *src, u8 key)
{
while (*src) { *dst++ = *src++ ^ key; }
*dst = 0;
}
Сама по себе такая защита примитивна. Ключ находится без труда. Но для простого strings или грубого анализа через grep уже появляется лишний барьер. И именно из таких мелких решений складывается общая устойчивость вредоносного инструмента.
Архитектура VoidLink сразу выделяется тем, как разработчики разделили обязанности между двумя частями. Загружаемый модуль ядра берет на себя грубую силовую работу: перехват функций ядра через ftrace, скрытие процессов, фильтрацию чтения чувствительных псевдофайлов, маскировку модулей, обработку команд через скрытый канал на базе ICMP. Параллельная eBPF-программа решает более тонкую задачу: скрывает сетевые соединения от утилиты ss, вмешиваясь в ответы Netlink уже на границе пространства пользователя. Такой разнос логики важен. Утилиты вроде netstat и ss получают данные из ядра разными путями, поэтому один только хук редко перекрывает оба направления.
По дампу исследователи выделили как минимум 4 поколения VoidLink. Самое раннее ориентировалось на CentOS 7 с ядром 3.10 и использовало старую прямолинейную технику: модификацию таблицы системных вызовов. В таких ядрах символ kallsyms_lookup_name() еще экспортируется публично, поэтому адрес sys_call_table можно получить напрямую. Дальше модуль временно отключает защиту записи в регистре CR0, меняет указатели на обработчики и затем возвращает защиту обратно:
write_cr0(read_cr0() & ~X86_CR0_WP); // Disable write protection
sys_call_table[__NR_getdents64] = (unsigned long)hooked_getdents64;
sys_call_table[__NR_getdents] = (unsigned long)hooked_getdents;
write_cr0(read_cr0() | X86_CR0_WP); // Re-enable write protection
Такой подход старый, шумный и хорошо известный, но для CentOS 7 он все еще рабочий. Интереснее другое: разработчики явно сталкивались с реальными проблемами развертывания. Компилятор GCC при межпроцедурных оптимизациях переименовывает функции, добавляя суффиксы вроде .isra.0, .constprop.5 или .part.3. Из-за этого поиск символов по точному имени часто ломается. В VoidLink под это сделали отдельную функцию, которая перебирает варианты автоматически:
static unsigned long find_symbol_flexible(const char *base_name)
{
unsigned long addr;
char buf[128];
int i;
addr = kallsyms_lookup_name(base_name);
if (addr) return addr;
for (i = 0; i <= 20; i++) {
snprintf(buf, sizeof(buf), "%s.isra.%d", base_name, i);
addr = kallsyms_lookup_name(buf);
if (addr) return addr;
}
for (i = 0; i <= 20; i++) {
snprintf(buf, sizeof(buf), "%s.constprop.%d", base_name, i);
addr = kallsyms_lookup_name(buf);
if (addr) return addr;
}
return 0;
}
Такой фрагмент хорошо показывает происхождение кода. Похожее пишут не в теории, а после раздражающей практики, когда модуль на одной системе грузится, а на другой падает из-за переименованного символа. В той же ранней ветке руткит перехватывал и getdents, и getdents64, потому что пользовательские инструменты в CentOS 7 используют оба формата записей каталогов. Для /proc/modules применялся отдельный трюк с заменой указателя seq_operations.show после открытия файла через filp_open(). Уже там появились таймер антиотладки и команда самоуничтожения, а вывод в журнал ядра просто глушился через переопределение pr_info, pr_err и pr_warn в пустые операции.
Переход к ядрам Linux 5.x потребовал уже не косметики, а смены стратегии. Начиная с Linux 5.7 символ kallsyms_lookup_name() перестал экспортироваться, а защита памяти ядра стала жестче из-за CONFIG_STRICT_KERNEL_RWX. Разработчики VoidLink обошли первую проблему трюком с kprobe. Вместо прямого вызова они регистрируют пробу на нужный символ, ядро само разрешает адрес при регистрации, после чего модуль читает значение из kp.addr и немедленно снимает пробу:
static int init_symbols(void)
{
struct kprobe kp = { .symbol_name = "kallsyms_lookup_name" };
if (register_kprobe(&kp) < 0)
return -EFAULT;
kln_func = (kln_t)kp.addr;
unregister_kprobe(&kp);
return kln_func ? 0 : -EFAULT;
}
Такой прием давно известен в современных Linux-руткитах, но сам факт его появления в VoidLink показывает, что проект адаптировали под новые ветки ядра не формально, а всерьез. После получения kallsyms_lookup_name модуль снова может находить остальные внутренние символы, уже не полагаясь на старые открытые интерфейсы.
Перехват системных вызовов в этом поколении тоже изменился. Вместо прямой правки таблицы вызовов разработчики перешли на ftrace. Изначально этот механизм создавался для трассировки и отладки производительности, но его удобно использовать как штатный способ перенаправить выполнение на свой обработчик. В VoidLink через ftrace цепляют как минимум __x64_sys_getdents64 и vfs_read. За счет флагов FTRACE_OPS_FL_SAVE_REGS и FTRACE_OPS_FL_IPMODIFY модуль получает полный контроль над сохраненным состоянием регистров и может подменить указатель инструкций до вызова оригинальной функции.
Хук на vfs_read оказался особенно полезен. Вместо точечной маскировки одного файла руткит перехватывает чтение сразу нескольких чувствительных псевдофайлов и выбрасывает из буфера строки, где встречается имя модуля или следы регистрации kprobe и kretprobe. В публикации упоминаются /proc/kallsyms, /proc/modules и /sys/kernel/debug/kprobes/list. Такой централизованный фильтр заметно удобнее старого подхода с заменой seq_operations.show только для одного пути.
Третье поколение исследователи уже считают фактически готовым к эксплуатации. В нем появились параметры загрузки модуля, чтобы оператор мог сразу задать скрываемые процессы, порты и режим скрытности еще при вставке модуля в ядро. Пример из дампа выглядит так:
insmod vl_stealth.ko init_pids=1234 init_ports=8080 stealth=1
Параметр init_pids задает процессы, которые нужно скрыть немедленно. init_ports перечисляет порты, которые надо убрать из выдачи netstat и ss. Флаг stealth управляет удалением модуля из списка загруженных модулей после инициализации. За счет такой схемы отпадает необходимость сначала загрузить руткит, а потом отдельно настраивать его через скрытый канал. Окно, в котором модуль уже присутствует, но еще не скрылся и не активировал фильтры, становится заметно меньше.
Там же усилили и прием команд через ICMP. Вместо регистрации только в одной цепочке Netfilter разработчики закрепились сразу в NF_INET_PRE_ROUTING и NF_INET_LOCAL_IN. Для руткита это практичная страховка: прием пакетов становится надежнее при разной сетевой конфигурации хоста и при разных правилах iptables. Многие подобные проекты ограничиваются одной точкой входа. Здесь видна попытка предусмотреть сбои заранее.
Одна из самых интересных частей VoidLink связана с тем, как он скрывает сетевые соединения от ss. Для Linux-руткитов это старая головная боль. Исторически скрыть процессы или модуль проще, чем аккуратно подменить сетевую картину сразу для разных диагностических инструментов. VoidLink решает задачу через eBPF-компонент, который вмешивается в работу Netlink и подчищает данные на пути к пользовательскому пространству. Отдельно в файле дан пример простой проверки на несовпадение сетевых представлений, когда данные из ss сравниваются с прямым разбором /proc/net/tcp и /proc/net/tcp6:
proc_ports=$(
awk 'NR>1 && $4 == "0A" {split($2, a, ":"); print a[2]}' \
/proc/net/tcp /proc/net/tcp6 2>/dev/null \
| while read -r hex; do printf "%d\n" "0x$hex"; done \
| sort -un
)
echo "ss listening ports : $(echo "$ss_port_nums" | tr '\n' ' ')"
echo "/proc/net/tcp listening : $(echo "$proc_ports" | tr '\n' ' ')"
diff_result=$(diff <(echo "$ss_port_nums") <(echo "$proc_ports") || true)
if [ -z "$diff_result" ]; then
echo "[OK] Network views match"
else
echo "[!] MISMATCH - possible hidden connections:"
echo "$diff_result"
fi
Такая проверка не доказывает присутствие именно VoidLink, но хорошо показывает, как можно ловить сам класс манипуляций, когда разные интерфейсы системы начинают рассказывать о сети разные истории.
В пятой версии разработчики добавили еще один неприятный механизм: защиту процессов от завершения. Для этого через ftrace перехватывается do_send_sig_info, а потом руткит выборочно отбрасывает сигналы, если они адресованы защищенному PID. Код из дампа выглядит так:
if (chk_protected(p->pid)) {
if (sig == SIGKILL || sig == SIGTERM || sig == SIGSTOP ||
sig == SIGINT || sig == SIGHUP || sig == SIGQUIT) {
return 0; // Pretend success but don't deliver
}
}
Под блокировку попадают SIGKILL, SIGTERM, SIGSTOP, SIGINT, SIGHUP и SIGQUIT, то есть почти все стандартные способы остановить или завершить процесс. Для вызывающей стороны функция возвращает успех, будто сигнал доставлен. На деле он тихо отбрасывается. Если сигнал уходит скрытому, но не специально защищенному процессу, руткит может вернуть -ESRCH, поддерживая иллюзию, что такого процесса вообще нет.
Отдельный интерес представляет загрузочный скрипт load_lkm.sh. Он показывает, что VoidLink задумывался не как одиночный модуль, а как часть более крупного набора инструментов. Перед загрузкой руткита скрипт проходит по /proc/*/exe и ищет процессы, которые запущены из memfd. Для Linux такая картина часто означает файловый имплант без файла на диске, то есть процесс, который живет только в памяти:
for pid in $(ls /proc 2>/dev/null | grep -E "^[0-9]+$"); do
exe=$(readlink /proc/$pid/exe 2>/dev/null)
if [[ "$exe" == *"memfd"* ]]; then
IMPLANT_PIDS="$IMPLANT_PIDS $pid"
fi
done
Смысл такого прохода понятен: руткит сначала ищет уже существующие безфайловые полезные нагрузки, а затем получает возможность скрыть и защитить их. То есть VoidLink выступает не только как самостоятельный механизм скрытности, но и как сервисный слой для других компонентов атаки.
Исследователи подчеркивают, что исходники содержат не только техническую логику, но и следы самого процесса разработки. В коде много поэтапных пометок рефакторинга, комментариев в стиле учебника и последовательной нумерации версий. Такой рисунок совпадает с ранними выводами Check Point о том, что VoidLink почти целиком создавался в AI-assisted workflow через среду TRAE. Если прежний отчет показывал общую картину, то дамп раскрыл уже внутреннюю механику: как отдельные узлы руткита по нескольку раз переписывали, проверяли и постепенно доводили до более устойчивого состояния.
Для защиты от таких инструментов исследователи советуют не ограничиваться обычным антивирусным контролем. Рекомендуется включать Secure Boot и обязательную подпись модулей ядра, использовать режим kernel lockdown, следить через Auditd за системными вызовами init_module и finit_module, а для eBPF по возможности ограничивать вызов bpf() через seccomp или политики LSM. Если отладка на основе eBPF не нужна, имеет смысл как минимум отключать непривилегированный eBPF через kernel.unprivileged_bpf_disabled=1.
В качестве сигнатурного детектора исследователи опубликовали YARA-правило, рассчитанное на модули VoidLink и связанные артефакты:
rule Linux_Rootkit_VoidLink {
meta:
author = "Elastic Security"
creation_date = "2026-03-12"
last_modified = "2026-03-12"
os = "Linux"
arch = "x86_64"
threat_name = "Linux.Rootkit.VoidLink"
description = "Detects VoidLink LKM rootkit variants"
strings:
$mod1 = "AMD Memory Encryption Support"
$mod2 = "AMD Memory Encryption Driver"
$mod3 = "Advanced Micro Devices, Inc."
$func1 = "vl_stealth"
$func2 = "g_data"
$func3 = "icmp_cmd"
$func4 = "chk_pid"
$func5 = "chk_port"
$func6 = "mod_hide"
$func7 = "amd_mem_encrypt"
$ebpf1 = "hidden_ports"
$ebpf2 = "recvmsg_ctx"
$ebpf3 = "SOCK_DIAG_BY_FAMILY"
condition:
(2 of ($mod*) and 3 of ($func*)) or
(1 of ($mod*) and 2 of ($ebpf*)) or
(4 of ($func*))
}
История VoidLink неприятна не только из-за набора техник. Куда важнее другое: перед исследователями оказался не единичный модуль для одной версии ядра, а целая развивающаяся платформа скрытности, которую адаптировали под разные Linux-дистрибутивы и разные поколения ядра. Она умеет маскироваться под легитимный драйвер, получать адреса скрытых символов на новых ядрах, фильтровать псевдофайлы, скрывать сетевую активность, принимать команды через ICMP и защищать процессы от завершения. Для облачных и серверных Linux-систем это уже не лабораторный курьез, а вполне прикладной набор средств, рассчитанный на долгую незаметную работу.