FTPクライアントのPassiveモード時の接続方法を調べてみた

2021.09.23

しばたです。

仕事でFTPサーバーへの接続状況を調査する必要があり、その際にクライアント毎でPassiveモードでの接続方法(実装)が結構違うことに気が付きました。
本記事ではその内容を雑多に解説します。

FTPのPassiveモードについて

FTPのPassiveモードが何ぞやという点については本記事では触れません。
こちらのサイトがわかりやすいので参照してください。

本記事において主題となる点は以下となります。

  • クライアントからPASVコマンドを発行した際サーバーは使用するIPアドレスとポート番号を返す
  • パブリックに公開されるFTPサーバーの場合PASVコマンドで返されるIPアドレスがPrivateにならない様にするための設定がある
    • 例えばProFTPDならMasqueradeAddressvsftpdならpasv_address
  • 主にIPV6向けに拡張Passiveモードがあり、この場合EPSVコマンドを使う
    • EPSVコマンドではIPアドレスは返さず使用するポート番号のみ返す

FTPクライアントによってPASVEPSVどちらを使うか、PASVで返されるIPアドレスをどう取り扱うかが結構異なっていました。

確認してみた

ここから実際にいくつかのクライアントで確認した結果を記載していきます。

私が普段Windows環境を使うため、基本的にはWindows用クライアントを選んでいます。
また、選定したクライアントに関しては「私が普段よく使う。単純に使い慣れてる。」だけでそれ以外の意図は特にありません。

検証環境

検証環境は私の検証用AWSアカウント上にパブリックに公開する形でFTPサーバーを立てました。
FTPDにはProFTPDを選び、ざっくり以下の設定としています。

  • 最新のAmazon Linux 2にProFTPDをよしなにインストール
    • EPELリポジトリからVer.1.3.5eをインストール
    • xinetdモード
    • セキュリティグループはよしなに開けておく
  • PassivePortsパラメーターを6000061000
  • 動作確認のため意図的にMasqueradeAddressパラメーターは設定せず

具体的な構築手順は割愛します。
図にすると以下の通りです。

今回割り当てたEIPは35.76.153.xx(xxは伏字)だったため、このアドレスに接続する形となります。
また、FTP接続に使うユーザーはscott、パスワードはtigerとしています。
(わかりやすいサンプルユーザーとパスワードを考えたらこれが出てきました...)

1. Telnetコマンドで接続する

まずは基本的な動作を確認するためTelnetコマンドで試してみます。

今回はWSL(Ubuntu 20.04)にあるコマンドを使っています。
最初にtelnet 35.76.153.xx 21を実行するとサーバーから応答が返りますので、ここからFTPコマンドを打ってコネクションを確立していきます。

$ telnet 35.76.153.xx 21
Trying 35.76.153.xx...
Connected to 35.76.153.xx.
Escape character is '^]'.
220 FTP Server ready.

USERPASSコマンドで認証を行い、PASVコマンドを打つとサーバーから使用するIPアドレスとポート番号が返されます。

USER scott
331 Password required for scott
PASS tiger
230 User scott logged in
PASV
227 Entering Passive Mode (10,0,11,251,235,233).

今回MasqueradeAddressパラメーターを指定してないので10,0,11,251とPrivate IPが返されていることがわかります。
ポート番号は235,233から235 x 256 + 233 = 60393を使う様に指示されています。

サーバーの指示通り10.0.11.251を使ってしまうと当然接続はできません。
ちなみにMasqueradeAddressパラメーターを指定した場合はこんな感じでEIPのアドレスを返し、これにより無事接続できる様になります。

PASV
227 Entering Passive Mode (35,76,153,xx,236,2).

また、PASVでなくEPSVコマンドを打つとこんな感じでポート番号のみ返されます。

EPSV
229 Entering Extended Passive Mode (|||60541|)

Passiveモードに入ったあとの手順は割愛しますが、これで通信の基本的なところは理解できたと思います。

2. WinSCPで接続する

次に私が良く使うWinSCPで試します。

バージョンはVer.5.19.2を使っています。
下図の様な設定でPassiveモードで接続する様にしています。

この状態でFTPサーバーに接続を試みます。
前節のTelnetで試した様にPASVコマンドの結果はPrivate IPを返す状態のため接続エラーになるはずなのですが、実際には無事接続できています。

