手っ取り早くやってみる 超XDP入門 Part3 (ICMP Echo Reply編 その2)

前回の記事では、XDPでICMP Echo Replyを返すプログラムを書きました。
ICMP Echo Replyを返すプログラムを実装しましたが、ICMPのチェックサムが正しく計算されていないことがわかりました。

今回は、ICMPのチェックサムを計算して正しく返す方法を書いていきたいと思います。

怠惰にならず、勢いで2日連続書けたので、ほめてください

ICMPのチェックサム

  • ICMPヘッダ構造
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |      Type      |     Code      |          Checksum           |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |           Identifier           |        Sequence Number      |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |                             Data                              |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    

ICMPヘッダにはChecksumが含まれており、ICMP Echo RequestからTypeを変更してICMP Echo Replyに変更し、パケットを返したため、Checksumの値を再計算してやる必要があります。

ICMPのチェックサムの計算は、ヘッダとデータ全体を16ビット単位で加算し、キャリーオーバーが発生した場合はその値をさらに加算します。最後に1の補数(ビット反転)を取ることで最終的なチェックサムを求めます。

この辺の計算方法は RFC 1624 に詳しく記載されています。RFC 1141を参照すると差分計算がうまくいかないので注意してください。RFC 1141は、差分計算の記述が間違っているため、RFC 1624に修正されています。

更新されていることに気付かず、RFC 1141を見てしまい、無限に時間をとかした経験があります。

RFCは誰かが翻訳したものとかではなく、必ず原文を読むようにしましょう。。。(自分への戒め)

16bitごとに加算していき、最後に1の補数を取って計算のため、実は16ビット単位で修正箇所の差分を取るだけで、全てのデータを再計算する必要はありません。

bpf_csum_diff

eBPFのプログラムでチェックサムの計算を行う際には、bpf_csum_diff という関数が用意されています。この関数を使うことで、簡単にチェックサムの計算を行うことができます。

bpf_csum_diff は、指定したメモリ領域の 16 ビット単位の変更に基づいて、チェックサムの変化量を計算する関数です。これにより、ICMPやIPのチェックサムを効率的に更新できます。

ICMP Echo Replyを返すプログラム

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/icmp.h>
#include <arpa/inet.h>

static __always_inline __u16 csum_fold(__u32 csum)
{
    // 上位16ビットの値を下位16ビットに加算
    csum = (csum & 0xffff) + (csum >> 16);
    // もう一度キャリーオーバー分を加算
    csum = (csum & 0xffff) + (csum >> 16);
    // 1の補数を取って最終的なチェックサムを返す
    return (__u16)~csum;
}

static __always_inline void swap_eth_addr(__u8 *a, __u8 *b)
{
    __u8 tmp[ETH_ALEN];
    __builtin_memcpy(tmp, a, ETH_ALEN);
    __builtin_memcpy(a, b, ETH_ALEN);
    __builtin_memcpy(b, tmp, ETH_ALEN);
}

static __always_inline void swap_ip_addr(__u32 *a, __u32 *b)
{
    __u32 tmp = *a;
    *a = *b;
    *b = tmp;
}

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

    // Ethernetヘッダのポインタを取得
    struct ethhdr *eth = data;
    if ((void *)eth + sizeof(*eth) > data_end)
        return XDP_PASS;

    // EthernetフレームがIPパケットを含んでいるか確認
    if (eth->h_proto != htons(ETH_P_IP))
        return XDP_PASS;

    // IPヘッダのポインタを取得
    struct iphdr *ip = data + sizeof(*eth);
    if ((void *)ip + sizeof(*ip) > data_end)
        return XDP_PASS;

    // プロトコルがICMPであるか確認
    if (ip->protocol != IPPROTO_ICMP)
        return XDP_PASS;

    // ICMPヘッダのポインタを取得
    struct icmphdr *icmp = (void *)ip + sizeof(*ip);
    if ((void *)icmp + sizeof(*icmp) > data_end)
        return XDP_PASS;

    // ICMP Echo Request以外はパケットを通す
    if (icmp->type != ICMP_ECHO)
        return XDP_PASS;

    // 送信元と宛先のMACアドレスを入れ替える
    swap_eth_addr(eth->h_dest, eth->h_source);
    // 送信元と宛先のIPアドレスを入れ替える
    swap_ip_addr(&ip->saddr, &ip->daddr); 

    // icmpをコピーしておく
    struct icmphdr icmp_before = *icmp;

    // ICMP Echo Replyにフラグを変える
    icmp->type = ICMP_ECHOREPLY;

    // checksumを0に初期化
    icmp->checksum = 0;
    __s64 value = bpf_csum_diff((void *)&icmp_before, sizeof(icmp_before), (void *)icmp, sizeof(*icmp), 0);
    if (value >= 0)
        icmp->checksum = csum_fold(value);

    // ICMP Echo Requestのパケットをドロップ
    // return XDP_DROP;
    return XDP_TX;
}

