UDPデータグラムのパース/構築と、疑似ヘッダによるチェックサム
UDPヘッダは8バイト固定で、送信元/宛先ポートとデータ長、チェックサムしか持ちません。フィールド数だけを見るとICMPよりもさらに単純ですが、チェックサムの計算対象がUDPヘッダの外(IPv4ヘッダの一部)にまで及ぶ点で、これまでのプロトコルとは設計が変わります。
型設計
// ip-icmp-udp/include/ipicmpudp/udp.hpp
struct UdpDatagram
{
std::uint16_t source_port;
std::uint16_t destination_port;
std::span<const std::byte> payload;
};
enum class UdpParseError
{
DatagramTooShort,
LengthMismatch,
ChecksumMismatch,
};
std::expected<UdpDatagram, UdpParseError> parse_udp_datagram(
std::span<const std::byte> datagram,
Ipv4Address source_ip,
Ipv4Address destination_ip
);
std::vector<std::byte> build_udp_datagram(
std::uint16_t source_port,
std::uint16_t destination_port,
std::span<const std::byte> payload,
Ipv4Address source_ip,
Ipv4Address destination_ip
);parse_icmp_echo_messageやparse_ipv4_packetとのシグネチャ上の違いは、送信元/宛先IPアドレスを引数として受け取っている点です。これはUDPのチェックサム計算に、IPv4ヘッダの情報が必要になるためです。
疑似ヘッダ: 上位層が下位層のアドレスも保護する
ICMPのチェックサムはメッセージそのもの(ヘッダ+データ部)だけを対象に計算していました。UDPのチェックサムはそれに加えて、**疑似ヘッダ(pseudo header)**と呼ばれる12バイトの仮想的な領域を先頭に付け足した状態で計算します。
| フィールド | サイズ | 内容 |
|---|---|---|
| Source IP | 4 | IPv4ヘッダの送信元アドレス |
| Destination IP | 4 | IPv4ヘッダの宛先アドレス |
| Zero | 1 | 0固定 |
| Protocol | 1 | 17 (UDP) |
| UDP Length | 2 | UDPヘッダ+ペイロードの長さ |
この12バイトは実際のパケットには含まれません。あくまで計算の際にだけ一時的に前置される仮想データです。
// ip-icmp-udp/src/udp.cpp(抜粋)
std::vector<std::byte> build_pseudo_header(
Ipv4Address source_ip,
Ipv4Address destination_ip,
std::uint16_t udp_length
)
{
std::vector<std::byte> pseudo(PseudoHeaderSize);
std::span<std::byte> bytes(pseudo);
std::ranges::copy(source_ip, bytes.begin());
std::ranges::copy(destination_ip, bytes.begin() + 4);
bytes[8] = std::byte{0};
bytes[9] = static_cast<std::byte>(IpProtocol::Udp);
detail::write_uint16(bytes.subspan<10, 2>(), udp_length);
return pseudo;
}なぜIPアドレスまで保護する必要があるのか。IPv4ヘッダ自体にもチェックサムはありますが、それが検証しているのはヘッダの部分だけです。仮に経路上のどこかでIPヘッダの送信元/宛先アドレスが破損し、たまたまIPv4ヘッダのチェックサムと矛盾しない値に化けてしまった場合、IPv4層の検証だけでは検出できません。UDPが自分のチェックサムにIPアドレスを含めることで、「間違った相手に届いたデータグラム」や「送信元を偽装されたデータグラム」を、UDP層でも独立して検出できるようにしています。
計算そのものは、疑似ヘッダとUDPメッセージを1本のバッファに連結してから、IPv4ヘッダで使っているのと同じcompute_checksumにそのまま渡すだけです。1の補数和はどこからどこまでを対象にするかが呼び出し側のspan次第なので、対象範囲が変わってもアルゴリズム自体を作り直す必要はありません。
// ip-icmp-udp/src/udp.cpp(抜粋)
std::uint16_t compute_udp_checksum(
std::span<const std::byte> udp_message,
Ipv4Address source_ip,
Ipv4Address destination_ip
)
{
auto pseudo = build_pseudo_header(
source_ip, destination_ip, static_cast<std::uint16_t>(udp_message.size())
);
std::vector<std::byte> combined(pseudo.size() + udp_message.size());
std::ranges::copy(pseudo, combined.begin());
std::ranges::copy(udp_message, combined.begin() + pseudo.size());
return compute_checksum(combined);
}チェックサム0は「未使用」を意味するため0xFFFFに読み替える
UDPの仕様上、チェックサムフィールドは省略(0固定で送信)することが許されています。そのため、計算結果がたまたま0x0000になった場合は、そのまま書き込むと「チェックサム未使用」と誤解されてしまいます。これを避けるため、計算結果が0の場合だけ0xFFFFとして送信するというルールがあります。
// ip-icmp-udp/src/udp.cpp(抜粋)
const auto checksum = compute_udp_checksum(bytes, source_ip, destination_ip);
detail::write_uint16(bytes.subspan<6, 2>(), checksum == 0 ? std::uint16_t{0xFFFF} : checksum);0x0000と0xFFFFはどちらも1の補数表現上の「ゼロ」(1の補数には正のゼロと負のゼロの2通りの表現がある)なので、この読み替えをしてもチェックサムの整合性そのものは崩れません。今回の実装では検証を必ず行う方針にしているため(未使用扱いを許容する分岐は入れていません)、このルールは主に「たまたま0になるケースを踏んでも壊れたパケットとして扱われないようにする」ための処理です。
1の補数和チェックサムが検出できないもの
疑似ヘッダを実装していて気づく性質として、1の補数和には16bitワード単位の入れ替えを検出できないという限界があります。加算は可換なので、A + BとB + Aは同じ値になります。疑似ヘッダの送信元IPと宛先IPを丸ごと入れ替えても、チェックサムの計算結果は変わりません。
これは実装のバグではなく、RFC 1071が定義するアルゴリズム自体の性質です。ほかにも、あるワードが+Xされ別のワードが-Xされるように誤りが打ち消し合うケースも検出できません。1の補数和が選ばれた理由は、非力なハードウェアでも高速に計算できる軽量さが優先されたためで、暗号学的な強度や完全性を保証する設計にはなっていません。
実運用でこの弱点が問題にならないのは、下位のEthernet層がFCS(CRC32)によってもっと強力な誤り検出を行っているためです。IP/UDP/TCPのチェックサムは、リンク層のFCSでは守れない範囲——ルータやNATによる転記ミス、中継ノードでのメモリ破損など——を拾うための、独立した二重目のチェックという位置づけです。
IPv4パケットの構築: parseと対になるbuild
ここまでのUDPデータグラムをそのまま送信するには、IPv4ヘッダで包む必要があります。これまでipv4.hppにはparse_ipv4_packetしかありませんでしたが、UDPを実際に送信するためにbuild_ipv4_packetを追加しました。
// ip-icmp-udp/src/ipv4.cpp(抜粋)
std::vector<std::byte> build_ipv4_packet(
IpProtocol protocol,
Ipv4Address source,
Ipv4Address destination,
std::uint16_t identification,
std::span<const std::byte> payload
)
{
constexpr std::size_t HeaderSize = 20;
constexpr std::uint8_t Ttl = 64;
std::vector<std::byte> packet(HeaderSize + payload.size());
std::span<std::byte> bytes(packet);
bytes[0] = std::byte{0x45}; // version 4, IHL 5
bytes[1] = std::byte{0x00};
detail::write_uint16(bytes.subspan<2, 2>(), static_cast<std::uint16_t>(packet.size()));
detail::write_uint16(bytes.subspan<4, 2>(), identification);
detail::write_uint16(bytes.subspan<6, 2>(), 0); // フラグメンテーションは扱わない
bytes[8] = static_cast<std::byte>(Ttl);
bytes[9] = static_cast<std::byte>(protocol);
detail::write_uint16(bytes.subspan<10, 2>(), 0); // checksum 仮置き
std::ranges::copy(source, bytes.begin() + 12);
std::ranges::copy(destination, bytes.begin() + 16);
std::ranges::copy(payload, bytes.begin() + HeaderSize);
const auto checksum = compute_checksum(bytes.subspan(0, HeaderSize));
detail::write_uint16(bytes.subspan<10, 2>(), checksum);
return packet;
}オプション無しのヘッダ長20バイト固定、TTL 64固定、フラグメンテーション関連のフィールドは常に0という割り切った実装です。チェックサムはフィールドを0にした状態で一度計算し、その結果を書き戻す2段階の手順になっており、これはparse_ipv4_packet側の検証(チェックサムフィールドを含めた全体を計算すると結果が0になる)と対になる手順です。
AF_PACKETでEthernet+IPv4+UDPを自前で組み立てて送受信する
UDPデータグラムとIPv4ヘッダのbuild関数が揃ったことで、arp_resolveと同じ構成のudp_echo_client/udp_echo_serverを実装できます。宛先のMACアドレスはARP解決で得るため、L2からL4まで全てのヘッダを自前のコードだけで組み立てて送信します。
// ip-icmp-udp/apps/udp_echo_client.cpp(抜粋)
auto udp_payload = ipicmpudp::build_udp_datagram(
LocalPort, target_port, std::as_bytes(std::span(message)), *sender_ip, *target_ip
);
auto ip_packet = ipicmpudp::build_ipv4_packet(
ipicmpudp::IpProtocol::Udp, *sender_ip, *target_ip, 1, udp_payload
);
std::vector<std::byte> frame(14 + ip_packet.size());
std::span<std::byte> frame_bytes(frame);
std::ranges::copy(*target_mac, frame_bytes.begin());
std::ranges::copy(*sender_mac, frame_bytes.begin() + 6);
frame_bytes[12] = std::byte{0x08};
frame_bytes[13] = std::byte{0x00}; // EtherType = IPv4(0x0800)
std::ranges::copy(ip_packet, frame_bytes.begin() + 14);サーバ側は受信したEthernetフレームをparse_ethernet_frame→parse_ipv4_packet→parse_udp_datagramの順に剥がしていき、指定ポート宛のデータグラムだけを拾って送信元にオウム返しします。応答フレームの宛先MACは、受信したフレームの送信元MACをそのまま使えるため、サーバ側では改めてARP解決をする必要はありません。実機で同一LAN上のホストに対してこの2つを動かすと、ARP解決からUDPデータグラムの送受信までの一連の流れを実際のフレームで確認できます。
参考リンク
- RFC 768: User Datagram Protocol —— UDPヘッダのフォーマットと、疑似ヘッダを用いたチェックサム計算方法の一次資料です
- RFC 1071: Computing the Internet Checksum —— 1の補数和チェックサムの計算方法と、その検出限界に関する仕様です
- IPv4ヘッダのチェックサム検証の基本設計はIPv4ヘッダをパースしてチェックサムを検証するで扱っています