Сетевой стек Linux не прост даже на первый взгляд: приложение — в юзерспейсе, а всё, что после сокета, — в ядре операционки. И там тысяча реализаций TCP. Любое взаимодействие с сетью — системный вызов с переключением контекста в ядре.
Чтобы лишний раз не дёргать ядро прерываниями, придумали DMA — Direct Memory Access. Это когда трафик пишется напрямую в память, откуда он считывается приложением в обход ядра. И это дало жизнь классу софта с режимом работы kernel bypass. Например, при DPDK (Intel Data Plane Development Kit) сетевая карта целиком передаётся в userspace, а ядро даже не подозревает о её существовании.
Потом был BPF. А ещё потом усилиями Алексея Старовойтова и компании миру была показана eBPF — штука, умеющая делать прокол в ядро и инжектировать туда микроскопические виртуальные машины с кодом, которые могут в обход всего и вся взаимодействовать с системными событиями, и в том числе с трафиком. Супербыстро и оптимально (на фоне стандартного стека, конечно же). А это в свою очередь дало возможность использовать XDP для ускорения обработки трафика.
Но даже помимо хаков работы с ядром есть такие штуки, как sk_buff, в которой хранятся метаданные всех миллионов протоколов (в большинстве случаев они вообще не нужны: тащим с собой легаси). Есть NAPI (New API), которая призвана уменьшить число прерываний. А 100500 вариантов разных tables? Iptables, arptables, ip6tables, ebtables, nftables…
Если вам мало — ещё придумали SR-IOV. Там тоже уже упомянутый DMA, а ещё можно посплитить физическую карточку на несколько виртуальных и раздать их в разные виртуалки и приложения. Под ручку с DMA идёт и RDMA, когда мы пишем трафик напрямую в память, но не в свою, а в чужую на удалённой по сети машине.
И в этих копаниях можно уйти безгранично далеко. Но сегодня мы всё же поговорим о вещах более приземлённых и повседневных, которые лишь приоткрывают вход в эту разветвлённую сеть кроличьих нор. Мы разберём одну любопытную задачку, на примере которой ужаснёмся (а кто-то ухмыльнётся деловито в усы) тому, как сложно может быть реализован такой простой протокол, как DHCP.
И прежде чем начнёте читать этот лонгрид, попробуйте сами заблокировать DHCP с помощью iptables — так будет интереснее.
DHCP
Один из самых простых протоколов с точки зрения сетевого взаимодействия — UDP 67+68. Это всего четыре сообщения: DHCPDISCOVER, DHCPOFFER, DHCPREQUEST, DHCPACK. И даже не будем усложнять себе жизнь, используя DHCP Relay.
Сделаю тут небольшое отступление: мы тут в Яндексе придумали провести Тренировки по DevOps. В рамках этого события есть пара уроков про сети, где Боря Лыточкин и Паша Пушкарёв рассказывают про фундаментальные нетворк-штуки, сетевой стек Linux и всякое такое. И в каждом уроке есть домашка. И вот Боря, просветлённый FreeBSD и повидавший разное в Linux, придумал каверзное задание: запускаем пару виртуалок, соединяем их бриджем, на одной настраиваем DHCP-сервер, на другой — клиент. Проверяем, что адрес выдаётся — супер. А теперь пробуем заблокировать через iptables на клиентетак, чтобы любой ценой адрес не выдавался.
Сначала мы убрали эту задачку под звёздочку. Подумали — убрали под две. А потом вообще исключили из домашки. И вот почему.
С первого взгляда всё же легко, да? Ну прямо в лоб можно? Прям IP блокируем.
❯ iptables -I INPUT -s 192.168.42.1 -j DROP
Ну да, норм — пинг перестал работать. И я благополучно потерял доступ к виртуалке.
Давайте отпустим адрес и перезапросим его снова:
❯ dhclient -r
Killed old client process
❯ ip -f inet a s enp0s1
❯
Адреса на интерфейсе нет.
❯ dhclient
❯ ip -f inet a s enp0s1
2: enp0s1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
inet 192.168.42.6/24 brd 192.168.42.255 scope global dynamic enp0s1
valid_lft 86389sec preferred_lft 86389sec
Хм…
Ну ок. Возможно, там есть какие-то нюансы с IP-адресами. Может, нам не с того DHCPOFFER приходит? Ну мало ли. Не будем пока в tcpdump заглядывать.
Давайте поблочим UDP-порты.
❯ iptables -I INPUT -p udp --dport 67 -j DROP
❯ iptables -I INPUT -p udp --dport 68 -j DROP
Релиз.
❯ dhclient -r
Killed old client process
❯ dhclient
❯ ip -f inet a s enp0s1 | grep inet
inet 192.168.42.6/24 brd 192.168.42.255 scope global dynamic enp0s1
Хм-м…
Ну, как бы тут очевидно уж должно попадать. Блокировка вообще всего UDP тоже результата не даёт.
`❯ iptables -I INPUT -p udp -j DROP
Ну, я не очень умный в части линуксового стека. Может, как-то DCHP-клиент перехватывает пакеты до обработки транспортных заголовков? Заблочим MAC.
❯ iptables -I INPUT -m mac --mac-source fa:4d:89:c8:82:64 -j DROP
Та же фигня: пинг снова ломается, а DHCP — нет.
Хм-м-м…
Ладно, лезем читать форумы. Где-то дают советы про уже испробованное. Но если чуточку покопать по ключевым словам "iptables doesn't block dhcp", находим зацепку: dhclient использует не нативный стек, а raw socket. То есть этот подлец открывает сырой сокет как есть и сам реализует обработку IP и транспортных заголовков.
Что это означает для простых людей? Что такие пакеты не доходят до дефолтной таблицы filter и не попадают в цепочку INPUT: мы их там никогда и не увидели бы.
Ок. Что же дальше?
Судя по всему, нам нужен raw PREROUTING. И некоторый дальнейший гуглёж (и Боря Лыточкин) подсказывают, что так оно и есть. Таблица и цепочка обрабатывают самый сырой трафик до всего — ещё до того, как доходит дело до conntrack.
Кажется, это то, что нам нужно! Е-е-е, бой, мы на финишной прямой!
❯ iptables -t raw -I PREROUTING -m mac --mac-source fa:4d:89:c8:82:64 -j DROP
Снова оторвал себе SSH…
❯ iptables -t raw -L
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
DROP all -- anywhere anywhere MACfa:4d:89:c8:82:66
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
Релиз.
❯ dhclient -r
Killed old client process
❯ dhclient
ip -f inet a s enp0s1 | grep inet
inet 192.168.42.6/24 brd 192.168.42.255 scope global dynamic enp0s1
Хм-м-м-м….
Так! Ну ладно, может, там src MAC какой-то другой? Типа широковещательный или в нём вовсе одни нули? Всё ещё не будем запускать tcpdump — это для слабых духом. Но UDP 67 тоже не работает.
И смотрите, какой прикол:
❯ iptables -t raw -vnL
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
2 706 DROP udp -- * * 0.0.0.0/0 0.0.0.0/0 udp dpt:68
Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
iptables матчит пакеты! Статистика не врёт: один DHCPOFFER + один DHCPACK = 2. Очень интересно: матчит, но не дропает!
Давайте пока этот вопрос отложим в оперативку. Он очень интересный.
Хорошо, давайте заблокируем вообще весь UDP. Ну мало ли, вдруг мы как-то с портами напутали? Тоже нет, не напутали, и dhclient по-прежнему работает.
Видим пробу: значит, логирование работает. А вот при работе DHCP пусто: пакеты матчит, а в логи не пишет. Каков проказник!
Ну как бы… Кажется, мы что-то перестали понимать в этой жизни. Причём Боря утверждал, что в своё время он это точно делал, и оно работало. И именно поэтому он придумал такую задачку с закавыкой, чтобы ученику нужно было слегка углубиться — совсем чуть-чуть, чтобы безобразие всего линуксового стека поразило своей разносторонностью, но всё же не отпугнуло. А получилось наоборот.
Слабая надежда?
Ebtables? Постулируется, что он работает на канальном уровне, чуть пониже iptables. Я проверил — не работает.
Ну, может, хоть nftables, который весь такой молодец и приходит на смену всему зверинцу: iptables, ip6tables, arptables, ebtables. Может, в нём всё сделали хорошо? Нет. Ну, то есть, наверно, да, но dhcp как не ловился, так и не ловится. Похоже, все эти таблицы ловят одни хуки. Косвенно об этом говорит то, что в конфигурацию nftables попадают и те правила, которые я только что настроил через ebtables — первый chain INPUT.
table bridge filter {
chain INPUT {
type filter hook input priority filter; policy accept;
ether type ip udp dport 67-68 counter packets 0 bytes 0 drop
ether saddr fa:4d:89:c8:82:64 counter packets 0 bytes 0 drop
}
chain input {
type filter hook input priority 0; policy accept;
iifname "enp0s1" udp sport { 67, 68 } counter packets 0 bytes 0 drop
iifname "enp0s1" udp dport { 67, 68 } counter packets 0 bytes 0 drop
}
chain output {
type filter hook output priority 0; policy accept;
}
}
table inet filter {
chain input {
type filter hook input priority filter; policy accept;
iifname "enp0s1" udp sport { 67, 68 } counter packets 0 bytes 0 drop
}
chain output {
type filter hook output priority filter; policy accept;
}
}
Потрачено!
Хм-м-м-м-м-м...
Ещё один шаг
Ну, где бы вы думали, лежит разгадка? Наверняка уже догадываетесь: она написана в выводе strace — утилиты, отображающей системные вызовы любых процессов.
Но давайте сформулируем вопрос, который и у вас наверняка назрел: зачем вот такому элементарному протоколу, совсем не требующему высокой нагрузки, быть настолько бессмертным?
А всё очень просто. DHCP работает в тот момент, когда сети на хосте ещё нет. Хост не только не может нормально матчить dst-ip в свои адреса, чтобы отправить пакеты в сетевой стек, но даже броадкастный дестинейшен (широковещательный IP-адрес 255.255.255.255) не обработает, потому что нет IP-адреса.
Receiving packets sent to 255.255.255.255 isn't a problem on most
modern unixes...so long as the interface is configured. When there
is no IPv4 address on the interface, things become much more murky.
So, for this convoluted and unfortunate state of affairs in the unix
systems of the day ISC DHCP was manufactured, in order to do what it
needs not only to implement the reference but to interoperate with
other implementations, the software must create some form of raw
socket to operate on.
What it actually does is create, for each interface detected on the
system, a Berkeley Packet Filter socket (or equivalent), and program
it with a filter that brings in only DHCP packets. A "fallback" UDP
Berkeley socket is generally also created, a single one no matter how
many interfaces.
И, например, в OpenBSD эта история так же стара, как моя «Камри». Вот переписка про эту проблему в marc.info, тут — ещё одна ветка на рассылке OpenBSD. А если взглянуть на сорцы, то истории вообще без малого 30 лет!
It's not clear how this should work, and that lack of clarity is
terribly detrimental to the NetBSD 1.1 kernel - it crashes and
burns.
Using raw sockets ought to be a big win over using BPF or something
like it, because you don't need to deal with the complexities of
the physical layer, but it appears not to be possible with existing
raw socket implementations. This may be worth revisiting in the
future. For now, this code can probably be considered a curiosity.
Sigh. */
В итоге, чтобы заработал скромный dhclient, он должен реализовать слой обработки Ethernet! Хуже того, он должен поддержать ещё и Token Ring (проклятое!) и FDDI (легаси!) — соответствующие файлы хедеров есть в репозитории.
А поскольку стандартный сетевой стек тут не при делах, то дырку между физическим уровнем и DHCP тоже приходится закрывать в коде, реализуя протоколы IP и UDP.
It may surprise you to realize that ISC DHCP implements 802.1
'Ethernet' framing, Token Ring, and FDDI. In order to bridge the gap
there between these physical and DHCP layers, it must also implement
IP and UDP framing.
И кажется, у нас осталось только оружие судного дня.
eBPF/XDP-программа
Будем воевать с dhclient на его поле равнозначным оружием — в ядре с XDP.
Тут у нас другая лаба. Два неймспейса в виртуалке: в одном DHCP-клиент, в другом — DHCP-сервер. Они соединены друг с другом через veth-пару.
ns-client-0:
❯ ip netns exec ns-client-0 ip link show
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
48: veth-client-0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/ether e2:45:5e:0c:89:c8 brd ff:ff:ff:ff:ff:ff
ns-server-0:
❯ ip netns exec ns-server-0 ip link show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
47: veth-server-0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/ether 4e:c1:ec:de:4a:95 brd ff:ff:ff:ff:ff:ff
Поехали! Запустили dhcpd в нужном NS, тут проблем нет.
❯ ip netns exec ns-server-0 /usr/sbin/dhcpd -f -cf /etc/dhcp/dhcpd.xdp.conf -user dhcpd -group dhcpd --no-pid
Internet Systems Consortium DHCP Server 4.4.2b1
Copyright 2004-2019 Internet Systems Consortium.
All rights reserved.
For info, please visit https://www.isc.org/software/dhcp/
ldap_gssapi_principal is not set,GSSAPI Authentication for LDAP will not be used
Not searching LDAP since ldap-server, ldap-port and ldap-base-dn were not specified in the config file
Config file: /etc/dhcp/dhcpd.xdp.conf
Database file: /var/lib/dhcpd/dhcpd.leases
PID file: /var/run/dhcpd.pid
Source compiled to use binary-leases
Wrote 0 deleted host decls to leases file.
Wrote 0 new dynamic host decls to leases file.
Wrote 0 leases to leases file.
Listening on LPF/veth-server-0/4e:c1:ec:de:4a:95/10.44.0.0/24
Sending on LPF/veth-server-0/4e:c1:ec:de:4a:95/10.44.0.0/24
Sending on Socket/fallback/fallback-net
Запускаем tcpdump в ns-client-0 и ns-server-0.
❯ ip netns exec ns-server-0 tcpdump -i any -nns0 port 67
❯ ip netns exec ns-client-0 tcpdump -i any -nns0 port 67
Nov 10 22:05:22 jarvis dhcpd[201423]: DHCPDISCOVER from e2:45:5e:0c:89:c8 via veth-server-0
Nov 10 22:05:22 jarvis dhcpd[201423]: ns1.brokenpipe.pro: host unknown.
Nov 10 22:05:22 jarvis dhcpd[201423]: ns2.brokenpipe.pro: host unknown.
Nov 10 22:05:22 jarvis dhcpd[201423]: DHCPOFFER on 10.44.0.40 to e2:45:5e:0c:89:c8 via veth-server-0
Nov 10 22:05:22 jarvis dhcpd[201423]: Dynamic and static leases present for 10.44.0.40.
Nov 10 22:05:22 jarvis dhcpd[201423]: Remove host declaration node-client-0 or remove 10.44.0.40
Nov 10 22:05:22 jarvis dhcpd[201423]: from the dynamic address pool for 10.44.0.0/24
Nov 10 22:05:22 jarvis dhcpd[201423]: DHCPREQUEST for 10.44.0.40 (10.44.0.200) from e2:45:5e:0c:89:c8 via veth-server-0
Nov 10 22:05:22 jarvis dhcpd[201423]: DHCPACK on 10.44.0.40 to e2:45:5e:0c:89:c8 via veth-server-0
tcpdump на NS ns-server-0:
22:05:22.126936 veth-server-0 B IP 0.0.0.0.68 > 255.255.255.255.67: BOOTP/DHCP, Request from e2:45:5e:0c:89:c8, length 300
22:05:22.127870 veth-server-0 Out IP 10.44.0.200.67 > 10.44.0.40.68: BOOTP/DHCP, Reply, length 300
22:05:22.128027 veth-server-0 B IP 0.0.0.0.68 > 255.255.255.255.67: BOOTP/DHCP, Request from e2:45:5e:0c:89:c8, length 300
22:05:22.128238 veth-server-0 Out IP 10.44.0.200.67 > 10.44.0.40.68: BOOTP/DHCP, Reply, length 300
tcpdump на NS ns-client-0:
22:05:22.126819 veth-client-0 Out IP 0.0.0.0.68 > 255.255.255.255.67: BOOTP/DHCP, Request from e2:45:5e:0c:89:c8, length 300
22:05:22.127954 veth-client-0 In IP 10.44.0.200.67 > 10.44.0.40.68: BOOTP/DHCP, Reply, length 300
22:05:22.128023 veth-client-0 Out IP 0.0.0.0.68 > 255.255.255.255.67: BOOTP/DHCP, Request from e2:45:5e:0c:89:c8, length 300
22:05:22.128241 veth-client-0 In IP 10.44.0.200.67 > 10.44.0.40.68: BOOTP/DHCP, Reply, length 300
Так, собственно, всё и живет. IP получили.
❯ ip netns exec ns-client-0 ip a s
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
48: veth-client-0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000
link/ether e2:45:5e:0c:89:c8 brd ff:ff:ff:ff:ff:ff
inet 10.44.0.40/24 brd 10.44.0.255 scope global dynamic veth-client-0
valid_lft 146sec preferred_lft 146sec
inet6 fe80::e045:5eff:fe0c:89c8/64 scope link
valid_lft forever preferred_lft forever
Супер! Но это у нас всё и так работало. Давайте блокировать.
Пробуем применить в неймспейсе клиента на интерфейс.
❯ ip netns exec ns-client-0 ip link set veth-client-0 xdpgeneric obj xdp_drop.o sec
Перезапрашиваем адрес.
❯ ip netns exec ns-client-0 dhclient -r
❯ ip netns exec ns-client-0 dhclient
И….
Да!
❯ ip -f inet a s enp0s1 | grep inet
❯
Да! Да! Да!
Оно работает!
Хочется с чувством хорошо выполненной работы и удовлетворением захлопнуть терминал, но точит сомнение. А при попытке встать от ноутбука аж жечь начинает.
Ок, ладно.
❯ sudo journalctl -u isc-dhcp-server
Nov 10 22:13:04 jarvis dhcpd[201423]: DHCPDISCOVER from e2:45:5e:0c:89:c8 via veth-server-0
Nov 10 22:13:04 jarvis dhcpd[201423]: DHCPOFFER on 10.44.0.40 to e2:45:5e:0c:89:c8 via veth-server-0
Nov 10 22:13:07 jarvis dhcpd[201423]: DHCPDISCOVER from e2:45:5e:0c:89:c8 via veth-server-0
Nov 10 22:13:07 jarvis dhcpd[201423]: DHCPOFFER on 10.44.0.40 to e2:45:5e:0c:89:c8 via veth-server-0
Nov 10 22:13:12 jarvis dhcpd[201423]: DHCPDISCOVER from e2:45:5e:0c:89:c8 via veth-server-0
Nov 10 22:13:12 jarvis dhcpd[201423]: DHCPOFFER on 10.44.0.40 to e2:45:5e:0c:89:c8 via veth-server-0
Nov 10 22:13:17 jarvis dhcpd[201423]: DHCPDISCOVER from e2:45:5e:0c:89:c8 via veth-server-0
Nov 10 22:13:17 jarvis dhcpd[201423]: DHCPOFFER on 10.44.0.40 to e2:45:5e:0c:89:c8 via veth-server-0
Nov 10 22:13:28 jarvis dhcpd[201423]: DHCPDISCOVER from e2:45:5e:0c:89:c8 via veth-server-0
Nov 10 22:13:28 jarvis dhcpd[201423]: ns1.brokenpipe.pro: host unknown.
Nov 10 22:13:28 jarvis dhcpd[201423]: ns2.brokenpipe.pro: host unknown.
Nov 10 22:13:28 jarvis dhcpd[201423]: DHCPOFFER on 10.44.0.40 to e2:45:5e:0c:89:c8 via veth-server-0
Nov 10 22:13:46 jarvis dhcpd[201423]: DHCPDISCOVER from e2:45:5e:0c:89:c8 via veth-server-0
Nov 10 22:13:46 jarvis dhcpd[201423]: DHCPOFFER on 10.44.0.40 to e2:45:5e:0c:89:c8 via veth-server-0
Nov 10 22:14:03 jarvis dhcpd[201423]: DHCPDISCOVER from e2:45:5e:0c:89:c8 via veth-server-0
Nov 10 22:14:03 jarvis dhcpd[201423]: DHCPOFFER on 10.44.0.40 to e2:45:5e:0c:89:c8 via veth-server-0
Ну серьёзно!
Мы заблокировали на клиенте весь UDP, но на сервере видим от клиентаDHCPDISCOVER! И сервер отправляет DHCPOFFER.
Оказывается, в XDP не поддержан egress path, и он не умеет работать с исходящими пакетами.
В целом даже есть попытки затащить egress path в XDP. Но пока — нет.
И в результате нам удалось сломать работу DHCP за счёт того, что мы заблокировали входящие DHCPOFFER, из-за чего процесс выдачи адреса не может завершиться. Но всё же нам не удалось заблокировать всю коммуникацию dhclient. Но я думаю, что тут стоит остановиться и не открывать следующие двери.
Остаётся загадкой, как всякие Cilium блокируют Egress. Вероятно, используя связку интерфейсов, где из одного интерфейса в другой перекладывается пакет, и там он становится IN, и его срезают в XDP-программе.
Похоже, самый простой вариант сломать DHCP — не запускать dhclient или выключить dhcp в конфигурации сети.
DHCP такой
DHCPv6
DHCPv6, может, для кого-то и странный зверёк, но он есть, работает и используется (я лично DHCP Relay на стоечных коммутаторах в дата-центрах настраивал).
Так вот, друзья, для него это всё не нужно. НЕ НУЖНО! BPF, своя реализация Ethernet Framing, UDP/IP-заголовки самому крафтить. И его спокойно можно ограничить iptable /nftables.
Ведь в чём состоит основной конфликт сегодняшней истории? IP-адреса на интерфейсе нет — сетевой стек отбрасывает пакет. Как только он появляется как результат работы DHCP, dhclient может использовать стандартные механизмы Линукса, что он и делает для продления аренды IP-адреса через юникастовые сообщения, которые уже можно заблокировать.
А в IPv6 на интерфейсе всегда есть адрес — Link-Local из сети FE80::/12 . Сеть там всегда инициализирована, и сетевой стек не отбросит пакеты. А, ну ещё там броадкастов нет!
Со всем разобрались?
Нет! У меня для вас пара вопросов:
Пунтим вопрос обратно на мозг: как так iptables пакет считает в статистику, но не может дропнуть? Или может?
Как libpcap видит все пакеты, которые никак не захватываются никакими *tables?
В целом, пока вы не хотите делать что-то редкое и необычное в Linux, скорее всего, всё будет просто, понятно и работоспособно. А вот если хочется высокой производительности, хорошенько закопаться в трафик на ранних этапах, сделать инъекцию безопасности в ядре или там же потраблшутить, ну или вот заблокировать dhcp на клиенте, то вы очень быстро столкнётесь со всей красотой и безобразием сети в Linux.
А ещё спасибо коллегам, которые со мной это траблшутили в ночи
Борису Лыточкину — за тренировки для DevOps, задачку, материал для статьи и помощь с лабой. Боря — сетевой прораб Яндекса, специализируется на Wi-Fi и сетевой безопасности (вот, кстати, его подкасты про файрволы).