char _license[] SEC("license") = "MIT";
static __always_inline __u16 csum_fold(__u32 csum)
{
    // 上位16ビットの値を下位16ビットに加算
    csum = (csum & 0xffff) + (csum >> 16);
    // もう一度キャリーオーバー分を加算
    csum = (csum & 0xffff) + (csum >> 16);
    // 1の補数を取って最終的なチェックサムを返す
    return (__u16)~csum;
}

csum_fold 関数は、32ビットのチェックサムを16ビットに圧縮する処理を行います。bpf_csum_diff() の戻り値は 32 ビットであり、キャリーが発生することがあります。そのため、まず上位16ビットの値を下位16ビットに加算し、さらにもう一度キャリー処理を行います。最後に1の補数(ビット反転)を取ることで、最終的なチェックサム値を求めます。

static __always_inline void swap_eth_addr(__u8 *a, __u8 *b)
{
    __u8 tmp[ETH_ALEN];
    __builtin_memcpy(tmp, a, ETH_ALEN);
    __builtin_memcpy(a, b, ETH_ALEN);
    __builtin_memcpy(b, tmp, ETH_ALEN);
}

swap_eth_addr 関数は、EthernetヘッダのMACアドレスを入れ替える関数です。

static __always_inline void swap_ip_addr(__u32 *a, __u32 *b)
{
    __u32 tmp = *a;
    *a = *b;
    *b = tmp;
}

swap_ip_addr 関数は、IPヘッダのIPアドレスを入れ替える関数です。

eBPFの場合、関数呼び出しはインライン展開する必要があるため、__always_inlineを付与しています。

    // 送信元と宛先のMACアドレスを入れ替える
    swap_eth_addr(eth->h_dest, eth->h_source);
    // 送信元と宛先のIPアドレスを入れ替える
    swap_ip_addr(&ip->saddr, &ip->daddr); 

    // ICMPヘッダをコピーしておく
    struct icmphdr icmp_before = *icmp;

    // ICMP Echo Replyにフラグを変える
    icmp->type = ICMP_ECHOREPLY;

    // checksumを0に初期化
    icmp->checksum = 0;
    __s64 value = bpf_csum_diff((void *)&icmp_before, sizeof(icmp_before), (void *)icmp, sizeof(*icmp), 0);
    if (value >= 0)
        icmp->checksum = csum_fold(value);

この部分が、以前とは異なる部分です。
swap_eth_addrswap_ip_addr でMACアドレスとIPアドレスを入れ替えは関数化しただけです。

icmp をコピーしておき、icmp->typeICMP_ECHOREPLY に変更します。
その後チェックサムを計算しています。

icmp->checksum = 0; でチェックサムを0に初期化してから、bpf_csum_diff() で差分を計算しています。その後、bpf_csum_diffの戻り値はキャリーオーバーしてくれないので、csum_fold() で折り畳んでいます。

動作確認

  • クライアントからpingを送信

    $ ping 192.168.XXX.XXX
    
  • クライアントからpingを送信すると、ICMP Echo Replyが返ってくることを確認します。

    $ sudo tcpdump -i eth0 icmp -vvv
    XX:XX:XX.413478 IP (tos 0x0, ttl 64, id 35523, offset 0, flags [DF], proto ICMP (1), length 84)
        192.168.XXX.1 > 192.168.XXX.2: ICMP echo request, id 64346, seq 16, length 64
    XX:XX:XX.414359 IP (tos 0x0, ttl 64, id 35523, offset 0, flags [DF], proto ICMP (1), length 84)
        192.168.XXX.2 > 192.168.XXX.1: ICMP echo reply, id 64346, seq 16, length 64
    

無事、チェックサムのエラーが解消され、ICMP Echo Replyが返ってくることが確認できました。

実は・・・

実は、IPヘッダにもチェックサムがあるため、実は再計算する必要があります。

  • IPヘッダ構造
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    | Version |  IHL  |  DSCP  | ECN  |        Total Length         |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |         Identification        |Flags|     Fragment Offset     |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |  Time to Live |   Protocol    |        Header Checksum        |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |                      Source IP Address                       |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |                   Destination IP Address                     |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |                    Options (if any)                          |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    

