BPF для найменших, частина нульова: classic BPF

Berkeley Packet Filters (BPF) - це технологія ядра Linux, яка не сходить з перших смуг англомовних технічних видань вже кілька років поспіль. Конференції забиті доповідями про використання та розробку BPF. David Miller, мантейнер мережної підсистеми Linux, називає свою доповідь на Linux Plumbers 2018 "Цей розмова не є XDP" (XDP – це один із варіантів використання BPF). Brendan Gregg читає доповіді під назвою Linux BPF Superpowers. Toke Høiland-Jørgensen сміється, Що ядро ​​тепер microkernel. Thomas Graf рекламує ідею про те, що BPF це javascript для ядра.

На Хабрі досі немає систематичного опису BPF, і тому я в серії статей постараюся розповісти про історію технології, описати архітектуру та засоби розробки, окреслити сфери застосування та практики використання BPF. У цій, нульовій статті циклу розповідається історія та архітектура класичного BPF, а також розкриваються таємниці принципів роботи tcpdump, seccomp, strace, і багато іншого.

Розробка BPF контролюється мережевою спільнотою Linux, основні існуючі застосування BPF пов'язані з мережами і тому, з дозволу @eucariotя назвав серію "BPF для найменших", на честь великої серії "Мережі для найменших".

Короткий курс історії BPF(c)

Сучасна технологія BPF - це покращена і доповнена версія старої технології з тією ж назвою, що називається нині, щоб уникнути плутанини, classic BPF. На основі класичного BPF були створені всім відома утиліта tcpdump, механізм seccomp, а також менш відомі модуль xt_bpf для iptables та класифікатор cls_bpf. У сучасному Linux класичні програми BPF автоматично транслюються в нову форму, однак, з точки зору користувачів, API залишився на місці і нові застосування класичного BPF, як ми побачимо в цій статті, знаходяться досі. З цієї причини, а також тому, що, ідучи за історією розвитку класичної BPF в Linux, стане ясніше, як і чому вона еволюціонувала в сучасну форму, я вирішив почати саме зі статті про класичний BPF.

Наприкінці вісімдесятих років минулого століття інженери із знаменитої Lawrence Berkeley Laboratory зацікавилися питанням про те, як правильно фільтрувати мережеві пакети на сучасному для кінця вісімдесятих років минулого століття залізі. Базова ідея фільтрації, реалізована спочатку технології CSPF (CMU/Stanford Packet Filter), полягала у тому, щоб фільтрувати зайві пакети якомога раніше, тобто. у просторі ядра, оскільки це дозволяє не копіювати зайві дані у простір користувача. Щоб забезпечити безпеку часу виконання для запуску коду користувача в просторі ядра, використовувалася віртуальна машина - пісочниця.

Однак віртуальні машини для фільтрів, що існували, були спроектовані для запуску на машинах зі стіковою архітектурою і на нових RISC машинах працювали не так ефективно. У результаті зусиллями інженерів з Berkeley Labs було розроблено нову технологію BPF (Berkeley Packet Filters), архітектуру віртуальної машини якої було спроектовано на основі процесора Motorola 6502 — робочої конячки таких відомих продуктів як Apple II або РЕШ. Нова віртуальна машина збільшувала продуктивність фільтрів у десятки разів у порівнянні з існуючими рішеннями.

Архітектура машини BPF

Ми познайомимося з архітектурою по-робочому, розбираючи приклади. Однак для початку все ж таки скажемо, що у машини було два доступні для користувача 32-бітові регістри, акумулятор A та індексний регістр X, 64 байти пам'яті (16 слів), доступної для запису та подальшого читання, і невелика система команд для роботи з цими об'єктами. У програмах були доступні й інструкції переходу для реалізації умовних виразів, проте для гарантії своєчасного закінчення програми переходити можна було тільки вперед, тобто, зокрема, заборонялося створювати цикли.

Загальна схема запуску машини така. Користувач створює програму для архітектури BPF і за допомогою якогось механізму ядра (наприклад, системного виклику), завантажує та підключає програму до якомусь генератору подій у ядрі (наприклад, подія - це прихід чергового пакета на мережеву картку). У разі виникнення події ядро ​​запускає програму (наприклад, в інтерпретаторі), у своїй пам'ять машини відповідає якомусь регіону пам'яті ядра (наприклад, даних пакету, що прийшов).

Сказаного вище нам буде достатньо для того, щоб почати розбирати приклади: ми познайомимося із системою та форматом команд за потребою. Якщо ж вам хочеться відразу вивчити систему команд віртуальної машини та дізнатися про всі її можливості, можна прочитати оригінальну статтю The BSD Packet Filter та/або першу половину файлу Documentation/networking/filter.txt документації ядра. Окрім цього, можна вивчити презентацію libpcap: An Architecture and Optimization Methodology for Packet Capture, в якій McCanne, один із авторів BPF, розповідає про історію створення libpcap.

Ми переходимо до розгляду всіх істотних прикладів застосування класичного BPF в Linux: tcpdump (libpcap), seccomp, xt_bpf, cls_bpf.

TCPDOMP

Розробка BPF велася паралельно з розробкою фронтенду для фільтрації пакетів - всім відомої утиліти tcpdump. І, оскільки це найстаріший і найвідоміший приклад використання класичного BPF, доступний на багатьох операційних системах, з нього ми вивчення технології і почнемо.

