2007年05月15日
TAP-Win32 でネットワークパケットと戯れる (後編)
■ はじめに
前回はTAP デバイスからパケットデータを読み出してみました。今回はこれに加えて書き込みを試してみましょう。 手近でわかりやすい題材として ping コマンドのエコー要求に応答してみることにします。
つまり、TAP 仮想ネットワーク上に存在するホストのような顔をして待機し、所定の IP アドレスへ ping が打たれるとそれに反応してレスポンスを返すプログラムを書いちゃえということですね。
このように、入出力パケットの操作をユーザモードのコードで自由に行えるのが TAP の面白いところです。
もくじ
■ 仮想ホストの IP アドレス?
前回、TAP-Win32 ドライバをインストール後、TAP デバイスの表示名を「TAP01」、IP アドレスを「192.168.0.1]、 サブネットマスクを「255.255.255.0」と設定しました。今回も引き続きそれを実験環境として使います。この仮想ネットワークのアドレスは「192.168.0.0/24」ですから、仮想ホストの IP アドレスを「192.168.0.2」と 想定しておくことにしましょう。
■ 考えかた
前回のサンプルプログラムを起動した状態で別のコンソールから「ping 192.168.0.2」と打ってみても返事はありません。 その理由は「自分のアドレスを 192.168.0.2 と認識して要求に応じるノードがネットワーク上に存在しないため」ですね。 と言うことは、このプログラムにそういう処理を加えてやればよいということになります。つまりプロトコル仕様に準じたレスポンスを自分で生成しそれを TAP に書き込んでみようというわけですが、まずは 実際のデータイメージを確認するためにパケットモニタで ping のエコー要求とレスポンスのパケット構造を調べてみることにしましょう。 下のスナップは、TAP 環境外のホスト 192.168.14.132 からホスト 192.168.14.128 へ ping を打ったときのリクエストパケットとレスポンスパケットの内容です。


