Перехват данных из libpam (аутентификация в OpenSSH, passwd) с помощью Golang и eBPF
- суббота, 22 февраля 2025 г. в 00:00:14
В огромном и сложном мире информационной безопасности защита конфиденциальной информации остаётся важной задачей как для разработчиков, так и для безопасников. Одной из главных проблем является управление и защита учётных данных во время процессов аутентификации. Традиционные меры безопасности часто не позволяют в режиме реального времени анализировать, как именно обрабатываются учётные данные и где они потенциально могут быть раскрыты — особенно в приложениях, использующих популярные модули подключаемой аутентификации наподобие PAM (Pluggable Authentication Modules).
Я работаю над платформой защиты в режиме реального времени (runtime security platform), поэтому такие темы, как BPF, Golang и Linux, мне особенно интересны. Кроме того, меня пригласили выступить на митапе по Golang, и, чтобы заинтересовать аудиторию, я решил создать проект, который продемонстрировал бы эти технологии. Так появился этот репозиторий и статья.
Исходный код проекта доступен здесь на github.
В основе проекта лежат две ключевые технологии: uprobes
(пользовательские зонды) и eBPF
(Extended Berkeley Packet Filter, «расширенный фильтр пакетов Беркли»).
Uprobes — это механизм динамического трассирования в ядре Linux, который позволяет разработчикам инструментировать бинарные файлы пользовательского пространства. Подключаясь к определённым точкам в исполняемом коде (например, вход в функцию или выход из неё), uprobes позволяют собирать данные о выполнении программы без изменения её кода. Это делает их мощным инструментом для анализа производительности, отладки и мониторинга безопасности.
Когда исполняется определённое место в коде, срабатывает uprobe, и управление передаётся обработчику, который может извлекать информацию о контексте выполнения — такую как аргументы функций, возвращаемые значения и идентификатор процесса. В данном проекте whispers использует uprobes для мониторинга процессов аутентификации, прикрепляясь к критически важным функциям внутри libpam и перехватывая учётные данные в момент их обработки.
eBPF — это революционная технология, расширяющая возможности классического Berkeley Packet Filter (BPF). Она позволяет безопасно выполнять небольшие программы внутри ядра Linux без изменения его исходного кода или загрузки модулей ядра. Программы eBPF пишутся на ограниченном подмножестве C, компилируются в байт-код и выполняются в защищённой среде ядра Linux.
eBPF имеет широкий спектр применений — от сетевой безопасности до мониторинга производительности и отладки. Он взаимодействует с ядром и пользовательским пространством через мапы (структуры данных для хранения состояния) и типы программ (определяющие, что может делать программа eBPF).
В нашем проекте eBPF используется для реализации uprobes, позволяя перехватывать данные аутентификации. Программа eBPF подключается к целевой функции в бинарном файле пользовательского пространства и срабатывает при её вызове, собирая необходимые данные и передавая их обратно в пользовательское пространство для анализа. Эта бесшовная интеграция между пользовательским пространством и ядром позволяет whispers эффективно отслеживать учётные данные. При этом весь процесс остаётся прозрачным для отслеживаемых приложений. Сочетание uprobes и eBPF создаёт мощный механизм для анализа и мониторинга поведения системы и приложений в реальном времени. Uprobes дают возможность подключаться к критически важным точкам в пользовательских приложениях, а eBPF обеспечивает безопасное выполнение пользовательской логики в ядре. Это позволяет разработчикам создавать сложные инструменты мониторинга, такие как whispers, которые при этом остаются эффективными и минимально инвазивными.
Этот инновационный подход к мониторингу системы повышает уровень безопасности, выявляя потенциальные уязвимости и утечки данных. Также он служит образовательным инструментом, раскрывая внутренние механизмы процессов аутентификации и возможности современных технологий ядра Linux.
Одним из ключевых элементов мощных возможностей eBPF являются BPF-мапы (BPF maps) — продвинутые структуры данных, которые предназначены для хранения и обмена данными между eBPF-программами, работающими в ядре, и приложениями в пользовательском пространстве. Эти мапы играют важную роль в поддержании состояния, передаче информации и управлении сложными данными в приложениях, использующих eBPF, таких как whispers.
BPF-мапы — это структуры данных, организованные по принципу ключ-значение, к которым могут обращаться как eBPF-программы, так и приложения из пространства пользователя. Они бывают разных типов, оптимизированных для различных сценариев использования — например, массивы, хэш-таблицы и кольцевые буферы. Выбор типа мапы зависит от характера данных и паттернов доступа, которые необходимы для работы eBPF-программы и пользовательского приложения.
Основные характеристики и преимущества:
Эффективный обмен данными: BPF-мапы обеспечивают высокопроизводительный механизм передачи данных между ядром и пользовательским пространством, что критично для приложений, требующих обработки и анализа данных в реальном времени.
Сохранение состояния: в отличие от традиционной обработки пакета без сохранения состояния, BPF-мапы позволяют eBPF-программам сохранять состояние между вызовами функций и стадиями обработки пакетов, обеспечивая более сложную логику и расширенные возможности отслеживания.
Гибкость: разнообразие доступных типов мапов делает их подходящими для множества задач — от мониторинга производительности и сетевого взаимодействия до инструментов безопасности и наблюдаемости, таких как whispers.
Безопасность и изоляция: доступ к BPF мапам из eBPF-программ тщательно проверяется верификатором ядра, что гарантирует выполнение только корректных и безопасных операций, защищая ядро от потенциально вредоносного или забагованного кода.
В контексте нашего проекта BPF-мапы служат основным механизмом передачи перехваченных данных аутентификации из eBPF-программы (подключённой к функциям libpam через uprobes) в пользовательский компонент инструмента. Например, кольцевые буферы используются для эффективной передачи данных о событиях (таких как попытки аутентификации) из ядра в пользовательское пространство, где они могут быть обработаны, проанализированы и записаны в лог. Это позволяет whispers отслеживать процессы аутентификации с минимальной нагрузкой и без необходимости прямого доступа к памяти или внутренним структурам отслеживаемого приложения.
Мы знаем, что хотим перехватывать учётные данные, передаваемые через libpam, используемый в OpenSSH. Чтобы «прослушивать» libpam, нам нужно определить, какие функции и структуры этой библиотеки следует отслеживать. Также мы знаем, что будем использовать uprobes, а значит, будем опираться на доступные символы libpam — это кажется разумной отправной точкой.
Когда я начинаю новое исследование, мне нравится создавать небольшое тестовое окружение, где можно экспериментировать, что-то ломать и изучать поведение элементов. Обычно это заканчивается созданием Dockerfile, и этот случай не стал исключением.
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update -yq && \
apt-get install -yq openssh-server && \
# некоторые утилиты для отладки, чтобы облегчить исследование
apt-get install -yq binutils bpftrace systemtap systemtap-sdt-dev linux-headers-$(uname -r) vim && \
mkdir -p /var/run/sshd && \
echo 'root:pass' | chpasswd && \
sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config && \
echo "StrictHostKeyChecking no" >> /etc/ssh/ssh_config
EXPOSE 22
CMD ["/usr/sbin/sshd", "-D"]
Этот Dockerfile
не только устанавливает и настраивает OpenSSH, но и включает несколько инструментов для отладки.
Используя его, я выполняю команды docker build -t=debug .
и docker run --privileged -p 2222:22 --rm --name debug -d debug
, чтобы запустить контейнер в фоновом режиме. Затем подключаюсь к нему с помощью команды docker exec -ti debug bash
Мы запускаем контейнер с флагом --privileged
, чтобы можно было тестировать подключение uprobes, а также пробрасываем порт -p 2222:22
, чтобы можно было попытаться подключиться к контейнеру по SSH из хостовой системы.
Скриншот выше показывает процесс поиска расположения libpam, начиная с sshd. Я мог бы просто выполнить find / -name "libpam*"
, но прежде хотел убедиться, что библиотека действительно связана с OpenSSH, поскольку именно его я собираюсь использовать для тестов.
Теперь, когда я определил местоположение libpam и убедился, что она действительно используется sshd, я могу приступить к поиску интересующих символов. Для этого я просто прошу readelf
вывести список символов и фильтрую их по слову auth
, так как ожидаю увидеть этот термин в именах функций, связанных с аутентификацией:
root@664e76f33de2:/# readelf -Ws /lib/x86_64-linux-gnu/libpam.so.0 | grep auth
82: 00000000000088e0 699 FUNC GLOBAL DEFAULT 15 pam_get_authtok_verify@@LIBPAM_EXTENSION_1.1.1
93: 00000000000088b0 12 FUNC GLOBAL DEFAULT 15 pam_get_authtok@@LIBPAM_EXTENSION_1.1
112: 00000000000088c0 26 FUNC GLOBAL DEFAULT 15 pam_get_authtok_noverify@@LIBPAM_EXTENSION_1.1.1
116: 0000000000009ab0 371 FUNC GLOBAL DEFAULT 15 pam_chauthtok@@LIBPAM_1.0
118: 0000000000009940 259 FUNC GLOBAL DEFAULT 15 pam_authenticate@@LIBPAM_1.0
Эти функции представляют особый интерес. Чтобы убедиться, что они действительно подходят для отслеживания, я тестирую их с помощью bpftrace:
bpftrace -e 'uprobe:/lib/x86_64-linux-gnu/
libpam.so
.0:pam_get_authtok { printf("pam_get_authtok called\n"); }'
Запустив bpftrace
и прикрепив uprobe, я подключаюсь к контейнеру через SSH из другой терминальной сессии с помощью команды ssh
root@localhost
-p 2222
Я вижу, что событие трассировки фиксируется в момент обработки аутентификации. Кажется, мы движемся в правильном направлении.
Теперь у меня есть конкретные функции для поиска в исходном коде, поэтому я перехожу к репозиторию Linux-PAM. После небольшого исследования символов нахожу функцию pam_get_authtok
, которая выглядит как целевая функция, необходимая для перехвата.
int
pam_get_authtok (pam_handle_t *pamh, int item, const char **authtok,
const char *prompt)
{
return pam_get_authtok_internal (pamh, item, authtok, prompt, 0);
}
Так как наша BPF-программа будет перехватывать аргументы, первым делом нужно разобраться со структурой pam_handle_t
. После детального анализа исходного кода я нахожу её в pam_private.h.
struct pam_handle {
char *authtok;
unsigned caller_is;
struct pam_conv *pam_conversation;
char *oldauthtok;
char *prompt; /* для использования в pam_get_user() */
char *service_name;
char *user;
char *rhost;
char *ruser;
char *tty;
char *xdisplay;
char *authtok_type; /* PAM_AUTHTOK_TYPE */
struct pam_data *data;
struct pam_environ *env; /* структура для управления списком переменных окружения */
struct _pam_fail_delay fail_delay; /* вспомогательная функция для удобных задержек */
struct pam_xauth_data xauth; /* данные аутентификации для X-отображения */
struct service handlers;
struct _pam_former_state former; /* состояние библиотеки — поддержка
событийно-ориентированных приложений */
const char *mod_name; /* имя модуля, который в данный момент выполняется */
int mod_argc; /* количество аргументов модуля */
char **mod_argv; /* аргументы модуля */
int choice; /* какую функцию вызывать из модуля */
#ifdef HAVE_LIBAUDIT
int audit_state; /* отслеживание состояния сообщений аудита */
#endif
int authtok_verified;
char *confdir;
};
Эта структура содержит несколько ключевых полей, например, authtok
(аутентификационный токен) и user
(имя пользователя). На этом этапе у нас уже есть достаточно информации, чтобы выбрать правильную точку подключения для uprobes.
В этом разделе представлен обзор разработки eBPF-программы для whispers
— инструмента, предназначенного для мониторинга и перехвата учётных данных, обрабатываемых libpam
. Используя eBPF, whispers подключается к функциям ядра, связанным с процессами аутентификации, что обеспечивает беспрецедентную глубину анализа управления учётными данными и выявления уязвимостей безопасности. Весь исходный код доступен на github.
Прежде чем приступить к разработке eBPF-программы, убедитесь, что ваше окружение настроено и содержит следующие инструменты и библиотеки:
LLVM & Clang: используются для компиляции eBPF-программ.
Заголовочные файлы ядра Linux: необходимы для компиляции eBPF-программы, так как они предоставляют доступ к API ядра.
libbpf: библиотека для загрузки и взаимодействия с eBPF-программами и BPF-мапами.
Инструментарий Go: требуется для компонентов whispers, написанных на Go.
eBPF-программа для whispers предназначена для перехвата вызовов функции pam_get_authtok с помощью uretprobe, фиксируя передаваемые учётные данные в процессе их обработки.
uretprobe.c: содержит eBPF-код, определяющий структуры данных и логику сбора и обработки учётной информации из pam_get_authtok.
При анализе структуры pam_get_authtok
я наткнулся на проект pamspy, откуда и позаимствовал значительную часть кода. Он полностью соответствует структуре, найденной нами в исходном коде libpam
.
Исходный код uretprobe.c
можно посмотреть на github.
...
// нам нужна структура pam_handle, чтобы мы могли читать данные в нашей BPF-программе
typedef struct pam_handle
{
char *authtok;
unsigned caller_is;
void *pam_conversation;
char *oldauthtok;
char *prompt;
char *service_name;
char *user;
char *rhost;
char *ruser;
char *tty;
char *xdisplay;
char *authtok_type;
void *data;
void *env;
} pam_handle_t;
// Мы используем структуру данных кольцевого буфера (ringbuffer),
// чтобы передавать данные обратно в нашу программу в пространстве пользователя.
struct
{
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} rb SEC(".maps");
// И мы будем использовать эту структуру для хранения элементов для нашей BPF-мапы.
typedef struct _event_t {
int pid;
char comm[16];
char username[80];
char password[80];
} event_t;
...
SEC("uretprobe/pam_get_authtok")
int trace_pam_get_authtok(struct pt_regs *ctx)
{
if (!PT_REGS_PARM1(ctx))
return 0;
pam_handle_t* phandle = (pam_handle_t*)PT_REGS_PARM1(ctx);
u32 pid = bpf_get_current_pid_tgid() >> 32;
u64 password_addr = 0;
bpf_probe_read(&password_addr, sizeof(password_addr), &phandle->authtok);
u64 username_addr = 0;
bpf_probe_read(&username_addr, sizeof(username_addr), &phandle->user);
event_t *e;
e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (e)
{
e->pid = pid;
bpf_probe_read(&e->password, sizeof(e->password), (void *)password_addr);
bpf_probe_read(&e->username, sizeof(e->username), (void *)username_addr);
bpf_get_current_comm(&e->comm, sizeof(e->comm));
bpf_ringbuf_submit(e, 0);
}
return 0;
};
Чтобы организовать процесс компиляции этого кода, так как мы используем Golang, мы позже определим директиву go generate
, которая будет указывать на bash-скрипт для компиляции. На данный момент мы можем создать gen.sh
, который будет отвечать за сборку.
#!/usr/bin/env bash
set -e
set -x
# Динамический поиск заголовков ядра. При необходимости скорректируйте эту строку.
KERNEL_HEADERS=$(find /usr/src -name "linux-headers-$(uname -r)" -type d | head -n 1)
# Директория include для bpf_helpers.h. Возможно, потребуется корректировка.
BPF_HELPERS_DIR="${KERNEL_HEADERS}/tools/bpf/resolve_btfids/libbpf/include/"
# Запуск bpf2go с динамическими путями для include-файлов
go run github.com/cilium/ebpf/cmd/bpf2go -target amd64 bpf \
../../bpf/uretprobe.c -- \
-I"${KERNEL_HEADERS}" \
-I"${BPF_HELPERS_DIR}" \
-I../../bpf/headers
Этот скрипт будет автоматически вызываться при выполнении go build
, если в коде встречается директива // go generate ../path/to/gen.sh
Команда go generate
является частью инструментария Go и предназначена для автоматической генерации кода перед процессом сборки. Она сканирует файлы исходного кода на наличие специальных комментариев, указывающих на команды для выполнения на этапе генерации. Эти команды могут запускать любые процессы, но чаще всего используются для автоматической генерации кода, что делает go generate
удобным инструментом для интеграции eBPF-программ в Go-приложения.
Этот раздел посвящён компоненту whispers, написанному на Go, который служит пользовательской частью eBPF-программы. В нём мы рассмотрим, как с помощью Go загружать eBPF-программу, подключать uprobes, читать данные из BPF-мапы и обрабатывать перехваченные учётные данные для мониторинга и анализа.
Перед началом работы убедитесь, что у вас настроено окружение Go с необходимыми зависимостями:
Go 1.21 или новее: убедитесь, что установлен Go toolchain.
Структура кода whispers:
cmd/:
содержит CLI-интерфейс для whispers.
pkg/config:
определяет структуры конфигурации.
pkg/whispers:
реализует основную логику загрузки и взаимодействия с eBPF-программой.
Зависимости проекта определены в go.mod
и могут быть загружены командой go mod tidy
В этой статье мы затронем только наиболее интересные части кода. Полный исходный код можно посмотреть на github.
Мы используем cilium/ebpf
для загрузки BPF-программы и работы с BPF-мапами. В следующем коде bpfObjects
и loadBpfObjects
определены в автоматически сгенерированном файле bpf_x86_bpfel.go
, который создаётся во время компиляции go build благодаря директиве // go:generate ...
, вызывающей gen.sh
:
func Listen(ctx context.Context, cfg *config.Config) error {
objs := bpfObjects{}
if err := loadBpfObjects(&objs, nil); err != nil {
return err
}
defer objs.Close()
...
}
Приведённый выше фрагмент демонстрирует процесс загрузки eBPF-программы и гарантирует её корректное завершение при остановке работы.
whispers
подключает uretprobes
к целевым функциям (например, pam_get_authtok
) с использованием библиотеки cilium/ebpf:
ex, err := link.OpenExecutable(cfg.BinPath)
if err != nil {
return err
}
up, err := ex.Uretprobe(cfg.Symbol, objs.TracePamGetAuthtok, nil)
if err != nil {
return err
}
defer up.Close()
...
Код на Go получает данные из BPF-мапы (кольцевого буфера), считывая события, захваченные eBPF-программой:
rb, err := ringbuf.NewReader(objs.Rb)
if err != nil {
log.Printf("failed to open ring buffer reader: %v", err)
}
defer rb.Close()
go func() {
for {
record, err := rb.Read()
...
event := parseEventData(record.RawSample)
...
}
}()
Нам нужно считать сырые данные из BPF-мапы и преобразовать их в удобный для работы формат в Golang:
...
// Это должно точно соответствовать структуре элементов нашей BPF-мапы (определённой в нашем .h файле как _event_t).
type eventT struct {
Pid int32
Comm [16]byte
Username [80]byte
Password [80]byte
}
...
func byteArrayToString(b []byte) string {
n := -1
for i, v := range b {
if v == 0 {
n = i
break
}
}
if n == -1 {
n = len(b)
}
return string(b[:n])
}
func parseEventData(data []byte) *eventT {
var event eventT
if len(data) >= int(unsafe.Sizeof(eventT{})) {
if err := binary.Read(bytes.NewReader(data), binary.LittleEndian, &event); err == nil {
return &event
}
}
return nil
}
...
После прочтения событий из кольцевого буфера whispers анализирует и логирует перехваченные учётные данные.
log.Printf("Event: PID: %d, Comm: %s, Username: %s, Password: %s",
event.Pid,
byteArrayToString(event.Comm[:]),
byteArrayToString(event.Username[:]),
byteArrayToString(event.Password[:]))
В репозитории содержится Dockerfile, который не только собирает whispers
, но и включает sshd-сервер, позволяя сразу протестировать его в действии. Пример запуска.
Изначально я планировал сделать этот проект достаточно простым, так как его цель — дать общий обзор технологии в рамках технического доклада. Поэтому его нельзя назвать полнофункциональным решением. В текущей реализации проект создаёт бинарный файл, которому передаётся путь к libpam через параметр -binPath=/path/to/libpam
. Однако этот процесс можно автоматизировать с помощью eBPF.
В данный момент для отслеживания аргументов целевого приложения в пользовательском пространстве используются uprobes. Однако мы можем автоматизировать подключение этих проб с помощью kprobes.
С использованием kprobes можно фильтровать вызовы execve
и fork
для процессов, которые представляют интерес (например, sshd
, passwd
). При обнаружении таких процессов можно выполнить команду ldd /proc/<pid>/exe
, чтобы проверить, загружена ли библиотека libpam
, и автоматически подключить uprobes.
При этом стоит учитывать некоторые сложности, например, пространство имён для конкретного процесса может отличаться (например, в случае контейнеров). Также может потребоваться анализ уже запущенных процессов и подключение uprobes к ним. Однако все эти задачи не представляют особой сложности.
Итог: whispers
можно реализовать как единый бинарный файл, который не требует явного указания целей для мониторинга — он автоматически будет подключаться к нужным процессам при их запуске.
В заключение приглашаем всех желающих на ближайшие открытые уроки: