Привет, Хабр!

Меня зовут Алексей, в компании R-Vision я занимаюсь исследованием и анализом киберугроз. И сегодня я бы хотел поговорить с вами о Dirty Pipe (CVE-2022-084) – одной из критичных уязвимостей Linux ядра, которая позволяет злоумышленникам локально повысить привилегии.Такие уязвимости выходят не так часто, но при этом несут особую опасность, так как затрагивают сразу множество дистрибутивов, работающих на уязвимой версии ядра.

Уязвимость Dirty Pipe была публично раскрыта в марте 2022, затронув несколько версий Linux, начиная с версии 5.8. После чего она была исправлена в версиях 5.16.115.15.25, а также 5.10.102. Данные версии ядра популярны во многих известных дистрибутивах, включая тот же Android. И несмотря на то, что с момента выхода патчей прошло достаточно времени, они до сих пор установлены далеко не во всех компаниях.

Особенность Dirty Pipe заключается в том, что она позволяет непривилегированному (non-root) пользователю вносить изменения в файл, доступный только для чтения, то есть с правами read для текущего пользователя. По сути это позволяет получить права root`а разными способами. Зачастую уязвимости, подобные Dirty Pipe могут быть проэксплуатированы даже из "ограниченных" сред контейнеров, если на хостах не настроены дополнительные политики безопасности (hardening). Для их устранения обычно требуется обновить само ядро, что в свою очередь может привести к большим рискам для промышленных систем.

В этой статье мною будут рассмотрены способы анализа Dirty Pipe и потенциальные варианты детектирования уязвимости. При построении возможного детекта мы не будем заострять внимание на пост-эксплуатационных шагах, а разберем способ мониторинга обращения к ядру, чтобы распознать попытки эксплуатации узявимости.

Но прежде давайте детальнее ознакомимся с тем, как работает Dirty Pipe.

Принципы работы Dirty Pipe

Для понимания работы Dirty Pipe нам нужно изучить основные системные вызовы или по-другому system calls, которые будут использоваться в процессе эксплуатации уязвимости. Системные вызовы предназначены для общения пользовательского пространства (user space) с простанством ядра (kernel space).

В свою очередь, эксплуатация Dirty Pipe стала возможна после добавления флага PIPE_BUF_FLAG_CAN_MERGE в версию ядра 5.8, позволяющего ядру объединить несколько маленьких операций в одну, что улучшает производительность.

Анализ исходного кода от автора уязвимости покажет нам, что основными системными вызовами при ее эксплуатации являются pipe и splice:

  • pipe создает "канал" между двумя процессами, позволяя одному писать в этот канал, а другому читать из него;

  • splice используется для передачи данных между двумя файловыми дескрипторами внутри пространства ядра, но без обращения к пользовательскому пространству. Он позволяет эффективно передавать данные, в том числе и между "каналами" pipe.

Кроме основных вызовов, используются и вполне "стандартные", такие как open, read и write:

  • open дает возможность открывать файл. Заметим, что у него есть аналог openat для которого указывается относительный путь файла;

  • write - используется для записи данных;

  • read - применяется для чтения.

Этапы эксплуатации Dirty Pipe выглядят следующим образом:

  1. Открывается файл с правами на чтение;

  2. Специальным образом готовится pipe c предварительной установкой флага PIPE_BUF_FLAG_CAN_MERGE;

  3. В подготовленный pipe передаются данные с помощью вызова splice;

  4. Вредоносные данные записываются в тот же pipe;

  5. Исходный файл также будет перезаписан, так как установлен флаг PIPE_BUF_FLAG_CAN_MERGE.

Для наглядности алгоритма покажу этапы в виде схемы:

Рисунок 1 - Принцип работы Dirty Pipe
Рисунок 1 - Принцип работы Dirty Pipe

Теперь давайте еще детальнее изучим работу Dirty Pipe на примере кода эксплоита. Проанализировав множество различных вариаций эксплуатации уязвимости,присутствующих в сети, можно сделать вывод, что принцип работы относительно ядра у них одинаковый. Поэтому для подготовки возможного способа детектирования мы будем использовать все тот же исходный код PoC'a автора уязвимости.

Для удобства ссылки на остальные эксплоиты я добавил ниже под спойлер.

Ссылки на эксплоиты

В коде мы увидим функцию prepare_pipe(), которая "подготавливает" pipe: сначала она наполняет его данными, а впоследствии опустошает полностью вычитывая все данные с помощью цикла. Это и позволяет создать pipe с флагом PIPE_BUF_FLAG_CAN_MERGE. На следующем шаге уже используется системный вызов splice, который передает данные с открытого файла в подготовленный pipe:

splice(fd, &offset, p[1], NULL, 1, 0)

Дальше происходит запись входных данных в дескриптор того же pipe с помощью вызова write:

write(p[1], data, data_size)

По итогу мы увидим, что данные, поданные на вход, записаны в файле несмотря на то, что он был открыт только на чтение.

Разобравшись с функциями в коде, давайте изучим, как они выглядят со стороны ядра Linux и на практике рассмотрим механизм работы Dirty Pipe.

Первичный анализ уязвимости

Для этого нам снова нужно обратиться к системным вызовам, которые будут использоваться во время эксплуатации уязвимости. Поэтому воспользуемся командой strace, компилируя и запустив PoC c параметрами атакуемого файла и номера бита для записи самого сообщения c помощью команды:

./bin/dirtypipe read_only_file.txt 10 exploit

После запуска strace, мы получаем следующий вывод:

# Открытие файла.
# 3 - это файловый дескриптор, который присвоен данному файлу в ядре.
openat(AT_FDCWD, "read_only_file.txt", O_RDONLY) = 3
newfstatat(3, "", {st_mode=S_IFREG|0444, st_size=28, ...}, AT_EMPTY_PATH) = 0

# Создание `pipe` с дескрипторами 4 и 5, для чтения и записи соответственно.
pipe2([4, 5], 0)                        = 0
fcntl(5, F_GETPIPE_SZ)                  = 65536

# Множество вызовов `write` с помощью которых происходит запись в `pipe`. 
write(5, "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 4096) = 4096
...
write(5, "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 4096) = 4096
# Соответственно происходит чтение.
read(4, "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 4096) = 4096
...
read(4, "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 4096) = 4096

# Передача данных внутри ядра из файла (дескриптор 3) в `pipe` (дескриптор 5).
splice(3, [19], 5, NULL, 1, 0)          = 1
# Запись в тот же 'pipe' (дескриптор 5).
write(5, "exploit", 7)                  = 7

В выводе видны уже знакомые нам системные вызовы с их аргументами.

"Но где же флаг PIPE_BUF_FLAG_CAN_MERGE?" - спросите Вы. Мы не увидим его в обращении к ядру, так как флаг устанавливается внутри ядра. Но чуть позже мы рассмотрим, как же его можно обнаружить.

Детектирование

Проанализировав работу Dirty Pipe, перейдем к важной части – возможным вариантам детектирования уязвимости. Использовать для этого я буду модули службу auditd и eBPF, позволяющие запускать изолированные программы внутри Linux и проводить аудит событий.

auditd

Давайте посмотрим как выглядят системные вызовы в самом распространённом демоне аудита Linux - audit. Для этого запустим мониторинг open, write, pipe (в современных Linux системах это pipe2) и splice. Чтобы осуществить запуск, нам нужно написать следующие правила в auditd:

-a always,exit -F arch=b64 -S splice -F key=dirtypipe1
-a always,exit -F arch=b64 -S pipe2 -F key=dirtypipe2
-a always,exit -F arch=b64 -S write -F key=dirtypipe3
-a always,exit -F arch=b64 -S openat -F key=dirtypipe4
  • always,exit - мониторинг всех событий после завершения;

  • arch=b64 - архитектуры системы;

  • В параметре -S выставляем название системного вызова для обнаружения;

  • Указываем key для дальнейшего удобства поиска, 1,2,3 для разделения системных вызовов.

Как настроить правила

Настроить правила можно с помощью утилиты auditctl или добавив строки в файл правил, по умолчанию это:/etc/audit/rules.d/audit.rules.В таком случае правила применятся и останутся после перезагрузки демона.

Результат можно посмотреть в лог файле auditd (audit.log) или с помощью команды ausearch, предварительно отфильтровав по названию исполняемого файла, так как данные системные вызовы часто используются в легитимных целях.

Из множества событий, нас будут интересовать следующие:

type=SYSCALL msg=audit(1678134656.879:947402): arch=c000003e syscall=257 success=yes exit=3 a0=ffffff9c a1=7fffb2093270 a2=0 a3=0 items=1 ppid=17131 pid=17144 auid=1000 uid=1000 gid=1000 euid=1000 suid=1000 fsuid=1000 egid=1000 sgid=1000 fsgid=1000 tty=pts2 ses=3 comm="dirtypipe" exe="/home/sea-admin/dirty-pipe-poc/bin/dirtypipe" subj==unconfined key="dirtypipe4"ARCH=x86_64 SYSCALL=openat AUID="sea-admin" UID="sea-admin" GID="sea-admin" EUID="sea-admin" SUID="sea-admin" FSUID="sea-admin" EGID="sea-admin" SGID="sea-admin" FSGID="sea-admin"
type=PATH msg=audit(1678134656.879:947402): item=0 name="read_only_file.txt" inode=1305601 dev=08:01 mode=0100444 ouid=0 ogid=0 rdev=00:00 nametype=NORMAL cap_fp=0 cap_fi=0 cap_fe=0 cap_fver=0 cap_frootid=0OUID="root" OGID="root"

type=SYSCALL msg=audit(1678134656.879:947403): arch=c000003e syscall=293 success=yes exit=0 a0=7fffb2091ea8 a1=0 a2=1c a3=7f0f92b2aff8 items=0 ppid=17131 pid=17144 auid=1000 uid=1000 gid=1000 euid=1000 suid=1000 fsuid=1000 egid=1000 sgid=1000 fsgid=1000 tty=pts2 ses=3 comm="dirtypipe" exe="/home/sea-admin/dirty-pipe-poc/bin/dirtypipe" subj==unconfined key="dirtypipe2"ARCH=x86_64 SYSCALL=pipe2 AUID="sea-admin" UID="sea-admin" GID="sea-admin" EUID="sea-admin" SUID="sea-admin" FSUID="sea-admin" EGID="sea-admin" SGID="sea-admin" FSGID="sea-admin"
type=FD_PAIR msg=audit(1678134656.879:947403): fd0=4 fd1=5

type=SYSCALL msg=audit(1678134656.879:947420): arch=c000003e syscall=275 success=yes exit=1 a0=3 a1=7fffb2091f40 a2=5 a3=0 items=0 ppid=17131 pid=17144 auid=1000 uid=1000 gid=1000 euid=1000 suid=1000 fsuid=1000 egid=1000 sgid=1000 fsgid=1000 tty=pts2 ses=3 comm="dirtypipe" exe="/home/sea-admin/dirty-pipe-poc/bin/dirtypipe" subj==unconfined key="dirtypipe1"ARCH=x86_64 SYSCALL=splice AUID="sea-admin" UID="sea-admin" GID="sea-admin" EUID="sea-admin" SUID="sea-admin" FSUID="sea-admin" EGID="sea-admin" SGID="sea-admin" FSGID="sea-admin"

type=SYSCALL msg=audit(1678134656.879:947421): arch=c000003e syscall=1 success=yes exit=7 a0=5 a1=7fffb2093286 a2=7 a3=0 items=0 ppid=17131 pid=17144 auid=1000 uid=1000 gid=1000 euid=1000 suid=1000 fsuid=1000 egid=1000 sgid=1000 fsgid=1000 tty=pts2 ses=3 comm="dirtypipe" exe="/home/sea-admin/dirty-pipe-poc/bin/dirtypipe" subj==unconfined key="dirtypipe3"ARCH=x86_64 SYSCALL=write AUID="sea-admin" UID="sea-admin" GID="sea-admin" EUID="sea-admin" SUID="sea-admin" FSUID="sea-admin" EGID="sea-admin" SGID="sea-admin" FSGID="sea-admin"

Эти системные вызовы сами по себе легитимны. Однако нам важна их последовательность и наличие одинакового файлового дескриптора a2=5 и a0=5 в вызовах splice и write, равного дескриптору для записи pipe - fd1=5. Такая совокупность событий редко встречается в обычных приложениях и соответствует принципу работы уязвимости.

Ниже приведен пример подобного псевдо-правила:

(
  event0.pid = event1.pid = event2.pid &&
  event0.SYSCALL = 'pipe2' &&
  event1.SYSCALL = 'splice' &&
  event2.SYSCALL ='write' &&
  event1.a2 = event2.a0 = event0.fd1
)

Однако события выше по‑прежнему не показывают наличия флага PIPE_BUF_FLAG_CAN_MERGE. Как следствие, отсюда могут возникнуть ложные сработки, поскольку эти вызовы отражают легитимную работу системы. Это является одним из недостатков детекта с помощью auditd. Еще один недостаток данного способа заключается в огромном количестве системных вызовов write , что может привести к сильной нагрузке как на саму операционную систему, так и на SIEM систему. Можно, конечно, сосредоточиться исключительно на системных вызовах splice, но в его аргументах содержится только информация о файловых дескрипторах и длине фрейма. Таким образом, отличить нелегитимный вызов splice только с помощью auditd невозможно.

Если вы увидите подобные варианты детектирования с помощью одного правила auditd, которое приведено ниже или приводится в некоторых источниках (1,2,3) не спешите вносить его в audit.rules.

-a always,exit -F arch=b64 -S splice -F a0=0x3 -F a2=0x5 -F a3=0x0 -F key=dirtypipe

Основной минус заключается в том, что если добавить дополнительный open любого файла в начало эксплоита, то вы получите a0=0x4 a2=0x6, так как значения файловых дескрипторов вырастут на 1. И так можно делать много раз, что позволяет очень легко обходить подобные правила.

Рассмотрев вариант детекта с помощью auditd,стало очевидным, что он имеет ряд недостатков. Поэтому мы проанализируем еще один способ - через eBPF.

eBPF

Как я уже говорил, уязвимость Dirty Pipe эксплуатируется начиная с версии ядра 5.8. В данной версии присутствует инструмент eBPF, который также может быть задействован для выявления эксплуатации.

eBPF (extended Berkeley Packet Filter) – это технология, позволяющая программно обрабатывать сетевые пакеты и события в операционной системе Linux без необходимости напрямую изменять код ядра. "Под капотом" у этой технологии находится виртуальная машина, выполняющая программируемые сценарии, написанные на языке C. Сценарии могут быть загружены в ядро во время выполнения и использоваться для настройки и оптимизации поведения различных компонентов сети и системы.

Программы eBPF возможно присоединить к различным системным событиям и вызовам в ядре. Для наглядности мы воспользуемся библиотекой BCC для Python и присоединим наш eBPF модуль к системному вызову splice, так как он встречается реже, чем write и pipe. Это минимизирует затрату ресурсов при обнаружении, также он содержит необходимые файловые дескрипторы. Затем проверим последний Page Buffer Ring (PBR) на наличие флага PIPE_BUF_FLAG_CAN_MERGE. PBR позволяет ядру отслеживать информацию о физическом расположении страниц в памяти, используемых для буферизации ввода-вывода (I/O) данных, таких как: файлы, сетевые пакеты и другие объекты.

А теперь давайте посмотрим как eBPF модуль поможет в выявлении эксплуатации DirtyPipe. Для этого воспроизведём следующие действия:

  • Заберем аргумент файлового дескриптора pipe из системного вызова splice и проверить флаги в PBR;

  • Для чтения информации из ядра воспользуемся BPF Helper bpf_probe_read;

  • Если флаг PIPE_BUF_FLAG_CAN_MERGE будет найден, получим PID и команду запуска вредоносного процесса с помощью bpf_get_current_pid_tgid() и bpf_get_current_comm(&comm, sizeof(comm);

  • После чего выведем информацию в наше пользовательское пространство, используя функцию bpf_trace_printk.

#include <linux/ptrace.h>
#include </linux/pipe_fs_i.h>
#include </linux/fdtable.h>

#define PIPE_BUF_FLAG_CAN_MERGE 0x10

int splice_check_flags(struct pt_regs *ctx) {
  //fd_out параметр системного вызова splice
  struct pt_regs *_ctx = (struct pt_regs *)PT_REGS_PARM1(ctx);
  int fd_out;
  bpf_probe_read_kernel(&fd_out, sizeof(fd_out), &(PT_REGS_PARM3(_ctx)));
  
  struct task_struct *task = (struct task_struct *)bpf_get_current_task();
  
  struct files_struct     *files;
  struct fdtable          *fdt;
  struct file             **fd;
  struct file             *f;
  struct dentry           *dentry;
  struct inode            *d_inode;
  struct pipe_inode_info  *i_pipe;
  struct pipe_buffer      *bufs;
  struct pipe_buffer       buf;

  unsigned int             flags;
  unsigned int             ring_size;
  //получаем флаги последнего buffer ring
  bpf_probe_read_kernel(&files, sizeof(files), &task->files);
  bpf_probe_read_kernel(&fdt, sizeof(fdt), &files->fdt);
  bpf_probe_read_kernel(&fd, sizeof(fd), &fdt->fd);
  bpf_probe_read_kernel(&f, sizeof(f), &fd[fd_out]);
  bpf_probe_read_kernel(&dentry, sizeof(dentry), &f->f_path.dentry);
  bpf_probe_read_kernel(&d_inode, sizeof(d_inode), &dentry->d_inode);
  bpf_probe_read_kernel(&i_pipe, sizeof(i_pipe), &d_inode->i_pipe);
  bpf_probe_read_kernel(&ring_size, sizeof(ring_size), &i_pipe->ring_size);
  bpf_probe_read_kernel(&bufs, sizeof(bufs), &i_pipe->bufs);
  bpf_probe_read_kernel(&buf, sizeof(buf), &bufs[ring_size-1]);
  bpf_probe_read_kernel(&flags, sizeof(flags), &buf.flags);


  // Если установлен флаг PIPE_BUF_FLAG_CAN_MERGE, выводим pid и команду 
  if(flags & PIPE_BUF_FLAG_CAN_MERGE){
    u32 task_id;
    char comm[TASK_COMM_LEN];
    task_id = bpf_get_current_pid_tgid();
    bpf_get_current_comm(&comm, sizeof(comm));
    bpf_trace_printk("%d  %s\n", task_id, comm);
    return 0;
    }
}

Также с помощью eBPF мы можем проверить следующие аспекты:

  • Открыт ли исходный файл в режиме чтения (read);

  • Является ли файловый дескриптор вывода дескриптором pipe;

  • Пишутся ли в этот же дескриптор другие данные.

Но это все возможно увидеть и в auditd. Однако при детекте с помощью eBPF можно будет проводить корреляцию прямо внутри ядра, что позволит значительно сэкономить ресурсы, но это уже будет темой отдельной статьи.

Чтобы загрузить eBPF, нам потребуется вспомогательный скрипт, который читает файл с данным модулем и выводит уведомление в консоль о возможных сработках:

#!/usr/bin/python

from bcc import BPF

# Читаем ebpf код из файла
with open("bpf.c") as file:
	bpf_txt = file.read()


# Присоединяем наш ebpf к системному вызову splice
bpf_ctx = BPF(text=bpf_txt)
bpf_ctx.attach_kprobe(event=bpf_ctx.get_syscall_fnname("splice"),
    fn_name="splice_check_flags")


# Настраиваем вывод в консоль 
print('Waiting for Dirty Pipe')
print('PID      Command')
while 1:
    try:
        bpf_ctx.trace_print(fmt="{5}")
    except KeyboardInterrupt:
        print()
        exit()

В результате сработки мы увидим уведомление о запущенной команде, пытающейся эксплуатировать Dirty Pipe:

Waiting for Dirty Pipe
PID      Command
b'22136  dirtypipe'

Следственно, можно сделать вывод, что выявление Dirty Pipe с использованием eBPF оказалось эффективнее. Данный вывод представлен больше для демонстрационных целей, поэтому для отправки данных в SIEM лучше настроить нормальный вывод в лог-файл.

Заключение

Уязвимости ядра могут быть очень опасны и порой их сложно детектировать из-за ограниченности популярных средств логирования в Linux. Рассмотрев два основных варианта детектирования, можно сделать вывод, что auditd предоставляет множество вариантов для аудита Linux, но зачастую их бывает недостаточно для обнаружения некоторых аспектов ядра. В данном примере мы увидели, что с помощью него нельзя обнаружить флаги в буфере pipe . В таком случае приходится прибегать к таким инструментам, как модуль eBPF.

В свою очередь, данный модуль позволяет самостоятельно обращаться к нужным элементам ядра и получать о них информацию. С помощью eBPF, проводя мониторинг системного вызова splice - неотъемлемой части эксплуатации уязвимости Dirty Pipe, мы смогли обнаружить флаг PIPE_BUF_FLAG_CAN_MERGE, чего не удавалось сделать с помощью auditd. В данном случае, этот способ детекта оказался эффективнее.

Если у вас остались вопросы, пишите в комментариях! Буду рад, если статья будет полезной для вас!

Автор: Епишев Алексей, аналитик-исследователь киберугроз в компании R-Vision

Комментарии (1)


  1. tafarberg
    00.00.0000 00:00

    Спасибо за такой подробный разбор и практические советы.

    Прекрасный пример анализа!