(Всі приклади у цій статті я запускав на Linux 5.6.0-rc6. Виведення деяких команд відредаговано для більшої зручності читання.)

Приклад: спостерігаємо IPv6 пакети

Уявимо, що ми хочемо дивитися на всі IPv6 пакети на інтерфейсі eth0. Для цього ми можемо запустити програму tcpdump з найпростішим фільтром ip6:

$ sudo tcpdump -i eth0 ip6

Водночас tcpdump скомпілює фільтр ip6 до байт-коду архітектури BPF і відправить його в ядро ​​(див. подробиці у розділі Tcpdump: завантаження). Завантажений фільтр буде запущений для кожного пакета, що проходить через інтерфейс eth0. Якщо фільтр поверне ненульове значення n, те до n байтів пакета буде скопійовано в простір користувача і ми побачимо його у виводі tcpdump.

BPF для найменших, частина нульова: classic BPF

Виявляється, ми можемо легко дізнатися, який саме байткод відправив у ядро. tcpdump за допомогою самого tcpdumpякщо запустимо його з опцією -d:

$ sudo tcpdump -i eth0 -d ip6
(000) ldh      [12]
(001) jeq      #0x86dd          jt 2    jf 3
(002) ret      #262144
(003) ret      #0

На нульовому рядку ми запускаємо команду ldh [12], яка розшифровується як «завантажити в регістр A пів-слова (16 біт), що знаходяться за адресою 12» і єдине питання - це що за пам'ять ми адресуємо? Відповідь у тому, що за адресою x починається (x+1)-й байт аналізованого мережного пакета Ми читаємо пакети з Ethernet інтерфейсу eth0, а це означає, що пакет виглядає так (для простоти ми вважаємо, що в пакеті немає VLAN тегів):

       6              6          2
|Destination MAC|Source MAC|Ether Type|...|

Отже після виконання команди ldh [12] у регістрі A виявиться поле Ether Type — тип пакету, що передається в цьому Ethernet-фреймі. На рядку 1 ми порівнюємо вміст регістру A (Тип пакета) c 0x86dd, а це і є цікавий для нас тип IPv6. На рядку 1 крім команди порівняння є ще два стовпці. jt 2 и jf 3 - Мітки, на які потрібно перейти у разі вдалого порівняння (A == 0x86dd) та невдалого. Отже, вдалому випадку (IPv6) ми переходимо на рядок 2, а в невдалому - на рядок 3. На рядку 3 програма завершується з кодом 0 (не копіюй пакет), на рядку 2 програма завершується з кодом 262144 (скопіюй мені максимум 256 кілобайт пакета).

Приклад складніше: дивимося на TCP пакети порту призначення

Подивимося як виглядає фільтр, який копіює всі TCP пакети з портом призначення 666. Ми розглянемо випадок IPv4, оскільки IPv6 випадок простіше. Після вивчення цього прикладу, ви можете як вправу самостійно вивчити фільтр для IPv6 (ip6 and tcp dst port 666) та фільтр для загального випадку (tcp dst port 666). Отже, фільтр, що цікавить нас, виглядає наступним чином:

$ sudo tcpdump -i eth0 -d ip and tcp dst port 666
(000) ldh      [12]
(001) jeq      #0x800           jt 2    jf 10
(002) ldb      [23]
(003) jeq      #0x6             jt 4    jf 10
(004) ldh      [20]
(005) jset     #0x1fff          jt 10   jf 6
(006) ldxb     4*([14]&0xf)
(007) ldh      [x + 16]
(008) jeq      #0x29a           jt 9    jf 10
(009) ret      #262144
(010) ret      #0

Що роблять рядки 0 та 1 ми вже знаємо. На рядку 2 ми вже перевірили, що це пакет IPv4 (Ether Type = 0x800) і завантажуємо в регістр A 24-й байт пакета. Наш пакет виглядає як

       14            8      1     1
|ethernet header|ip fields|ttl|protocol|...|

а значить ми завантажуємо в регістр A поле Protocol заголовка IP, що логічно, адже ми хочемо копіювати тільки TCP пакети. Ми порівнюємо Protocol з 0x6 (IPPROTO_TCP) на рядку 3.

На рядках 4 та 5 ми завантажуємо півслова, що знаходяться за адресою 20, та за допомогою команди jset перевіряємо, чи не виставлений один із трьох прапори - У масці виданої jset очищено три старші біти. Два біти з трьох говорять нам, чи є пакет частиною фрагментованого IP пакета, і якщо так, то він останнім фрагментом. Третій біт зарезервований і повинен дорівнювати нулю. Ми не хочемо перевіряти ні нецілі ні биті пакети, тому й перевіряємо всі три біти.

Рядок 6 - найцікавіша в цьому лістингу. Вираз ldxb 4*([14]&0xf) означає, що ми завантажуємо в регістр X чотири молодші біти п'ятнадцятого байта пакета, помножені на 4. Чотири молодші біти п'ятнадцятого байта — це поле Довжина заголовка Інтернету заголовка IPv4, в якому зберігається довжина заголовка в словах, тому і потрібно потім помножити на 4. Цікаво, що вираз 4*([14]&0xf) — це позначення спеціальної схеми адресації, яке можна використовувати лише у такому вигляді і лише для регістру X, тобто. ми не можемо сказати ні ldb 4*([14]&0xf) ні ldxb 5*([14]&0xf) (Ми можемо тільки вказати інший offset, наприклад, ldxb 4*([16]&0xf)). Зрозуміло, що ця схема адресації була додана BPF рівно для того, щоб отримувати в X (Індексний регістр) довжину заголовка IPv4.

Таким чином, на рядку 7 ми намагаємося завантажити півслова, за адресою (X+16). Згадавши, що 14 байт займає заголовок Ethernet, а X містить довжину заголовка IPv4, ми розуміємо, що в A завантажується порт призначення TCP:

       14           X           2             2
|ethernet header|ip header|source port|destination port|

Зрештою, на рядку 8 ми порівнюємо порт призначення з потрібним значенням і на рядках 9 або 10 повертаємо результат — копіювати пакет чи ні.

Tcpdump: завантаження

У попередніх прикладах ми спеціально не зупинялися докладно на тому, як ми завантажуємо BPF байткод в ядро ​​для фільтрації пакетів. Взагалі кажучи, tcpdump портований на багато систем та для роботи з фільтрами tcpdump використовує бібліотеку libpcap. Коротко, щоб посадити фільтр на інтерфейс за допомогою libpcap, Потрібно зробити наступне:

  • створити дескриптор типу pcap_t з імені інтерфейсу: pcap_create,
  • активувати інтерфейс: pcap_activate,
  • скомпілювати фільтр: pcap_compile,
  • підключити фільтр: pcap_setfilter.

Для того, щоб переглянути як функція pcap_setfilter реалізована в Linux, ми використовуємо strace (Деякі рядки були видалені):

$ sudo strace -f -e trace=%network tcpdump -p -i eth0 ip
socket(AF_PACKET, SOCK_RAW, 768)        = 3
bind(3, {sa_family=AF_PACKET, sll_protocol=htons(ETH_P_ALL), sll_ifindex=if_nametoindex("eth0"), sll_hatype=ARPHRD_NETROM, sll_pkttype=PACKET_HOST, sll_halen=0}, 20) = 0
setsockopt(3, SOL_SOCKET, SO_ATTACH_FILTER, {len=4, filter=0xb00bb00bb00b}, 16) = 0
...

На перших двох рядках висновку ми створюємо raw сокет для читання всіх Ethernet кадрів і прив'язуємо його до інтерфейсу eth0. з нашого першого прикладу ми знаємо, що фільтр ip буде складатися з чотирьох BPF інструкцій, і на третьому рядку ми бачимо, як за допомогою опції SO_ATTACH_FILTER системного виклику setsockopt ми завантажуємо та приєднуємо фільтр довжини 4. Це і є наш фільтр.

Варто зазначити, що в класичному BPF завантаження та приєднання фільтра завжди відбувається як атомарна операція, а в новій версії BPF завантаження програми та прив'язка її до генератора подій розділені за часом.

Прихована правда

Трохи повніша версія висновку виглядає так:

$ sudo strace -f -e trace=%network tcpdump -p -i eth0 ip
socket(AF_PACKET, SOCK_RAW, 768)        = 3
bind(3, {sa_family=AF_PACKET, sll_protocol=htons(ETH_P_ALL), sll_ifindex=if_nametoindex("eth0"), sll_hatype=ARPHRD_NETROM, sll_pkttype=PACKET_HOST, sll_halen=0}, 20) = 0
setsockopt(3, SOL_SOCKET, SO_ATTACH_FILTER, {len=1, filter=0xbeefbeefbeef}, 16) = 0
recvfrom(3, 0x7ffcad394257, 1, MSG_TRUNC, NULL, NULL) = -1 EAGAIN (Resource temporarily unavailable)
setsockopt(3, SOL_SOCKET, SO_ATTACH_FILTER, {len=4, filter=0xb00bb00bb00b}, 16) = 0
...

Як було сказано вище, ми завантажуємо та приєднуємо до сокету наш фільтр на рядку 5, але що відбувається на рядках 3 та 4? Виявляється, це libpcap піклується про нас — для того, щоб у виведення нашого фільтра не потрапили пакети, які йому не задовольняють, бібліотека приєднує фіктивний фільтр ret #0 (Драпнути всі пакети), переводить сокет в неблокуючий режим і намагається віднімати всі пакети, які могли залишитися від минулих фільтрів.

Для того, щоб фільтрувати пакети на Linux за допомогою класичного BPF, необхідно мати фільтр у вигляді структури типу struct sock_fprog і відкритий сокет, після чого фільтр можна приєднати до сокету за допомогою системного виклику setsockopt.

Цікаво, що фільтр можна приєднувати до будь-якого сокету, не лише до raw. Ось приклад програми, яка відрізає все, крім двох перших байт у всіх вхідних UDP датаграм. (Коментарі я додав у коді, щоб не захаращувати статтю.)

Докладніше про використання setsockopt для підключення фільтрів див. socket(7), а про написання своїх фільтрів виду struct sock_fprog без допомоги tcpdump ми поговоримо у розділі Програмуємо BPF за допомогою власних рук.

Класичний BPF та XXI століття

BPF був включений до Linux у 1997 році і довгий час залишався робочим конячком libpcap без особливих змін (Linux-специфічні зміни, звичайно, було, але де вони змінювали глобальної картини). Перші серйозні ознаки того, що BPF еволюціонуватиме з'явилися у 2011 році, коли Eric Dumazet запропонував патч, що додає в ядро ​​Just In Time Compiler - транслятор для перекладу байткоду BPF в нативний x86_64 код.

