ARP requestを実際に送ってIPアドレスをMACアドレスに解決する

指定したIPアドレスに対してARP requestを送信し、応答(reply)を受け取ってMACアドレスを解決するarp_resolveというプログラムを実装しました。ARPパケットの受信・解析だけでなく、送信までを含めた一連の流れが実機で確認できるようになります。

Ethernetヘッダを手で組み立てて送信する

ARPパケット本体を組み立てるbuild_arp_request()は28バイトのARPペイロードを返しますが、実際にNICから送信するには、その手前に14バイトのEthernetヘッダ(宛先MAC・送信元MAC・EtherType)を付ける必要があります。今のところEthernetヘッダを組み立てる関数は用意していないため、送信するその場でバイト列を直接組み立てています。

// l2-arp/apps/arp_resolve.cpp
auto arp_payload = l2arp::build_arp_request(*sender_mac, *sender_ip, *target_ip);

std::array<std::byte, 14 + arp_payload.size()> frame{};
std::ranges::fill(std::span(frame).subspan<0, 6>(), std::byte{0xFF});  // 宛先: broadcast
std::ranges::copy(*sender_mac, frame.begin() + 6);                     // 送信元MAC
frame[12] = std::byte{0x08};
frame[13] = std::byte{0x06};                                           // EtherType = ARP(0x0806)
std::ranges::copy(arp_payload, frame.begin() + 14);

socket->send(frame);

ARP requestは相手のMACアドレスを知らない状態で送るものなので、宛先MACは全ビット1ff:ff:ff:ff:ff:ff)のブロードキャストにします。同一LAN上の全ホストがこのフレームを受け取り、target_ipを持つホストだけが応答(reply)を返す、という仕組みです。

ioctlでローカルのMAC/IPアドレスを取得する

ARP requestの送信元情報(sender_mac/sender_ip)には、このプログラムを実行しているマシン自身のMAC/IPアドレスが必要です。これを毎回コマンドライン引数で手入力するのは面倒なので、ioctlでインターフェース名から自動取得するようにしました。

// l2-arp/apps/arp_resolve.cpp(抜粋)
std::optional<l2arp::MacAddress> local_mac(std::string_view interface_name)
{
    int fd = socket(AF_INET, SOCK_DGRAM, 0);
    if (fd == -1)
    {
        return std::nullopt;
    }

    ifreq ifr{};
    std::string name(interface_name);
    std::strncpy(ifr.ifr_name, name.c_str(), IFNAMSIZ - 1);

    bool ok = ioctl(fd, SIOCGIFHWADDR, &ifr) != -1;
    close(fd);
    if (!ok)
    {
        return std::nullopt;
    }

    l2arp::MacAddress mac{};
    std::memcpy(mac.data(), ifr.ifr_hwaddr.sa_data, mac.size());
    return mac;
}

ioctlifreq構造体(インターフェース名を詰めたもの)を渡して呼ぶと、カーネルがそのインターフェースの情報を埋めて返してくれます。SIOCGIFHWADDRでMACアドレス、SIOCGIFADDRでIPv4アドレスが取得できます。ここで開いているAF_INETのソケットは実際の通信には使わず、ioctlを呼ぶための一時的なハンドルとして使っているだけです。

受信したパケットが「本当に待っていたreply」かを確認する

送信後は受信ループに入り、ARPパケットを見つけるたびに中身を検証します。ここで重要なのが、単に「ARPパケットが来たら即採用」ではなく、次の条件を両方満たすかをチェックしている点です。

// l2-arp/apps/arp_resolve.cpp(抜粋)
auto arp = l2arp::parse_arp_packet(eth_frame->payload);
if (!arp || arp->operation != l2arp::ArpOperation::Reply || arp->sender_ip != *target_ip)
{
    continue;
}

AF_PACKETのraw socketは、多くの場合そのインターフェースから自分自身が送信したフレームのコピーも受信対象に含みます。つまりこのチェックを省略すると、送信した直後に自分自身のARP request(operation == Requestsender_ip = 自分自身のIP)を受信・パースしてしまい、「解決できた」と誤判定して自分自身のIP/MACを表示してしまう可能性があります。同一LAN上を流れる他のARPトラフィック(無関係なホスト同士のRequest/Reply)を拾ってしまう可能性も同様です。operationがReplyであること、かつsender_ipが今探しているtarget_ipと一致することの両方を確認して初めて、それが自分の送ったrequestに対する応答だと判断できます。

実機での動作確認

実機でsudo権限付きで実行し、同一LAN上の実在するホストのIPアドレスを指定すると、ARP requestを送信した直後にreplyを受信し、MACアドレスが正しく解決されることを確認できました。

// l2-arp/apps/arp_resolve.cpp(main関数の全体像)
auto sender_mac = local_mac(argv[1]);
auto sender_ip = local_ipv4(argv[1]);
auto target_ip = parse_ipv4(argv[2]);
// ... requestを送信 ...

l2arp::ArpCache cache;
while (true)
{
    auto received = socket->receive(buffer);
    auto eth_frame = l2arp::parse_ethernet_frame(*received);
    if (!eth_frame || eth_frame->ether_type != l2arp::EtherType::Arp) continue;

    auto arp = l2arp::parse_arp_packet(eth_frame->payload);
    if (!arp || arp->operation != l2arp::ArpOperation::Reply || arp->sender_ip != *target_ip) continue;

    cache.insert(arp->sender_ip, arp->sender_mac);
    break;
}

参考リンク