Что ты такое, dhclient?

от автора

Сетевой стек 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.

Так выглядит DHCP. Источник

Так выглядит DHCP. Источник

Сделаю тут небольшое отступление: мы тут в Яндексе придумали провести Тренировки по 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: мы их там никогда и не увидели бы.

Ок. Что же дальше?

Есть вот такая картинка, дающая представление о том, как устроен процесс обработки трафика в простом iptables. Источник

Есть вот такая картинка, дающая представление о том, как устроен процесс обработки трафика в простом iptables. Источник

Судя по всему, нам нужен 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 по-прежнему работает.

А давайте вообще выключим сеть через policy DROP!

❯ iptables -t raw -L | grep policy Chain PREROUTING (policy DROP) Chain OUTPUT (policy ACCEPT)

Всё, мы вообще оторвали сеть. Тут даже tcpdump не поможет. Нет смысла его теперь уже открывать.

Нет! Работает. Точнее, не работает. То есть что хотим — не работает, а чего не хотим — работает.

Хм-м-м-м-м…..

Осталось читать инструкцию смотреть логи.

❯ iptables -t raw -I PREROUTING -p udp -j LOG

Попробуем послать с клиента udp-пробу.

❯ nc -zvu 192.168.42.1 67
❯ tail -f /var/log/syslog | grep kernel Nov 11 03:15:48 fish kernel: [14018.625457] IN=enp0s1 OUT= MAC=0e:21:3f:d8:09:36:12:e0:e2:b7:e0:f8:08:00 SRC=192.168.42.6 DST=192.168.42.1 LEN=29 TOS=0x00 PREC=0x00 TTL=64 ID=47682 DF PROTO=UDP SPT=39688 DPT=67 LEN=9

Видим пробу: значит, логирование работает. А вот при работе 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 — утилиты, отображающей системные вызовы любых процессов.

Запускаем:

❯ strace dhclient -d eth0

На нас льётся тонна текста.

