关于 TCP 建立连接和断开连接的流程,很多人都能大致说出来,可以参考协议森林
正常的数据传输是在三次握手结束之后进行的,但是如果打破了这个流程,数据传输仍然可能成功,而部分防火墙 IDS 就可能被绕过,下面的两个例子来自 https://github.com/kirillwow/ids_bypass。
CVE-2018-6794
# 客户端开始三次握手 发送 SYN
Client -> [SYN] [Seq=0 Ack=0] -> Evil Server
# 服务器端正常的响应了 SYN-ACK
Client <- [SYN, ACK] [Seq=0 Ack=1] <- Evil Server
# 但是服务器端在握手结束之前就发送了 PSH,里面包含了一些数据
Client <- [PSH, ACK] [Seq=1 Ack=1] <- Evil Server
# 服务器端主动关闭了连接
Client <- [FIN, ACK] [Seq=83 Ack=1] <- Evil Server
# 三次握手完成
Client -> [ACK] [Seq=1 Ack=84] -> Evil Server
# 客户端正常的发送数据
Client -> [PSH, ACK] [Seq=1 Ack= 4] -> Evil Server
Suricata IDS 在 4.0.4 版本之前存在这个问题
RST 导致的绕过
有些 Windows 客户端在收到 RST 包之后,如果紧接着又收到了其他的 TCP 数据,那仍然是可以读取和处理的,有些 IDS 正确处理了这个问题,有的在收到 RST 包之后就停止了检查 TCP 包。
# Client starts a TCP 3-way handshake
Client -> [SYN] [Seq=0 Ack=0] -> Evil Server
# Server responses with TCP RST
Client <- [RST, ACK] [Seq=0x0 Ack=1] <- Evil Server
# And SYN-ACK shortly after RST
Client <- [SYN, ACK] [Seq=1 Ack=1] <- Evil Server
... 三次握手继续 ...
Suricata IDS(全版本?)存在这个问题。对于 UDP 数据包,也有一个类似的问题。
应用
某些云服务器厂商会实时的去过滤每台机器的 HTTP 请求的域名,也就是 Host 字段,一旦发现是没有[[(备)]]案的,就会返回一个拦截页面,怎么绕过这个呢。经过测试发现某云应该是不检测 HTTPS的,如果可以让 80 端口重定向到 443,然后设置 HSTS 头,这样基本长时间内浏览器就不会再访问 80 端口了,虽然 SSL SNI 和 证书中也是含有域名信息的。
访问 80 端口,发现三次握手是正常进行的,而拦截发生在客户端发送了 HTTP 请求包之后,这也说明,防火墙不是无条件封禁的和屏蔽端口的,而是实时的过滤。如果可以抢在防火墙发包之前发送,那就可以实现重定向了。
写了一个 Python 的脚本来完成这个事情
# coding=utf-8
from scapy.all import IP, TCP, send, sniff
SERVER_DOMAIN = "example.me"
SERVER_PORT = 4445
FIN = 0x01
SYN = 0x02
ACK = 0x10
def build_synack(syn):
seq = 1
# 确认 SYN
ack = syn[TCP].seq + 1
ip = IP(src=syn[IP].dst, dst=syn[IP].src)
tcp = TCP(
sport=syn[IP].dport,
dport=syn[TCP].sport,
flags="SA",
seq=seq,
ack=ack,
options=[("MSS", 1460)]
)
return ip / tcp
def build_finack(syn):
"""
带重定向指令的包
"""
seq = 2
ack = syn[TCP].seq + 1
ip = IP(src=syn[IP].dst, dst=syn[IP].src)
tcp = TCP(
sport=syn[IP].dport,
dport=syn[TCP].sport,
flags="FA",
seq=seq,
ack=ack,
options=[("MSS", 1460)]
)
resp = b"HTTP/1.1 307 Internal Redirect\r\n" \
b"Content-Length: 0\r\n" \
b"Location: https://%s:443\r\n" \
b"Strict-Transport-Security: max-age=31536000\r\n" \
b"\r\n" % SERVER_DOMAIN
return ip / tcp / resp
def build_ack(p):
seq = 3
ack = p[TCP].seq + 1
ip = IP(src=p[IP].dst, dst=p[IP].src)
tcp = TCP(
sport=p[IP].dport,
dport=p[TCP].sport,
flags="A",
seq=seq,
ack=ack,
options=[("MSS", 1460)]
)
return ip / tcp
def handle_packet(p):
# 如果是 SYN 就回复 SYN-ACK 和 FIN-ACK
if p[TCP].flags & SYN and not p[TCP].flags & ACK:
send(build_synack(p))
print("SYN ACK sent")
send(build_finack(p))
send("FIN ACK sent")
elif p[TCP].flags & FIN and p[TCP].flags & ACK:
# 如果不 ACK,客户端可能一直重传
send(build_ack(p))
send("ACK sent")
if __name__ == "__main__":
# 对于 TCP 和 SERVER PORT 端口的包,回调 handle_packet 函数
sniff(filter="tcp and port %d" % SERVER_PORT, prn=handle_packet)
使用 scapy 框架,监听一个端口,在接收到 SYN 包之后,按照正常的握手流程返回 SYN-ACK,然后不等接收到 ACK 就继续发送 FIN-ACK,告诉客户端我要断开连接了,然后在这个包中包含有重定向的 HTTP 包。
在服务器端视角看是这样的
在客户端视角看是这样的
42 号包是代码的重定向,47 号包就是防火墙的重定向,可以看到 TTL 明显不一致,而且 seq 被我们代码扰乱,导致被认为 out-of-order 了。
因为 scapy 是用户态的,防止内核不知道整个连接流程而发送 rst 包,可以使用下面的命令屏蔽掉
iptables -A OUTPUT -p tcp --tcp-flags RST RST -s 172.21.0.3 -j DROP
也有人使用内核模块实现了这个功能
https://github.com/ptpt52/hstshack