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

3d19669b6c2afaa20cc287764a6d7440.jpeg

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

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

841f7f6a99e5ddb006e93b934a5eb456.png

​​Давайте пока этот вопрос отложим в оперативку. Он очень интересный.

Хорошо, давайте заблокируем вообще весь 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.2555.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:  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:  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:  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:  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:  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:  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 
#include 
#include 
#include 
#include 

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.

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

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

© Habrahabr.ru