C#のSocketでFTPクライアントを実装しながらFTPプロトコルを理解する


概要

FTPプロトコルの定義はRFCに書かれている事が恐らくは全てではあるとは思われるのですが、RFCの文書を読んでもすっと腑に落ちる感覚にはならないので、tcpdumpやWiresharkを使ってFTPプロトコルの解析をしながら、C#のSocketを使ってFTPクライアントを実装し、FTPプロトコルの理解を深めることを本記事の目的としています。
これを読めば、FTPプロトコルがパスワードを平文で流しているのでセキリティ上の危険があることや、FTP通信が制御ストリームとデータストリームの2つのストリームを使用しているということが自分の目で確認できるようになります。

お断り

この記事ではFTPプロトコルの全ての実装を網羅するものでもなく、エラー処理も実装しません。
実業務プログラムにや役に立つプログラムはここでは作成しません。
ここでの目的はネットワークプロトコルとは何かを肌で感じて、ネットワークプロトコルをSocketを使って実装する糸口を掴むことにあります。
実業務では例外ケースや、様々なFTPサーバーの独自実装などに対応する必要があります。

用意するもの

FTPサーバーはここでは、Docker for Windows上のCentos7のvsftpdを使用しました。
また、パケットの解析にWiresharkを使用します。また、Centos上でのパケットキャプチャには、tcpdumpを使用します。
また、WinSCPを使って、FTPサーバーに接続しファイルを取得する操作を行います。
WinSCPとFTPサーバーとのネットワーク通信をCentos7上のtcpdumpコマンドでパケットを取得し、Wiresharkでパケットの中身を解析します。

vsftpdのDocker for Windows上のCentosへのインストールについてはこちらの記事を参照お願いします。

centosでvsftpdを起動するDockerコンテナをDockerfileで作成しながらDockerの使い方を覚える

なお、FTPサーバーをローカルPC以外に用意できる場合は、Docker for Windowsによるvsftpdの準備は不要で、tcpdumpも不要です。パケットキャプチャソフトとしてWiresharkだけあれば問題ありません。

tcpdumpによるパケットキャプチャ

Windows上でパケットキャプチャを取得するには、Wiresharkがあれば通常は十分ですが、ローカルのDocker Container上のホストとの通信パケットは通常の方法ではキャプチャ出来ないため、Centos上でパケットは取得します。

tcpdumpをCentos7にインストールするには

>yum -y install tcpdump

でOKです。

また、パケットキャプチャの取得は、

>tcpdump -p -w filename

となります。

filename部分は保存するファイル名に置き換えます。
例えば、「ftpcapture1.pcap」などです。
拡張子の「pcap」はWiresharkをWindowsにインストールするとWiresharkで開くように関連付けられる拡張子です。

Docker コンテナ上で保存したファイルをWindows側にコピーするには、Windows側のコマンドプロンプトで次の
Dockerコマンドを使用します。

>docker cp コンテナID:/home/test/filename .

コンテナIDは、「docker ps」で表示されるIDを使用します。
「:」より後ろの部分はCentos上でtcpdumpコマンドを使用して保存したパケットキャプチャのファイルのパスです。
最後の「.」はWindows上の保存先のフォルダです。「.」はカレントフォルダです。

パケット取得と解析

起動中のDockerコンテナのCentosのbashを下記のコマンドで起動します。

>docker exec -it コンテナID bash

すると、Centosのシェルが起動しますので、tcpdumpコマンドを実行します。

# tcpdump -p -w filename

ここで、WinSCPを使って、ホスト「localhost」、ポート「21」、ユーザー「anonymous」で
FTPサーバーにアクセスしてファイル「/pub/test.txt」をダウンロードする操作を実行します。
ファイルダウンロードが完了したら、centos上のシェル上で「Ctrl」キー+「C」でtcpdumpの実行を停止します。
これで、パケットキャプチャファイルが保存されます。
Windows上で、「docker cp」コマンドを使ってDocker コンテナ上のcentosに保存されているキャプチャファイルを
Windowsのフォルダーに保存しましょう。

保存したファイルをWiresharkを使って開くと下記のようにパケット解析結果が表示されます。

[SYN][SYN,ACK][ACK]の3wayハンドシェークからTCP通信が開始している事が分かります。
また、クライアント側はIPアドレスが「172.20.0.1」、ポートが「44750」、サーバー側はIPアドレスが「172.20.0.2」、ポート番号が「21」となっている事が分かります。
なお、このIPアドレスは、Docker for Windowsで作成されたHyper-V上の仮想ネットワークのIPアドレスとなっています。

ここで、表示されている任意の行を右クリックして表示されるコンテキストメニューから「追跡」=>「TCPストリーム」を選択すると、TCPストリーム上で送受信されているデータのみが表示されます。

クライアントからサーバーへの通信と、サーバーからクライアントへの通信が色分けされて表示されています。
これをみれば、FTPサーバーとFTPクライアントの間でどのようなデータが送受信されているかが分かります。
ネットワーク・プロトコルを理解するには、これを見るのが一番です。

FTPプロトコルの解析

クライアントからサーバーの21番ポートに接続するとサーバー側からまずは、レスポンスコードとWelcomeメッセージが送信されてきます。
今回はvsftpdから「220 (vsFTPd 3.0.2)」が送信されています。
FTPプロトコルではサーバーからのメッセージの先頭には必ず3桁のレスポンスコードが付いています。
FTPクライアントを正しく実装する場合はこのレスポンスコードを見ながら処理分岐が必要になりますが、ここでは常に想定しているレスポンスが返ってくるものとして、後の実装ではレスポンスコードは無視します。

次に、クライアントから「USER anonymous」をサーバーに送信しています。
クライアントからのメッセージの先頭には必ずFTPのコマンドメッセージが付きます。
ここでは「USER」がコマンドで「anonymous」は引数です。
ユーザー名「anonymous」でアクセスしますという通知をしています。
なお、FTPのanonymous認証というのはユーザー名が「anonymous」でパスワードは任意のメールアドレスで認証するということで、通常のユーザー名とパスワードによる認証と同じしくみを使っており、特別にanonymous認証という仕組みがある訳ではありません。

「PASS [email protected]
パスワードを送信しています。

「230 Login successful.」でサーバー側から認証成功の応答が返ってきています。
先頭の「230」の意味は、Googleなどで「FTP RFC 230」などで調べればRFCの文書が読めるかと思われますので
ここでは割愛します。

「TYPE A」
「TYPE I」
これは、ASCIIモード、バイナリモードを切り替えるためのコマンドです。

「EPSV」
「229 Entering Extended Passive Mode (|||30060|)」

いわいるPASV(パッシブモード)に切り替えるコマンドの送信と応答です。
EPSVの先頭の「E」は「Extensive」(拡張)の意味です。
後ろの方の、(|||30060|)の部分でサーバー側からPASVモードで使用するデータ通信用のコネクションのポートを通知してきています。

「RETR test.txt」でファイル名を取得してファイルダウンロードを開始します。
クライアント側からはEPSVで通信されたポートに接続してサーバーのファイルのダウンロードを行います。

なお、ファイルダウンロード用のコネクションは、制御用のコネクションとは別ストリームになるため、Wiresharkの「追跡」「TCPストリーム」を一旦解除する必要があります。

Wiresharkのもとの画面の上部に表示されている表示フィルター部分の右の「X」をクリックすればフィルターが解除されます。
次に、30060番ポートの通信パケットを探します。

画面上で30060番ポートのパケットを見つけたら右クリックして「追跡」「TCPストリーム」で
30060番のポート番号の通信内容を確認します。
なお、ポート番号は環境によって変わります。
制御ストリームの、EPSVコマンドの応答メッセージのポート番号がデータストリーム用のポートとなっているため、
EPSVコマンドの応答ポート番号を検索してください。
データストリームの内容は下記の通りです。
表示されているのは、ファイルの中身です。

データストリームでは純粋にファイルの中身をサーバーからクライアントに取得しているだけです。
コマンドやレスポンスコードはありません。

ここまでで、FTPプロトコルの解析はだいたい終了です。
次に、C#のソケットを使ってWinSCPの今回のクライアント側処理を実装していきます。

C#のSocketでFTPクライアントの実装

で、いきなり実装例です。
なお、「お断り」にも記載して通り、サーバーからのレスポンスコードによる分岐も実施しておりませんし、
サーバーからレスポンスがなかった場合の考慮もしておりません。
あくまでも、FTPプロトコルを肌身で感じるためのコードですので、実業務でこのコードは使用できません。

下記のコードをFormのボタンのクリックイベント等に貼り付けて試して見ることが出来ます。

         IPEndPoint hostEndPoint; 

            IPAddress hostAddress = null; 

            int conPort = 21; 



            hostAddress = IPAddress.Parse("127.0.0.1"); 

            hostEndPoint = new IPEndPoint(hostAddress, conPort); 



            Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); 

            s.Connect(hostEndPoint); 



            byte[] buffer = new byte[1024]; 

            s.Receive(buffer); 



            buffer = Encoding.ASCII.GetBytes("USER anonymous\r\n"); 

            s.Send(buffer); 

            buffer = new byte[1024]; 

            s.Receive(buffer); 



            buffer = Encoding.ASCII.GetBytes("PASS [email protected]\r\n"); 

            s.Send(buffer); 

            buffer = new byte[1024]; 

            s.Receive(buffer); 



            buffer = Encoding.ASCII.GetBytes("CWD /pub\r\n"); 

            s.Send(buffer); 

            buffer = new byte[1024]; 

            s.Receive(buffer); 



            buffer = Encoding.ASCII.GetBytes("EPSV\r\n"); 

            s.Send(buffer); 

            buffer = new byte[1024]; 

            int size = s.Receive(buffer); 

            String response = Encoding.ASCII.GetString(buffer, 0, size); 

            String[] commands = response.Split('|'); 

            if (commands.Length > 4) 

            { 

                string portStr = commands[3]; 



                buffer = Encoding.ASCII.GetBytes("RETR test.txt\r\n"); 

                s.Send(buffer); 

                buffer = new byte[1024]; 

                Socket dataS = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); 

                IPEndPoint dataHostEndPoint = new IPEndPoint(hostAddress, Int32.Parse(portStr)); 

                dataS.Connect(dataHostEndPoint); 

                int fileSize = dataS.Receive(buffer); 



                using (FileStream fileStream = new FileStream("test.txt", FileMode.Create, FileAccess.Write)) 

                { 

                    fileStream.Write(buffer, 0, fileSize); 

                } 



            } 

            s.Close(); 

今回はVisual Studio Community 2017を使用しています。
新規プロジェクトの作成で、WindowsデスクトップのWindowsフォームアプリケーション(.NET Framework)をテンプレートとして選択してプロジェクトを作成し、Formにボタンを貼り付けて、ボタンのクリックイベントの処理に上記のコードを貼り付けて利用できます。

なお、ビルドをするにはクラスの先頭に下記のインポート文(using文)が必要です。

using System.Net;
using System.Net.Sockets;
using System.IO;

実装の解説

TCPストリーム上のプロトコルは会話のキャッチボールです。
ソケットでサーバーに接続したらFTPプロトコルの場合はまずはサーバー側からボールが投げられます。(Welcomeメッセージです)
そのあとは、クライアントからのFTPのコマンド送信を行い、サーバーからの応答メッセージを受け取るというやり取りの繰り返しになります。
どのようなコマンドが送信可能で、どのような応答メッセージが返ってくるかを定めたものがプロトコルです。
FTPサーバーはFTPプロコトルに従って実装されていますので、FTPクライアントの開発者はFTPプロトコルにそって実装すれば、FTPサーバーとの会話が可能になります。

FTPコマンドの送信の完了の目印は改行コードになっていますので、送信コマンドの最後には「\r\n」の改行コードを入れています。
サーバー側ではコマンドの完了の目印がないとクライアントからのメッセージの受信状態が続きます。
そのため、改行コードなしのコマンドを送信後に、「s.Receive(buffer); 」でサーバーからの応答を受け取ろうとしても、
サーバーから応答メッセージが送信されないため、クライアント側は「s.Receive(buffer); 」で処理が止まったままになります。

今回はサーバーからの応答は無視して実装していますが、1つだけ無視できない箇所があります。
「EPSV」コマンドの応答で、サーバーからデータ接続用のポート番号が通知されている箇所です。
サーバー側から通知されたポート番号にデータ接続用のコネクションを張る必要があるため、
「EPSV」の応答メッセージからポート番号を取り出しています。
なお、サーバー側から通信されるデータ接続用のポート番号は、vsftpdの場合は、vsftpd.confの設定ファイルの下記の記載の範囲内になります。毎回異なるポート番号が通知されますので固定の値は使用できません。

pasv_min_port=30000
pasv_max_port=30100

また、「int fileSize = dataS.Receive(buffer); 」でデータストリームからデータを受け取っていますが、ファイルサイズが大きい場合は、fileSize == 0となるまで、繰り返し「int fileSize = dataS.Receive(buffer); 」を呼び出す必要があります。
つまり、本来はループ処理にすべき箇所ですが、今回は省略しています。

なお、接続先のIPアドレスに127.0.0.1を使用しています。
「hostAddress = IPAddress.Parse("127.0.0.1");

これは、自ホスト(ループバックアドレス)です。
今回は、Docker for Windows上のCentosのホストアドレスは自アドレスとなりますので、127.0.0.1を利用しています。

 int conPort = 21; 

FTPサーバーの制御用の待受ポートは通常は「21」番を使用しています。

WinScpの代わりに自分で実装したFtpClientを使ってファイル取得を実行すると、/bin/Debugフォルダーの直下に
「test.txt」のファイルが出来ていたら自作のFtpClientによるファイル取得がとりあえずは成功です。

自作のFtpClientでどのようなパケットのやりとりが実施されているかを、WinScpの場合と同様に、Centos側のtcpdumpで確認すると良いでしょう。また、Visual Studioのデバッグ機能を使って、
「s.Receive(buffer); 」の処理後に、「buffer」に格納されているレスポンスメッセージを目で確かめていくと良いかと思います。

最後に

FTPの他に、SMTPやHTTPなどの古典的なプロトコルのほとんどはパスワードも含めて平文でコマンド、レスポンスの送受信を実施していますので、パケットキャプチャを行えばパスワードやその他の通信データが丸見えになっています。
会社でWiresharkなどのパケットキャプチャーを使って別ホストの通信を傍受することは不正な操作とみなされることがありますので、会社で使用する場合は上司の許可をとってからにしましょう。
ただし最近のスイッチングハブでは別ホスト間の通信は他のPCには流れませんので、基本的には自PCとサーバーとの通信しか見れないはずです。