Ломаем пинг с помощью eBPF/XDP

Оригинал: Alexey Novikov

(возможно, это и так всем известно, но я новичок в подобных делах. Решил покопаться в программировании eBPF и случайно наткнулся на это)

TL;DR: Очень просто злоупотребить странной логикой расчёта времени в ping и добиться такого результата:

$ ping pingme.x3lfy.space
PING pingme.x3lfy.space (91.239.23.176) 56(84) bytes of data.
64 bytes from 91.239.23.176: icmp_seq=1 ttl=206 time=3200828 ms
64 bytes from 91.239.23.176: icmp_seq=2 ttl=184 time=3719749 ms
64 bytes from 91.239.23.176: icmp_seq=3 ttl=87 time=1705390 ms
64 bytes from 91.239.23.176: icmp_seq=4 ttl=190 time=952669 ms
64 bytes from 91.239.23.176: icmp_seq=5 ttl=52 time=1225036 ms
64 bytes from 91.239.23.176: icmp_seq=6 ttl=192 time=3620882 ms
64 bytes from 91.239.23.176: icmp_seq=7 ttl=167 time=2060680 ms
64 bytes from 91.239.23.176: icmp_seq=8 ttl=163 time=406768 ms

мой код обманывает только реализацию ping из iputils. Но, думаю, технически можно реализовать то же самое и для busybox. А вот Windows ping к этой фишке неуязвим — он считает время совсем иначе

Как работает ping?

ping шлёт так называемый ICMP Echo Request — это пакет с IP-заголовком, где номер протокола 1 (то есть ICMP), плюс заголовок ICMP и немного данных в нагрузке. Вот как выглядит структура ICMP-заголовка:

+--------+--------+--------+--------+
|   0    |   1    |   2    |   3    |
|01234567|01234567|01234567|01234567|
+--------+--------+--------+--------+
|  Type  |  Code  |    Checksum     |
+--------+--------+--------+--------+
|   Identifier    | Sequence number |
+--------+--------+--------+--------+
|              Payload...           |

Хост, который пингует, заполняет поля так:

  • Type ставит 8, Code — 0 — это значит, что пакет — echo request.
  • Identifier — просто какое-то число, чтобы отличать ICMP-пакеты от разных процессов ping (обычно это PID или что-то в этом духе).
  • Sequence Number — нарастающий номер, который виден в поле icmp_seq в выводе ping.

Целевой хост получает этот пакет, и его ядро делает своё дело:

  • меняет Type на 0 (теперь это Echo Reply),
  • пересчитывает контрольную сумму,
  • отправляет обновлённый пакет обратно тому, кто пинговал.

Как ping считает время?

Если вы ковырялись в трафике через Wireshark, то могли заметить странное поле в запросах ping:

wireshark

Timestamp from icmp data”… Откуда в запросе эхо взялась метка времени?

Заглянем в исходники iputils-ping (это самая популярная имплементация ping), и всё станет ясно:

icp = (struct icmphdr *)packet;
icp->type = ICMP_ECHO;
icp->code = 0;
icp->checksum = 0;
icp->un.echo.sequence = htons(rts->ntransmitted + 1);
icp->un.echo.id = rts->ident;

rcvd_clear(rts, rts->ntransmitted + 1);

if (rts->timing) {
    if (rts->opt_latency) {
        struct timeval tmp_tv;
        gettimeofday(&tmp_tv, NULL);
        memcpy(icp + 1, &tmp_tv, sizeof(tmp_tv)); // вот оно!
    } else {
        memset(icp + 1, 0, sizeof(struct timeval));
    }
}

ping записывает текущую метку времени прямо после заголовка ICMP в пакете. Но зачем?

А затем, что когда ping ловит ответ (Echo Reply), он вычисляет время в пути, используя эту метку:

uint8_t *ptr = icmph + icmplen; // icmph — указатель на заголовок ICMP