この挙動が初見では全く理解できず悩んでいたのですが、デバッグログを取得してみると以下の様なログを吐いていました。

# ・・・前略・・・

< 2021-09-23 09:24:14.611 220 FTP Server ready.
> 2021-09-23 09:24:14.612 USER scott
< 2021-09-23 09:24:14.638 331 Password required for scott
> 2021-09-23 09:24:14.638 PASS *****
< 2021-09-23 09:24:14.684 230 User scott logged in

# ・・・中略・・・

> 2021-09-23 09:24:15.068 PASV
< 2021-09-23 09:24:15.093 227 Entering Passive Mode (10,0,11,251,235,44).
. 2021-09-23 09:24:15.093 Server sent passive reply with unroutable address 10.0.11.251, using host address instead.
> 2021-09-23 09:24:15.093 MLSD
. 2021-09-23 09:24:15.093 35.76.153.xx:60204 に接続中...
< 2021-09-23 09:24:15.151 150 Opening ASCII mode data connection for MLSD
. 2021-09-23 09:24:15.154 modify=20210923001720;perm=flcdmpe;type=cdir;unique=CA01UA3C75;UNIX.group=1002;UNIX.mode=0700;UNIX.owner=1002; .
. 2021-09-23 09:24:15.154 modify=20210923001720;perm=flcdmpe;type=pdir;unique=CA01UA3C75;UNIX.group=1002;UNIX.mode=0700;UNIX.owner=1002; ..
. 2021-09-23 09:24:15.154 modify=20200715055811;perm=adfrw;size=18;type=file;unique=CA01UA3C78;UNIX.group=1002;UNIX.mode=0644;UNIX.owner=1002; .bash_logout
. 2021-09-23 09:24:15.154 modify=20200715055811;perm=adfrw;size=193;type=file;unique=CA01UA3C79;UNIX.group=1002;UNIX.mode=0644;UNIX.owner=1002; .bash_profile
. 2021-09-23 09:24:15.154 modify=20200715055811;perm=adfrw;size=231;type=file;unique=CA01UA3C7A;UNIX.group=1002;UNIX.mode=0644;UNIX.owner=1002; .bashrc
. 2021-09-23 09:24:15.154 modify=20210923001641;perm=adfrw;size=37990;type=file;unique=CA01UA3C7B;UNIX.group=1002;UNIX.mode=0644;UNIX.owner=1002; 騾イ謐励←縺・〒縺吶°.jpg
. 2021-09-23 09:24:15.154 modify=20210923001720;perm=adfrw;size=12;type=file;unique=CA01UA3C7C;UNIX.group=1002;UNIX.mode=0644;UNIX.owner=1002; sample.txt
. 2021-09-23 09:24:15.166 Data connection closed
< 2021-09-23 09:24:15.181 226 Transfer complete
. 2021-09-23 09:24:15.181 ディレクトリ一覧の取得が成功しました

# ・・・後略・・・

ログを見ると、

< 2021-09-23 09:24:15.093 227 Entering Passive Mode (10,0,11,251,235,44).
. 2021-09-23 09:24:15.093 Server sent passive reply with unroutable address 10.0.11.251, using host address instead.

PASVコマンドで戻されたIPアドレスを意図的に無視していることが分かりました。
この挙動はFTP設定の「パッシブモードでIPアドレスを強制する (Force IP Address for passive mode)」で制御されており、「自動」または「オン」にするとPASVコマンドで返されたIPアドレスを無視します。

この設定を「オフ」にするとPASVコマンドで返されたIPを使用する様になります。

この場合正しく?接続エラーとなります。

エラー時のデバッグログもこんな感じです。

> 2021-09-23 09:30:49.400 PASV
< 2021-09-23 09:30:49.424 227 Entering Passive Mode (10,0,11,251,238,18).
> 2021-09-23 09:30:49.424 MLSD
. 2021-09-23 09:30:49.424 10.0.11.251:60946 に接続中...
. 2021-09-23 09:31:04.143 タイムアウトしました。 (データ用接続)
. 2021-09-23 09:31:04.143 ディレクトリの一覧を取得できません。
. 2021-09-23 09:31:04.143 Got reply 1004 to the command 2
* 2021-09-23 09:31:04.200 (EFatal) **切断されました。**
* 2021-09-23 09:31:04.200 タイムアウトしました。 (データ用接続)
* 2021-09-23 09:31:04.200 ディレクトリの一覧を取得できません。
* 2021-09-23 09:31:04.200 ディレクトリ '/' のリスト取得のエラー。

3. curlで接続する

次にcurlコマンドで試します。
今回は手元のWindows 10に標準でインストールされているVer.7.55.1を使います。

PS C:\> curl.exe -V
curl 7.55.1 (Windows) libcurl/7.55.1 WinSSL

curlの場合もWinSCP同様に予想に反してFTP接続できてしまいました。

PS C:\> curl.exe ftp://scott:tiger@35.76.153.xx/
-rw-r--r--   1 scott    scott          12 Sep 23 00:17 sample.txt
-rw-r--r--   1 scott    scott       37990 Sep 23 00:16 進捗どうですか.jpg

curlでは--traceパラメーターを付けるとトレースログを取得できますので内容を確認していきます。

PS C:\> curl.exe --trace - ftp://scott:tiger@35.76.153.xx/

# ・・・(中略)・・・

=> Send header, 6 bytes (0x6)
0000: 45 50 53 56 0d 0a                               EPSV..
== Info: Connect data stream passively
== Info: ftp_perform ends with SECONDARY: 0
<= Recv header, 48 bytes (0x30)
0000: 32 32 39 20 45 6e 74 65 72 69 6e 67 20 45 78 74 229 Entering Ext
0010: 65 6e 64 65 64 20 50 61 73 73 69 76 65 20 4d 6f ended Passive Mo
0020: 64 65 20 28 7c 7c 7c 36 30 34 36 39 7c 29 0d 0a de (|||60469|)..

# ・・・(後略)・・・

すると、curlでFTPを扱う場合サーバー側で対応しているとデフォルトでEPSVを使う挙動となっていることが分かりました。
今回のサーバーではEPSVを制限していなかったため問題なく結果を返したわけです。

ちなみにPASVコマンドを強制したい場合は--disable-epsvオプションを付けてやる必要があります。
この場合、

PS C:\> curl.exe --trace - --disable-epsv ftp://scott:tiger@35.76.153.xx/

# ・・・(中略)・・・

=> Send header, 6 bytes (0x6)
0000: 50 41 53 56 0d 0a                               PASV..
== Info: Connect data stream passively
== Info: ftp_perform ends with SECONDARY: 0
<= Recv header, 50 bytes (0x32)
0000: 32 32 37 20 45 6e 74 65 72 69 6e 67 20 50 61 73 227 Entering Pas
0010: 73 69 76 65 20 4d 6f 64 65 20 28 31 30 2c 30 2c sive Mode (10,0,
0020: 31 31 2c 32 35 31 2c 32 33 37 2c 31 36 38 29 2e 11,251,237,168).
0030: 0d 0a                                           ..
== Info:   Trying 10.0.11.251...
== Info: TCP_NODELAY set
== Info: Connecting to 10.0.11.251 (10.0.11.251) port 60840
== Info: connect to 10.0.11.251 port 21 failed: Timed out
== Info: Failed to connect to 35.76.153.xx port 21: Timed out
== Info: Closing connection 0
curl: (7) Failed to connect to 35.76.153.xx port 21: Timed out

といった感じでPASVコマンドを発行し、接続エラーとなります。

ただ、より新しいバージョン(Ver.7.74.0以降)のcurlではWinSCP同様PASVコマンドが返すIPを無視するオプション(--ftp-skip-pasv-ip)がデフォルトになっています。

# 新しいバージョンのcurlを別途用意
PS C:\curl\bin> .\curl.exe -V
curl 7.79.1 (x86_64-pc-win32) libcurl/7.79.1 OpenSSL/3.0.0 (Schannel) zlib/1.2.11 brotli/1.0.9 zstd/1.5.0 libidn2/2.3.2 libssh2/1.10.0 nghttp2/1.45.1 libgsasl/1.10.0

新しいバージョンのcurlで先程と同様にFTP接続を実施、

# 新しいバージョンのcurlでFTP接続
PS C:\curl\bin> .\curl.exe --trace - --disable-epsv ftp://scott:tiger@35.76.153.xx/

# ・・・(中略)・・・

=> Send header, 6 bytes (0x6)
0000: 50 41 53 56 0d 0a                               PASV..
== Info: Connect data stream passively
== Info: ftp_perform ends with SECONDARY: 0
<= Recv header, 49 bytes (0x31)
0000: 32 32 37 20 45 6e 74 65 72 69 6e 67 20 50 61 73 227 Entering Pas
0010: 73 69 76 65 20 4d 6f 64 65 20 28 31 30 2c 30 2c sive Mode (10,0,
0020: 31 31 2c 32 35 31 2c 32 33 38 2c 35 36 29 2e 0d 11,251,238,56)..
0030: 0a                                              .
== Info: Skip 10.0.11.251 for data connection, re-use 35.76.153.xx instead
== Info:   Trying 35.76.153.xx:60984...
== Info: Connecting to 35.76.153.xx (35.76.153.xx) port 60984
== Info: Connected to 35.76.153.xx (35.76.153.xx) port 21 (#0)

# ・・・(後略)・・・

結果

== Info: Skip 10.0.11.251 for data connection, re-use 35.76.153.xx instead
== Info: Trying 35.76.153.xx:60984...

PASVコマンドの返すIPアドレスが無視されていることが分かります。

ちなみに古いバージョンのcurlでも--ftp-skip-pasv-ipオプションを付けてやると同じ挙動になります。

# 古いバージョンなら --ftp-skip-pasv-ip オプションを付けてやると良い
PS C:\> curl.exe --disable-epsv --ftp-skip-pasv-ip ftp://scott:tiger@35.76.153.xx/
-rw-r--r--   1 scott    scott          12 Sep 23 00:17 sample.txt
-rw-r--r--   1 scott    scott       37990 Sep 23 00:16 進捗どうですか.jpg

【追記】デフォルト挙動の変更理由

curl 7.74.0から挙動が変更された理由は脆弱性対応(CVE-2020-8284)でした。

要は悪意あるFTPサーバーを建てるとPASVで返すIPアドレスとポート番号をユーザーが意図しない攻撃者のサイトに向けることが可能になるという事です。
このためデフォルトでは「PASVで返すアドレスを信頼しない」ことになっています。

世の中にどの程度悪意あるFTPサーバーがあるのか疑問に思わなくもないですが、脆弱性の指摘自体はもっともだと思いますので古いcurlでも--ftp-skip-pasv-ipオプションを積極的に使うと良いでしょう。

4. C# (FtpWebRequestクラス) で接続する

最後にプログラムから接続した場合を調べてみました。
今回は私が使い慣れてるC#を対象としています。

C#でFTP接続をする場合は太古の昔からあるFtpWebRequestクラスを使うのがメジャーかと思います。
このクラスは.NET Framework 2.0から存在し.NET Coreにも移植済み(.NET Standard 2.0以上)です。

このクラスで行われるFTP接続確立処理の実装はFtpControlStreamクラスにあります。
基本的な実装は昔から変わっていなかったのでGitHubのソースを見て挙動を調べてみます。

すると、

FtpControlStream.cs

                if (request.UsePassive)
                {
                    string passiveCommand = (ServerAddress.AddressFamily == AddressFamily.InterNetwork || ServerAddress.IsIPv4MappedToIPv6) ? "PASV" : "EPSV";
                    commandList.Add(new PipelineEntry(FormatFtpCommand(passiveCommand, null), PipelineEntryFlags.CreateDataConnection));
                }

の様に接続先がIPV4ならPASV、それ以外(IPV6)ならEPSVを使うことがわかりました。
そしてPASVコマンドで返されるIPアドレスは常に無視されることも分かりました。

FtpControlStream.cs

            // Handle passive responses by parsing the port and later doing a Connect(...)
            bool isPassive = false;
            int port = -1;

            if (entry.Command == "PASV\r\n" || entry.Command == "EPSV\r\n")
            {
                if (!response.PositiveCompletion)
                {
                    _abortReason = SR.Format(SR.net_ftp_server_failed_passive, response.Status);
                    return PipelineInstruction.Abort;
                }
                if (entry.Command == "PASV\r\n")
                {
                    port = GetPortV4(response.StatusDescription!);
                }
                else
                {
                    port = GetPortV6(response.StatusDescription!);
                }

                isPassive = true;
            }

            if (isPassive)
            {
                if (port == -1)
                {
                    NetEventSource.Fail(this, "'port' not set.");
                }

                try
                {
                    _dataSocket = CreateFtpDataSocket((FtpWebRequest)_request!, Socket);
                }
                catch (ObjectDisposedException)
                {
                    throw ExceptionHelper.RequestAbortedException;
                }

                IPEndPoint localEndPoint = new IPEndPoint(((IPEndPoint)Socket.LocalEndPoint!).Address, 0);
                _dataSocket.Bind(localEndPoint);

                _passiveEndPoint = new IPEndPoint(ServerAddress, port);
            }

_passiveEndPoint = new IPEndPoint(ServerAddress, port);

PASVコマンドの結果はポート番号しか使われてない実装となっています。

サンプルプログラム

ここまでを踏まえて簡単なサンプルプログラムを使って挙動を確認してみました。
環境は.NET 5(.NET SDK 5.0.401)で以下の簡単なコンソールアプリケーションを書きました。

Program.cs

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

namespace ftpsample
{
    class Program
    {
        static void Main(string[] args)
        {
            var serverAddress = "35.76.153.xx";
            var user = "scott";
            var password = "tiger";

            // Create request
            var request = (FtpWebRequest)FtpWebRequest.Create($"ftp://{serverAddress}/");
            request.Credentials = new NetworkCredential(user, password);
            request.KeepAlive = false;
            request.UseBinary = true;
            request.UsePassive = true; // Passiveモードを有効に
            request.Method = WebRequestMethods.Ftp.ListDirectoryDetails; // LIST
            Console.WriteLine($"Connecting to ftp://{user}:{password}@{serverAddress}/");
            
            // Get response
            using (var response = request.GetResponse())
            using (var stream = response.GetResponseStream())
            using (var reader = new StreamReader(stream))
            {
                var message = reader.ReadToEnd();
                Console.WriteLine(message);
            }
        }
    }
}

シンプルにLISTコマンドを発行してその結果をコンソール表示するだけのものとなります。
実行結果は以下の通りサーバーに接続できています。

PS C:\ftpsample> dotnet run
Connecting to ftp://scott:tiger@35.76.153.xx/
-rw-r--r--   1 scott    scott          12 Sep 23 00:17 sample.txt
-rw-r--r--   1 scott    scott       37990 Sep 23 00:16 進捗どうですか.jpg

念のためWireSharkで通信を覗いてみてもPASVコマンドで返されたIPが無視されていることがわかります。

5. 【追記】Python (ftplib) で接続する

Pythonは標準ライブラリftplibでFTPを扱えます。

ライブラリのソースを見てみるとC#の場合と同様にIPV4ならPASV、それ以外ならEPSVコマンドを使う様になっています。

ftplib.py

    def makepasv(self):
        """Internal: Does the PASV or EPSV handshake -> (address, port)"""
        if self.af == socket.AF_INET:
            untrusted_host, port = parse227(self.sendcmd('PASV'))
            if self.trust_server_pasv_ipv4_address:
                host = untrusted_host
            else:
                host = self.sock.getpeername()[0]
        else:
            host, port = parse229(self.sendcmd('EPSV'), self.sock.getpeername())
        return host, port

また、先述のCVE-2020-8284の影響を受けてPASVコマンドで返されるIPアドレスを信頼しない更新が加えられています。

ftplib.py

    # Disables https://bugs.python.org/issue43285 security if set to True.
    trust_server_pasv_ipv4_address = False

終わりに

以上となります。

最初にWinSCPの挙動を知り他のクライアントではどうなのかと興味を持ち本記事を書くに至りました。
たった45種類を試しただけですが思っていた以上にクライアントの実装やデフォルトの挙動に違いがあり新しい知見を得ることができました。

2021年現在でもなんやかんやでFTPを扱うことはあるかと思います。
通信において変わった挙動や気になる挙動が見られた場合はサーバーだけでなくクライアントの実装も確認しておくと良いでしょう。