JIT compiler був першим у ланцюжку змін: у 2012 році з'явилася можливість писати фільтри для seccomp, використовуючи BPF, у січні 2013 був доданий модуль xt_bpf, що дозволяє писати правила для iptables за допомогою BPF, а у жовтні 2013 був доданий ще й модуль cls_bpfдозволяє писати за допомогою BPF класифікатори трафіку.

Ми скоро розглянемо всі ці приклади докладніше, проте спочатку нам буде корисно навчитися писати та компілювати довільні програми для BPF, оскільки можливості, що надаються бібліотекою libpcap обмежені (простий приклад: фільтр, згенерований libpcap може повернути лише два значення - 0 або 0x40000) або взагалі, як у випадку seccomp, непридатні.

Програмуємо BPF за допомогою власних рук

Познайомимося з бінарним форматом інструкцій BPF, він дуже простий:

   16    8    8     32
| code | jt | jf |  k  |

Кожна інструкція займає 64 біти, в яких перші 16 біт - це код команди, потім йдуть два восьмибітні відступи, jt и jf, і 32 біти для аргументу Kпризначення якого змінюється від команди до команди. Наприклад, команда ret, завершальна робота програми має код 6, а значення, що повертається, береться з константи K. Мовою C одна інструкція BPF подається у вигляді структури

struct sock_filter {
        __u16   code;
        __u8    jt;
        __u8    jf;
        __u32   k;
}

а ціла програма - у вигляді структури

struct sock_fprog {
        unsigned short len;
        struct sock_filter *filter;
}

Таким чином, ми вже можемо писати програми (коди інструкцій ми, скажімо, знаємо з [1]). Ось так виглядатиме фільтр ip6 з нашого першого прикладу:

struct sock_filter code[] = {
        { 0x28, 0, 0, 0x0000000c },
        { 0x15, 0, 1, 0x000086dd },
        { 0x06, 0, 0, 0x00040000 },
        { 0x06, 0, 0, 0x00000000 },
};
struct sock_fprog prog = {
        .len = ARRAY_SIZE(code),
        .filter = code,
};

Програму prog ми можемо легально використовувати у виклику

setsockopt(sk, SOL_SOCKET, SO_ATTACH_FILTER, &prog, sizeof(prog))

Писати програми у вигляді машинних кодів не дуже зручно, але іноді доводиться (наприклад, для налагодження, створення юніт-тестів, написання статей на хабрі тощо). Для зручності у файлі <linux/filter.h> визначаються макроси-помічники — той самий приклад, як і вище, можна було б переписати як

struct sock_filter code[] = {
        BPF_STMT(BPF_LD|BPF_H|BPF_ABS, 12),
        BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, ETH_P_IPV6, 0, 1),
        BPF_STMT(BPF_RET|BPF_K, 0x00040000),
        BPF_STMT(BPF_RET|BPF_K, 0),
}

Однак, і такий варіант не дуже зручний. Так розсудили і програмісти ядра Linux і тому директорії tools/bpf ядра можна знайти асемблер і дебагер для роботи з класичним BPF.

Мова асемблера дуже схожа на налагоджувальний висновок tcpdump, але на додаток ми можемо вказувати символічні позначки. Наприклад, ось програма, яка тремтить всі пакети, крім TCP/IPv4:

$ cat /tmp/tcp-over-ipv4.bpf
ldh [12]
jne #0x800, drop
ldb [23]
jneq #6, drop
ret #-1
drop: ret #0

За замовчуванням асемблер генерує код у форматі <количество инструкций>,<code1> <jt1> <jf1> <k1>,..., для нашого прикладу з TCP вийде

$ tools/bpf/bpf_asm /tmp/tcp-over-ipv4.bpf
6,40 0 0 12,21 0 3 2048,48 0 0 23,21 0 1 6,6 0 0 4294967295,6 0 0 0,

Для зручності C програмістів можна використовувати інший формат виводу:

$ tools/bpf/bpf_asm -c /tmp/tcp-over-ipv4.bpf
{ 0x28,  0,  0, 0x0000000c },
{ 0x15,  0,  3, 0x00000800 },
{ 0x30,  0,  0, 0x00000017 },
{ 0x15,  0,  1, 0x00000006 },
{ 0x06,  0,  0, 0xffffffff },
{ 0x06,  0,  0, 0000000000 },

Цей текст можна скопіювати для визначення структури типу struct sock_filterЯк ми й проробили на початку цього розділу.

Розширення Linux та netsniff-ng