++rts->nreceived;
if (!csfailed)
    acknowledge(rts, seq);

if (rts->timing && cc >= (int)(8 + sizeof(struct timeval))) {
    struct timeval tmp_tv;
    memcpy(&tmp_tv, ptr, sizeof(tmp_tv)); // читаем метку из пакета

restamp:
    tvsub(tv, &tmp_tv); // tv — это текущая метка (момент получения), определена выше
    triptime = tv->tv_sec * 1000000 + tv->tv_usec;

Так что ping просто берёт время получения ответа и вычитает из него время отправки, записанное в пакете — вот тебе и время в пути!

Злоупотребляем этим

А что, если наш сервер подкрутит метку времени в запросе эхо? Допустим, вычтем из неё какое-то число — и ping решит, что пакет был отправлен оооооочень давно. Идея проста до гениальности, но как это провернуть? ICMP-пакеты обрабатываются в ядре, и из пользовательского пространства туда не залезть. Да и навыков ковырять ядро у меня нет.

И тут на сцену выходит eBPF! О нём болтают много, но если коротко — это технология в ядре Linux, которая позволяет закинуть код в kernel space и запускать его на определённых событиях. А подсистема XDP пускает eBPF-программы прямо после того, как данные пакета считываются с сетевой карты — даже до того, как ядро разберёт их в свои структуры.

Кроме самой eBPF-программы, нужен ещё userspace-инструмент, чтобы закинуть её в ядро и прицепить к интерфейсу. Я взял библиотеку ebpf-go.

Пишем обработчик ping на eBPF

Сначала проверяем, что пакет — это IP с ICMP внутри, и что это именно Echo Request. Если нет — пропускаем:

void *data_end = (void*)(long)ctx->data_end;
void *data     = (void*)(long)ctx->data;

struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end) { // проверяем, есть ли заголовок Ethernet
    return XDP_PASS;
}

// ntohs нужен, потому что порядок байтов в пакете может отличаться
if (bpf_ntohs(eth->h_proto) != ETH_P_IP) { // ETH_P_IP — константа для IP
    return XDP_PASS;
}

struct iphdr *iph = (void*)(eth + 1);
if ((void*)(iph + 1) > data_end) { // есть ли IP-заголовок?
    return XDP_PASS;
}

if (iph->protocol != IPPROTO_ICMP) { // ICMP ли это?
    return XDP_PASS;
}

struct icmphdr* icmphdr = (void*)(iph + 1); // есть ли заголовок ICMP?
if ((void*)(icmphdr + 1) > data_end) {
    return XDP_PASS;
}

if (icmphdr->type != 8) { // это *Echo Request*?
    return XDP_PASS;
}

Теперь надо:

  1. Поменять местами MAC-адреса источника и назначения в заголовке Ethernet.
  2. Поменять местами IP-адреса в IP-заголовке.
  3. Поменять тип ICMP-пакета.

Хорошая новость: замена байтов не ломает контрольную сумму, так что пункты 1 и 2 — просты в реализации. Плохая новость: смена типа в пункте 3 ломает контрольную сумму ICMP, и её надо пересчитать.

Лучшего способа в XDP я не нашёл, кроме как взять код из этого проекта (спасибо @sbernard31 за код и подсказку в этой теме). Там используется метод из RFC 1624 для инкрементального пересчёта контрольной суммы.

Хотя в исходном проекте эта функция используется только для пересчёта контрольной суммы при замене IP-адресов, на самом деле она способна корректировать контрольную сумму для любого изменённого байта. Вот она, а также небольшие вспомогательные функции для ICMP и IP:

__attribute__((__always_inline__)) static inline __u16 csum_fold_helper(
    __u64 csum) {
  int i;
#pragma unroll
  for (i = 0; i < 4; i++) {
    if (csum >> 16)
      csum = (csum & 0xffff) + (csum >> 16);
  }
  return ~csum;
}

// https://github.com/AirVantage/sbulb/blob/master/sbulb/bpf/checksum.c#L21
__attribute__((__always_inline__))
static inline void update_csum(__u64 *csum, __be32 old_addr,__be32 new_addr ) {
    *csum = ~*csum;
    *csum = *csum & 0xffff;
    __u32 tmp;
    tmp = ~old_addr;
    *csum += tmp;
    *csum += new_addr;
    *csum = csum_fold_helper(*csum);
}

__attribute__((__always_inline__))
static inline void recalc_icmp_csum(struct icmphdr* hdr, __be32 old_value, __be32 new_value) {
    __u64 csum = hdr->checksum;
    update_csum(&csum, old_value, new_value);
    hdr->checksum = csum;
}

__attribute__((__always_inline__))
static inline void recalc_ip_csum(struct iphdr* hdr, __be32 old_value, __be32 new_value) {
    __u64 csum = hdr->check;
    update_csum(&csum, old_value, new_value);
    hdr->check = csum;
}

Теперь меняем адреса и тип пакета:

// Меняем MAC-адреса
__u8 tmp_mac[ETH_ALEN]; // ETH_ALEN — число октетов в MAC из linux/if_ether.h
bpf_memcpy(tmp_mac, eth->h_dest, ETH_ALEN);
bpf_memcpy(eth->h_dest, eth->h_source, ETH_ALEN);
bpf_memcpy(eth->h_source, tmp_mac, ETH_ALEN);

// Меняем IP-адреса
__u32 tmp_ip = iph->daddr;
iph->daddr = iph->saddr;
iph->saddr = tmp_ip;

icmphdr->type = 0; // Меняем тип ICMP на *Echo Reply*
recalc_icmp_csum(icmphdr, 8, icmphdr->type);

И отправляем пакет обратно:

Итоговый код

#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/bpf.h>
#include <linux/icmp.h>
#include <bpf/bpf_helpers.h>
#include <linux/in.h>
#include <bpf/bpf_endian.h>

#define bpf_memcpy __builtin_memcpy

__attribute__((__always_inline__)) static inline __u16 csum_fold_helper(
    __u64 csum) {
  int i;
#pragma unroll
  for (i = 0; i < 4; i++) {
    if (csum >> 16)
      csum = (csum & 0xffff) + (csum >> 16);
  }
  return ~csum;
}

__attribute__((__always_inline__))
static inline void update_csum(__u64 *csum, __be32 old_addr,__be32 new_addr ) {
    *csum = ~*csum;
    *csum = *csum & 0xffff;
    __u32 tmp;
    tmp = ~old_addr;
    *csum += tmp;
    *csum += new_addr;
    *csum = csum_fold_helper(*csum);
}

__attribute__((__always_inline__))
static inline void recalc_icmp_csum(struct icmphdr* hdr, __be32 old_value, __be32 new_value) {
    __u64 csum = hdr->checksum;
    update_csum(&csum, old_value, new_value);
    hdr->checksum = csum;
}

__attribute__((__always_inline__))
static inline void recalc_ip_csum(struct iphdr* hdr, __be32 old_value, __be32 new_value) {
    __u64 csum = hdr->check;
    update_csum(&csum, old_value, new_value);
    hdr->check = csum;
}

