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.

tcpdump

Распрацоўка 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. Чатыры малодшыя біта пятнаццатага байта - гэтае поле Internet Header Length загалоўка 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 compiler яны распавядаюць аб тым, што такое 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 c 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 і Van Jacobson, "The BSD Packet Filter: A New Architecture for User-level Packet Capture", https://www.tcpdump.org/papers/bpf-usenix93.pdf
  2. Steven McCanne, "libpcap: "Ан архітэктура і аптымізацыя метадалогіі для пакета 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 the 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: а здавацца пад ходам", https://fosdem.org/2020/schedule/event/debugging_strace_bpf/
  13. netsniff-ng: http://netsniff-ng.org/

Крыніца: habr.com

Дадаць каментар