Крім стандартних інструкцій BPF, Linux та tools/bpf/bpf_asm підтримують та нестандартний набір. В основному, інструкції служать для доступу до полів структури struct sk_buff, що описує мережевий пакет у ядрі. Проте, є й інструкції-помічники іншого типу, наприклад ldw cpu завантажить у регістр A результат запуску функції ядра raw_smp_processor_id(). (У новій версії BPF ці нестандартні розширення були розширені у вигляді надання програм набору kernel helpers для доступу до пам'яті, структур, та генерації подій.) Ось цікавий приклад фільтра, в якому ми копіюємо в простір користувача тільки заголовки пакетів, використовуючи розширення poff, payload offset:

ld poff
ret a

Розширення BPF не вдасться використовувати в tcpdump, але це гарна нагода познайомитися з пакетом утиліт netsniff-ng, який, крім іншого, містить просунуту програму netsniff-ng, яка, крім фільтрації за допомогою BPF містить також ефективний генератор трафіку, і більш просунутий, ніж tools/bpf/bpf_asm, асемблер BPF під назвою bpfc. Пакет містить докладну документацію, див. також посилання в кінці статті.

seccomp

Отже, ми вже вміємо писати BPF програми довільної складності і готові подивитися на нові приклади, перший з яких – це технологія seccomp, що дозволяє за допомогою фільтрів BPF керувати безліччю та набором аргументів системних викликів, доступних даному процесу та його нащадкам.

Перша версія seccomp була додана в ядро ​​в 2005 році і не мала великої популярності, оскільки надавала лише єдину можливість — обмежити безліч системних викликів, доступних процесу, таким: read, write, exit и sigreturn, а процес, що порушив правила, вбивався за допомогою SIGKILL. Однак у 2012 році до seccomp було додано можливість використовувати BPF фільтри, що дозволяють визначати безліч дозволених системних викликів і навіть виконувати перевірки над їх аргументами. (Цікаво, що одним з перших користувачів цієї функціональності був Chrome, а зараз людьми з Chrome розробляється механізм KRSI, заснований на новій версії BPF і що дозволяє кастомізувати Linux Security Modules.) Посилання на додаткову документацію можна знайти в кінці статті.

Зазначимо, що на хабрі вже були статті про використання seccomp, може комусь захочеться прочитати їх до (або замість) читання наступних підрозділів. у статті Контейнери та безпека: seccomp наведено приклади використання seccomp як версії 2007 року, так і версії з використанням BPF (фільтри генеруються за допомогою libseccomp), розповідається про зв'язок seccomp з Docker, а також наведено багато корисних посилань. у статті Ізолюємо демони з systemd або вам не потрібен Docker для цього! розповідається, зокрема, про те, як додавати чорні чи білі списки системних викликів для демонів під керуванням systemd.

Далі ми подивимося як писати та завантажувати фільтри для seccomp на голому C та за допомогою бібліотеки libseccomp і які плюси та мінуси є у кожного варіанта, а насамкінець подивимося як seccomp використовується програмою strace.

Пишемо та завантажуємо фільтри для seccomp

Ми вже вміємо писати програми BPF і тому подивимося спочатку на програмний інтерфейс seccomp. Встановити фільтр можна лише на рівні процесу, у своїй всі дочірні процеси будуть обмеження успадковувати. Робиться це за допомогою системного виклику seccomp(2):

seccomp(SECCOMP_SET_MODE_FILTER, flags, &filter)

де &filter це покажчик на вже знайому нам структуру struct sock_fprog, тобто. програму BPF.

Чим відрізняються програми для seccomp від програм для сокетів? Переданим контекстом. У разі сокетів нам передавалася область пам'яті, що містить пакет, а у випадку seccomp нам передається структура виду

struct seccomp_data {
    int   nr;
    __u32 arch;
    __u64 instruction_pointer;
    __u64 args[6];
};

Тут nr — це номер системного виклику, що запускається, arch - поточна архітектура (про це нижче), args - до шести аргументів системного виклику, а instruction_pointer — це вказівник на інструкцію у просторі користувача, яка зробила цей системний виклик. Таким чином, наприклад, щоб завантажити номер системного виклику в регістр A ми повинні сказати

ldw [0]

Для програм seccomp існують інші особливості, наприклад, доступ до контексту можливий тільки по 32-бітному вирівнюванню і не можна завантажувати півслова або байт - при спробі завантажити фільтр ldh [0] системний виклик seccomp поверне EINVAL. Перевірку завантажуваних фільтрів виконує функція seccomp_check_filter() ядра. (Зі смішного, в оригінальному коміті, що додає функціональність seccomp, в цю функцію забули додати дозвіл на використання інструкції mod (залишок від розподілу) і тепер вона недоступна для seccomp BPF програм, оскільки її додавання зламає ABI.)

В принципі, ми вже знаємо все, щоб писати та читати seccomp програми. Зазвичай логіка програми влаштована як білий або чорний список системних викликів, наприклад, програма

ld [0]
jeq #304, bad
jeq #176, bad
jeq #239, bad
jeq #279, bad
good: ret #0x7fff0000 /* SECCOMP_RET_ALLOW */
bad: ret #0

перевіряє чорний список із чотирьох системних викликів під номерами 304, 176, 239, 279. Що це за системні виклики? Ми не можемо сказати точно, оскільки ми не знаємо, для якої архітектури писалася програма. Тому автори seccomp пропонують починати всі програми з перевірки архітектури (поточна архітектура вказується у контексті як поле arch структури struct seccomp_data). З перевіркою архітектури початок прикладу виглядав би як:

ld [4]
jne #0xc000003e, bad_arch ; SCMP_ARCH_X86_64

і тоді наші номери системних викликів набули б певних значень.

Пишемо та завантажуємо фільтри для seccomp за допомогою libseccomp

Написання фільтрів у машинних кодах або для асемблера BPF дозволяє отримати повний контроль над результатом, але в той же час іноді краще мати переносний та/або код, що читається. У цьому нам допоможе бібліотека libseccomp, що надає стандартний інтерфейс для написання чорних чи білих фільтрів.

Давайте, наприклад, напишемо програму, яка запускає бінарний файл на вибір користувача, встановивши, попередньо, чорний список системних викликів з вищезазначеної статті (програма спрощена для більшої читальності, повний варіант можна знайти тут):

#include <seccomp.h>
#include <unistd.h>
#include <err.h>

static int sys_numbers[] = {
        __NR_mount,
        __NR_umount2,
       // ... еще 40 системных вызовов ...
        __NR_vmsplice,
        __NR_perf_event_open,
};

int main(int argc, char **argv)
{
        scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ALLOW);

        for (size_t i = 0; i < sizeof(sys_numbers)/sizeof(sys_numbers[0]); i++)
                seccomp_rule_add(ctx, SCMP_ACT_TRAP, sys_numbers[i], 0);

        seccomp_load(ctx);

        execvp(argv[1], &argv[1]);
        err(1, "execlp: %s", argv[1]);
}

Спочатку ми визначаємо масив sys_numbers із 40+ номерів системних викликів для блокування. Потім, ініціалізуємо контекст ctx і кажемо бібліотеці, що ми хочемо дозволити (SCMP_ACT_ALLOW) всі системні дзвінки за промовчанням (будувати чорні списки простіше). Потім один за одним ми додаємо всі системні дзвінки з чорного списку. Як реакцію системний виклик зі списку ми запитуємо SCMP_ACT_TRAP, у цьому випадку seccomp надішле процесу сигнал SIGSYS з описом того, який системний виклик порушив правила. Зрештою, ми завантажуємо програму в ядро ​​за допомогою seccomp_load, яка скомпілює програму та підключить її до процесу за допомогою системного виклику seccomp(2).

Для успішної компіляції програму потрібно з'єднати з бібліотекою libseccomp, Наприклад:

cc -std=c17 -Wall -Wextra -c -o seccomp_lib.o seccomp_lib.c
cc -o seccomp_lib seccomp_lib.o -lseccomp

Приклад успішного запуску:

$ ./seccomp_lib echo ok
ok

Приклад заблокованого системного виклику:

$ sudo ./seccomp_lib mount -t bpf bpf /tmp
Bad system call

використовуємо strace, щоб дізнатися подробиці:

$ sudo strace -e seccomp ./seccomp_lib mount -t bpf bpf /tmp
seccomp(SECCOMP_SET_MODE_FILTER, 0, {len=50, filter=0x55d8e78428e0}) = 0
--- SIGSYS {si_signo=SIGSYS, si_code=SYS_SECCOMP, si_call_addr=0xboobdeadbeef, si_syscall=__NR_mount, si_arch=AUDIT_ARCH_X86_64} ---
+++ killed by SIGSYS (core dumped) +++
Bad system call

звідки ми можемо дізнатися, що програму було завершено через використання забороненого системного виклику mount(2).

Отже, ми написали фільтр за допомогою бібліотеки libseccomp, помістивши нетривіальний код чотири рядки. У прикладі вище за наявності великої кількості системних викликів час виконання може помітно знизитися, оскільки перевірка — це список порівнянь. Для оптимізації нещодавно в libseccomp був включений патч, що додає підтримку атрибуту фільтра SCMP_FLTATR_CTL_OPTIMIZE. Якщо встановити цей атрибут рівним 2, то фільтр буде перетворено на програму бінарного пошуку.

Якщо хочете подивитися як влаштовані фільтри з бінарним пошуком, то погляньте на простий скрипт, що генерує такі програми на ассемблері BPF з набору номерів системних викликів, наприклад:

$ echo 1 3 6 8 13 | ./generate_bin_search_bpf.py
ld [0]
jeq #6, bad
jgt #6, check8
jeq #1, bad
jeq #3, bad
ret #0x7fff0000
check8:
jeq #8, bad
jeq #13, bad
ret #0x7fff0000
bad: ret #0

Нічого суттєво швидше написати не вийде, оскільки програми BPF не можуть здійснювати переходи по відступу (ми не можемо зробити, наприклад, jmp A або jmp [label+X]) І тому всі переходи статичні.

seccomp та strace

Усі знають утиліту strace — незамінний інструмент щодо поведінки процесів на Linux. Однак, багато хто також чув про проблеми з продуктивністю при використанні цієї утиліти. Справа в тому що strace реалізований за допомогою ptrace(2), а в цьому механізмі ми не можемо вказати, на якій саме множині системних викликів нам потрібно зупинити процес, тобто, наприклад, команди

$ time strace du /usr/share/ >/dev/null 2>&1

real    0m3.081s
user    0m0.531s
sys     0m2.073s

и

$ time strace -e open du /usr/share/ >/dev/null 2>&1

real    0m2.404s
user    0m0.193s
sys     0m1.800s

відпрацьовують приблизно один і той самий час, хоча у другому випадку хочемо трейсить лише одне системний виклик.

Нова опція --seccomp-bpf, додана в strace версії 5.3 дозволяє прискорити процес багаторазово і час запуску під трасуванням одного системного виклику вже порівняно з часом звичайного запуску:

$ time strace --seccomp-bpf -e open du /usr/share/ >/dev/null 2>&1

real    0m0.148s
user    0m0.017s
sys     0m0.131s

$ time du /usr/share/ >/dev/null 2>&1

