C言語によるRaw Socketを用いたICMPパケット送信入門

はじめに

ネットワークプログラミングの世界では、TCPやUDPといったトランスポート層のプロトコルを使うことが一般的です。しかし、時にはより低レイヤーでの制御が必要になる場面があります。例えば、ネットワーク診断ツールの ping コマンドのように、ICMP (Internet Control Message Protocol) パケットを直接送受信したい場合です。

ICMPは、IPネットワーク上でエラー通知や制御メッセージを転送するために使用されるプロトコルです。ルーターやホストが通信相手に到達不能であること、生存時間 (TTL) が超過したことなどを通知する際に利用されます。また、ping コマンドで使われる Echo Request / Echo Reply メッセージもICMPの一部です。

このようなICMPパケットをプログラムから直接扱うためには、Raw Socket (生ソケット) を使用する必要があります。Raw Socket は、通常のソケットとは異なり、トランスポート層 (TCP/UDP) を介さず、IP層やさらに下位のレイヤーに直接アクセスすることを可能にします。これにより、IPヘッダーやICMPヘッダーを自前で構築し、OSのプロトコルスタックによる自動処理をバイパスしてパケットを送受信できます。

この記事では、C言語を用いてRaw Socketを作成し、ICMP Echo Request パケットを送信する基本的な方法について解説します。ネットワークの仕組みを深く理解したい方や、独自のネットワークツールを作成したい方にとって、Raw Socket プログラミングは非常に興味深く、有用な技術となるでしょう。

注意: Raw Socket の作成と使用には、通常、管理者権限 (root権限) が必要です。これは、Raw Socket が低レイヤーのプロトコルにアクセスでき、悪用されるとネットワークに影響を与える可能性があるため、セキュリティ上の制限が設けられているからです。Linux環境では、ケーパビリティ (CAP_NET_RAW) を利用して、root権限なしでRaw Socketを使用できる場合もありますが、ここでは基本的なroot権限下での実行を前提とします。

Raw Socketの作成

ICMPパケットを送信するための最初のステップは、Raw Socketを作成することです。これには標準のソケットAPI関数である socket() を使用します。

Raw Socket プログラミングを行うには、いくつかのヘッダーファイルをインクルードする必要があります。最低限、以下のヘッダーファイルが必要となるでしょう(環境によって多少異なる場合があります)。

#include <stdio.h>          // 標準入出力関数 (printf, perror など)
#include <stdlib.h>         // 標準ライブラリ関数 (exit など)
#include <string.h>         // 文字列操作関数 (memset など)
#include <unistd.h>         // POSIX API (close など)
#include <sys/socket.h>     // ソケット関数の定義 (socket, sendto など)
#include <netinet/in.h>       // インターネットアドレス構造体 (sockaddr_in など)
#include <netinet/ip.h>       // IPヘッダー構造体 (struct iphdr) ※IP_HDRINCLを使う場合など
#include <netinet/ip_icmp.h>  // ICMPヘッダー構造体 (struct icmphdr)
#include <arpa/inet.h>      // IPアドレス変換関数 (inet_addr など)
#include <netdb.h>          // ホスト名解決関数 (gethostbyname など) ※今回は使用しない
#include <errno.h>          // エラー番号

socket() 関数は、通信のためのエンドポイント(ソケット)を作成します。Raw Socketを作成するには、引数を以下のように指定します。

  • domain: AF_INET (IPv4) または AF_INET6 (IPv6)。今回はIPv4を扱います。
  • type: SOCK_RAW を指定します。これによりRaw Socketが作成されます。
  • protocol: 扱うプロトコルを指定します。ICMPを扱う場合は IPPROTO_ICMP を指定します。
int sockfd;

// Raw Socketを作成 (IPv4, ICMPプロトコル)
sockfd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
if (sockfd < 0) {
    perror("socket() failed");
    exit(EXIT_FAILURE);
}

printf("Raw socket created successfully.\n");

このコードを実行すると、ICMPプロトコル用のRaw Socketが作成されます。成功した場合、socket() 関数は非負の整数値(ソケットディスクリプタ)を返します。エラーが発生した場合は -1 を返し、errno にエラーコードが設定されます。perror() 関数は、errno に対応するエラーメッセージを表示するのに便利です。

前述の通り、SOCK_RAW を指定してソケットを作成するには、通常はプロセスが管理者権限(root権限)を持っている必要があります。これは、Raw Socketがネットワークプロトコルのヘッダー情報にアクセスし、それを改変できるため、セキュリティ上のリスクとなり得るからです。悪意のあるプログラムがRaw Socketを使用して偽装パケットを送信したり、ネットワークをスキャンしたりすることを防ぐための措置です。

もし一般ユーザー権限で上記のコードを実行しようとすると、socket() 関数は失敗し、「Operation not permitted」のようなエラーメッセージが表示されるでしょう。

# 一般ユーザーで実行した場合のエラー例
$ ./icmp_sender 8.8.8.8
socket() failed: Operation not permitted

プログラムを実行する際は、sudo コマンドを使用するなどして、root権限で実行してください。

# root権限で実行
$ sudo ./icmp_sender 8.8.8.8
Raw socket created successfully.
...

Linuxでは、ケーパビリティ (capabilities) という仕組みを利用して、プロセスにroot権限全体ではなく、特定の操作(例えばRaw Socketの利用に必要な CAP_NET_RAW)のみを許可することも可能です。これにより、より安全にRaw Socketを利用できる場合がありますが、設定は少し複雑になります。一般的な ping コマンドなども、setuidビットが設定されているか、ケーパビリティが付与されていることで一般ユーザーでも実行可能になっています。

Raw Socketを使用する際、IPヘッダーを自分で構築するか、OSに任せるかを選択できます。デフォルト(IP_HDRINCL オプションがオフ)では、OSがIPヘッダーを自動的に生成・付加してくれます。我々はICMPヘッダーとデータ部分を用意するだけで済みます。

もしIPヘッダーのフィールド(送信元IPアドレスの偽装など、通常は非推奨)を完全に制御したい場合は、setsockopt() 関数を使って IP_HDRINCL オプションを有効にする必要があります。

int on = 1;
if (setsockopt(sockfd, IPPROTO_IP, IP_HDRINCL, &on, sizeof(on)) < 0) {
    perror("setsockopt(IP_HDRINCL) failed");
    // エラー処理
}

IP_HDRINCL を有効にした場合、送信するデータバッファの先頭に、自分で構築したIPヘッダーを含める必要があります。この記事では、簡単のため IP_HDRINCL オプションは使用せず、OSにIPヘッダーの生成を任せる方法を前提とします。この場合、プロトコルとして IPPROTO_RAW ではなく IPPROTO_ICMP を指定することが一般的です。

ICMPヘッダーの構築

Raw Socketを作成したら、次は送信するICMPパケットの中身、特にICMPヘッダーを構築します。ICMPヘッダーの構造は、メッセージのタイプによって若干異なりますが、基本的なフィールドは共通しています。ここでは、ping で使用される Echo Request メッセージを例に説明します。

ICMPヘッダーは通常、IPヘッダーの直後に配置されます。Linux環境では、<netinet/ip_icmp.h> ヘッダーファイルで struct icmphdr として定義されています。

フィールド名 (struct icmphdr) ビット数 説明 Echo Request の値例
type 8 ICMPメッセージのタイプを示します。タイプによってメッセージの種類が決まります。(RFC 792 参照) 8 (Echo Request)
code 8 ICMPメッセージのコード。タイプをさらに細分化します。タイプによっては未使用 (0) の場合もあります。 0
checksum 16 ICMPヘッダーとデータ部分のエラー検出用チェックサム。計算方法は後述します。 計算により設定
un.echo.id 16 識別子 (Identifier)。Echo Request/Reply のペアを識別するために使用されます。通常はプロセスIDなどが使われます。 プロセスIDなど (例: getpid())
un.echo.sequence 16 シーケンス番号 (Sequence Number)。Echo Request/Reply のペアを識別するために使用されます。通常は送信ごとにインクリメントされます。 1, 2, …
データ (Data) 可変長 オプションのデータ部分。Echo Requestでは、通常、送信時刻などの情報を含めて往復時間 (RTT) の計算に使用されます。 任意のデータ(例: “Hello ICMP!”)

struct icmphdr の定義はシステムによって若干異なることがありますが、基本的なフィールド構成は上記のとおりです。特に、識別子とシーケンス番号は union (共用体) の一部として定義されていることが多いです (un.echo.id, un.echo.sequence)。

実際にC言語でICMP Echo Requestヘッダーを構築してみましょう。送信するデータも含めたバッファを用意し、各フィールドに値を設定します。

#define PACKET_SIZE 64  // 送信するパケット全体のサイズ (IPヘッダ除く)
#define ICMP_DATA_LEN (PACKET_SIZE - sizeof(struct icmphdr)) // データ部分の長さ

char send_buf[PACKET_SIZE];
struct icmphdr *icmp_hdr;
char *data_part;

// バッファ全体をゼロクリア
memset(send_buf, 0, PACKET_SIZE);

// ICMPヘッダー部分へのポインタを取得
icmp_hdr = (struct icmphdr *)send_buf;

// データ部分へのポインタを取得
data_part = send_buf + sizeof(struct icmphdr);

// ICMPヘッダーの各フィールドを設定
icmp_hdr->type = ICMP_ECHO;       // Echo Request (通常 8)
icmp_hdr->code = 0;              // Code 0
icmp_hdr->un.echo.id = htons(getpid() & 0xFFFF); // プロセスID下位16bitを識別子に (ネットワークバイトオーダーへ変換)
icmp_hdr->un.echo.sequence = htons(1); // シーケンス番号 (ネットワークバイトオーダーへ変換)
icmp_hdr->checksum = 0;          // チェックサムは後で計算して設定

// データ部分を設定 (例: 簡単な文字列)
strncpy(data_part, "Hello ICMP!", ICMP_DATA_LEN);

// チェックサムを計算して設定 (詳細は次章)
icmp_hdr->checksum = calculate_checksum((unsigned short *)icmp_hdr, PACKET_SIZE);

printf("ICMP header constructed.\n");

ポイントは以下の通りです。

  • 送信バッファ (send_buf) を用意し、ゼロクリアします。
  • バッファの先頭を struct icmphdr へのポインタとしてキャストし、各フィールドを設定します。
  • type には ICMP_ECHO (通常 8)、code には 0 を設定します。
  • id には、他のプロセスと区別できるようにプロセスIDなどユニークな値を設定します。getpid() で取得したプロセスIDを使い、下位16ビットに切り詰めています。
  • sequence には、送信ごとにインクリメントする値を設定します。最初は 1 としています。
  • 重要: idsequence は16ビット整数なので、htons() (Host TO Network Short) 関数を使ってネットワークバイトオーダー(ビッグエンディアン)に変換する必要があります。チェックサムも同様です。
  • データ部分には任意のデータを設定できます。ここでは簡単な文字列を入れています。ping コマンドでは、RTT計算のためにタイムスタンプを入れることが多いです。
  • checksum フィールドは、他のフィールドとデータ部分を設定したに計算します。計算前には必ず 0 に初期化しておく必要があります。

チェックサムの計算

ICMPヘッダーには、パケットの内容が転送中に破損していないかを確認するためのチェックサムフィールドがあります。送信側はこのチェックサムを計算して設定し、受信側は受け取ったパケットで同様にチェックサムを計算し、ヘッダー内の値と比較することでエラーを検出します。

IP、ICMP、UDP、TCPなどのチェックサムは、基本的に同じアルゴリズムで計算されます(TCP/UDPではIPアドレスなどを含む疑似ヘッダーも計算対象に含めますが、ICMPではICMPヘッダーとデータ部分のみが対象です)。

計算手順は以下の通りです。

  1. チェックサムを計算する対象領域(ICMPの場合はICMPヘッダー+データ部)のチェックサムフィールドを 0 に設定します。
  2. 対象領域を16ビット単位のワード(unsigned short)に区切ります。
  3. もし対象領域のバイト数が奇数の場合は、最後に 0x00 を1バイト追加して16ビットにします(パディング)。
  4. 全ての16ビットワードを1の補数和で加算していきます。
    • 1の補数和とは、通常の加算を行い、計算結果が16ビットを超えて桁あふれ(キャリー)が発生した場合、その桁あふれ分を結果の下位ビットに加算する操作です。これを桁あふれがなくなるまで繰り返します。
    • 具体的には、32ビットの変数で全てのワードを加算し、最後に上位16ビットと下位16ビットを加算し、さらに桁あふれがあれば再度加算する、という方法がよく使われます。
  5. 得られた1の補数和の結果の、さらに1の補数(ビット反転)を取ります。これが最終的なチェックサム値となります。

以下に、ICMPチェックサムを計算する関数の一般的な実装例を示します。

/*
 * チェックサム計算関数
 * buf: チェックサム計算対象のデータへのポインタ
 * size: データのサイズ (バイト単位)
 * 戻り値: 計算されたチェックサム値 (ネットワークバイトオーダー)
 */
unsigned short calculate_checksum(unsigned short *buf, int size) {
    unsigned long sum = 0; // 32ビットで合計を計算

    // 16ビット(2バイト)単位で加算していく
    while (size > 1) {
        sum += *buf++;
        size -= 2;
    }

    // データサイズが奇数の場合、残りの1バイトを処理
    if (size == 1) {
        // リトルエンディアン環境を想定: 上位バイトに0を詰める
        sum += *(unsigned char *)buf;
    }

    // 桁あふれ(キャリー)を処理
    // 上位16ビットと下位16ビットを加算し、再度桁あふれがあれば加算
    sum = (sum & 0xffff) + (sum >> 16); // 1回目の加算
    sum = (sum & 0xffff) + (sum >> 16); // 桁あふれがあれば2回目の加算

    // 1の補数を取る (ビット反転)
    return (unsigned short)(~sum);
    // 注意: この関数はホストバイトオーダーの値を返す。
    // ICMPヘッダーにセットする際は htons() でネットワークバイトオーダーに変換が必要
    // と思われるかもしれないが、多くの実装ではこのままセットする。
    // checksum フィールド自体もネットワークバイトオーダーで読み書きされるため、
    // この計算結果をそのまま(バイトオーダー変換せずに)セットするのが一般的。
    // 混乱を避けるため、ヘッダーセット時に htons() は不要とする。
}

// ICMPヘッダーへのセット例
// ... (icmp_hdr に他の値を設定後) ...
icmp_hdr->checksum = 0; // 計算前にゼロクリア
icmp_hdr->checksum = calculate_checksum((unsigned short *)icmp_hdr, PACKET_SIZE);
// printf("Checksum: 0x%04x\n", ntohs(icmp_hdr->checksum)); // 表示時に変換する場合

この関数は、指定されたバッファ (buf) とサイズ (size) に基づいてチェックサムを計算し、16ビットのチェックサム値を返します。

バイトオーダーに関する注意点: チェックサム計算自体はバイトオーダーに依存しにくい性質がありますが、計算結果をICMPヘッダーの checksum フィールドに格納する際、および他の16ビットフィールド (id, sequence) を設定する際には、ネットワークバイトオーダー(ビッグエンディアン)であることが求められます。しかし、チェックサム計算関数の実装や、それをヘッダーにセットする際の htons() の要否については、様々な実装が存在し、混乱しやすい点です。多くのLinux環境のサンプルコードでは、上記の calculate_checksum 関数の戻り値をそのまま(htons() なしで)icmp_hdr->checksum に代入しています。これは、チェックサムフィールド自体もネットワークバイトオーダー(ビッグエンディアン)として扱われ、16ビットワード単位での加算がうまく機能するためと考えられます。本記事のコード例でも、この慣例に従い、calculate_checksum の戻り値はそのままセットします。

宛先アドレスの設定とパケット送信

ICMPヘッダーとチェックサムの準備ができたら、いよいよパケットを送信します。送信には sendto() 関数を使用します。この関数は、送信先のアドレス情報を指定できるため、コネクションレス型のソケット(Raw SocketやUDPソケット)でよく使われます。

sendto() 関数で宛先を指定するには、struct sockaddr_in 構造体(IPv4の場合)を使用します。この構造体に宛先のIPアドレスとポート番号(Raw Socket/ICMPの場合はポート番号は意味を持ちませんが、構造体の形式上設定します)を設定します。

struct sockaddr_in dest_addr;
const char *dest_ip_str = "8.8.8.8"; // 送信先のIPアドレス (例: Google Public DNS)

memset(&dest_addr, 0, sizeof(dest_addr));
dest_addr.sin_family = AF_INET; // アドレスファミリ (IPv4)

// IPアドレス文字列をネットワークバイトオーダーの数値形式に変換して設定
if (inet_pton(AF_INET, dest_ip_str, &dest_addr.sin_addr) <= 0) {
    perror("inet_pton() failed");
    // IPアドレス文字列が不正な場合の処理
    close(sockfd);
    exit(EXIT_FAILURE);
}
// ポート番号はICMPでは使用されないが、形式上設定 (0でよい)
// dest_addr.sin_port = 0; // htons(0) でも可 (memsetで0になっている)

printf("Destination address set to %s\n", dest_ip_str);