SEC("xdp")
int pinger(struct xdp_md* ctx) {
    void *data_end = (void *)(long)ctx->data_end;
    void *data     = (void *)(long)ctx->data;

    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > data_end) {
        return XDP_PASS;
    }

    if (bpf_ntohs(eth->h_proto) != ETH_P_IP) {
        return XDP_PASS;
    }

    struct iphdr *iph = (void*)(eth + 1);
    if ((void*)(iph + 1) > data_end) {
        return XDP_PASS;
    }

    if (iph->protocol != IPPROTO_ICMP) {
        return XDP_PASS;
    }

    struct icmphdr* icmphdr = (void*)(iph + 1);
    if ((void*)(icmphdr + 1) > data_end) {
        return XDP_PASS;
    }

    if (icmphdr->type != 8) {
        return XDP_PASS;
    }

    __u8 tmp_mac[ETH_ALEN];
    bpf_memcpy(tmp_mac, eth->h_dest, ETH_ALEN);
    bpf_memcpy(eth->h_dest, eth->h_source, ETH_ALEN);
    bpf_memcpy(eth->h_source, tmp_mac, ETH_ALEN);

    __u32 tmp_ip = iph->daddr;
    iph->daddr = iph->saddr;
    iph->saddr = tmp_ip;

    icmphdr->type = 0;
    recalc_icmp_csum(icmphdr, 8, icmphdr->type);

    return XDP_TX;
}

char LICENSE[] SEC("license") = "GPL";

Теперь, если загрузить и подключить эту программу, интерфейс будет отвечать на пинги как обычно. Однако в Wireshark вы не увидите пакетов ICMP echo, поскольку они обрабатываются до того, как ядро успевает их перехватить.

Ломаем его полностью!

К сожалению, это не сработает с ping из Busybox (он использует немного другой формат метки времени, источник)), а также с Windows ping (в нём метка времени в пакете вообще не сохраняется).

По данным Wireshark и кода iputils, метка времени лежит сразу за заголовком ICMP. Вот указатели на её части (ts_secs — целые секунды с эпохи, ts_nsecs — наносекунды; смотри struct timespec):

__u64* ts_secs = (void*)(icmphdr + 1);
__u64* ts_nsecs = (void*)(icmphdr + 1) + sizeof(__u64);

Проверяем, есть ли метка в пакете, сохраняем старые значения для пересчёта контрольной суммы и отнимаем рандомные числа:

if ((void*)ts_nsecs + sizeof(__u64) <= data_end) {
    __u64 old_secs = *ts_secs;
    __u64 old_nsecs = *ts_nsecs;

    *ts_secs -= bpf_get_prandom_u32() % 500;
    *ts_nsecs -= bpf_get_prandom_u32();

    recalc_icmp_csum(icmphdr, old_secs, *ts_secs);
    recalc_icmp_csum(icmphdr, old_nsecs, *ts_nsecs);
}

А ещё можно рандомизировать TTL и порядковый номер ICMP:

__u8 old_ttl = iph->ttl;
iph->ttl = bpf_get_prandom_u32() % 200 + 40;
recalc_ip_csum(iph, old_ttl, iph->ttl);

__be16 old_seq = icmphdr->un.echo.sequence;
icmphdr->un.echo.sequence = bpf_htons(bpf_get_prandom_u32() % 1000);
recalc_icmp_csum(icmphdr, old_seq, icmphdr->un.echo.sequence);

Кстати, у меня в регионе ICMP-ответы со случайным порядковым номером почему-то блокируются, так что на pingme.x3lfy.space они не случайные.

Финальный код с ломалкой

#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/bpf.h>
#include <linux/icmp.h>
#include <bpf/bpf_helpers.h>
#include <linux/in.h>
#include <bpf/bpf_endian.h>

#define bpf_memcpy __builtin_memcpy

__attribute__((__always_inline__)) static inline __u16 csum_fold_helper(
    __u64 csum) {
  int i;
#pragma unroll
  for (i = 0; i < 4; i++) {
    if (csum >> 16)
      csum = (csum & 0xffff) + (csum >> 16);
  }
  return ~csum;
}

__attribute__((__always_inline__))
static inline void update_csum(__u64 *csum, __be32 old_addr,__be32 new_addr ) {
    *csum = ~*csum;
    *csum = *csum & 0xffff;
    __u32 tmp;
    tmp = ~old_addr;
    *csum += tmp;
    *csum += new_addr;
    *csum = csum_fold_helper(*csum);
}