real    0m0.140s
user    0m0.024s
sys     0m0.116s

(Тут, звичайно, є невеликий обман у тому, що ми треймемо не основний системний виклик цієї команди. Якби ми трейсили, наприклад, newfsstat, То strace гальмував би так само сильно, як і без --seccomp-bpf.)

Як працює ця опція? Без неї strace підключається до процесу та запускає його за допомогою PTRACE_SYSCALL. Коли керований процес запускає (будь-який) системний виклик, управління передається strace, який дивиться на аргументи системного виклику та запускає його за допомогою PTRACE_SYSCALL. Через деякий час процес завершує системний виклик і при виході з нього керування знову передається strace, який дивиться на значення, що повертаються і запускає процес за допомогою PTRACE_SYSCALL, і т.п.

BPF для найменших, частина нульова: classic BPF

За допомогою seccomp, однак цей процес можна оптимізувати саме так, як нам хотілося б. Саме якщо ми хочемо дивитися тільки на системний виклик X, то ми можемо написати BPF фільтр, який для X повертає значення SECCOMP_RET_TRACE, а для викликів, що нас не цікавлять. SECCOMP_RET_ALLOW:

ld [0]
jneq #X, ignore
trace: ret #0x7ff00000
ignore: ret #0x7fff0000

У цьому випадку strace спочатку запускає процес як PTRACE_CONTна кожен системний виклик відпрацьовує наш фільтр, якщо системний виклик не X, то процес продовжує роботу, але якщо це X, то seccomp передасть управління strace, який подивиться на аргументи та запустить процес як PTRACE_SYSCALL (оскільки у seccomp немає можливості запустити програму на виході із системного виклику). Коли системний виклик повернеться, strace перезапустить процес за допомогою PTRACE_CONT і буде чекати на нові повідомлення від seccomp.

BPF для найменших, частина нульова: classic BPF

При використанні опції --seccomp-bpf є два обмеження. По-перше, не вдасться приєднатися до вже існуючого процесу (опція -p програми strace), оскільки це не підтримується seccomp. По-друге, немає можливості НЕ дивитися на дочірні процеси, тому що seccomp фільтри успадковуються всіма дочірніми процесами без можливості відключити це.

Трохи більше подробиць про те, як саме strace працює з seccomp можна дізнатися з недавньої доповіді. Для нас найбільш цікавим фактом є те, що класичний BPF в особі seccomp знаходить застосування досі.

xt_bpf

Вирушимо тепер назад у світ мереж.

Передісторія: давним-давно, у 2007 році, в ядро ​​був доданий модуль xt_u32 для netfilter. Він був написаний за аналогією з ще давнішим класифікатором трафіку cls_u32 і дозволяв писати довільні бінарні правила для iptables за допомогою наступних простих операцій: завантажити 32 біти з пакета та зробити з ними набір арифметичних операцій. Наприклад,

sudo iptables -A INPUT -m u32 --u32 "6&0xFF=1" -j LOG --log-prefix "seen-by-xt_u32"

Завантажує 32 біта заголовка IP, починаючи з відступу 6, та застосовує до них маску 0xFF (Взяти молодший байт). Це - поле protocol заголовка IP, і ми його порівнюємо з 1 (ICMP). В одному правилі можна комбінувати багато перевірок, а також можна виконувати оператор @ - Перейти на X байт праворуч. Наприклад, правило

iptables -m u32 --u32 "6&0xFF=0x6 && 0>>22&0x3C@4=0x29"

перевіряє, чи не TCP Sequence Number 0x29. Не далі вдаватимуся в подробиці, тому що вже ясно, що руками такі правила писати не дуже зручно. у статті BPF - the forgotten bytecode, є кілька посилань з прикладами використання та генерації правил для xt_u32. також посилання наприкінці цієї статті.

Починаючи з 2013 року модуль замість модуля xt_u32 можна використовувати заснований на BPF модуль xt_bpf. Всім, хто дочитав до сюди, вже повинен бути зрозумілий принцип його роботи: запускати байткод BPF як правила iptables. Створити нове правило можна, наприклад, так:

iptables -A INPUT -m bpf --bytecode <байткод> -j LOG

тут <байткод> - це код у форматі виведення асемблера bpf_asm за замовчуванням, наприклад,

$ cat /tmp/test.bpf
ldb [9]
jneq #17, ignore
ret #1
ignore: ret #0

$ bpf_asm /tmp/test.bpf
4,48 0 0 9,21 0 1 17,6 0 0 1,6 0 0 0,

# iptables -A INPUT -m bpf --bytecode "$(bpf_asm /tmp/test.bpf)" -j LOG

У цьому прикладі ми фільтруємо всі пакети UDP. Контекст для BPF програми у модулі xt_bpf, звичайно, вказує на дані пакета, у випадку iptables - на початок заголовка IPv4. Значення, що повертається з BPF програми бульово, Де false означає, що пакет не збігся.

Зрозуміло, що модуль xt_bpf підтримує складніші фільтри, ніж у прикладі вище. Погляньмо на справжні приклади від компанії Cloudfare. Донедавна вони використовували модуль xt_bpf для захисту від DDoS атак. у статті Introducing the BPF Tools вони розповідають як (і чому) вони генерують BPF фільтри та публікують посилання на набір утиліт для створення таких фільтрів. Наприклад, за допомогою утиліти bpfgen можна створити BPF програму, яка матчить DNS-запит на ім'я habr.com:

$ ./bpfgen --assembly dns -- habr.com
ldx 4*([0]&0xf)
ld #20
add x
tax

lb_0:
    ld [x + 0]
    jneq #0x04686162, lb_1
    ld [x + 4]
    jneq #0x7203636f, lb_1
    ldh [x + 8]
    jneq #0x6d00, lb_1
    ret #65535

lb_1:
    ret #0

У програмі ми спочатку завантажуємо в регістр X адреса початку рядка x04habrx03comx00 всередині UDP-датаграми і потім перевіряємо запит: 0x04686162 <-> "x04hab" тощо.

Трохи згодом Cloudfare опублікувала код компілятора p0f -> BPF. у статті Introducing the p0f BPF компілер вони розповідають про те, що таке p0f і як перетворювати p0f сигнатури на BPF:

$ ./bpfgen p0f -- 4:64:0:0:*,0::ack+:0
39,0 0 0 0,48 0 0 8,37 35 0 64,37 0 34 29,48 0 0 0,
84 0 0 15,21 0 31 5,48 0 0 9,21 0 29 6,40 0 0 6,
...

На даний момент Cloudfare більше не використовує xt_bpf, оскільки вони переїхали на XDP - один із варіантів використання нової версії BPF, див. L4Drop: XDP DDoS Mitigations.

cls_bpf

Останній із прикладів використання класичного BPF в ядрі – це класифікатор. cls_bpf для підсистеми контролю трафіку в Linux, доданий в Linux наприкінці 2013 року і стародавній концептуально замінив cls_u32.

Ми, однак, не будемо зараз описувати роботу cls_bpf, оскільки з погляду знань про класичний BPF це нам нічого не дасть - ми вже познайомилися з усією функціональністю. До того ж, у наступних статтях, які розповідають про Extended BPF, ми ще неодноразово зустрінемося з цим класифікатором.

Ще одна причина не розповідати про використання класичного BPF cls_bpf полягає в тому, що в порівнянні з Extended BPF у цьому випадку кардинально звужується область застосування: класичні програми не можуть змінювати вміст пакетів і не можуть зберігати стан між викликами.

Тож настав час попрощатися з класичним BPF та зазирнути у майбутнє.

Прощання з classic BPF

Ми подивилися на те, як технологія BPF, розроблена на початку дев'яностих, успішно прожила чверть століття і до кінця знаходила нові застосування. Однак, подібно до переходу зі стічних машин на RISC, що послужив поштовхом до розробки класичного BPF, у двохтисячні стався перехід з 32-бітних на 64-бітні машини і класичний BPF став застаріти. Окрім цього, можливості класичного BPF дуже обмежені та крім застарілої архітектури — у нас немає можливості зберігати стан між викликами BPF програм, немає можливості прямої взаємодії з користувачем, немає можливості взаємодії з ядром, крім читання обмеженої кількості полів структури sk_buff і запуск найпростіших функцій-помічників, не можна змінювати вміст пакетів і переадресовувати їх.

Насправді, в даний час від класичного BPF в Linux залишився тільки API інтерфейс, а всередині ядра всі класичні програми, будь то фільтри сокетів або фільтри seccomp автоматично транслюються в новий формат, Extended BPF. (Ми розповімо про те, як саме це відбувається в наступній статті.)

Перехід на нову архітектуру розпочався у 2013 році, коли Олексій Старовойтов запропонував схему оновлення BPF. У 2014 році відповідні патчі стали з'являтися у ядрі. Наскільки я розумію, спочатку планувалося лише оптимізувати архітектуру та JIT-compiler для більш ефективної роботи на 64-бітних машинах, але натомість ці оптимізації започаткували новий розділ у розробці Linux.

Подальші статті в цій серії розкажуть про архітектуру та застосування нової технології, спочатку відомої як internal BPF, потім extended BPF, а тепер як просто BPF.

Посилання

  1. Steven McCanne and Van Jacobson, "БСД пакет пакета: Нова архітектура для User-level Packet Capture", https://www.tcpdump.org/papers/bpf-usenix93.pdf
  2. Steven McCanne, "libpcap: An Architecture and Optimization Methodology for Packet Capture", https://sharkfestus.wireshark.org/sharkfest.11/presentations/McCanne-Sharkfest'11_Keynote_Address.pdf
  3. tcpdump, libpcap: https://www.tcpdump.org/
  4. IPtable U32 Match Tutorial.
  5. BPF - the forgotten bytecode: https://blog.cloudflare.com/bpf-the-forgotten-bytecode/
  6. Introducing BPF Tool: https://blog.cloudflare.com/introducing-the-bpf-tools/
  7. bpf_cls: http://man7.org/linux/man-pages/man8/tc-bpf.8.html
  8. A seccomp overview: https://lwn.net/Articles/656307/
  9. https://github.com/torvalds/linux/blob/master/Documentation/userspace-api/seccomp_filter.rst
  10. habr: Контейнери та безпека: seccomp
  11. habr: Ізолюємо демони з systemd або вам не потрібен Docker для цього!
  12. Paul Chaignon, "strace - seccomp-bpf: a look under the hood", https://fosdem.org/2020/schedule/event/debugging_strace_bpf/
  13. netsniff-ng: http://netsniff-ng.org/

Джерело: habr.com

Додати коментар або відгук