ここでは、宛先IPアドレスとして “8.8.8.8” (Google Public DNS) を指定しています。

  • sin_family には AF_INET を設定します。
  • sin_addr フィールドに、宛先IPアドレスをネットワークバイトオーダーの数値形式で設定します。inet_pton() 関数(または古い inet_addr() 関数)を使用すると、”xxx.xxx.xxx.xxx” 形式のIPアドレス文字列を適切な形式に変換できます。inet_pton() の方がより推奨される方法です。
  • sin_port はICMPでは使用されませんが、構造体のために存在します。通常は 0 を設定しておけば問題ありません。memset でゼロクリアされているため、明示的な設定は不要な場合もあります。

宛先アドレス構造体の準備ができたら、sendto() 関数を使って、作成したICMPパケット(send_buf)を送信します。

int bytes_sent;

bytes_sent = sendto(sockfd,             // Raw Socketのディスクリプタ
                    send_buf,           // 送信するデータバッファ (ICMPヘッダ+データ)
                    PACKET_SIZE,        // 送信するデータのサイズ
                    0,                  // フラグ (通常は0)
                    (struct sockaddr *)&dest_addr, // 宛先アドレス構造体へのポインタ
                    sizeof(dest_addr)); // 宛先アドレス構造体のサイズ

if (bytes_sent < 0) {
    perror("sendto() failed");
    // 送信エラー処理
    close(sockfd);
    exit(EXIT_FAILURE);
} else if (bytes_sent != PACKET_SIZE) {
    fprintf(stderr, "sendto() sent only %d bytes out of %d\n", bytes_sent, PACKET_SIZE);
    // 部分的にしか送信されなかった場合のエラー処理 (通常は発生しにくい)
    close(sockfd);
    exit(EXIT_FAILURE);
}

printf("Sent %d bytes ICMP Echo Request to %s\n", bytes_sent, dest_ip_str);

// ソケットを閉じる
close(sockfd);

sendto() 関数の引数は以下の通りです。

  • 第1引数: socket() で作成したソケットディスクリプタ。
  • 第2引数: 送信するデータが格納されたバッファへのポインタ (send_buf)。これにはICMPヘッダーとデータが含まれます。IPヘッダーはOSが付加します(IP_HDRINCL がオフの場合)。
  • 第3引数: 送信するデータのサイズ(バイト単位)。
  • 第4引数: 送信時のフラグ。通常は 0 を指定します。
  • 第5引数: 宛先アドレス情報を含む struct sockaddr 型へのポインタ。事前に作成した struct sockaddr_in 型の変数をキャストして渡します。
  • 第6引数: 宛先アドレス構造体のサイズ。

送信に成功すると、sendto() は送信したバイト数を返します。エラーが発生した場合は -1 を返し、errno にエラーコードが設定されます。送信後、不要になったソケットは close() 関数で閉じます。

このコードを実行すると、指定した宛先IPアドレス(例: 8.8.8.8)に対してICMP Echo Requestパケットが送信されます。もし宛先ホストが応答可能であれば、ICMP Echo Replyパケットが返ってくるはずです。返信パケットを受信して解析するには、別途 recvfrom() 関数などを使用するコードを追加する必要がありますが、この記事では送信部分に焦点を当てています。

サンプルコード全体

ここまでの内容をまとめた、ICMP Echo Requestを送信するC言語のサンプルコード全体を示します。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>
#include <arpa/inet.h>
#include <errno.h>
#include <sys/types.h> // pid_t

#define PACKET_SIZE 64  // ICMPヘッダ + データ
#define ICMP_DATA_LEN (PACKET_SIZE - sizeof(struct icmphdr))

// チェックサム計算関数
unsigned short calculate_checksum(unsigned short *buf, int size) {
    unsigned long sum = 0;
    while (size > 1) {
        sum += *buf++;
        size -= 2;
    }
    if (size == 1) {
        sum += *(unsigned char *)buf;
    }
    sum = (sum & 0xffff) + (sum >> 16);
    sum = (sum & 0xffff) + (sum >> 16);
    return (unsigned short)(~sum);
}