HeaderChecksumのフィールドがあり、本来はここの値を再計算する必要があります。

しかしながら、今回はSource IPとDestination IPを16ビット単位でそのまま入れ替えているだけのため、チェックサムの計算方法上、結果的にチェックサムが変わりません。
これは、「IPアドレスを16ビット単位で入れ替えた場合、合算値の1の補数としてのチェックサムが変化しない」という性質によるものです。そのため、今回はたまたま影響がなかっただけであり、他のフィールドを変更した場合はIPチェックサムを必ず再計算する必要があります。

必要に応じて、IPヘッダをICMPヘッダと同様に再計算して利用しましょう。

IPヘッダもチェックサムを計算する

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/icmp.h>
#include <arpa/inet.h>

static __always_inline __u16 csum_fold(__u32 csum)
{
    // 上位16ビットの値を下位16ビットに加算
    csum = (csum & 0xffff) + (csum >> 16);
    // もう一度キャリーオーバー分を加算
    csum = (csum & 0xffff) + (csum >> 16);
    // 1の補数を取って最終的なチェックサムを返す
    return (__u16)~csum;
}

static __always_inline void swap_eth_addr(__u8 *a, __u8 *b)
{
    __u8 tmp[ETH_ALEN];
    __builtin_memcpy(tmp, a, ETH_ALEN);
    __builtin_memcpy(a, b, ETH_ALEN);
    __builtin_memcpy(b, tmp, ETH_ALEN);
}

static __always_inline void swap_ip_addr(__u32 *a, __u32 *b)
{
    __u32 tmp = *a;
    *a = *b;
    *b = tmp;
}

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

    // Ethernetヘッダのポインタを取得
    struct ethhdr *eth = data;
    if ((void *)eth + sizeof(*eth) > data_end)
        return XDP_PASS;

    // EthernetフレームがIPパケットを含んでいるか確認
    if (eth->h_proto != htons(ETH_P_IP))
        return XDP_PASS;

    // IPヘッダのポインタを取得
    struct iphdr *ip = data + sizeof(*eth);
    if ((void *)ip + sizeof(*ip) > data_end)
        return XDP_PASS;

    // プロトコルがICMPであるか確認
    if (ip->protocol != IPPROTO_ICMP)
        return XDP_PASS;

    // ICMPヘッダのポインタを取得
    struct icmphdr *icmp = (void *)ip + sizeof(*ip);
    if ((void *)icmp + sizeof(*icmp) > data_end)
        return XDP_PASS;

    // ICMP Echo Request以外はパケットを通す
    if (icmp->type != ICMP_ECHO)
        return XDP_PASS;

    // IPヘッダをコピーしておく
    struct iphdr ip_before = *ip;

    // 送信元と宛先のMACアドレスを入れ替える
    swap_eth_addr(eth->h_dest, eth->h_source);
    // 送信元と宛先のIPアドレスを入れ替える
    swap_ip_addr(&ip->saddr, &ip->daddr); 

    // ICMPヘッダをコピーしておく
    struct icmphdr icmp_before = *icmp;

    // ICMP Echo Replyにフラグを変える
    icmp->type = ICMP_ECHOREPLY;

    // checksumを0に初期化
    icmp->checksum = 0;
    __s64 value = bpf_csum_diff((void *)&icmp_before, sizeof(icmp_before), (void *)icmp, sizeof(*icmp), 0);
    if (value >= 0)
        icmp->checksum = csum_fold(value);

    // ipheader checksum
    ip->check = 0;
    value = bpf_csum_diff((void *)&ip_before, sizeof(ip_before), (void *)ip, sizeof(*ip), 0);
    if (value >= 0)
        ip->check = csum_fold(value);

    // ICMP Echo Requestのパケットをドロップ
    // return XDP_DROP;
    return XDP_TX;
}

char _license[] SEC("license") = "MIT";

swap_ip_addr でipヘッダを書き換える前にコピーしておき、ICMPのチェックサムと同様に、bpf_csum_diff で差分を計算しています。これで、IPヘッダのチェックサムも正しく計算されるようになりました。

まとめ

  • XDP を用いてICMP Echo Requestに対してICMP Echo Replyを返すXDPプログラムを実装しました。
  • ICMPのチェックサムを計算して正しく返す方法を紹介しました。
  • チェックサムの計算は、16ビット単位で修正箇所の差分を取るだけで、全てのデータを再計算する必要はありません。
  • bpf_csum_diff() という関数を使うことで、簡単にチェックサムの計算を行うことができます。