Но возьмём важный для нас момент…
socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)) = 5 ioctl(5, SIOCGIFINDEX, {ifr_name="enp0s1", ifr_ifindex=3}) = 0 bind(5, {sa_family=AF_PACKET, sll_protocol=htons(ETH_P_ALL), sll_ifindex=if_nametoindex("enp0s1"), sll_hatype=ARPHRD_NETROM, sll_pkttype=PACKET_HOST, sll_halen=0}, 20) = 0 setsockopt(5, SOL_PACKET, PACKET_AUXDATA, [1], 4) = 0 setsockopt(5, SOL_SOCKET, SO_ATTACH_FILTER, {len=11, filter=0x559d30be3820}, 16) = 0 getpid()                                = 1194 sendto(3, "<30>Nov 11 11:48:03 dhclient[119"..., 77, MSG_NOSIGNAL, NULL, 0) = 77 write(2, "Listening on LPF/enp0s1/08:00:27"..., 41) = 41 write(2, "\n", 1)                       = 1 getpid()                                = 1194 sendto(3, "<30>Nov 11 11:48:03 dhclient[119"..., 77, MSG_NOSIGNAL, NULL, 0) = 77 write(2, "Sending on   LPF/enp0s1/08:00:27"..., 41) = 41 write(2, "\n", 1)                       = 1 fcntl(5, F_SETFD, FD_CLOEXEC)           = 0 socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP) = 6 setsockopt(6, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0 bind(6, {sa_family=AF_INET, sin_port=htons(68), sin_addr=inet_addr("0.0.0.0")}, 16) = 0 getpid()                                = 1194 sendto(3, "<30>Nov 11 11:48:03 dhclient[119"..., 64, MSG_NOSIGNAL, NULL, 0) = 64 write(2, "Sending on   Socket/fallback", 28) = 28 write(2, "\n", 1)                       = 1 fcntl(6, F_SETFD, FD_CLOEXEC)           = 0 openat(AT_FDCWD, "/dev/random", O_RDONLY) = 7 newfstatat(7, "", {st_mode=S_IFCHR|0666, st_rdev=makedev(0x1, 0x8), ...}, AT_EMPTY_PATH) = 0 ioctl(7, TCGETS, 0x7ffe88f80c60)        = -1 EINVAL (Invalid argument) read(7, "6\346\32p\341\265\234\343I\260\220\374\330\367^@\335|\266/\212\264\31\370\315~ \1x\224\217O"..., 4096) = 4096 close(7)                                = 0 openat(AT_FDCWD, "/etc/hostid", O_RDONLY) = -1 ENOENT (No such file or directory) uname({sysname="Linux", nodename="client", ...}) = 0 newfstatat(AT_FDCWD, "/etc/resolv.conf", {st_mode=S_IFREG|0644, st_size=933, ...}, 0) = 0 openat(AT_FDCWD, "/etc/host.conf", O_RDONLY|O_CLOEXEC) = 7 newfstatat(7, "", {st_mode=S_IFREG|0644, st_size=92, ...}, AT_EMPTY_PATH) = 0 read(7, "# The \"order\" line is only used "..., 4096) = 92 read(7, "", 4096)                       = 0 close(7)                                = 0 futex(0x7efd50a0742c, FUTEX_WAKE_PRIVATE, 2147483647) = 0 openat(AT_FDCWD, "/etc/resolv.conf", O_RDONLY|O_CLOEXEC) = 7 newfstatat(7, "", {st_mode=S_IFREG|0644, st_size=933, ...}, AT_EMPTY_PATH) = 0 read(7, "# This is /run/systemd/resolve/s"..., 4096) = 933 read(7, "", 4096)                       = 0 newfstatat(7, "", {st_mode=S_IFREG|0644, st_size=933, ...}, AT_EMPTY_PATH) = 0 close(7)                                = 0 socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0) = 7 connect(7, {sa_family=AF_UNIX, sun_path="/var/run/nscd/socket"}, 110) = -1 ENOENT (No such file or directory) close(7)                                = 0 socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0) = 7 connect(7, {sa_family=AF_UNIX, sun_path="/var/run/nscd/socket"}, 110) = -1 ENOENT (No such file or directory) close(7)                                = 0 newfstatat(AT_FDCWD, "/etc/nsswitch.conf", {st_mode=S_IFREG|0644, st_size=526, ...}, 0) = 0 openat(AT_FDCWD, "/etc/hosts", O_RDONLY|O_CLOEXEC) = 7 newfstatat(7, "", {st_mode=S_IFREG|0644, st_size=221, ...}, AT_EMPTY_PATH) = 0 lseek(7, 0, SEEK_SET)                   = 0 read(7, "127.0.0.1 localhost\n127.0.1.1 cl"..., 4096) = 221 read(7, "", 4096)                       = 0 close(7)                                = 0 getpid()                                = 1194 sendto(3, "<30>Nov 11 11:48:03 dhclient[119"..., 119, MSG_NOSIGNAL, NULL, 0) = 119 write(2, "xid: warning: no netdev with use"..., 83) = 83 write(2, "\n", 1)                       = 1 getpid()                                = 1194 sendto(3, "<30>Nov 11 11:48:03 dhclient[119"..., 90, MSG_NOSIGNAL, NULL, 0) = 90 write(2, "xid: rand init seed (0x653068f2)"..., 54) = 54 write(2, "\n", 1)                       = 1 uname({sysname="Linux", nodename="client", ...}) = 0 getpid()                                = 1194 sendto(3, "<30>Nov 11 11:48:03 dhclient[119"..., 113, MSG_NOSIGNAL, NULL, 0) = 113 write(2, "DHCPDISCOVER on enp0s1 to 255.25"..., 77) = 77 write(2, "\n", 1)                       = 1 write(5, "\377\377\377\377\377\377\10\0'\255\220s\10\0E\20\1H\0\0\0\0\200\219\226\0\0\0\0\377\377"..., 342) = 342 openat(AT_FDCWD, "/var/run/dhclient.pid", O_WRONLY|O_CREAT|O_TRUNC, 0644) = 7 fcntl(7, F_GETFL)                       = 0x8001 (flags O_WRONLY|O_LARGEFILE) getpid()                                = 1194 newfstatat(7, "", {st_mode=S_IFREG|0644, st_size=0, ...}, AT_EMPTY_PATH) = 0 write(7, "1194\n", 5)                   = 5 close(7)                                = 0 pselect6(7, [5 6], [], NULL, {tv_sec=0, tv_nsec=0}, NULL) = 0 (Timeout) pselect6(7, [5 6], [], NULL, {tv_sec=2, tv_nsec=91713000}, NULL) = 0 (Timeout) getpid()                                = 1194 sendto(3, "<30>Nov 11 11:48:06 dhclient[119"..., 113, MSG_NOSIGNAL, NULL, 0) = 113 write(2, "DHCPDISCOVER on enp0s1 to 255.25"..., 77) = 77 write(2, "\n", 1)                       = 1 write(5, "\377\377\377\377\377\377\10\0'\255\220s\10\0E\20\1H\0\0\0\0\200\219\226\0\0\0\0\377\377"..., 342) = 342 pselect6(7, [5 6], [], NULL, {tv_sec=0, tv_nsec=0}, NULL) = 0 (Timeout) pselect6(7, [5 6], [], NULL, {tv_sec=7, tv_nsec=118920000}, NULL) = 1 (in [5], left {tv_sec=6, tv_nsec=204640291}) recvmsg(5, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\10\0'\255\220s\10\0'&\334h\10\0E\300\1H\27\212\0\0@\21q\251\300\2507\1\300\250"..., iov_len=1536}], msg_iovlen=1, msg_control=[{cmsg_len=36, cmsg_level=SOL_PACKET, cmsg_type=0x8}], msg_controllen=36, msg_flags=0}, 0) = 342 getpid()                                = 1194 sendto(3, "<30>Nov 11 11:48:06 dhclient[119"..., 80, MSG_NOSIGNAL, NULL, 0) = 80 write(2, "DHCPOFFER of 192.168.42.6 from "..., 44) = 44 write(2, "\n", 1)                       = 1 uname({sysname="Linux", nodename="client", ...}) = 0 getpid()                                = 1194 sendto(3, "<30>Nov 11 11:48:06 dhclient[119"..., 119, MSG_NOSIGNAL, NULL, 0) = 119 write(2, "DHCPREQUEST for 192.168.42.6 on"..., 83) = 83 write(2, "\n", 1)                       = 1 write(5, "\377\377\377\377\377\377\10\0'\255\220s\10\0E\20\1H\0\0\0\0\200\219\226\0\0\0\0\377\377"..., 342) = 342 pselect6(7, [5 6], [], NULL, {tv_sec=2, tv_nsec=248626000}, NULL) = 1 (in [5], left {tv_sec=2, tv_nsec=248624007}) recvmsg(5, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\10\0'\255\220s\10\0'&\334h\10\0E\300\1H\27\213\0\0@\21q\250\300\2507\1\300\250"..., iov_len=1536}], msg_iovlen=1, msg_control=[{cmsg_len=36, cmsg_level=SOL_PACKET, cmsg_type=0x8}], msg_controllen=36, msg_flags=0}, 0) = 342 pselect6(7, [5 6], [], NULL, {tv_sec=2, tv_nsec=248572000}, NULL) = 1 (in [5], left {tv_sec=2, tv_nsec=242423897}) recvmsg(5, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\10\0'\255\220s\10\0'&\334h\10\0E\300\1H\27\215\0\0@\21q\246\300\2507\1\300\250"..., iov_len=1536}], msg_iovlen=1, msg_control=[{cmsg_len=36, cmsg_level=SOL_PACKET, cmsg_type=0x8}], msg_controllen=36, msg_flags=0}, 0) = 342 getpid()                                = 1194 sendto(3, "<30>Nov 11 11:48:06 dhclient[119"..., 95, MSG_NOSIGNAL, NULL, 0) = 95 write(2, "DHCPACK of 192.168.42.6 from 19"..., 59) = 59 write(2, "\n", 1)                       = 1 getpid()                                = 1194 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7efd50b79a10) = 1213 wait4(-1, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 1213 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=1213, si_uid=0, si_status=0, si_utime=0, si_stime=0} --- write(4, "lease {\n  interface \"enp0s1\";\n  "..., 475) = 475 fsync(4)                                = 0 getpid()                                = 1194 sendto(3, "<30>Nov 11 11:48:07 dhclient[119"..., 87, MSG_NOSIGNAL, NULL, 0) = 87 write(2, "bound to 192.168.42.6 -- renewa"..., 51) = 51 write(2, "\n", 1)                       = 1 openat(AT_FDCWD, "/var/run/dhclient.pid", O_WRONLY|O_CREAT|O_TRUNC, 0644) = 7 fcntl(7, F_GETFL)                       = 0x8001 (flags O_WRONLY|O_LARGEFILE) getpid()                                = 1194 newfstatat(7, "", {st_mode=S_IFREG|0644, st_size=0, ...}, AT_EMPTY_PATH) = 0 write(7, "1194\n", 5)                   = 5 close(7)                                = 0 pselect6(7, [5 6], [], NULL, {tv_sec=2, tv_nsec=206116000}, NULL) = 0 (Timeout) pselect6(7, [5 6], [], NULL, {tv_sec=20364, tv_nsec=131507000}, NULL) = ? ERESTARTNOHAND (To be restarted if no handler) --- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL} --- +++ killed by SIGINT +++

И только в самом конце, на 88-й строчке видим уже знакомые нам DHCPOFFER of 192.168.55.96 from.

Хм-м, а что происходит прямо перед этим, на 85-й строке?

recvmsg(5, {msg_name=NULL,              msg_namelen=0,              msg_iov=[{iov_base="\10\0'\255\220s\10\0'&\334h\10\0E\300\1H\27\212\0\0@\21q\251\300\2507\1\300\250"..., iov_len=1536}],              msg_iovlen=1,             msg_control=[{cmsg_len=36, cmsg_level=SOL_PACKET, cmsg_type=0x8}],              msg_controllen=36,             msg_flags=0},         0) = 342

Получили 342 байта из пятого дескриптора.

Мотаем пораньше, на 82-ю строку. Вот мы что-то в него записываем:

write(5, "\377\377\377\377\377\377\10\0'\255\220s\10\0E\20\1H\0\0\0\0\200\219\226\0\0\0\0\377\377"..., 342) = 342

И рядом же, ещё чуть раньше, печатаем: DHCPDISCOVER on enp0s8 to 255.25... 

Хм-м-м… Что же это за загадочный пятый дескриптор? А это самая первая строчка нашего листинга!

socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)) = 5

Конечно же, это

BPF!

И мы приближаемся к ответу

И мы приближаемся к ответу

Но давайте сформулируем вопрос, который и у вас наверняка назрел: зачем вот такому элементарному протоколу, совсем не требующему высокой нагрузки, быть настолько бессмертным?

А всё очень просто. 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 лет!

А вот и кусочек про raw socket:

   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.

На секундочку:

❯ git ls-files | grep '\.c' | xargs wc -l                                                                                           07:34:42        7 .cvsignore       81 client_tables.c     2347 clparse.c     6129 dhc6.c     5890 dhclient.c      786 dhclient.conf.5       36 dhclient.conf.example      136 tests/duid_unittest.c    15412 total

Наконец решение! Да ведь?

И кажется, у нас осталось только оружие судного дня.

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

Запускаем dhclient в NS ns-client-0.

❯ ip netns exec ns-client-0 dhclient -1 -lf /var/lib/dhclient/dhclient--veth-client-0.lease -pf /var/run/dhclient--veth-client-0.pid

Логи DHCP-сервера:

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

Супер! Но это у нас всё и так работало. Давайте блокировать. 

Пишем небольшую ebpf-программу.

Код xdp_drop.c

#include <linux/bpf.h> #include <bpf/bpf_helpers.h> #include <linux/ip.h> #include <linux/in.h> #include <linux/if_ether.h>  SEC("xdp_udp_drop") int xdp_udp_drop_prog(struct xdp_md *ctx) {     int ipsize = 0;     void *data = (void *)(long)ctx->data;     void *data_end = (void *)(long)ctx->data_end;     struct ethhdr *eth = data;      ipsize = sizeof(*eth);     struct iphdr *ip = data + ipsize;     ipsize += sizeof(struct iphdr);     if (data + ipsize > data_end) {         return XDP_PASS;     }     if (ip->protocol == IPPROTO_UDP) {         return XDP_DROP;     }     return XDP_PASS; }  char _license[] SEC("license") = "GPL";

Компонуем её в объектный файл.

❯ clang -O2 -g -Wall -target bpf -c xdp_drop.c -o xdp_drop.o

Пробуем применить в неймспейсе клиента на интерфейс.

❯ 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 такой
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.

А ещё спасибо коллегам, которые со мной это траблшутили в ночи

И напоследок.


ссылка на оригинал статьи https://habr.com/ru/articles/774462/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *