← Назад в базу знаний

Прокси на уровне ядра: перехват трафика и подмена TCP-сегментов

Прокси на уровне ядра: перехват трафика и подмена TCP-сегментов

Сетевой стек 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. Модуль ядра пишите только когда точно знаете, зачем он нужен. И всегда тестируйте на виртуалке, а не на проде.

✔️Купить прокси