__attribute__((__always_inline__))
static inline void recalc_icmp_csum(struct icmphdr* hdr, __be32 old_value, __be32 new_value) {
    __u64 csum = hdr->checksum;
    update_csum(&csum, old_value, new_value);
    hdr->checksum = csum;
}

__attribute__((__always_inline__))
static inline void recalc_ip_csum(struct iphdr* hdr, __be32 old_value, __be32 new_value) {
    __u64 csum = hdr->check;
    update_csum(&csum, old_value, new_value);
    hdr->check = csum;
}

SEC("xdp")
int pinger(struct xdp_md* ctx) {
    void *data_end = (void *)(long)ctx->data_end;
    void *data     = (void *)(long)ctx->data;

    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > data_end) {
        return XDP_PASS;
    }

    if (bpf_ntohs(eth->h_proto) != ETH_P_IP) {
        return XDP_PASS;
    }

    struct iphdr *iph = (void*)(eth + 1);
    if ((void*)(iph + 1) > data_end) {
        return XDP_PASS;
    }

    if (iph->protocol != IPPROTO_ICMP) {
        return XDP_PASS;
    }

    struct icmphdr* icmphdr = (void*)(iph + 1);
    if ((void*)(icmphdr + 1) > data_end) {
        return XDP_PASS;
    }

    if (icmphdr->type != 8) {
        return XDP_PASS;
    }

    __u8 tmp_mac[ETH_ALEN];
    bpf_memcpy(tmp_mac, eth->h_dest, ETH_ALEN);
    bpf_memcpy(eth->h_dest, eth->h_source, ETH_ALEN);
    bpf_memcpy(eth->h_source, tmp_mac, ETH_ALEN);

    __u32 tmp_ip = iph->daddr;
    iph->daddr = iph->saddr;
    iph->saddr = tmp_ip;

    icmphdr->type = 0;
    recalc_icmp_csum(icmphdr, 8, icmphdr->type);

    __u64* ts_secs = (void*)(icmphdr + 1);
    __u64* ts_nsecs = (void*)(icmphdr + 1) + sizeof(__u64);

    if ((void*)ts_nsecs + sizeof(__u64) <= data_end) {
        __u64 old_secs = *ts_secs;
        __u64 old_nsecs = *ts_nsecs;

        *ts_secs -= bpf_get_prandom_u32() % 500;
        *ts_nsecs -= bpf_get_prandom_u32();

        recalc_icmp_csum(icmphdr, old_secs, *ts_secs);
        recalc_icmp_csum(icmphdr, old_nsecs, *ts_nsecs);
    }

    __u8 old_ttl = iph->ttl;
    iph->ttl = bpf_get_prandom_u32() % 200 + 40;
    recalc_ip_csum(iph, old_ttl, iph->ttl);

    __be16 old_seq = icmphdr->un.echo.sequence;
    icmphdr->un.echo.sequence = bpf_htons(bpf_get_prandom_u32() % 1000);
    recalc_icmp_csum(icmphdr, old_seq, icmphdr->un.echo.sequence);

    return XDP_TX;
}

char LICENSE[] SEC("license") = "GPL";

И вот что из этого выходит

$ ping 127.0.0.1
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=789 ttl=180 time=1257520 ms
64 bytes from 127.0.0.1: icmp_seq=965 ttl=73 time=3875372 ms
64 bytes from 127.0.0.1: icmp_seq=701 ttl=183 time=434820 ms
64 bytes from 127.0.0.1: icmp_seq=689 ttl=95 time=771651 ms
64 bytes from 127.0.0.1: icmp_seq=777 ttl=55 time=2024511 ms
64 bytes from 127.0.0.1: icmp_seq=906 ttl=66 time=211697 ms
64 bytes from 127.0.0.1: icmp_seq=674 ttl=163 time=2369164 ms

Код лежит на GitHub.

Проверь сам! — ping pingme.x3lfy.space

Комментарии