int main(int argc, char *argv[]) {
    int sockfd;
    struct sockaddr_in dest_addr;
    char send_buf[PACKET_SIZE];
    struct icmphdr *icmp_hdr;
    char *data_part;
    int bytes_sent;
    pid_t pid;

    // 引数チェック (送信先IPアドレスが必要)
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <destination_ip>\n", argv[0]);
        exit(EXIT_FAILURE);
    }
    const char *dest_ip_str = argv[1];

    // --- 1. Raw Socketの作成 ---
    sockfd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
    if (sockfd < 0) {
        perror("socket() failed");
        exit(EXIT_FAILURE);
    }
    printf("Raw socket created successfully.\n");

    // --- 2. 宛先アドレスの設定 ---
    memset(&dest_addr, 0, sizeof(dest_addr));
    dest_addr.sin_family = AF_INET;
    if (inet_pton(AF_INET, dest_ip_str, &dest_addr.sin_addr) <= 0) {
        perror("inet_pton() failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }
    printf("Destination address set to %s\n", dest_ip_str);

    // --- 3. ICMPヘッダーとデータの構築 ---
    memset(send_buf, 0, PACKET_SIZE);
    icmp_hdr = (struct icmphdr *)send_buf;
    data_part = send_buf + sizeof(struct icmphdr);
    pid = getpid(); // プロセスIDを取得

    icmp_hdr->type = ICMP_ECHO;       // Echo Request
    icmp_hdr->code = 0;
    icmp_hdr->un.echo.id = htons(pid & 0xFFFF); // プロセスID下位16bit (ネットワークバイトオーダー)
    icmp_hdr->un.echo.sequence = htons(1);    // シーケンス番号 1 (ネットワークバイトオーダー)
    icmp_hdr->checksum = 0;          // チェックサム計算前に0にする

    // データ部分を設定 (例: "Ping" + ヌル文字)
    strncpy(data_part, "Ping", ICMP_DATA_LEN -1); // -1 for null terminator
    data_part[ICMP_DATA_LEN - 1] = '\0'; // Ensure null termination if space allows
    // または、pingのようにタイムスタンプを入れることも可能
    // struct timeval tv;
    // gettimeofday(&tv, NULL);
    // memcpy(data_part, &tv, sizeof(tv));

    // --- 4. チェックサムの計算と設定 ---
    icmp_hdr->checksum = calculate_checksum((unsigned short *)icmp_hdr, PACKET_SIZE);
    printf("ICMP header constructed with checksum: 0x%04x\n", ntohs(icmp_hdr->checksum));

    // --- 5. パケットの送信 ---
    bytes_sent = sendto(sockfd, send_buf, PACKET_SIZE, 0,
                        (struct sockaddr *)&dest_addr, sizeof(dest_addr));

    if (bytes_sent < 0) {
        perror("sendto() failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    } else if (bytes_sent != PACKET_SIZE) {
        fprintf(stderr, "sendto() sent only %d bytes out of %d\n", bytes_sent, PACKET_SIZE);
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    printf("Sent %d bytes ICMP Echo Request to %s (PID: %d, Seq: 1)\n",
           bytes_sent, dest_ip_str, pid & 0xFFFF);

    // --- 6. ソケットを閉じる ---
    close(sockfd);
    printf("Socket closed.\n");

    return EXIT_SUCCESS;
}

上記のコードを例えば icmp_sender.c という名前で保存し、gcc を使ってコンパイルします。

gcc icmp_sender.c -o icmp_sender

コンパイルが成功したら、root権限で実行します。引数として送信先のIPアドレスを指定してください。

sudo ./icmp_sender 8.8.8.8

実行すると、指定したIPアドレスに対してICMP Echo Requestが1パケット送信されます。ネットワークキャプチャツール(例: tcpdump や Wireshark)を使用すると、実際にパケットが送信されていることを確認できます。

# 別のターミナルで tcpdump を実行して監視 (root権限が必要)
sudo tcpdump -i any icmp -n -v

tcpdump の出力で、自分のホストから宛先IPアドレスへ ICMP echo request が送信されているのが確認できるはずです。

注意点と発展

Raw Socket を用いたICMPパケット送信は強力な機能ですが、いくつかの注意点と、さらに発展させるためのアイデアがあります。

サンプルコードでは基本的なエラーハンドリング(perror() によるエラー表示と exit())しか行っていません。実際のアプリケーションでは、socket(), inet_pton(), sendto() などの各関数呼び出しで返されるエラーコード (errno) を詳細にチェックし、状況に応じた適切な処理(リトライ、ログ記録、ユーザーへの通知など)を行うことが重要です。

この記事ではパケットの送信のみを扱いましたが、ping コマンドのように動作させるには、送信した Echo Request に対する Echo Reply を受信し、解析する必要があります。

  • recvfrom() 関数を使用して、Raw Socketからパケットを受信します。
  • 受信したパケットはIPヘッダーを含んでいるため、まずIPヘッダーの長さを読み取り、その後に続くICMPヘッダーの位置を特定する必要があります。IPヘッダー長は可変(オプションが含まれる場合がある)なので注意が必要です。
  • 受信したICMPパケットのタイプが Echo Reply (ICMP_ECHOREPLY, 通常 0) であるか、また、識別子 (id) とシーケンス番号 (sequence) が、自分が送信したものと一致するかを確認します。これにより、他のプロセスが送受信しているICMPパケットと区別します。
  • データ部分にタイムスタンプを埋め込んでいた場合は、それを取り出して現在の時刻と比較し、往復時間 (RTT) を計算できます。
  • タイムアウト処理も重要です。一定時間内に応答がない場合に、パケットロスと判断する仕組みが必要です (select()poll()、ソケットオプション SO_RCVTIMEO などを使用)。

ICMPには Echo Request/Reply 以外にも様々なメッセージタイプがあります。

  • Destination Unreachable (タイプ 3): 宛先ホストやポートに到達できない場合にルーターなどから返されます。コードによって具体的な理由(Network Unreachable, Host Unreachable, Port Unreachable など)が示されます。
  • Time Exceeded (タイプ 11): パケットのTTL (Time To Live) がゼロになった場合にルーターから返されます。traceroute コマンドはこの仕組みを利用しています。
  • Redirect (タイプ 5): より適切なゲートウェイが存在する場合に、現在のゲートウェイから送信元ホストへ通知されます。

Raw Socketを使えば、これらのメッセージを受信して解析したり、特定のタイプのICMPメッセージを(テスト目的などで)送信したりすることも可能です。ただし、タイプによってヘッダー構造やデータ部分の意味が異なるため、RFC 792 などの仕様を確認する必要があります。

この記事ではIPv4 (AF_INET) を前提としましたが、IPv6 (AF_INET6) 環境では ICMPv6 を使用します。基本的な考え方は同様ですが、アドレス構造体 (struct sockaddr_in6)、ソケット作成時のプロトコル定数 (IPPROTO_ICMPV6)、ICMPv6ヘッダー構造などが異なります。チェックサム計算にはIPv6疑似ヘッダーを含める必要があるなど、IPv4とは異なる点も多いです。

Raw Socketは強力な反面、悪用されるリスクも伴います。

  • 権限管理: Raw Socketを使用するプログラムには必要最小限の権限(可能であればケーパビリティを使用)のみを与えるようにします。
  • 入力検証: 外部からの入力(宛先IPアドレスなど)をプログラムで使用する場合は、厳密な検証を行い、意図しない動作を防ぎます。
  • フラッド攻撃への加担防止: 大量のICMPパケットを短時間に送信するような実装は、意図せずともDoS攻撃(フラッド攻撃)に加担してしまう可能性があるため避けるべきです。送信レートには適切な制限を設けましょう。

まとめ

この記事では、C言語を用いてRaw Socketを作成し、ICMP Echo Requestパケットを送信する基本的な手順を解説しました。

  1. socket(AF_INET, SOCK_RAW, IPPROTO_ICMP) を使用してRaw Socketを作成します(通常、root権限が必要です)。
  2. 送信バッファを用意し、struct icmphdr を使ってICMPヘッダー(Type, Code, ID, Sequenceなど)を構築します。データ部分も必要に応じて設定します。IDとSequenceには htons() を使ってネットワークバイトオーダーに変換します。
  3. ICMPヘッダーのチェックサムフィールドを0にし、ICMPヘッダー全体とデータ部分を対象として1の補数和の1の補数を計算し、チェックサムフィールドに設定します。
  4. struct sockaddr_in に宛先IPアドレスを設定します。
  5. sendto() 関数を使用して、構築したICMPパケットを宛先に送信します。

Raw Socketプログラミングは、通常のソケットプログラミングよりも低レイヤーを扱うため複雑な側面もありますが、ネットワークプロトコルの動作を深く理解する上で非常に役立ちます。また、pingtraceroute のような標準的なネットワークツールが内部でどのように動作しているのかを知る良い機会にもなります。

この記事が、C言語によるネットワークプログラミング、特にRaw SocketとICMPに興味を持つ方々の第一歩となれば幸いです。