ここでの応酬内容が送受信パケットの処理をプログラムに実装するためのよいサンプルとなりそうです。
ざっと眺めると両者のイーサネットフレームサイズは同一であり、また各層のフィールドはその大半が共通していることが見てとれます。と言うことは、受信パケット全体のコピーを作り、必要な箇所のみ返信用に変更してやるのが手早そうですね。
これで基本的な方針が決まりました。次に各プロトコル層を見ていきましょう。
■ イーサネットヘッダ
まず、返信用のイーサネットヘッダについては受信分のヘッダに含まれる目的地と出発点の MAC アドレス情報を単に逆転すればよさそうです。もっとも、いま作ろうとしている仮想ホストにはもちろん MAC アドレスなどありませんから、何らかのダミーの MAC アドレスをプログラムに定義する必要があります。ただ、狭い空間での実験とはいえ MAC アドレスの固有性は気にしておきたいところですね。実は、TAP デバイス本体には、ドライバインストール時に稼動ホスト固有の情報を元にドライバがランダムかつユニークに生成したダミーの MAC アドレスが割り当てられています。
プログラムから TAP ハンドルに対して TAP_IOCTL_GET_MAC コードを送るとその内容を参照することができますので、その最終バイトに 1 を加えたものを仮想ホスト 192.168.0.2 の MAC アドレスとすることにしましょう。
<関連サイト> IEEE Registration Authority - IEEE OUI and Company_id Assignments
■ IP ヘッダ
RFC 791 [Page 10] からの IP ヘッダの仕様を確認してみます。(要旨) Version: 4 bits -- IP ヘッダのバージョン番号 IHL: 4 bits -- IP ヘッダ長を示す 32 ビット単位の値 Type of Service: 8 bits -- サービスの品質を示すビットフィールド Total Length: 16 bits -- IP ヘッダ+データの長さを示すオクテット(8 bits)値 Identification: 16 bits -- パケットが分割されている場合にその復元を支援するためのID Flags: 3 bits -- パケット分割に関する情報を保持するビットフィールド Fragment Offset: 13 bits -- この分割パケット内のデータの、元のデータ上のオフセット値 Time to Live: 8 bits -- このパケットがネットワーク上に存在してよい最大秒数 Protocol: 8 bits -- IP ヘッダの後に続く上位層のプロトコルの識別値 Header Checksum: 16 bits -- IP ヘッダ範囲(のみ)のデータのチェックサム Source Address: 32 bits -- 送信元 IP アドレス Destination Address: 32 bits -- あて先 IP アドレス
前掲のスナップにおいて、送受信 IP パケット間で異なっているのは Identification と Header Checksum、あとは Source Address と Destination Address のみですね。
Source Address と Destination Address は送信元とあて先を逆転すればよさそうです。また、RFC によると Identification は送受信パケット間にまたがって意味をなすものではありませんので、受信パケットの Identification をそのまま返信パケットで使いまわしても特に問題はなさそうです。
Checksum はそれ自体に意味のある値ではなく IP ヘッダを構成するデータから算出して記述するものですから、受信した IP ヘッダを元に編集する返信用 IP ヘッダで明示的に変更すべき箇所は、結局「送信元とあて先の IP アドレスの記述位置のみ」ということになります。とすると、返信用 IP ヘッダを構成する値の総和は受信パケットでのそれと同一であるため、チェックサムを書き換える必要もありません(※)。IP ヘッダは楽勝ですね。
(※)パケットのチェックサム計算方法については末尾のコラムで触れています
■ ICMP ヘッダ+データ
ICMP エコー・応答メッセージの仕様は RFC 792 [Page 13-14] に記述されています。(要旨) Type: 8 bits -- [8] エコーメッセージ / [0] 応答メッセージ Code: 8 bits -- [0] Checksum: 16 bits -- Type 位置から終端までの範囲のチェックサム Identifier: 16 bits -- エコー・応答メッセージのひも付けのために使用される Sequence Number: 16 bits -- エコー・応答メッセージのひも付けのために使用される Data: (可変長)-- エコーメッセージで渡されたデータは応答メッセージに 含めなければならない
シンプルですね。ただし、ICMP は Type によりフィールド構成が一様ではありませんから、Type の値が今回対象とする「8」であるもののみを処理する必要があります。
返信パケットにおいては Type を 8 -> 0 に変更しなければなりません。また、これに伴い、Checksum の再計算が必要となります。
と言ってもチェックサムをゼロから計算し直すのは不経済ですし、また、ping コマンドでイーサネットの MTU である 1500 バイトを超えるサイズのデータの送出が指定されると IP パケットが分割されてしまうので、その場合にはデータ全体をなめてチェックサムを再計算するのはさらに面倒なことになるでしょう。このため、チェックサムの更新には末尾のコラムで触れている Incremental Update の手法を使うのが最良でしょう。
■ まずは軽く・・
以上でやるべきことが大体わかりました。各論は後にして、まず太い線からコードを試し書きしてみることにします。前回のサンプルコードにはパケット受信時に内容をダンプ表示している箇所があります。まずはそこに、パケットデータが ICMP かどうかを判定するだけの処理を加えてみることにします。
・先頭のイーサネットヘッダから後続のプロトコル種別を取得
↓
・種別が IP (0x0800) であれば IP ヘッダから後続のプロトコル種別を取得
↓
・種別が ICMP (0x01) であればコンソールに「ICMP!!」と出力
これをざっくり関数にしてみるとこんな感じですね。構造体やフィールド名は別途定義したものを使っていますが文脈から内容を汲んで下さい。
void HandlePacket(UCHAR *buf, DWORD len) { EthHdr *pEth = (EthHdr*)buf; // イーサネットヘッダ上の後続プロトコル種別判定 if (pEth->h_proto == htons(0x0800)) { // IP IpHdr *pIp = (IpHdr*)((UCHAR*)pEth + sizeof(EthHdr)); // IP ヘッダ上の後続プロトコル種別判定 if (pIp->protocol == 0x01) { // ICMP printf("ICMP!!\n"); } } }dump() 呼び出しの後にこの HandlePacket() 呼び出しを追加して「sample.exe TAP01」を実行し、別のコンソールから ping 192.168.0.2 を打ってみます。sample.exe が ICMP パケットを検出しその旨のメッセージを表示すればプロトコル識別 OK というわけです。
■ あれれ?
試してみると駄目でした。sample.exe は「ICMP!!」を表示しません。バイトオーダーもネットワークに合わせたのに何がおかしいのでしょう?原因をさぐるためにパケットモニタを走らせながらもう一度 ping を試してみます。C:\temp>ping 192.168.0.2 Request timed out. Request timed out. Request timed out. Request timed out. Ping statistics for 192.168.0.2: Packets: Sent = 4, Received = 0, Lost = 4 (100% loss),

ICMP 要求は見当たらず、ARP 要求のブロードキャストメッセージが 4 回続いてそれっきりです。どうやら、192.168.0.2 の IP アドレスに対応する MAC アドレスの問い合わせを行い、どこからも返事が返ってこないのでそこで終わっている状況のようです。
前掲の例のこの部分を見返してみると、たしかに最初に 192.168.14.128 の MAC アドレスを問い合わせる ARP メッセージが送られ、それに対するレスポンスを得た後に ICMP エコー要求が発行されています。 そして、その ICMP 要求ではイーサネット層の DestinationAddress フィールドに、先ほどの ARP レスポンスで回答された MAC アドレスが指定されています。
うっかりしていました。 ソケットレベルの TCP/IP プログラミングなどでは普段直接意識しない低い層のことですが、イーサネットというのはそもそも常に相手(パケットを送るべき対象)のハードウェアアドレスを拠りどころとして通信を行う仕組みでした。なので、MAC アドレス問い合わせの ARP メッセージにも正しく応答する必要がありますね。
■ ARP メッセージ
あらためて、前掲の例から今度は ARP リクエストとレスポンスのパケットを眺めてみましょう。

イーサネットヘッダにおいては、リクエスト・レスポンスともに EthernetType = ARP (0x0806) となっています。リクエストでの DestinationAddress は 0xFFFFFF のブロードキャストアドレスが指定され、レスポンスでの SourceAdress には応答ノードの MAC アドレスが格納されています。
ARP の仕様説明は RFC 826 にあります。
(要旨) ar$hrd: 16 bits -- ハードウェアアドレス空間(Ethernet, Packet Radio Net など) ar$pro: 16 bits -- プロトコルアドレス空間 ar$hln: 8 bits -- ハードウェアアドレスのバイト長 ar$pln: 8 bits -- プロトコルアドレスのバイト長 ar$op: 16 bits -- オペレーションコード(REQUEST, REPLY) ar$sha: n bytes -- 送信者のハードウェアアドレス, n = ar$hln ar$spa: m bytes -- 送信者のプロトコルアドレス, m = ar$pln ar$tha: n bytes -- 対象者のハードウェアアドレス ar$tpa: m bytes -- 対象者のプロトコルアドレス
上記スナップの ARP 層に注目すると、リクエスト時の OpCode は 0x1、TargetMacAddress は 00-00-00-00-00-00 となっており、TargetIp4Address である 192.168.14.128 の MAC アドレスに関する問い合わせであることが示されています。
レスポンス時の OpCode は 0x2、SendersMacAddress には SendersIp4Address である 192.168.14.128 の MAC アドレスが格納されています。なるほど、これで返信の要領がわかりました。
ところで、レスポンスの末尾に 18 バイトのゼロデータがくっついていますがこれはなんでしょう? RFC 894 によると、イーサネットではフレームにふくまれるデータの最小の長さが 46 オクテットという規定であるため、データ長がそれに満たない場合には必要ならデータフィールドはパディングされるべき(If necessary, the data field should be padded)なのだそうです。たしかに ARP メッセージ本体は 28 オクテットしかありませんね。
でもなぜこの例ではリクエスト側のパケットはパディングされていないのでしょう?ブロードキャストメッセージであることと関係があるのか?あるいは実装依存の許容範囲内なのか?それともパケットモニタが介在する位置に起因するものなのか?この点はわかりませんでした。
ちょっと疑問に思いつつも、ARP リクエストに応えてみることにします。
■ ARP レスポンスに挑戦
以下の内容でコードを追加してみました。全ソースはこちらです。自分自身の IP アドレスと MAC アドレスを記憶するための変数を用意します。
// グローバル変数 // 仮想ホストの IP アドレスと MAC アドレス #define MYIPSTR "192.168.0.2" static ULONG MyIpAddress; static UCHAR MyMacAddress[ETH_ALEN];
main() でパケット受信を始める前にそれらを設定しておきます。
// TAP の MAC アドレスを利用して // 仮想ホスト(自分)の仮想 MAC アドレスを用意 if (!DeviceIoControl(hTap,TAP_IOCTL_GET_MAC, NULL, 0, MyMacAddress, ETH_ALEN, &dwLen, NULL)) { printf("TAP-Win32: TAP_IOCTL_GET_MAC err\n"); CloseHandle(hTap); return -1; } MyMacAddress[5] += 0x01; // 仮想ホスト(自分)の IP アドレス MyIpAddress = inet_addr(MYIPSTR);
main() でのパケット受信時に HandlePacket() を呼び出し、自分の IP アドレスに対する ARP 要求であれば自分の MAC アドレスを返信してやります。
// 受信パケットの処理 int HandlePacket(HANDLE hTap, UCHAR *pRecvBuf, DWORD dwRecvLen) { EthHdr *pEth = (EthHdr*)pRecvBuf; UCHAR SendBuf[BUFMAX]; DWORD dwSendLen = 0; BOOL bRet; USHORT nEthProto = ntohs(pEth->h_proto); // Ethernet ヘッダのプロトコル情報を判定し必要なら返信パケットを生成 switch (nEthProto) { case ETH_P_ARP: // ARP bRet = doArp(pRecvBuf, dwRecvLen, SendBuf, &dwSendLen); break; default: bRet = FALSE; break; } // 返信パケットが生成されていれば TAP へ出力 if (bRet == TRUE && dwSendLen > sizeof(EthHdr)) { if (doWriteTap(hTap, SendBuf, dwSendLen) != 0) { printf("doResponse: Write error\n"); return -1; } } return 0; } // ARP パケット処理 BOOL doArp(UCHAR *pRecvBuf, DWORD dwRecvLen, UCHAR *pSendBuf, DWORD *pdwSendLen) { EthHdr *pEth = (EthHdr*)pRecvBuf; // 受信済 Ethernet ヘッダ EthHdr *pEthx = (EthHdr*)pSendBuf; // 返信用 Ethernet ヘッダ ArpHdr *pArp = (ArpHdr*)((UCHAR*)pEth + sizeof(EthHdr)); // 受信済 Arp ヘッダ ArpHdr *pArpx = (ArpHdr*)((UCHAR*)pEthx + sizeof(EthHdr)); // 返信用 Arp ヘッダ // 自分の MAC アドレスへの問い合わせでなければ抜ける if (pArp->ar_tip != MyIpAddress) { return FALSE; } // 返信用 Ethernet ヘッダを編集 doEther(pEth, pEthx); // 返信用 ARP ヘッダを編集 memcpy(pArpx, pArp, sizeof(ArpHdr)); pArpx->ar_op = htons(ARPOP_REPLY); // "Response" memcpy(pArpx->ar_tha, pArp->ar_sha, ETH_ALEN); pArpx->ar_tip = pArp->ar_sip; memcpy(pArpx->ar_sha, MyMacAddress, ETH_ALEN); pArpx->ar_sip = MyIpAddress; *pdwSendLen = dwRecvLen; return TRUE; } // パケットデータを TAP へ書き出す int doWriteTap(HANDLE hTap, UCHAR *pSendBuf, DWORD len) { #define ETHERDATALEN_MIN 46 DWORD dwWriteLen; HANDLE hEvent; OVERLAPPED ovl; memset(&ovl, 0, sizeof(OVERLAPPED)); ovl.hEvent = hEvent = CreateEvent(NULL, FALSE, FALSE, NULL); ovl.Offset = 0; ovl.OffsetHigh = 0; // イーサネットデータが最小サイズに満たなければパディング if (len - sizeof(EthHdr) < ETHERDATALEN_MIN) { int padlen = ETHERDATALEN_MIN - (len - sizeof(EthHdr)); memset(pSendBuf+len, '\0', padlen); len += padlen; } if (!WriteFile(hTap, pSendBuf, len, &dwWriteLen, &ovl)) { DWORD err = GetLastError(); if (err == ERROR_IO_PENDING) { WaitForSingleObject(hEvent, INFINITE); // 完了待ち GetOverlappedResult(hTap, &ovl, &dwWriteLen, FALSE); } else { printf("TAP-Win32: WriteFile err=0x%08X\n", err); return -1; } } printf("send %u bytes\n", dwWriteLen); return 0; }
■ ARP レスポンス成功!!
上記のコードを sample2.exe としてビルドし例によって「sample2.exe TAP01」を起動しておきます。 他のコンソールから ping を打ってやると依然失敗はしますが、目的の ARP まわりはどうでしょう。

おお!うまくいってるようです。プログラムが仮想 MAC アドレスを正しく返信したため、後続の ICMP メッセージの送信にこぎつけたようです。ARP レスポンスがきちんとシステムに受け入れられたかどうか、つまり ARP テーブルに登録されたかどうかを確かめてみることにします。
C:\temp>arp -a Interface: 192.168.0.1 --- 0x4 Internet Address Physical Address Type 192.168.0.2 00-ff-b0-dd-34-42 dynamic :ちゃんと 192.168.0.2 のエントリが登録されています。
この結果は、今回の考え方によるパケットデータ出力が有効であることの証明でもあります。あとはこれまでに得た知識を元にひたすら ICMP エコー要求に対応するためのコードを書くべし!ですね。
■ ICMP エコーレスポンスの実装
そういうわけでさらにコードを加えてみました。sample3.c sample3.hこのコードは Microsoft Visual C++ .NET 2003 および、Microsoft Visual C++ 2005 (無償)でのビルドを確認しています。 また、フリーのMinGW (Minimalist GNU for Win32) でもビルド可能です。
ICMP パケットは以下の関数内で処理しています。
// IP パケット処理 BOOL doIp(UCHAR *pRecvBuf, DWORD dwRecvLen, UCHAR *pSendBuf, DWORD *pdwSendLen) { EthHdr *pEth = (EthHdr*)pRecvBuf; // 受信済 Ethernet ヘッダ EthHdr *pEthx = (EthHdr*)pSendBuf; // 返信用 Ethernet ヘッダ IpHdr *pIp = (IpHdr*)((UCHAR*)pEth + sizeof(EthHdr)); // 受信済 IP ヘッダ IpHdr *pIpx = (IpHdr*)((UCHAR*)pEthx + sizeof(EthHdr)); // 返信用 IP ヘッダ IcmpHdr *pIcmp, *pIcmpx; int IpHeaderLen = pIp->ihl*4; // 自分宛てのパケットでなければ抜ける if (pIp->daddr != MyIpAddress) { return FALSE; } doEther(pEth, pEthx); // 返信用 Ethernet ヘッダを編集 doIpHeader(pIp, pIpx); // 返信用 IP ヘッダを編集 // IP ヘッダ上のプロトコル情報が ICMP の場合のみ対応 if (pIp->protocol == IPPROTO_ICMP) { // ICMP ブロックのサイズを計算 int IcmpPacketLen = dwRecvLen - sizeof(EthHdr) - IpHeaderLen; // IP パケットのフラグメント情報 -> RFC 791: P.12 USHORT f = pIp->frag_off & htons(0xBFFF); // 評価のために DF ビットをマスク BOOL MF = (pIp->frag_off & htons(0x2000)) ? TRUE : FALSE; USHORT Oft = pIp->frag_off & htons(0x1FFF); pIcmp = (IcmpHdr*)((UCHAR*)pIp + IpHeaderLen); // 受信済 ICMP ブロック位置 pIcmpx = (IcmpHdr*)((UCHAR*)pIpx + IpHeaderLen); // 返信用 ICMP ブロック位置 memcpy(pIcmpx, pIcmp, IcmpPacketLen); // フラグメントが発生していないか、または // フラグメントパケットの先頭であれば ICMP ヘッダが存在する if (f == 0 || (MF && Oft == 0)) { // ICMP エコー要求でなければ抜ける if (pIcmp->type != ICMP_ECHO) { return FALSE; } // ICMP パケットのタイプをエコー応答に pIcmpx->type = ICMP_ECHOREPLY; // ICMP チェックサムを再計算 pIcmpx->checksum = RecalcCheckSum(pIcmpx->checksum, ICMP_ECHO, ICMP_ECHOREPLY); } *pdwSendLen = dwRecvLen; return TRUE; } return FALSE; }
■ ICMP エコーレスポンス成功!!
上記のコードを sample3.exe としてビルドの上「sample3.exe TAP01」を起動し、他のコンソールから ping を打ってみます。C:\temp>ping 192.168.0.2 Pinging 192.168.0.2 with 32 bytes of data: Reply from 192.168.0.2: bytes=32 time<1ms TTL=128 Reply from 192.168.0.2: bytes=32 time<1ms TTL=128 Reply from 192.168.0.2: bytes=32 time<1ms TTL=128 Reply from 192.168.0.2: bytes=32 time<1ms TTL=128 Ping statistics for 192.168.0.2: Packets: Sent = 4, Received = 4, Lost = 0 (0% loss), Approximate round trip times in milli-seconds: Minimum = 0ms, Maximum = 0ms, Average = 0ms


ついでに、大きなデータを送ることでパケット分割(フラグメント)を起こしてみましょう。
C:\temp>ping -l 2000 192.168.0.2 Pinging 192.168.0.2 with 2000 bytes of data: Reply from 192.168.0.2: bytes=2000 time<1ms TTL=128 Reply from 192.168.0.2: bytes=2000 time<1ms TTL=128 Reply from 192.168.0.2: bytes=2000 time<1ms TTL=128 Reply from 192.168.0.2: bytes=2000 time<1ms TTL=128 Ping statistics for 192.168.0.2: Packets: Sent = 4, Received = 4, Lost = 0 (0% loss), Approximate round trip times in milli-seconds: Minimum = 0ms, Maximum = 0ms, Average = 0ms


うまくいったようですね :-)
● チェックサムの計算方法について
インターネットプロトコルパケットのチェックサムの計算方法は、
[[対象範囲のデータの16ビット単位での1の補数和] の1の補数]です。
ただし、対象範囲内にチェックサム自身のフィールドがあればそこはゼロとして計算します。
なんのこっちゃ(^^; という感じですが、たとえば、対象が 0xAA 0x11 0xBB 0x22 0xCC 0x33 という 6 バイトのデータで、0xCC 0x33 がチェックサムの格納されたフィールドだとすると、要するにこういうことです:
これは、元のチェックサム値の 1の補数(=元のデータの1の補数和)から、変更前のデータの16ビット値をマイナスして変更後のデータの16ビット値をプラスし、その結果の 1の補数をとるというやり方です。これが新しいチェックサムとなります。興味のある方は RFC 1624 を参照して下さい。
インターネットプロトコルパケットのチェックサムの計算方法は、
[[対象範囲のデータの16ビット単位での1の補数和] の1の補数]です。
ただし、対象範囲内にチェックサム自身のフィールドがあればそこはゼロとして計算します。
なんのこっちゃ(^^; という感じですが、たとえば、対象が 0xAA 0x11 0xBB 0x22 0xCC 0x33 という 6 バイトのデータで、0xCC 0x33 がチェックサムの格納されたフィールドだとすると、要するにこういうことです:
- 0xAA11 + 0xBB22 + 0x0000 を行い、和の 0x16533 を得る
- 0x16533 の、桁あふれしている 1 を最下位に加算する
すなわち、0x6533 + 0x0001 = 0x6534 ←これがこの 6 バイトデータの「1の補数和」 - 0x6534 のビット反転により 1の補数 0x9ACB を得る ←これがこの 6 バイトデータのチェックサム
これは、元のチェックサム値の 1の補数(=元のデータの1の補数和)から、変更前のデータの16ビット値をマイナスして変更後のデータの16ビット値をプラスし、その結果の 1の補数をとるというやり方です。これが新しいチェックサムとなります。興味のある方は RFC 1624 を参照して下さい。
参考文献
・「マスタリング TCP/IP 入門編」 オーム社刊
竹下隆史・村山公保・荒井透・苅田幸雄 共著
・「TCP/IP 解析とソケットプログラミング」 オーム社刊
澤川渡・綱島明浩 共著
・RFC
トラックバックURL
この記事へのコメント
1. Posted by C_is_best 2008年03月04日 15:30
ARPレスポンスの生パケットは助かります