はじめに
本稿では、nbtstatコマンドで使用されているNBNSパケットを生成し、リアルタイムでネットワーク内のコンピュータを列挙するプログラムを作成します。また、サンプルを通してプロトコル仕様に従ったパケットの作成および、解釈についても紹介します。
対象読者
- .NET Frameworkを用いてWindowsアプリケーションを開発している方。
- ネットワーク関連のプログラムを作成される方。
必要な環境
サンプルはVisual Studio .NET 2003で作成し、.NET Framework 1.1で動作確認をしています。
最新を表示しないネットワークコンピュータの一覧
デスクトップ上のネットワークコンピュータを開くと、ネットワークにつながったWindows端末の一覧を表示することができます。しかし、この一覧では、表示されているのにアクセスできない端末や、逆に、居るはずなのに表示されない端末があることを皆さんは体験的によく知っていると思います。これは、ネットワークコンピュータで表示する一覧が、現在の状態を検索しているのではなく、その時点でマスターブラウザが保持している「ブラウズ・リスト」を表示していることが原因です。「ブラウズ・リスト」は、独自の時間間隔で情報を更新しており、リアルタイムで最新状態を反映できていないため、このような症状が発生しているのです。
また、マスタブラウザは、自動的に割り当てられるため、マスタブラウザとなっている端末が急に居なくなった場合も、混乱を生じます。
なお、このNetBIOSの名前解決は、同一ネットワーク内での動作が基本となっているため、複数のネットワークにまたがったワークグループなどの更新は、その整合のためにさらに時間を要することになります。
詳しくは、たかはしもとのぶ氏の下記のページで解説されていますので、紹介しておきます。
NetBIOSネームサービス
ネットワークコンピュータの一覧に表示されていなくても、直接その端末を指定して開いたり、「コンピュータの検索」を使用した場合にアクセスできる事があります。これは、これらの動作が先の「ブラウズ・リスト」に頼っていないからです。そして、この動作で使用されているのが、NetBIOSネームサービス(以降、「NBNS」と表記)というプロトコルです。
nbtstatコマンド
WindowsでNBNSをコマンドとして実装しているのがnbtstatです。nbtstatコマンドは、次のように使用することができます。
C:\>nbtstat -A 192.168.1.211 ローカル エリア接続: Node IpAddress: [192.168.1.211] Scope Id: [] NetBIOS Remote Machine Name Table Name Type Status --------------------------------------------- WS1 <00> UNIQUE Registered WORKGROUP <00> GROUP Registered WS1 <20> UNIQUE Registered WORKGROUP <1E> GROUP Registered WORKGROUP <1D> UNIQUE Registered ..__MSBROWSE__.<01> GROUP Registered MAC Address = 00-09-46-89-F9-ED
出力から分かるとおり、IPアドレスを頼りに、その端末のコンピュータ名や所属しているワークグループ名などをリアルタイムで取得することができます。
NBNSのパケット
図2は、nbtstatコマンドを実行した際に、どのようなパケットの送受で取得されたのかをパケットモニターで取得した様子です。
モニタした結果、NBNSのメッセージは、50バイトの要求パケットと、約200バイトの返答パケットがUDPのポート137(送受)でやり取りされている事が分かります。NBNSは、DNSプロトコルフォーマットの拡張という形で仕様化されており、ほとんどDNSのクエリーと同じと考えて問題ありません。
DNSクエリーメッセージと、NBNSメッセージの違いは、フラグフィールドの12ビット目に「ブロードキャストフラグ」が追加されているだけです。詳しくは、次の図(図3)を参照してください。
図3で示される、質問エントリーおよび応答・権威・追加情報の各セクションで使用されるリソースレコードのフォーマットは、図4のようになっています。
本来は、ドメイン名の部分は長さを指定して可変長で表現できるのですが、NBNSの場合は、0x16(32オクテット)に固定されています。
要求パケット
「nbtstat -A」を実行したときに送信される要求パケットでは、ヘッダ部分の質問数に1がセットされ、その後に質問エントリーが1つだけ含まれたメッセージが送信されます(応答などの各セクションはありません)。
この質問エントリのドメイン名部分には「* <00>
」を表現する「CKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x00
」が挿入され、この後に続くタイプには、\x21
(NetBIOS Node Status)、クラスには\x01
(インターネット)が入ります。
パケットのサイズは、ヘッダ(12)+ドメイン名(32+2 サイズ・終端を含む)+タイプ(2)+ クラス (2) の合計で常に50オクテットとなります。
符号化の手順は、下記のとおりです。
- 例としてNetBIOS名[
ABC
]を変換します - 名前を16進数で表現する(空白は
0x20
になります) - 4ビット単位で分解する
- 符号化ASCII文字で変換する(0はA、1はB、...、FはPとなる)
ABC <00>
」41-42-43-20-20-20-20-20-20-20-20-20-20-20-20-00
4-1-4-2-4-3-2-0-2-0-2-0-2-0-2-0-2-0-2-0-2-0-2-0-2-0-2-0-2-0-0-0
AECEDCACACACACACACACACACACACAAA
*<00>
]は、「CKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
」になっています。応答パケット
一方、応答メッセージでは、ヘッダ部分の応答セクション数に1がセットされ、その後の応答セクション部分にリソースレコードが1つだけ含まれたメッセージが返されます(質問エントリやその他のセクションはありません)。
リソースレコードでは、ドメイン名、タイプ、クラスの部分に、要求メッセージ内の質問エントリで送ったものが、そのまま格納され、その後に生存時間、データ長、データ部が続きます。
データ部には、nbtstatで本来取得したかった、各種のNetBIOS名情報が格納されています。データ部の細部のフォーマットは、図5のとおりです。
図5から分かる通り、最初の1バイトには、これから続く各種のNetBIOS名情報の数が格納されています。そしてその後には、その数分だけ固定長18バイトのNetBIOS名情報が続き、最後にMACアドレスが格納されています。
固定長18バイトのNetBIOS名情報は、「NetBIOS名」「タイプ」「ステータス」の3つで構成されています。
ABC<03>
」のように表現される名前があった場合、最後の<03>
が、このサフィックスです。nbtstatコマンドで表示される各種のNetBIOS名も、このサフィックスを見ることで、どういう機能の名前であるかを区別することができるようになっています。
サンプルプログラムでも、このサフィックスを使用してコンピュータ名やワークグループ名を判断しています。
サフィックスについての細部は、『NetBIOS サフィックス (NetBIOS 名の 16 番目の文字)』を参照してください。
ドメイン名圧縮
各エントリーに出てくるドメイン名については、DNSのパケットでは、重複を避けるために「ドメイン名圧縮」が使用されています。しかし、NBNSのパケットでは、要求パケットで質問エントリが1つ、応答パケットでも応答セクションが1つだけという形式ですので、ドメイン名が1回以上出現する可能性がありません。そのため、サンプルプログラムでは、この「ドメイン名圧縮」については実装していません。
一方、DNSパケットでは、ドメイン名の先頭部分が一致することがほとんどであり、この圧縮は非常に有効に働いています。DNSパケットの作成および解釈には、「ドメイン名圧縮」のアルゴリズムの実装は必須ですのでご注意ください。
パケットを操作するプログラム
パケットを直接操作すると言っても、EtherヘッダやIPヘッダなどを操作する訳ではありませんので、送受信に関しては通常のソケットプログラムとなんら変わるところはありません。
今回のサンプルプログラムでは、ソケットを扱う基本的ライブラリであるSystem.Net.socket
クラスを使用しました。NBNSはUDPで動作するプロトコルであるため、ソケットはUDPで作成し、SendTo
メソッドで137番ポートに送信しています。
なお、対象IPアドレスの端末が存在しない場合などに返事を待つタイムラグを解消するため、受信は非同期で行うようBeginReceiveFrom
メソッドを使用し、別スレッドで受信するようにしました。
// 送信先の指定 IPEndPoint ep = new IPEndPoint(IPAddress.Parse(ipAddress),137); // UDPソケット作成 Socket socket = new Socket(ep.AddressFamily,SocketType.Dgram,ProtocolType.Udp); // 要求パケット送信 socket.SendTo(sendBuffer,0,sendBuffer.Length,SocketFlags.None,ep); // パラメータ受け渡し用クラスを生成 StateObject so = new StateObject(socket,ep); // 非同期受信スレッドの開始 socket.BeginReceiveFrom(so.recvBuffer,0,so.recvBuffer.Length, SocketFlags.None,ref so.ep,new AsyncCallback(ReciveCallback),so);
要求パケットの作成
テキストデータを送信するのではなく、プロトコル仕様に整合したデータを作成するには、少しテクニックが必要です。通常このような場合、プロトコルのフォーマット仕様を構造体で表現し、その値を設定して送信データ用のバッファにコピーするという手法を使用します。
要求パケットで必要な、NBNSのヘッダと質問エントリを表現した構造体は、次のようになります。
//NBNSヘッダ構造体 [StructLayout(LayoutKind.Sequential,Pack=1)] struct NBTHeader{ public ushort identification; // 識別 public ushort flags; // 各種フラグ public ushort qd_count; // 質問数 public ushort an_aount; // 回答数 public ushort ns_count; // オーソリティ数 public ushort ar_count; // 追加情報数 }
//質問エントリ構造体 [StructLayout(LayoutKind.Sequential,Pack=1)] struct QNSection{ public byte name_len; // 名前の長さ [MarshalAs(UnmanagedType.ByValTStr,SizeConst=33)] public string name; // 名前 public ushort qtype; // タイプ public ushort qclass; // クラス }
メモリ上のイメージを構造体で表現するには、StructLayout
属性を設定します。StructLayout
属性には配置方法の指定が可能ですが、ここでLayoutKind
列挙体のSequential
を指定すると、構造体で定義した順番で各メンバがメモリ上に配置されるようになります。
StructLayout
はデフォルトでパッキングが8になっています。これはプログラムの効率化のために、各メンバを8バイト境界にあわせて配置するということになります。構造体の中でメンバがushort
、ushort
と並んだ場合などに、本当は4バイト、4バイトと並んでほしいのに最初の4バイトの後に隙間を作ってしまうことになります。そこで、StructLayout
の中でPack=1
を指定して、1byte境界に配置するように指定しています。プロトコルフォーマットを構造体で表現する場合は、必ずパッキングの問題に注意が必要です。
次に定義した構造体を使用して、送信データを作成する手順を示します。
// ヘッダ NBTHeader nbtHeader = new NBTHeader(); nbtHeader.identification = NetUtil.htons(identification); // 識別子 nbtHeader.qd_count = NetUtil.htons(1);// 質問数 int nbtHeaderSize = Marshal.SizeOf(nbtHeader); // 質問エントリ // ・名前 QNSection qnSection = new QNSection(); qnSection.name = "CKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; // ・名前のサイズ qnSection.name_len = Convert.ToByte(qnSection.name.Length); // ・タイプ(0x21 NBNS Node Status) qnSection.qtype = NetUtil.htons(0x21); // ・クラス(0x01 Internet) qnSection.qclass = NetUtil.htons(0x01); int qnSectionSize = Marshal.SizeOf(qnSection); // 要求パケット作成 byte [] sendBuffer = new byte[nbtHeaderSize+qnSectionSize]; unsafe{ fixed(byte *p = sendBuffer) { // ヘッダのコピー Marshal.StructureToPtr(nbtHeader,(IntPtr)(p),true); // 質問エントリのコピー Marshal.StructureToPtr(qnSection, (IntPtr)(p+nbtHeaderSize),true); } }
C#では、ローカル変数を生成するとすべてのメンバが0で初期化されているため、ヘッダ構造体と質問エントリ構造体を生成した後、0でないメンバだけの値を代入しています。
完成した構造体を、実際の送信データにコピーする段階では、unsafe
を使用してポインタによるメモリコピーを使用しています。unsafe
は、メモリ管理がすべてプログラマの責任にゆだねられますのでコピー先のバッファサイズを確実に確保する必要があります。サンプルプログラムでは、ヘッダ構造体と質問エントリ構造体のサイズを足した分だけ送信バッファを確保しています。
サンプル内で使用されているNetUtil.htons
は、バイトオーダをネットワーク形式に変更するために、独自に作成したメソッドです。.NET Framework では、System.Net.IPAddress.HostToNetworkOrder
で同様の処理が可能ですが、プログラムを簡素に書けるように「NetUtil.cs」で用意したものです。
応答パケットの解釈
応答パケットの解釈は、送信パケット作成の逆順と考えてください。まずは、応答パケットの解釈に必要なフィールドを構造体で表現します。
//リソースレコード構造体 [StructLayout(LayoutKind.Sequential,Pack=1)] struct QNSection{ public byte name_len; // 名前の長さ [MarshalAs(UnmanagedType.ByValTStr,SizeConst=33)] public string name; // 名前 public ushort qtype; // タイプ public ushort qclass; // クラス }
必要なフィールドを表現する構造体が揃ったら、これに受信したデータをコピーしていきます。
まずは、NBNSヘッダを解釈し、質問エントリおよび各セクションのレコード数を確認します。続いて、その数だけフィールドを読み取っていくことになります。この際、先にも書いたとおり、unsafe
の中でのメモリ操作には細心の注意が必要です。
受信したパケットは外部からの直接入力であり、常に悪意がある可能性があることを念頭におく必要があります。サンプルプログラムでは、コピーの前に必要なサイズが確実にあるかどうかを確認し、問題ある場合は例外を発生させるようにしました。
int len=0; // 受信サイズ if(0<(len = so.socket.EndReceiveFrom(ar,ref so.ep))){ // ヘッダ NBTHeader nbtHeader = new NBTHeader(); int nbtHeaderSize = Marshal.SizeOf(nbtHeader); // 質問フィールド QNSection qnSection = new QNSection(); int qnSectionSize = Marshal.SizeOf(qnSection); // リソースレコード ResourceRecord resourceRecord = new ResourceRecord(); int resourceRecordSize = Marshal.SizeOf(resourceRecord); // リソースデータ byte [] rdata=null; unsafe{ int offSet=0; fixed(byte *p = so.recvBuffer) { // ヘッダのコピー // 受信バイト数超過(例外処理) if(offSet+nbtHeaderSize>len) throw new Exception(); nbtHeader = (NBTHeader)Marshal.PtrToStructure( (IntPtr)(p),typeof(NBTHeader)); offSet+=nbtHeaderSize; // RCodeおよびQRの確認 int flags = NetUtil.htons(nbtHeader.flags); int RCode = flags & 0x000F; int QR = flags>>15; if(RCode!=0) // RCodeがNOERROR(0)でない場合(例外処理) throw new Exception(); if(QR!=1) // QRが応答(1)でない場合(例外処理) throw new Exception(); // 回答セクションがない場合(例外処理) if(1>NetUtil.htons(nbtHeader.an_aount)) throw new Exception(); // 質問セクションのコピー(通常は0) for(int i=0;i<nbtHeader.qd_count;i++){ // 受信バイト数超過(例外処理) if(offSet+qnSectionSize>len) throw new Exception(); qnSection = (QNSection)Marshal.PtrToStructure( (IntPtr)(p+offSet),typeof(QNSection)); offSet+=qnSectionSize; } // 回答セクションのコピー(1セクションのみ) // 受信バイト数超過(例外処理) if(offSet+resourceRecordSize>len) throw new Exception(); resourceRecord = (ResourceRecord)Marshal.PtrToStructure( (IntPtr)(p+offSet),typeof(ResourceRecord)); offSet+=resourceRecordSize; int rdata_len = NetUtil.htons(resourceRecord.rdata_len); if(offSet+rdata_len>len) // 受信バイト数超過(例外処理) throw new Exception(); rdata = new byte[rdata_len]; Marshal.Copy((IntPtr)(p+offSet),rdata,0,rdata_len); // オーソリティおよび追加情報は読み飛ばす } } // この時点でデータ部は、rdataに確保されている // rdataの処理 }
今回紹介しているNBNSパケットの応答のパケットには、質問エントリーは存在せず、応答セクションに1つのリソースレコードだけが含まれています。しかし、プロトコル上は、それぞれにレコードを挿入することが可能になっているため、正当にレコード数を確認し、順番に読み込みを行っています。ただし、応答セクションを1レコード読み込んだ後は、どのようなデータが入っているかを判断せずに、そのまま取得完了としています。
今回紹介したNBNSには、応答パケットにMACアドレス情報が入っていますので、このような用途に使用することが可能です。
まとめ
本稿では、NBNSパケットの送受信を通してプロトコル仕様に従ったパケットの作成・解釈の一例を紹介しました。わずかながらでも、ネットワーク関連のプログラム作成に参考になれば幸いです。
参考資料
- Microsoftサポートオンライン 『NetBIOS サフィックス (NetBIOS 名の 16 番目の文字)』
- Microsoft 『NetBIOS 名のリファレンス』
- 『Network Programming in .NET: With C# and Visual Basic .NET』 Fiach Reid 著、Digital PR、2004年6月
- 『TCP/IP プロトコル&サービスガイド』 Joseph G. Davies・Thomas Lee 著、有限会社トップスタジオ 訳、日経BPソフトプレス、2000年9月
- 『アンドキュメンテッドMicrosoftネットワーク』 高橋基信 著、翔泳社、2002年6月
- パケットモニタ network sniffer VIGIL