AF_PACKET raw socketによるL2フレームキャプチャの実装

NICから実際にEthernetフレームを送受信するraw socketキャプチャを実装し、実機上のARPトラフィックを観測するプログラムを実装します。

raw socketをクラスとして設計する

NICとの間でパケットを送受信するには、OSのソケットfd(ファイルディスクリプタ)というリソースを保持し続ける必要があります。これは関数ではなく、リソースの所有権を型として表現するクラスにするのが妥当な場面です。

// l2-arp/include/l2arp/capture.hpp
enum class CaptureError {
    InterfaceNotFound,
    SocketCreateFailed,
    BindFailed,
    ReceiveFailed,
    SendFailed,
};

class RawSocket {
public:
    static std::expected<RawSocket, CaptureError> open(std::string_view interface_name);

    RawSocket(const RawSocket&) = delete;
    RawSocket& operator=(const RawSocket&) = delete;
    RawSocket(RawSocket&&) noexcept;
    RawSocket& operator=(RawSocket&&) noexcept;
    ~RawSocket();

    std::expected<std::span<std::byte>, CaptureError> receive(std::span<std::byte> buffer);
    std::expected<void, CaptureError> send(std::span<const std::byte> frame);

private:
    RawSocket(int fd, int interface_index);

    int fd_;
    int interface_index_;
};

コピーを= deleteで禁止し、ムーブだけを許可しているのは、fdの所有者を常に1つに保つためです。コピーを許すと2つのRawSocketが同じfdを指してしまい、片方が破棄されたときにclose()し、もう片方がそのfdを使おうとする、あるいは両方が同じfdをclose()してしまうといった問題が起きます。ムーブコンストラクタ・ムーブ代入演算子の実装では、移動元のfdを-1にリセットすることで、デストラクタが誤って二重にclose()しないようにしています。

生成をopen()という静的メンバ関数(ファクトリ)に切り出しているのは、socket()if_nametoindex()bind()のいずれもが失敗しうるためです。コンストラクタは失敗を表現できない(例外を投げるか、不完全なオブジェクトを作るしかない)ので、失敗しうる初期化処理はstd::expectedを返せる静的関数に外出しし、コンストラクタ自体は「必ず成功する」ことが分かっている単純な代入だけにしています。

// l2-arp/src/capture.cpp(抜粋)
std::expected<RawSocket, CaptureError> RawSocket::open(std::string_view interface_name) {
    int fd = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
    if (fd == -1) {
        return std::unexpected(CaptureError::SocketCreateFailed);
    }

    unsigned int index = if_nametoindex(std::string(interface_name).c_str());
    if (index == 0) {
        close(fd);
        return std::unexpected(CaptureError::InterfaceNotFound);
    }

    sockaddr_ll addr{};
    addr.sll_family = AF_PACKET;
    addr.sll_protocol = htons(ETH_P_ALL);
    addr.sll_ifindex = static_cast<int>(index);

    if (bind(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == -1) {
        close(fd);
        return std::unexpected(CaptureError::BindFailed);
    }

    return RawSocket(fd, static_cast<int>(index));
}

AF_PACKET + SOCK_RAWのソケットは、Ethernetヘッダを含むフレーム全体をそのまま扱えます。bind()で対象インターフェースに紐付けることで、そのNICを通過するフレームだけを受信できるようになります。このソケットの作成にはroot権限(またはCAP_NET_RAW)が必要です。

receive()は呼び出し側が確保したバッファに書き込み、実際に読めたバイト数分のstd::spanだけを返します。アロケーションが発生しないのは、これまでのspanベースの設計と一貫しています。

// l2-arp/src/capture.cpp
std::expected<std::span<std::byte>, CaptureError> RawSocket::receive(std::span<std::byte> buffer) {
    ssize_t received = recv(fd_, buffer.data(), buffer.size(), 0);
    if (received < 0) {
        return std::unexpected(CaptureError::ReceiveFailed);
    }
    return buffer.subspan(0, static_cast<std::size_t>(received));
}

Ethernet解析・ARP解析と繋いで実際に受信する

RawSocket::receive()で読んだバイト列は、そのままparse_ethernet_frame()に渡せます。EtherTypeがARPであれば、そのペイロードをさらに、ARPパケットの解析とバイト列の組み立てで実装したparse_arp_packet()に渡すことで、L2からARPまでの解析パイプラインがつながります。

// l2-arp/apps/arp_sniff.cpp(抜粋)
auto socket = l2arp::RawSocket::open(argv[1]);
std::array<std::byte, 2048> buffer{};

while (true) {
    auto received = socket->receive(buffer);
    if (!received) continue;

    auto frame = l2arp::parse_ethernet_frame(*received);
    if (!frame || frame->ether_type != l2arp::EtherType::Arp) continue;

    auto arp = l2arp::parse_arp_packet(frame->payload);
    if (!arp) continue;

    std::cout << std::format(
        "ARP {} sender={} ({}) target={}\n",
        arp->operation == l2arp::ArpOperation::Request ? "request" : "reply",
        l2arp::to_string(arp->sender_ip),
        l2arp::to_string(arp->sender_mac),
        l2arp::to_string(arp->target_ip)
    );
}

表示するのはsender_ipsender_mactarget_ipのみです。target_macはRequestでは常に全ゼロ(まだ分からないから聞いている)、Replyでは元々質問した側自身のMACなので、いずれの場合も表示する情報としての価値がありません。ARPが運ぶ新しい情報は常に「senderのIP↔MAC対応」だけです。

実機での動作確認

実機(Ubuntu、GCC 15)でsudo権限付きでこのプログラムを実行し、同一LAN上の別ホストに向けてpingを打つと、ARP requestとreplyが実際に流れてくることを確認できました。合成したバイト列でのユニットテストとは別に、実ネットワーク上のフレームを実装が正しく解釈できることを確認できたことになります。

参考リンク