虚拟主机FTP被动模式下返回localhost IP

前言

准备将备案后的博客放到阿里虚拟主机上,但是无奈 hexo 官方的 ftp 部署插件年久失修,自己有没有修复能力,所以只好自己写脚本上传了,调试程序时发现了很诡异的一个问题。

问题症状

先贴调试脚本,使用被动模式,众所周知在现在 NAT 满天飞的家庭宽带中主动模式基本不可用:

1
2
3
4
5
6
7
8
9
10
from ftplib import FTP

ftp = FTP(host='xxxx.my3w.com')
ftp.encoding = 'utf8
ftp.set_debuglevel(2)
ftp.login(user='xxxx', passwd='xxxx')
ftp.sendcmd('OPTS UTF8 ON')
ftp.sendcmd('PWD')
ftp.sendcmd('TYPE I')
ftp.sendcmd('PASV')

问题症状:

在本机上执行脚本,返回日志如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
*cmd* 'USER xxxx'
*put* 'USER xxxx\r\n'
*get* '331 Please specify the password.\n'
*resp* '331 Please specify the password.'
*cmd* 'PASS ***************'
*put* 'PASS ***************\r\n'
*get* '230 Login successful.\n'
*resp* '230 Login successful.'
*cmd* 'OPTS UTF8 ON'
*put* 'OPTS UTF8 ON\r\n'
*get* '200 Always in UTF8 mode.\n'
*resp* '200 Always in UTF8 mode.'
*cmd* 'PWD'
*put* 'PWD\r\n'
*get* '257 "/"\n'
*resp* '257 "/"'
*cmd* 'TYPE I'
*put* 'TYPE I\r\n'
*get* '200 Switching to Binary mode.\n'
*resp* '200 Switching to Binary mode.'
*cmd* 'PASV'
*put* 'PASV\r\n'
*get* '227 Entering Passive Mode(127,0,0,1,156,69).\n'
*resp* '227 Entering Passive Mode(127,0,0,1,156,69).'

在虚拟机中执行脚本,输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
*cmd* 'USER xxxx'
*put* 'USER xxxx'
*get* '331 Please specify the password.\n'
*resp* '331 Please specify the password.'
*cmd* 'PASS ***************'
*put* 'PASS ***************\r\n'
*get* '230 Login successful.\n'
*resp* '230 Login successful.'
*cmd* 'OPTS UTF8 ON'
*put* 'OPTS UTF8 ON\r\n'
*get* '200 Always in UTF8 mode.\n'
*resp* '200 Always in UTF8 mode.'
*cmd* 'PWD'
*put* 'PWD\r\n'
*get* '257 "/"\n'
*resp* '257 "/"'
*cmd* 'TYPE I'
*put* 'TYPE I\r\n'
*get* '200 Switching to Binary mode.\n'
*resp* '200 Switching to Binary mode.'
*cmd* 'PASV'
*put* 'PASV\r\n'
*get* '227 Entering Passive Mode(116,62,0,0,156,73).\n'
*resp* '227 Entering Passive Mode(116,62,0,0,156,73).' # 这里本应该是一个真实的外网地址,隐私原因隐藏掉了。

问题出在 PASV 命令的回复上:

*resp* '227 Entering Passive Mode(127,0,0,1,156,69).'

*resp* '227 Entering Passive Mode(116,62,0,0,156,73).'

两次服务器返回的高位端口没什么问题, 但是 IP 地址是不一样的,一个是正常的公网 IP一个是 localhost 环回地址,这就导致我在本机执行脚本时无法连接到服务器返回给我的 IP 地址(环回地址)上,最后连接失败!

本着控制变量的原则,我将测试脚本放到虚拟机中,然后将虚拟机的网络模式由 NAT 模式改为 bridge 模式,发现运行结果和本机一致,虚拟机的环境没有任何更改,只是将网络模式更改了,也就是说将虚拟机的内网 IP 由原来的 192.168.233.x 网段更改为了 192.168.1.x 网段,同样的脚本虚拟主机的 FTP 服务器返回给我的结果是不一样的!这就直接导致了我无法通过被动模式和 FTP 服务器进行连接。

注:filezilla 这类 ftp 客户端在被动模式下貌似都有一个设置,就是会自动替换服务器返回的错误的 IP,但是自己写的脚本没有这个功能,所以会出现问题。

image-20210216184517451

image-20210216184235779

本地抓包分析

在虚拟机中抓包结果如下,数据包看起来没有任何问题,仅仅只是源 IP 不一样。

