PHP の FTP 関数のクソい動作

2年ぐらい前に書いてたネタを発掘したのでポスト、2年たってもクソいものはクソいです。


ある環境の PHP(5.3.2) の FTP 関数で、パッシブモードを指定しているはずなのに FTP サーバのログに PORT コマンドが残っていることがありました。

PHPFTP 関数のソースとにらめっこしたところ、次のコードで再現することがわかりました。iptables であれやこれやするので root で実行してください。

<?php
$addr = '192.168.1.100';
$port = 21;
$user = 'ore';
$pass = 'are';

`iptables -F`;
`iptables -A INPUT -j REJECT -p tcp --sport 20`;

$ftp = ftp_connect($addr, $port, 1);

ftp_login($ftp, $user, $pass);

ftp_pasv($ftp, true);
ftp_put($ftp, "0001.txt", __FILE__, FTP_BINARY); // (1)

`iptables -A INPUT -j DROP -p tcp --sport 21`;

ftp_put($ftp, "0002.txt", __FILE__, FTP_BINARY); // (2)

`iptables -R INPUT 2`;

ftp_put($ftp, "0003.txt", __FILE__, FTP_BINARY); // (3)
ftp_put($ftp, "0004.txt", __FILE__, FTP_BINARY); // (4)

ftp_close($ftp);

元々アクティブモードでは通信できないサーバに対して・・・

  • (1) でファイルを正常にアップロード
  • (2) の ftp_put の応答が何らかの原因で DROP された
  • (3) と (4) でファイルのアップロードを試みた

という動作をイメージしています。このとき (2)~(4) でそれぞれ次の PHP Warning が発生します。

  • (2) Warning: ftp_put(): Transfer complete
  • (3) Warning: ftp_put(): Entering Passive Mode (192,168,1,100,225,80).
  • (4) Warning: ftp_put(): PORT command successful

メッセージは異なっていることがあるかもしれません。それでも明らかにおかしなメッセージが表示されるはずです。

FTP サーバには次のログが記録されています(コマンド 応答コード バイト数 の順)。

"USER hoge" 331 -
"PASS (hidden)" 230 -
"PASV" 227 -
"TYPE I" 200 -
"STOR 0001.txt" 226 1760
"PASV" 227 -
"PORT 192,168,1,101,136,135" 200 -
"PORT 192,168,1,101,199,141" 200 -
"STOR 0004.txt" 425 0
"QUIT" 221 -

FTP 関数のソースを見た感じ (2) の ftp_put の中で発行されている PASV コマンドの応答が DROP されると、その次の (3) からは勝手にアクティブモードになるようでした。

この時点で十分不具合っぽいですが、さらに PHP Warning のメッセージをよく見ると、次のようにズレていることがわかります。

  • (2) のエラーメッセージは (1) の転送完了のメッセージ
  • (3) のエラーメッセージは (2) の PASV の応答メッセージ
  • (4) のエラーメッセージは (3) の PORT の応答メッセージ

単純に次のフローで FTP コマンドの送信と応答がズレます。

  • クライアントからサーバへコマンド A を送信
  • サーバからクライアントへの A の応答が何らかの原因で遅延
  • クライアントは A をタイムアウトと判断して次のコマンド B を送信 (FTP 関数は単に false が返るだけ)
  • A の応答がクライアントに到達する
  • クライアントは A の応答を B の応答だと解釈する

PASV の応答がドロップしたときにアクティブに切り替わってしまうだけの問題であれば ftp_put の前に必ずftp_pasvを呼べば解決できそうですが、 エラーメッセージがズレる問題はもっと深刻です、コマンドの送信と応答がズレてしまっているので FTP サーバとの接続を切って再接続しなければ復帰できません。

が、FTP 関数は FTP のリターンコードによるコマンドの失敗も、タイムアウトなどによる失敗も、同じように false を返すだけなのでその2つを区別できません。

そのため・・・

  • PHP のエラーメッセージから失敗の原因を判断する
  • FTP 関数で false が返ったときは必ず再接続する

前者はさすがにどうかと思うので後者の方法でどうにかしようと考えましたが、ftp_cwdftp_mkdir が普通に FTP のレイヤで失敗しただけで再接続する必要があり、使い勝手がとても悪いです。


FTP 層のエラーとそれ以下のエラーを区別できない今の FTP 関数ではどうしようも無い・・・と思ったのでそれを区別できる FTP クラスを作りました。