Прокси на уровне ядра: перехват трафика и подмена TCP-сегментов
Содержание
- Архитектура: где живёт перехват
- Netfilter: фундамент перехвата
- include
- include
- include
- include
- Подмена TCP-сегментов: что именно происходит
- TPROXY: прозрачный перехват
- Проблема с MTU и фрагментацией
- Реальный кейс: сервер с MTU 9000
- Прячем прокси: подмена IP в заголовках
- Пример: HTTP-прокси на eBPF
- include
- include
- include
- include
- include
- Сравнение подходов
- Кейс: IPv6-прокси с 2015 года
- Где это применяется
- Диагностика
- Проверить, что модуль загружен
- Посмотреть счётчики netfilter
- Трассировка пакетов
- Итог
Сетевой стек Linux — не игрушка. Когда пакет проходит через ядро, он проходит через десятки подсистем: netfilter, conntrack, routing, qdisc. Прокси на пользовательском уровне — это просто процесс, который читает из сокета. Прокси на уровне ядра — это уже хирургия.
Разберём, как это работает на практике.
Архитектура: где живёт перехват
Пользовательский прокси получает данные через сокет. Ядро уже собрало TCP-сегменты, проверило контрольные суммы, обработало переупорядочивание. Всё это происходит до того, как данные попадут в приложение.
Ядерный прокси работает раньше. Он перехватывает пакет на уровне IP-стека, до того как TCP-подсистема начнёт свою работу. Это даёт доступ к сырым сегментам, флагам, номерам последовательности.
Основные точки перехвата:
- **NF_INET_PRE_ROUTING** — пакет только пришёл с сетевого интерфейса
- **NF_INET_LOCAL_OUT** — пакет только покинул стек приложения
- **NF_INET_FORWARD** — пакет транзитом через систему
Каждая точка даёт свой уровень контроля. Для прокси чаще всего нужны PRE_ROUTING и LOCAL_OUT.
Netfilter: фундамент перехвата
Netfilter — это каркас для обработки пакетов в ядре. Он предоставляет хуки, на которые можно повесить callback-функции. Всё, что вы делаете с iptables или nftables, — это управление этими хуками из пользовательского пространства.
Но iptables — это только верхушка. Настоящая мощь — в написании собственных модулей ядра.
```c
include
include
include
include
static struct nf_hook_ops nfho;
static unsigned int hook_func(void *priv,
struct sk_buff *skb,
const struct nf_hook_state *state)
{
struct iphdr *iph;
struct tcphdr *tcph;
if (!skb)
return NF_ACCEPT;
iph = ip_hdr(skb);
if (iph->protocol != IPPROTO_TCP)
return NF_ACCEPT;
tcph = tcp_hdr(skb);
// Здесь можно менять заголовки TCP
// Например, подменять порт назначения
tcph->dest = htons(8080);
// Пересчитать контрольную сумму
tcph->check = 0;
skb->csum = 0;
return NF_ACCEPT;
}
static int __init init_module(void)
{
nfho.hook = hook_func;
nfho.hooknum = NF_INET_PRE_ROUTING;
nfho.pf = NFPROTO_IPV4;
nfho.priority = NF_IP_PRI_FIRST;
nf_register_net_hook(&init_net, &nfho);
return 0;
}
```
Этот код перехватывает каждый входящий TCP-пакет и принудительно меняет порт назначения на 8080. Грубо, но работает.
Подмена TCP-сегментов: что именно происходит
TCP — это не просто поток байтов. Это последовательность сегментов с номерами, флагами, опциями. Когда вы хотите подменить данные, нужно учитывать:
- Номер последовательности (SEQ)
- Номер подтверждения (ACK)
- Контрольную сумму (checksum)
- Опции TCP (MSS, window scale, SACK)
Если изменить SEQ, принимающая сторона либо отбросит пакет, либо запорет всю сессию. Поэтому подмена должна быть аккуратной.
```c
static void tcp_mangle(struct sk_buff *skb, const char *old_data,
const char *new_data, int len)
{
struct tcphdr *tcph = tcp_hdr(skb);
unsigned char *payload = (unsigned char *)tcph + tcph->doff * 4;
int payload_len = skb->len - (tcph->doff * 4) - ip_hdr(skb)->ihl * 4;
// Ищем старые данные в payload
for (int i = 0; i <= payload_len - len; i++) {
if (memcmp(payload + i, old_data, len) == 0) {
memcpy(payload + i, new_data, len);
// Пересчитываем контрольную сумму TCP
tcph->check = 0;
tcph->check = tcp_v4_check(skb->len - ip_hdr(skb)->ihl * 4,
ip_hdr(skb)->saddr,
ip_hdr(skb)->daddr,
csum_partial((unsigned char *)tcph,
skb->len - ip_hdr(skb)->ihl * 4, 0));
break;
}
}
}
```
Почему это важно: если не пересчитать checksum, ядро на принимающей стороне отбросит пакет. И соединение молча умрёт.
TPROXY: прозрачный перехват
Вместо ручного модуля ядра можно использовать TPROXY — механизм, встроенный в Linux. Он работает через iptables и позволяет перехватывать трафик без изменения IP-адресов.
```bash
iptables -t mangle -N DIVERT
iptables -t mangle -A PREROUTING -p tcp -m socket -j DIVERT
iptables -t mangle -A DIVERT -j MARK --set-mark 1
iptables -t mangle -A DIVERT -j ACCEPT
iptables -t mangle -A PREROUTING -p tcp --dport 80 -j TPROXY \
--tproxy-mark 0x1/0x1 --on-port 8080
```
Суть: пакеты на порт 80 перехватываются на уровне ядра и передаются в процесс, слушающий порт 8080. При этом исходный адрес сохраняется. Для приложения выглядит, будто оно само получило соединение.
Важное ограничение: TPROXY работает только для новых соединений. Установленные сессии он не трогает.
Проблема с MTU и фрагментацией
Когда вы меняете данные в TCP-сегменте, размер пакета может измениться. Если новый пакет больше MTU интерфейса, ядро должно его фрагментировать. Фрагментация — это боль.
Пример: клиент шлёт запрос 1460 байт (максимум для MTU 1500). Прокси добавляет 10 байт. Получается 1470. Это уже больше MTU. Пакет фрагментируется, и это ломает TCP — фрагменты теряются, приходят в другом порядке, увеличивают задержку.
Решение — уменьшить MSS при установке соединения:
```c
static void tcp_mss_clamp(struct sk_buff *skb)
{
struct tcphdr *tcph = tcp_hdr(skb);
int len = (tcph->doff * 4) - sizeof(struct tcphdr);
unsigned char *options = (unsigned char *)tcph + sizeof(struct tcphdr);
for (int i = 0; i < len; ) {
if (options[i] == 2) { // MSS option
if (i + 3 < len) {
__u16 mss = ntohs(*(__u16 *)(options + i + 2));
if (mss > 1400)
*(__u16 *)(options + i + 2) = htons(1400);
}
break;
}
i += options[i] == 1 ? 1 : options[i + 1];
}
}
```
Этот код уменьшает MSS до 1400 на SYN-пакетах. Тогда максимальный сегмент данных будет 1400 байт, и добавление служебных данных не вызовет фрагментации.
Реальный кейс: сервер с MTU 9000
Пример: сервер на nginx 1.24, MTU 9000 (jumbo frames). Прокси на ядре перехватывает трафик, добавляет заголовок аутентификации. Клиент получает пакеты 9000 байт, но его MTU 1500.
Проблема: клиент отбрасывает пакеты. TCP retransmission, задержка растёт с 2 мс до 500 мс. Соединение деградирует.
Решение: модуль ядра, который уменьшает MSS до 1400 на SYN-пакетах, идущих от клиента. Плюс настройка `ip link set eth0 mtu 1500` на интерфейсе прокси. Итог: пакеты 1400 байт, никакой фрагментации, задержка 2 мс.
Прячем прокси: подмена IP в заголовках
Иногда нужно не просто перехватывать, а подменять адреса. Пример: сервер отвечает с адреса 10.0.0.1, но клиент должен видеть 192.168.1.1.
Это делается в два этапа:
1. В PRE_ROUTING меняем адрес назначения
2. В LOCAL_OUT меняем адрес источника
```c
static unsigned int rewrite_src(struct sk_buff *skb)
{
struct iphdr *iph = ip_hdr(skb);
__be32 old_addr = in_aton("10.0.0.1");
__be32 new_addr = in_aton("192.168.1.1");
if (iph->saddr == old_addr) {
iph->saddr = new_addr;
ip_send_check(iph);
skb->csum = 0;
}
return NF_ACCEPT;
}
```
Это работает, но есть нюанс: conntrack запомнит старый адрес. Если соединение уже отслеживается, подмена вызовет конфликт. Поэтому модуль должен работать с учётом conntrack.
Пример: HTTP-прокси на eBPF
Вместо модуля ядра можно использовать eBPF. Это безопаснее: код проверяется верификатором, не может уронить систему.
```c
include
include
include
include
include
SEC("xdp")
int xdp_proxy(struct xdp_md *ctx)
{
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
if ((void *)eth + sizeof(*eth) > data_end)
return XDP_PASS;
if (eth->h_proto != htons(ETH_P_IP))
return XDP_PASS;
struct iphdr *iph = (void *)eth + sizeof(*eth);
if ((void *)iph + sizeof(*iph) > data_end)
return XDP_PASS;
if (iph->protocol != IPPROTO_TCP)
return XDP_PASS;
struct tcphdr *tcph = (void *)iph + sizeof(*iph);
if ((void *)tcph + sizeof(*tcph) > data_end)
return XDP_PASS;
if (tcph->dest == htons(80)) {
tcph->dest = htons(8080);
tcph->check = 0;
}
return XDP_PASS;
}
char _license[] SEC("license") = "GPL";
```
Этот код на XDP перехватывает пакеты прямо на сетевом драйвере, до того как они попадут в стек ядра. Задержка — микросекунды, а не миллисекунды как у пользовательских прокси.
Сравнение подходов
| Подход | Задержка | Сложность | Безопасность |
|--------|----------|-----------|--------------|
| Пользовательский прокси | 1-5 мс | Низкая | Высокая |
| TPROXY | 0.1-1 мс | Средняя | Высокая |
| Модуль ядра | 0.01-0.1 мс | Высокая | Низкая |
| eBPF/XDP | 0.001-0.01 мс | Средняя | Высокая |
Кейс: IPv6-прокси с 2015 года
На lexic.ml с 2015 года работает IPv6-прокси. Изначально — пользовательский, на Python. Через год переписали на ядерный модуль.
Проблема: пользовательский прокси держал 10 тысяч соединений, но задержка была 3-4 мс. Для IPv6-трафика это критично — клиенты на мобильных сетях отваливались по таймауту.
Решение: модуль ядра, который перехватывает TCP-сегменты и меняет адрес назначения с IPv4 на IPv6. NAT64 на уровне ядра. Задержка упала до 0.2 мс. Соединений — 50 тысяч.
Подводный камень: пришлось вручную обрабатывать ICMPv6, потому что ядро не знало о подмене адресов. И ошибка в подсчёте контрольной суммы приводила к молчаливому сбросу пакетов.
Где это применяется
Ядерные прокси — это не игрушка для энтузиастов. Их используют:
- CDN для балансировки трафика
- Провайдеры для прозрачного проксирования
- Системы безопасности для фильтрации
- Шлюзы для трансляции адресов
Если вам нужно обрабатывать более 100 тысяч соединений с минимальной задержкой — пользовательский прокси не справится. Ядро — единственный вариант.
Диагностика
Когда ядерный прокси не работает, диагностика — ад. `tcpdump` показывает пакеты, но не показывает, что происходит внутри ядра.
```bash
Проверить, что модуль загружен
lsmod | grep proxy
Посмотреть счётчики netfilter
cat /proc/net/stat/nf_conntrack
Трассировка пакетов
echo 1 > /proc/sys/net/netfilter/nf_log_all_netns
nft add rule ip filter INPUT log prefix "proxy: " flags all
```
Если пакеты доходят до хука, но не уходят — проверяйте контрольные суммы. 90% проблем с ядерными прокси — это неправильный checksum.
Итог
Прокси на уровне ядра — это мощь и боль одновременно. Мощь — потому что задержка в микросекунды и контроль над каждым байтом. Боль — потому что любая ошибка роняет систему целиком.
Начинайте с TPROXY или eBPF. Модуль ядра пишите только когда точно знаете, зачем он нужен. И всегда тестируйте на виртуалке, а не на проде.