image-20210215224433600

image-20210215224509170

服务器抓包分析

为了看一下服务器端收到的数据是什么样的,我在自己的服务器上搭建了一个简单的 ftp server,然后使用 tcpdump 抓包,结果如下:

image-20210216190115418

追踪 TCP 流数据如下:

image-20210216190152688

服务器仍然返回的是内网地址,看起来服务器并没有收到任何关于我内网的任何信息,按理说不可能根据我的内网信息返回不同的 IP 地址。

验证

到这里,我有一个疑问,在虚拟机的网络模式转换过程中,网络模式的转换导致了在数据包到达服务器前多了一层 NAT,仅此而已,这应该不影响 ftp 数据包的抓发。

注:在验证这个问题之前走了许多弯路,前前后后大概花了将近两天的时间在里面。包括但不限于和阿里人员使用工单进行沟通,自己搭建服务器进行单向双向抓包等等。

但是为了确认虚拟机的 NAT 不影响 ftp 数据包的转发,我在虚拟机和本机中同时抓包验证了一下(事实证明这里还真有影响)。

在虚拟机中抓包的数据包如下:

image-20210217202243926

在本机中抓到的包如下:

image-20210217202330208

真相大白了,虚拟机的 NAT 功能的确替换了 ftp 服务器返回的错误的 IP 地址,但是不知道为什么会这样。

翻了一下 VMware 的知识库,找到了这个,但是没有看明白到底是不是支持 ftp 的 IP 地址自动替换功能,在另外一篇文章中找到解释。

中国的ip地址资源太少,很多情况下ftp会话两端要经过层层NAT、网关、防火墙。 导致PORT PASV中的ip 信息不一定都是正确的。比如client的ip是192.168.0.107,其实它是通过NAT连接上网的,该NAT对外的internet地址是218.2.135.1,那么client 发送的PORT指令中携带的192,168,0,107,xx,yy 对于server是没有意义的。 所幸现在的 NAT、防火墙一般都有FTP应用层感知能力,它能够截获ftp会话中的PORT PASSIVE, 自动将private的ip翻译成对外的正确的ip,并实时的在NAT上开放临时端口转发 。刚才的例子就会翻译成 218,2,135,1,xx,yy。 所以一般情况下,ftp还是能够正常工作的。

所以说这个问题可以解释为我的路由器自带的 NAT 没有 ftp 应用自动感知功能,所以导致了本机无法获取到正确的服务器 IP 地址,而 VMware 的 NAT 带有自动翻译 IP 地址功能,将服务器返回的错误的 IP 自动替换成了正确的服务器 IP,导致了这个诡异问题的出现。

解决方案

问题的原因搞清楚了,这里贴一下解决方案吧。

既然服务器返回的是错误的 IP,那么我们将它替换掉就好了,ftplib 的源码也不多,很简单就可以读懂,我们创建一个类,然后重写原来解析 PASV 命令的函数,让它返回服务器的真实 IP 就好了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from ftplib import FTP, parse227, parse229
import socket
from typing import Tuple


class NewFTP(FTP):
def makepasv(self) -> Tuple[str, int]:
if self.af == socket.AF_INET:
_, port = parse227(self.sendcmd('PASV'))
return self.sock.getpeername()[0], port
else:
host, port = parse229(self.sendcmd('EPSV'), self.sock.getpeername())
return host, port


ftps = NewFTP('xxxxx')
ftps.set_debuglevel(2)

ftps.login('xxxxx', 'xxxxx')
ftps.makepasv()
ftps.set_pasv(True)
ftps.cwd("/htdocs")
ftps.dir()

使用上面的代码就可以在任何环境下正常的连接 ftp 服务器了,因为我们直接忽略了服务器返回的 IP,使用服务器的真实外网 IP 替换了~

总结

看来以后遇到问题还是要多多动手验证,才能更快的发现问题在哪,还有尽量不留疑点,不能盲目相信其他软件🤣,因为你无法保证它会在后台做什么奇奇怪怪的事~。

抓包分析是解决问题的最快的解决方案,单向抓包不可以的话就双向抓包,如果再不行那肯定是方向错了~

本文章首发于个人博客 LLLibra146’s blog
本文作者:LLLibra146
版权声明:本博客所有文章除特别声明外,均采用 © BY-NC-ND 许可协议。非商用转载请注明出处!严禁商业转载!
本文链接https://blog.d77.xyz/archives/586b93bb.html