浅谈TCP粘包
说明
本实验是在Ubuntu上测试,Windows上测试有时候不成功
这里说的“粘包”就是《TCP/IP 详解卷I》22.3 章节中的 "糊涂窗口综合症"
为什么粘包只在TCP中有?
粘包现象值存在于TCP协议,UDP协议不存在粘包的情况。
UDP是一个简单的面向数据报的运输层协议,进程的每个输出操作都正好产生一个UDP数据报,并组装成一份待发送数据报。也就是说一个UDP数据报对应一个IP数据报的。 对于数据报的解释是由UDP协议自身来解释的,也就是说UDP自己就知道灭个书报的边界在哪里。
TCP是面向流字符的协议,应用程序产生的全体数据与真正发送的单个IP数据报可能没有什么联系。两个应用程序通过TCP连接交换8bit字节构成的字节流。TCP不在字节流中插入记录标识 符。我们将这称为字节流服务(byte stream service)
在TCP中如果一方的应用程序先传10字节,又传20字节,再传50字节,连接的另一方将无法了解发方每次发送了多少字节。收方可以分4次接收这80个字节,每次接收20字节。一端将字节流放到TCP连接上,同样的字节流将出现在TCP连接的另一端。
另外,TCP对字节流的内容不作任何解释。 TCP不知道传输的数据字节流是二进制数据,还是ASCII字符、EBCDIC字符或者其他类型数据。对字节流的解释由TCP连接双方的应用层解释。也就是说对于TCP的数据流,边界如何定义是有应用程序来定,而不是TCP协议本身。这种对字节流的处理方式与Unix操作系统对文件的处理方式很相似。Unix的内核对一个应用读或写的内容不作任何解释,而是交给应用程序处理。对Unix的内核来说,它无法区分一个二进制文件与一个文本文件。
任何1个运输层首部只出现在第1片数据中。
另外需要解释几个已经提到到的和后面将要用到的术语:
- IP分片:物理网络层一般要限制每次发送数据帧的最大长度,任何时候IP层接收到一份要发送的IP数据报时,它要判断向本地哪个接口发送数据(选路),并查询该接口获得其MTU(链路层的最大传输单元)。IP把MTU与数据报长度进行比较,如果需要则进行分片。分片可以发生在原始发送端主机上,也可以发生在中间路由器上。把一份I P数据报分片以后,只有到达目的地才进行重新组装(这里的重新组装与其他网络协议不同,它们要求在下一站就进行进行重新组装,而不是在最终的目的地)。重新组装由目的端的IP层来完成,其目的是使分片和重新组装过程对运输层(TCP和UDP)是透明的,除了某些可能的越级操作外。已经分片过的数据报有可能会再次进行分片(可能不止一次)。IP首部中包含的数据为分片和重新组装提供了足够的信息。
- IP数据报:是指IP层端到端的传输单元(在分片之前和重新组装之后),可以理解是一个逻辑上的概念。
- 分组:是指在IP层和链路层之间传送的数据单元。一个分组可以是一个完整的IP数据报,也可以是IP数据报的一个分片。
- Nagle算法:如果一个客户端一般每次发送一个字节到服务器,这就产生了一些41字节长的分组:20字节的IP首部、20字节的TCP首部和1个字节的数据。在局域网上,这些小分组(被称为微小分组(tinygram))通常不会引起麻烦,因为局域网一般不会出现拥塞。但在广域网上,这些小分组则会增加拥塞出现的可能。一种简单和好的方法就是采用RFC 896 [Nagle 1984]中所建议的Nagle算法。该算法要求一个TCP连接上最多只能有一个未被确认的未完成的小分组,在该分组的确认到达之前不能发送其他的小分组。相反, TCP收集这些少量的分组,并在确认到来时以一个分组的方式发出去。该算法的优越之处在于它是自适应的:确认到达得越快,数据也就发送得越快。而在希望减少微小分组数目的低速广域网上,则会发送更少的分组。"小"的含义就是指字节数小于报文段的大小(也就是将要接收的MSS)。
- MSS:Maximum Segment Size(最大段(segment)的大小)
- segment:TCP传递IP的信息单位称为报文段或段(segment)。The application data is broken into what TCP considers the best sized chunks to send.This is totally different from UDP, where each write by the application generates a UDP datagram of that size.
数据在TCP层称为流(Stream),数据分组称为分段(Segment)。作为比较,数据在IP层称为Datagram,数据分组称为分片(Fragment)。 UDP 中分组称为Message。
由于发送端原因导致的粘包
由于发送端连续发送多个小的数据包(就是指字节数小于报文段-MSS的大小),由于Nagle优化算法导致的几个小包合并为一个包。
发送端代码 client.py
from socket import *
ip_port = ('127.0.0.1', 8080)
back_log = 5
buffer_size = 1024
tcp_client = socket(AF_INET, SOCK_STREAM)
tcp_client.connect(ip_port)
tcp_client.send('hello'.encode('utf-8'))
tcp_client.send('world'.encode('utf-8'))
tcp_client.send('wellcome'.encode('utf-8'))
tcp_client.send('to'.encode('utf-8'))
tcp_client.send('Ameriac'.encode('utf-8'))
tcp_client.close()
接收端代码 server.py
import socket
ip_port=('127.0.0.1',8080)
back_log=5
buffer_size=1024
tcp_server=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
tcp_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 解决多次连续执行端口被占用
tcp_server.bind(ip_port)
tcp_server.listen(back_log)
conn,addr=tcp_server.accept()
data = conn.recv(buffer_size)
print(data)
data = conn.recv(buffer_size)
print(data)
data = conn.recv(buffer_size)
print(data)
conn.close()
tcp_server.close()
---------print(data)结果,有时候是2条或3条,是一条或2条的时候就发生了粘包------------------
b'helloworldwellcometoAmeriac'
b''
b''
由于接收端原因导致的粘包
此中情框是由于接收端一次从自己的缓冲区中能够读取的字节数少于对端发送的一个数据包的字节数,导致本段在读取的时候数据不能一次读取完毕。
发送端代码 client.py
from socket import *
ip_port = ('127.0.0.1', 8080)
back_log = 5
buffer_size = 1024
tcp_client = socket(AF_INET, SOCK_STREAM)
tcp_client.connect(ip_port)
tcp_client.send('helloworldwellcometoAmeriac'.encode('utf-8'))
tcp_client.close()
接收端代码 server.py
import socket
ip_port=('127.0.0.1',8080)
back_log=5
# buffer_size=1024
tcp_server=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
tcp_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 解决多次连续执行端口被占用
tcp_server.bind(ip_port)
tcp_server.listen(back_log)
conn,addr=tcp_server.accept()
data = conn.recv(5)
print(data)
data = conn.recv(5)
print(data)
data = conn.recv(5)
print(data)
conn.close()
tcp_server.close()
---------print(data)结果------------------
b'hello'
b'world'
b'wellc'
粘包的拆包方式
下面通过一个具体的示例介绍几种粘包的解决方式。前面我们看到的两种粘包的产生都可以通过下面的方式解决。
方法1:提前告知数据的大小
接收端代码 client.py
import socket
ip_port=('127.0.0.1',8081)
back_log=5
buffer_size=1024
tcp_client=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
tcp_client.connect(ip_port)
while True:
cmd = input('>>>:').strip()
if not cmd: continue
if cmd == 'q': break
cmd_bytes = cmd.encode(encoding='utf-8')
tcp_client.send(cmd_bytes)
data = tcp_client.recv(buffer_size)
if data == b'shell': continue
try:
length = int(data.decode(encoding='utf-8'))
tcp_client.send(b'ready')
ret = b''
l = 0
while l < length:
temp = tcp_client.recv(buffer_size)
ret = ret + temp
l += len(temp)
print(ret.decode('utf-8'))
except Exception as e:
print('Error:',e)
tcp_client.close()
发送端代码 server.py
import socket
import subprocess
SHELLS = ('bash','sh','csh','tcsh','/bin/sh','/bin/bash','/usr/bin/sh','/usr/bin/bash','/bin/tcsh','/bin/csh')
ip_port = ('127.0.0.1', 8081)
back_log = 5
buffer_size = 1024
tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcp_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
tcp_server.bind(ip_port)
tcp_server.listen(back_log)
while True:
client_conn, addr = tcp_server.accept()
while True:
try:
cmd_bytes = client_conn.recv(buffer_size)
# print('cmd_bytes',cmd_bytes)
if not cmd_bytes: break # 此处breaek,当client键入 'q'的时候防止server死循环。
cmd_str = cmd_bytes.decode(encoding='utf-8')
if cmd_str in SHELLS:
client_conn.send(b'shell')
continue
ret = subprocess.Popen(cmd_str, shell=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
stdin=subprocess.PIPE)
data_error = ret.stderr.read()
if data_error:
data_send = data_error
else:
data_send = ret.stdout.read()
# print('data_send',data_send)
if not data_send:
data_send = b'successful!'
data_len = len(data_send)
client_conn.send(str(data_len).encode('utf-8'))
ready = client_conn.recv(buffer_size)
if ready == b'ready':
client_conn.send(data_send)
except Exception as e:
print(e)
client_conn.close()
break
client_conn.close()
tcp_server.close()
方法2:将数据长度合并到报文中
采用固定长度的小包放置数据长度,利用Nagle算法将此小包合并后面的数据包中。
接收端代码 client.py
import socket
import struct
ip_port=('127.0.0.1',8081)
back_log=5
buffer_size=1024
tcp_client=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
tcp_client.connect(ip_port)
while True:
cmd = input('>>>:').strip()
if not cmd: continue
if cmd == 'q': break
cmd_bytes = cmd.encode(encoding='utf-8')
tcp_client.send(cmd_bytes)
try:
shell_flag = b'shell'
length = struct.unpack('i',tcp_client.recv(4))[0] # 前 4 字节是固定放置数据长度的
ret = b''
l = 0
while l < length:
temp = tcp_client.recv(buffer_size)
ret = ret + temp
l += len(temp)
if ret == shell_flag: continue
# print(ret.decode('utf-8'))
except Exception as e:
print('Error:',e)
tcp_client.close()
发送端代码 server.py
import socket
import subprocess
import struct
SHELLS = ('bash','sh','csh','tcsh','/bin/sh','/bin/bash','/usr/bin/sh','/usr/bin/bash','/bin/tcsh','/bin/csh')
ip_port = ('127.0.0.1', 8081)
back_log = 5
buffer_size = 1024
tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcp_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
tcp_server.bind(ip_port)
tcp_server.listen(back_log)
while True:
client_conn, addr = tcp_server.accept()
shell_flag = b'shell'
while True:
try:
cmd_bytes = client_conn.recv(buffer_size)
print('cmd_bytes',cmd_bytes)
if not cmd_bytes: break # 此处breaek,当client键入 ‘q’的时候防止server死循环。
cmd_str = cmd_bytes.decode(encoding='utf-8')
print('cmd_str',cmd_str)
if cmd_str in SHELLS:
data_send = shell_flag
else:
ret = subprocess.Popen(cmd_str, shell=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
stdin=subprocess.PIPE)
print(ret)
data_error = ret.stderr.read()
if data_error:
data_send = data_error
else:
data_send = ret.stdout.read()
print('data_send',data_send)
if not data_send:
data_send = b'successful!'
client_conn.send(struct.pack('i', len(data_send)))
client_conn.send(data_send)
except Exception as e:
print(e)
client_conn.close()
break
client_conn.close()
tcp_server.close()
方法3:由程序标识数据流的结束
由程序在发送方数据流的结尾做标记,接收方在接收到标记后,就可以从该标记处截断数据流。
接收端代码 client.py
import socket
from functools import partial
ip_port=('127.0.0.1',8081)
back_log=5
buffer_size=1024
tcp_client=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
tcp_client.connect(ip_port)
while True:
cmd = input('>>>:').strip()
if not cmd: continue
if cmd == 'q': break
cmd_bytes = cmd.encode(encoding='utf-8')
tcp_client.send(cmd_bytes)
try:
shell_flag = b'shell'
ret = b''
data_g = iter(partial(tcp_client.recv, 2), b'\r\n')
for i in data_g:
ret += i
# 拼接后的结果中最后两个字节是b'\r\n',就一定要跳出,
# 否则还会去 data_g 中 tcp_client.recv(2),但是此时已经没有数据,导致阻塞
if ret[-2:] == b'\r\n':break
if ret == shell_flag: continue
print(ret.decode('utf-8'))
except Exception as e:
print('Error:',e)
tcp_client.close()
发送端代码 server.py
import socket
import subprocess
SHELLS = ('bash','sh','csh','tcsh','/bin/sh','/bin/bash','/usr/bin/sh','/usr/bin/bash','/bin/tcsh','/bin/csh')
ip_port = ('127.0.0.1', 8081)
back_log = 5
buffer_size = 1024
tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcp_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
tcp_server.bind(ip_port)
tcp_server.listen(back_log)
while True:
client_conn, addr = tcp_server.accept()
shell_flag = b'shell'
while True:
try:
cmd_bytes = client_conn.recv(buffer_size)
print('cmd_bytes',cmd_bytes)
if not cmd_bytes: break # 此处breaek,当client键入 ‘q’的时候防止server死循环。
cmd_str = cmd_bytes.decode(encoding='utf-8')
print('cmd_str',cmd_str)
if cmd_str in SHELLS:
data_send = shell_flag
else:
ret = subprocess.Popen(cmd_str, shell=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
print(ret)
data_error = ret.stderr.read()
if data_error:
data_send = data_error
else:
data_send = ret.stdout.read()
print('data_send',data_send)
if not data_send:
data_send = b'successful!'
# 由程序在结尾做标记表示数据流的结束
client_conn.send(data_send + b'\r\n')
except Exception as e:
print(e)
client_conn.close()
break
client_conn.close()
tcp_server.close()