论文研读《Detecting and deceiving network scans》

论文原文:https://inai.de/documents/Chaostables.pdf

iptables无法实现复杂检测规则,iptables编写的防火墙有较大概率假阳或假阴,不建议在生产环境中使用

隐蔽扫描

标准的TCP连接以SYN包开始,其他一切都会被视为异常,隐蔽扫描就是通过构造异常的数据包来进行扫描

隐蔽扫描非常容易被阻止,只需要简单的丢弃所有异常数据包就可以(一点也不隐蔽,为什么要起这个名字,雾)

隐蔽扫描同时也非常不可靠,可能在路由上就被丢弃导致无法到达目标,目标也可能全部不响应或全部RST导致误判

NULL扫描

NULL扫描的原理是发送一个没有任何标志位的TCP包(RFC-793标准规定:开放端口应忽略没有标志位的数据包)

NULL扫描无法判断出端口是开放还是被过滤(因为都没有响应)

端口开放,目标主机不会回复
端口关闭,目标回复RST-ACK

如果所有标志位都为0,数据包是无效的,路由器、防火墙、操作系统可能会直接丢弃这种数据包,导致NULL扫描无法正常工作

在我测试过程中,NULL包确实不能到达阿里云上的服务器,只能对内网设备进行扫描

以下是一个NULL扫描器示例代码,扫描内网的设备

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from scapy.all import *

SYN = 0x02
RST = 0x04
ACK = 0x10
SYN_ACK = 0x12
RST_ACK = 0x14

def null_scan(ip:str, port:int, timeout:int = 5) -> bool:
ip_pkt = IP(dst = ip)
tcp_pkt = TCP(sport = RandShort()._fix(), dport = port, flags = 0)
response = sr1(ip_pkt / tcp_pkt, timeout = timeout, verbose = 0)
if response is None:
return True
elif response.haslayer(TCP):
if response.getlayer(TCP).flags == RST_ACK:
return False
else:
raise RuntimeError(f"未知flags:{flags}")

for i in [21,22,80,443,8889,39001]:
try:
if null_scan("192.168.0.1", i):
print(f"{i} 开放/过滤")
else:
print(f"{i} 关闭")
except RuntimeError as e:
print(e)

iptables阻止NULL扫描:

既然NULL数据包是无效的,那直接全部丢弃就行,也不用怕丢错导致网络出现问题

1
-A INPUT -p tcp --tcp-flags ALL NONE -j DROP

FIN扫描、XMAS扫描和ACK扫描

FIN扫描原理和NULL扫描原理类似,但是只设置FIN标志位

XMAS扫描同理,设置FIN、URG和PSH标志位

ACK扫描同理,只设置ACK标志位

此处不再提供示例代码

iptables阻止隐蔽扫描

1
2
3
4
5
6
7
8
9
10
11
12
# tcp_inval链
-N tcp_inval;
## 允许正常的RST包
-A tcp_inval -p tcp --tcp-flags SYN,FIN,RST,ACK RST,ACK -j RETURN;
## 记录异常包
-A tcp_inval -j LOG --log-prefix "[STEALTH] ";
## 丢弃异常包
-A tcp_inval -j DROP;

# 匹配大多数隐蔽扫描数据包(NULL,FIN,Xmas)
# 但是不检测ACK扫描,因为这可能导致误判
-A INPUT -p tcp ! --syn -m conntrack --ctstate INVALID -j tcp_inval;

半开扫描

半开扫描,基本思想是不完成完整的三次握手,以规避防火墙的检测

隐蔽扫描构造异常数据包,半开扫描构造的是合法数据包

SYN扫描

SYN扫描利用TCP三次握手机制,TCP三次握手此处不再赘述

RFC-793标准规定:当尝试连接到一个关闭的端口时,目标主机应返回一个RST响应,表示该端口不可达

扫描器向目标端口发送SYN
端口开放,目标回复SYN-ACK
端口关闭,目标回复RST-ACK

以下是一个SYN扫描器示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from scapy.all import *

SYN = 0x02
RST = 0x04
ACK = 0x10
SYN_ACK = 0x12
RST_ACK = 0x14

def syn_scan(ip:str, port:int, rst:bool = True, timeout:int = 5) -> bool:
ip_pkt = IP(dst = ip)
tcp_pkt = TCP(sport = RandShort()._fix(), dport = port, flags = "S")
response = sr1(ip_pkt / tcp_pkt, timeout = timeout, verbose = 0)
if response and response.haslayer(TCP):
flags = response.getlayer(TCP).flags
if flags == SYN_ACK:
if rst:
send(ip_pkt / TCP(sport = tcp_pkt.sport, dport = port, flags = "RA", seq = response.ack, ack = response.seq + 1), verbose = 0)
return True
elif flags == RST_ACK:
return False
else:
raise RuntimeError(f"{port} 未知flags:{flags}")
else:
raise RuntimeError(f"{port} 服务器无响应")

for i in [21,22,80,443,8889,39001]:
try:
if syn_scan("目标IP", i):
print(f"{i} 开放")
else:
print(f"{i} 关闭")
except RuntimeError as e:
print(e)

完全阻止SYN扫描是不可能的,因为无法判断客户端发送的SYN是真实连接还是扫描尝试
唯一能做的,就是标记可能的SYN扫描,阻止进一步的扫描

注意到,SYN扫描特征:

多次尝试连接不同的端口,且不完成完整的三次握手

扫描器发送RST终止三次握手

扫描器不发送RST,此时服务器多次重发SYN+ACK直到超时才终止三次握手

iptables阻止SYN扫描:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 定义四个状态
SYN=401
CLOSED=402
SYNSCAN=403
ESTAB=404

# 标记关闭的连接
-N mark_closed
-A mark_closed -j CONNMARK --set-mark $CLOSED

# 标记已建立的连接
-N mark_estab
-A mark_estab -j CONNMARK --set-mark $ESTAB

# tcp_new1链用于处理新TCP连接
-N tcp_new1
## 处理回环接口特殊情况
### 数据包来自回环接口且是SYN-ACK包,直接返回不做处理
-A tcp_new1 -i lo -p tcp --tcp-flags ALL SYN,ACK -j RETURN
### 数据包来自回环接口且是RST-ACK包,跳转到mark_closed链,将连接标记为CLOSED状态
-A tcp_new1 -i lo -p tcp --tcp-flags ALL RST,ACK -g mark_closed
## 处理正常TCP连接
### 数据包是ACK包,跳转到mark_estab链,将连接标记为ESTAB状态
-A tcp_new1 -p tcp --tcp-flags ALL ACK -g mark_estab
### 数据包不符合上述条件,将其标记为SYNSCAN状态
-A tcp_new1 -j CONNMARK --set-mark $SYNSCAN

# 匹配标记为SYN的连接,并将其交给tcp_new1链处理
-A INPUT -m connmark --mark $SYN -j tcp_new1

# 如果收到一个新的SYN包(即新连接),将其标记为SYN状态
-A INPUT -p tcp --syn -m conntrack --ctstate NEW -j CONNMARK --set-mark $SYN

# 处理SYN扫描的逻辑
## 方式1:在tcp_new1链中直接跳转到handle_evil链
-A tcp_new1 -j handle_evil
## 方式2:标记为SYNSCAN的连接丢到handle_evil链中
-A INPUT -m connmark --mark $SYNSCAN -j handle_evil

# handle_evil链逻辑,比如禁止IP访问等
# ...

全开扫描

全开扫描经过三次握手建立完整的TCP连接,

半开扫描使用raw socket通常需要root权限,全开扫描主要用于无权限情况

Connect扫描

以下是一个Connect扫描器示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import socket

def connect_scan(ip:str, port:int, timeout:int = 5) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(timeout)
try:
sock.connect((ip, port))
except (socket.timeout, socket.error):
return False
return True

for i in [21,22,80,443,8889,39001]:
try:
if connect_scan("121.40.34.51", i):
print(f"{i} 开放")
else:
print(f"{i} 关闭")
except RuntimeError as e:
print(e)

注意到,连接扫描特征:

多次尝试连接不同的端口,连接建立后不发送任何数据就终止(RST或FIN)

以下规则检测刚建立连接就立即断开的异常流量

1
2
3
4
5
6
7
8
9
10
11
12
CNSCAN=406;
VALID=408;

-N mark_cnscan;
-A mark_cnscan -j CONNMARK --set-mark $CNSCAN;

-N tcp_new3;
-A tcp_new3 -p tcp --tcp-flags SYN,FIN,RST RST -g mark_cnscan;
-A tcp_new3 -p tcp --tcp-flags SYN,FIN,RST FIN -g mark_cnscan;
-A tcp_new3 -j CONNMARK --set-mark $VALID;

-A INPUT -m connmark --mark $ESTAB -j tcp_new3;

Grab扫描

Grab扫描不仅检测端口是否开放,还会获取服务器返回的Banner信息以判断出服务器上运行的服务及其版本

连接建立后服务器返回Banner信息,客户端响应Banner的ACK后就断开连接

以下规则检测建立连接后发送一个ACK包就断开的异常流量

1
2
3
4
5
6
-N tcp_new4;
-A tcp_new4 -p tcp --tcp-flags SYN,FIN,RST,ACK ACK
-m length --length 52 -j RETURN;
-A tcp_new4 -p tcp --tcp-flags SYN,FIN,RST RST -g mark_grscan;
-A tcp_new4 -p tcp --tcp-flags SYN,FIN,RST FIN -g mark_grscan;
-A tcp_new4 -j CONNMARK --set-mark $VALID;

真正的”暴力检测”

依我看,要检测端口扫描哪有那么复杂,我连完整的连接都不跟踪了,那样只会复杂化算法,直接暴力判断某个IP是否多次发送不同目标端口的数据包就行

简单有效易维护,符合KISS原则(喜

正常一个网站阈值为4非常合理,普通用户最多访问80和443两个端口(笑

nmap扫一下,后台哐哐报

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from scapy.all import *
import ipaddress

PORT_SCAN_THRESHOLD = 4
record = {}
def packet_callback(packet):
if packet.haslayer(IP) and packet.haslayer(TCP):
src_ip = packet[IP].src
dst_port = packet[TCP].dport

if ipaddress.IPv4Address(src_ip).is_global:
if src_ip not in record:
record[src_ip] = []

if dst_port not in record[src_ip]:
record[src_ip].append(dst_port)

if len(record[src_ip]) >= PORT_SCAN_THRESHOLD:
print(f"{src_ip} 尝试端口扫描(IP可伪造仅供参考)")
del record[src_ip]

sniff(iface="eth0", prn=packet_callback, filter="tcp")

论文研读《Detecting and deceiving network scans》
https://crackme.net/articles/antiscan/
作者
Brassinolide
发布于
2025年2月11日
许可协议