本节我们看看TFTP数据包的组装方式,为我们代码实现该协议奠定基础。TFTP协议总共有5中不同数据包,分别对应读请求,写请求,数据块,接收回应(ACK),以及错误。前两种数据包格式一样,只不过某些值域设置有差别,剩下的三种数据包格式各不相同。但无论哪一种数据包,他们都包含一个值域叫操作码,用来定义该数据包属于那种类型。
我们先看读请求和写请求数据包的格式,首先是2字节表示操作码,它用来表示当前数据包的类型,取值1表示该数据包是个读请求,2表示该数据包是;接下来是可变长字段,它用来表示要读取或上传的文件名,它使用ASCII码并以0表示结尾;第三个字段叫Mode,也是可变长字段,用来表示传输文件的数据类型,如果传输的是字符串文件,那么它填写字符串"netascii",如果传输的是二进制文件,那么它填写字符串"octet",这些字符串都以0结尾,其结构用下图表示:
我们看看对应的wireshak抓包:
接着我们看看传输数据块的数据包,它头2字节也是操作码,取值3用于表示数据包用于数据块传输,接下来是2字节,用于表示数据块编号,最后是可变长字段Data,用于装载数据块,该数据包的格式如下:
我们看看对应的wireshark抓包:
然后是应答数据包,它开始2字节也是操作码,取值4,接下来2自己拥有表示接收到的数据块编号,相应结构如下图:
最后一个是错误数据报,它首2字节表示操作码,取值5;接下来2字节表示错误码,0表示未知错误,1表示文件不存在,2表示权限不足,3表示磁盘已满,具体的错误码我们在实践时再具体分析;接下来是可变长字段,它用字符串的形式描述具体错误,该数据包的结构如下图:
它对应的wireshark抓包如下:
接下来我们看看如何代码实现TFTP协议:
public class TFTPClient extends Application{
private byte[] sever_ip = null;
private static short OPTION_CODE_READ = 1; //读请求操作码
private static short OPTION_CODE_WRITE = 2; //写请求操作码
private static short OPTION_CODE_ACK = 4; //应答
private static final short OPTION_CODE_DATA = 3; //数据块
private static final short OPTION_CODE_ERR = 5; //错误消息
private static short TFTP_ERROR_FILE_NOT_FOUND = 1;
private static short OPTION_CODE_LENGTH = 2; //操作码字段占据2字节
private short data_block = 1;
private static char TFTP_SERVER_PORT = 69;
private char server_port = 0;
private File download_file;
private String file_name;
FileOutputStream file_stream;
public TFTPClient(byte[] server_ip) {
this.sever_ip = server_ip;
//指定一个固定端口
this.port = (short)56276;
server_port = TFTP_SERVER_PORT;
}
public void getFile(String file_name) {
download_file = new File(file_name);
this.file_name = file_name;
try {
file_stream = new FileOutputStream(download_file);
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
sendReadPacket();
}
....
}
首先我们定义了协议所需要的各项特定数值,创建的TFTPClient类将承当客户端的角色,它将连接TFTP服务器,从对方那里下载或上传文件。getFile是负责下载文件的接口,它获取要下载的文件名,现在本地创建一个空文件,然后向服务器请求下载对应文件的数据块,拿到数据块后再写入空文件。
在getFile函数中,它调用了sendReadPacket函数,该函数的作用是构造一个读请求数据包发送给服务器:
private void sendReadPacket() {
//向服务器发送读请求包
String mode = "netascii";
//+1表示要用0表示结尾
byte[] read_request = new byte[OPTION_CODE_LENGTH + this.file_name.length() + 1 + mode.length() + 1];
ByteBuffer buffer = ByteBuffer.wrap(read_request);
buffer.putShort(OPTION_CODE_READ);
buffer.put(this.file_name.getBytes());
buffer.put((byte)0);
buffer.put(mode.getBytes());
buffer.put((byte)0);
byte[] udpHeader = createUDPHeader(read_request);
byte[] ipHeader = createIP4Header(udpHeader.length);
byte[] readRequestPacket = new byte[udpHeader.length + ipHeader.length];
buffer = ByteBuffer.wrap(readRequestPacket);
buffer.put(ipHeader);
buffer.put(udpHeader);
//将消息发送给路由器
try {
ProtocolManager.getInstance().sendData(readRequestPacket, sever_ip);
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
该函数根据前面描述的数据包结构填写相应字段,同时将要下载的文件名放在数据包内发送给服务器,如果文件存在,服务器就会将数据块发送回来。一旦服务器返回数据后,我们就得分析返回的数据包,根据返回内容采取行动:
public void handleData(HashMap<String, Object> headerInfo) {
byte[] data = (byte[])headerInfo.get("data");
if (data == null) {
System.out.println("empty data");
return;
}
short port = (short)headerInfo.get("src_port");
server_port = (char)port;
ByteBuffer buff = ByteBuffer.wrap(data);
short opCode = buff.getShort();
switch (opCode) {
case OPTION_CODE_ERR:
//处理错误数据包
handleErrorPacket(buff);
break;
case OPTION_CODE_DATA:
handleDataPacket(buff);
break;
}
}
一旦有数据从服务器返回后,上面函数就会被调用。它首先分析返回的是数据块还是错误信息,如果是错误信息就会调用handleErrorPacket函数进行处理,如果是数据块,它会调用handleDataPacket进行处理。这里需要注意的一点是,我们从服务器返回的数据包中重新获取服务器端口。前面我们说过TFTP服务器使用两个端口,一个固定端口69用来等待连接,然后启动另外端口进行数据收发,因此我们与服务器完成连接之后,就必须通过服务器返回的数据包获得它用于数据交换的端口,我们先看看错误的处理流程:
private void handleErrorPacket(ByteBuffer buff) {
//获取具体错误码
short err_info = buff.getShort();
if (err_info == TFTP_ERROR_FILE_NOT_FOUND) {
System.out.println("TFTP server return file not found packet");
}
byte[] data = buff.array();
int pos = buff.position();
int left_len = data.length - pos;
byte[] err_msg = new byte[left_len];
buff.get(err_msg);
String err_str = new String(err_msg);
System.out.println("error message from server : " + err_str);
}
在上面函数中,我们根据前面讲解的错误数据报解读错误数据。错误数据报只包含两个字段,第一个操作码含有值5,用于表示数据包包含错误数据;第二个字段包含一个字符串,用于描述错误的内容。接下来我们看看如何处理数据块:
private void handleDataPacket(ByteBuffer buff) {
//获取数据块编号
data_block = buff.getShort();
System.out.println("receive data block " + data_block);
byte[] data = buff.array();
int content_len = data.length - buff.position();
//将数据块写入文件
byte[] file_content = new byte[content_len];
buff.get(file_content);
try {
file_stream.write(file_content);
System.out.println("write data block " + data_block + " to file");
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
if (content_len == 512) {
sendACKPacket();
data_block++;
}
if (content_len < 512) {
sendACKPacket();
try {
file_stream.close();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
data_block = 1;
}
}
handleDataPacket负责接收数据块并写入文件。同时要记录下当前数据块编号,并调用sendACKPacket向服务器返回确认数据包。如果当前数据块有512字节长,那么数据块编号就要增加已对应后面的数据块,如果数据块不到512字节长度,那么意味着最后一个数据块到达,写入后文件就下载完毕。
在这里还有一个技术难点要解决,那就是我的TFTP服务器运行在虚拟机里,而我的客户端程序运行在本地系统MacOs,也就是说服务器所在的硬件与我的客户端程序锁运行的硬件相同。这造成一个问题是,服务器发出来的数据包并没有传递到物理网卡上,而是通过进程通讯的方式之间传递给Mac,由于我开发的客户端无论是接收还是发生数据包都必须通过物理网卡,虚拟机发出的数据包不经过物理网卡而是直接交给Mac系统意味着我客户端收不到服务器数据包,因此我要做一些小手段促使服务器将数据包发送到物理网卡上,相关代码如下:
protected byte[] createIP4Header(int dataLength) {
IProtocol ip4Proto = ProtocolManager.getInstance().getProtocol("ip");
if (ip4Proto == null || dataLength <= 0) {
return null;
}
//创建IP包头默认情况下只需要发送数据长度,下层协议号,接收方ip地址
HashMap<String, Object> headerInfo = new HashMap<String, Object>();
headerInfo.put("data_length", dataLength);
ByteBuffer destIP = ByteBuffer.wrap(sever_ip);
headerInfo.put("destination_ip", destIP.getInt());
//假装数据包是192.168.2.128发送的,当前主机ip是192.168.2.243,如果不伪造ip,虚拟机发出的数据包就不会走网卡于是我们就抓不到数据包
try {
InetAddress fake_ip = InetAddress.getByName("192.168.2.127");
ByteBuffer buf = ByteBuffer.wrap(fake_ip.getAddress());
headerInfo.put("source_ip", buf.getInt());
} catch (UnknownHostException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
byte protocol = UDPProtocolLayer.PROTOCOL_UDP;
headerInfo.put("protocol", protocol);
byte[] ipHeader = ip4Proto.createHeader(headerInfo);
return ipHeader;
}
在构造IP包头时,我使用了一个虚假的IP地址,我本机地址时2.243,服务器运行的虚拟机地址时2.140,由于虚拟机与我的客户端程序同在一个机器里,因此虚拟机像IP:2.243发送的数据包都不走物理网卡,而是通过进程通讯的方式直接发给系统,这样我们原来设计的框架就不能处理,所以在给服务器发包时,我使用另外一个IP:2.127,它是我手机IP,这样就能让服务器以为数据包发送给其他设备,因此数据包就会发给物理网卡,于是我们的程序框架就可以截获数据包,这样才能让通讯正常进行,有关数据包欺骗的更多内容请参考视频。
更多技术信息,包括操作系统,编译器,面试算法,机器学习,人工智能,请关照我的公众号: