目录
前言
正文
TCP扫描
1.1 全连接Connect扫描
1.2 半连接SYN扫描
1.3 FIN扫描
1.4 NULL扫描
1.5 圣诞树Xmas-Tree扫描
1.6 TCP ACK扫描
1.7 TCP窗口扫描
1.8 Dump僵尸扫描
UDP扫描
结语
上篇文章我们讲解了如何编写脚本进行子域名挖掘,并返回子域名对应的IP列表。找到了IP,也就找到了渗透测试的目标主机,那接下来,就要对这台主机所开放的可访问服务进行进一步探测了,这就用到了端口扫描技术。在本文中,我将尝试着自己动手写一个端口扫描器,并手动实现常见的端口扫描方式。
阅读本篇文章,你需要:
- 一定的计算机网络基础
- 了解传输层的TCP三次握手和四次挥手方式
- 多线程编程进程互斥机制
- 了解端口扫描基本概念
- 了解并会使用pip引入第三方库
端口扫描,即对目标主机TCP或UDP的一段端口或指定的端口进行探测。由于大部分主机配置的服务都运行在默认端口上,通过开放端口可以推测一台计算机上都提供了哪些服务,这就为我们通过这些服务的己知漏洞留下了攻击的路径。
按照扫描的协议不同,分为TCP与UDP扫描。其中由于UDP协议较为简单,所能使用的扫描方式比较单一。大部分网络应用均基于TCP连接,因而TCP扫描又衍生出的许多分支,主要分为准确但易被发现的全连接扫描、SYN扫描,以及较为隐蔽但精度差的TCP FIN扫描、NULL扫描、XMAS扫描、TCP ACK扫描、TCP窗口扫描、Dump扫描等。
首先,我们先来看张图:
连接建立的过程中,主要存在两种情况,目标端口开放时:
1. 请求方发送SYN;
2. 响应方返回SYN/ACK,表明端口开放;
3. 请求方返回ACK,表明连接已建立;
而目标端口关闭时:
1. 请求方发送SYN;
2. 响应方返回RST/ACK,表明端口未开放。
全连接扫描正是通过三次握手,建立一个完整的连接,通过判断这两种情况得知当前端口是否开放。使用Python内置的socket包实现TCP连接,这里我们只需要通过返回代码判断连接建立成功与否,connect_ex()函数就可满足这个需求,成功返回0,失败则返回相应错误代码。有了这个思路,我们就可以完成一个端口发现函数:
import socket
# 端口发现-全连接
def PortIsUp_Connect(ip,port,timeout = 3):
result = [500,port]
#链接初始化
try:
connect = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
# 设置连接等待时延,默认为3秒,超时认为端口不开放
connect.settimeout(timeout)
rescode = connect.connect_ex((ip,port))
# 关闭连接
if rescode == 0:
result = [200,port]
connect.shutdown(2)
except Exception as e:
print(e)
return result
上篇文章已经提到过,单线程扫描就像是机械雷达,速度慢,一旦被对方IDS发现封禁IP,后续所有连接都将失效。这次,我们直接封装多线程:
import threading
# 线程锁:
threadLock = threading.Lock()
resultList = []
# 端口扫描线程类
class PortScan(threading.Thread):
def __init__(self,ip,port,mode,timeout = 3):
threading.Thread.__init__(self)
self.port = port
self.ip = ip
self.timeout = timeout
self.mode = mode
def run(self):
if self.mode == 1:
result = PortIsUp_Connect(self.ip,self.port,self.timeout)
if result[0] == 200:
# 临界区:
threadLock.acquire()
resultList.append(result[1])
threadLock.release()
这里仍然需要注意,返回值是多个线程互斥访问的临界区,需要使用线程互斥协调机制去保护,如果主机性能有限,可以使用信号量或管程限制同时可运行的线程数,如果对运行顺序有要求,可以结合内置的queue设置进程队列。
接下来封装扫描函数:
# 扫描主函数
def Scan(ip,start_port,end_port,mode = 1,timeout = 3):
portlist = range(start_port,end_port + 1)
threads = []
results = []
try:
for port in portlist:
thread = PortScan(ip,port,mode,timeout)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
except Exception as e:
print(e)
for result in resultList:
results.append(result)
return results
接下来,我们来测试一下扫描效果,在shodan上寻找一个开放主机。
这里有一个小Tips,贸然测试国内IP容易触发告警,而国外的语言壁垒常让我们望而却步,相比之下,东南某省的安全措施参差不齐,且没有一致的管理措施。某品牌方便面在此地量产之前,使用这个省的系统进行练习,还算比较安全。
找到这个省某系统:
没有IDS防护,我们遍历它的所有端口:
可看到,shodan探测出的开放端口,我们都探测了出来。
全连接实现简单,且不需要任何特殊权限即可运行,也便于我们进一步从目标端口返回banners信息,在未来的设备指纹识别中也会用到。但缺点也很明显,扫描会在目标主机的日志记录中留下痕迹,极易触发IDS告警,溯源起来也很容易。
相较于全连接扫描,半连接扫描省去了第三次握手,用RST包直接终止连接。
目标端口开放时:
- 请求方发送SYN
- 响应方发送SYN/ACK
- 请求方发送RST断开
目标端口关闭时:
- 请求方发送SYN
- 响应方发送RST
通过这种方式,无需进行三次握手,即可判断目标端口是否开放,但此时需要精细地操作握手流程,Python内置的函数就显得有些无力了,这里使用第三方包Scapy来实现。
实际上,Scapy提供了report_port()函数封装了SYN端口扫描功能,但我们的目的是自己实现一个SYN,因此我们选用sr1发送和接收数据包,手动实现一个SYN端口发现函数:
# 引入Scapy
from scapy.layers.inet import TCP, IP, ICMP, UDP
from scapy.sendrecv import sr1, send
# 端口发现-SYN扫描
def PortIsUp_SYN(ip, port, timesout = 3):
result = [500,port]
try:
# 发送一个SYN包
ans = sr1(IP(dst=ip)/
TCP(dport=port,flags="S"),
timeout=timesout,verbose=False)
# 回显判断
# 返回空包,说明被过滤
if ans == None:
result = [300,port]
# 返回ICMP包,仍然被过滤
elif ans.haslayer(ICMP):
result = [300,port]
# 未被过滤
elif ans.haslayer(TCP):
# 验证为ACK包,记录开放端口
if ans.getlayer(TCP).flags == 0x12:
# 回复一个RST包
send(IP(dst=ip)/
TCP(dport=port,flags='R'),
verbose=False)
result = [200,port]
# 验证为RST包,说明端口关闭
elif ans.getlayer(TCP).flags == 0x14:
result = [500,port]
except Exception as e:
print(e)
return result
这里有必要讲解一下sr1的各个参数(send函数同理):
- IP()构造下层的IP数据包,dst为目的主机ip地址。
- TCP()构造上层的数据包,dport指明目的地址、flags设置数据包标记。
- timeout为接收回复所能接受的最大时延
- verbose为是否将扫描过程输出到控制台
测试一下写好的扫描器:
得到了相同的扫描结果,但扫描用时高达881秒!在后台查看了一下日志,推测scapy对并发的线程数和时延有限制,这就是引用外部包的痛点:你永远不知道这个包在后台实现的过程中做了什么!
SYN扫描要比TCP Connect()扫描隐蔽一些,仅仅需要发送初始的SYN数据包给目标主机,扫描的过程就不会被记录到系统日志中,一般不会在目标主机上留下扫描痕迹。但代价是,使用这种扫描需要获得root权限,否则效率极低。
这类扫描使用了逆向思维,直接向响应方发送一个FIN报文,共存在两种情况:
目标端口开放时:发送FIN,没有响应。
目标端口关闭时:发送FIN,响应RST。
值得注意的是,由于无响应情况下存在着被过滤的可能。因此这种方式只能帮助我们推测端口的,无法准确判断一个端口的开放情况。下面我们来手动实现一个FIN扫描函数:
# 端口发现-FIN扫描
def PortIsUp_FIN(ip, port, timesout = 3):
result = [500,port]
try:
# 发送一个FIN包
ans = sr1(IP(dst=ip)/
TCP(dport=port,flags="F"),
timeout=timesout,verbose=False)
# 回显判断
# 无响应,说明端口可能开放或过滤
if ans == None:
result = [200,port]
# 返回ICMP包
elif ans.haslayer(ICMP):
# ICMP 目标不可达错误类型3,被过滤
if ans.getlayer(ICMP).type == 3:
result = [300,port]
# ICMP 代码为1,2,3,9,10或13,被过滤
elif ans.getlayer(ICMP).type in [1,2,3,9,10,13]:
result = [300, port]
# 返回TCP包
elif ans.haslayer(TCP):
# 验证为RST包,说明端口关闭
if ans.getlayer(TCP).flags == 0x14:
result = [500,port]
except Exception as e:
print(e)
return result
接下来对我们的测试IP进行扫描测试:
可看到,其返回的可能开放端口极多且繁杂,这是因为对方可能设置了FIN过滤,也可能对方使用了不严格遵循FRC 793要求的操作系统(比如Windows)。
接下来,我们挑选一个Linux系统的目标主机:
对这个主机进行FIN探测:
可以看到,虽然开放的端口均在推测开放列表中,但仍然返回了极多的扫描结果。
FIN扫描可帮助我们前期初步筛选目标主机的端口开放情况,也可以帮助我们粗略判断目标主机的操作系统,但不能给我们提供一个准确的端口开放结果。
NULL扫描与FIN类似,同样使用了逆向思维的思路,将一个没有设置任何标志位的数据包发送给TCP端口,根据FRC 793的要求,在端口关闭的情况下,若收到一个没有设置标志位的数据字段,那么主机应该舍弃这个分段,并发送一个RST数据包,否则不会响应发起扫描的客户端计算机。
这里需要注意的是,NULL扫描要求所有的主机都符合RFC 793规定。而Windows设备中,只要主机收到NULL数据包,不管端口是否处于开放状态,都响应一个RST数据包,因此不可使用NULL扫描。而基于Unix的操作系统遵从RFC 793标准,可以使用NULL扫描。
接下来我们来看看遵循RFC 793规范主机的两种情况:
目标端口开放时:发送NULL包,没有响应
目标端口关闭时:发送NULL包,响应RST。
NULL扫描函数的编写与FIN扫描基本相同,区别只在于发送的包不同:
# 端口发现-NULL扫描
def PortIsUp_NULL(ip, port, timesout = 3):
result = [500,port]
try:
# 发送一个NULL包
ans = sr1(IP(dst=ip)/
TCP(dport=port,flags=""),
timeout=timesout,verbose=False)
# 回显判断
# 无响应,说明端口可能开放或过滤
if ans == None:
result = [200,port]
# 返回ICMP包
elif ans.haslayer(ICMP):
# ICMP 目标不可达错误类型3,被过滤
if ans.getlayer(ICMP).type == 3:
result = [300,port]
# ICMP 代码为1,2,3,9,10或13,被过滤
elif ans.getlayer(ICMP).type in [1,2,3,9,10,13]:
result = [300, port]
# 返回TCP包
elif ans.haslayer(TCP):
# 验证为RST包,说明端口关闭
if ans.getlayer(TCP).flags == 0x14:
result = [500,port]
except Exception as e:
print(e)
return result
这里我们直接寻找一个使用Linux系统的主机IP进行测试:
可以看到,NULL扫描返回的结果与FIN类似,只能用于粗略估计开放列表,以及估计操作系统类型。
首先,我们要了解三种标志位以及其适用情况:
URG:指示数据时紧急数据,应立即处理。
PSH:强制将数据压入缓冲区。
FIN:在结束TCP会话时使用。
正常情况下,三个标志位不能被同时设置,但在此种扫描中可以用来判断哪些端口关闭还是开放,与上面的反向扫描情况相同,依然不能判断windows平台上的端口。
同样,扫描存在两种情况:
目标端口开放时:请求方会向服务器发送带有 PSH,FIN,URG 标识和端口号的数据包,不会有任何来自服务器的回应。
目标端口关闭时:请求方会向服务器发送带有 PSH,FIN,URG 标识和端口号的数据包,响应方返回一个带有 RST 标识的 TCP 数据包。
扫描的核心代码和上文提到的FIN与NULL扫描类似:
# 端口发现-Xmas扫描
def PortIsUp_Xmas(ip, port, timesout = 3):
result = [500,port]
try:
# 发送一个PSH,FIN,URG包
ans = sr1(IP(dst=ip)/
TCP(dport=port,flags="FPU"),
timeout=timesout,verbose=False)
# 回显判断
# 无响应,说明端口可能开放或过滤
if ans == None:
result = [200,port]
# 返回ICMP包
elif ans.haslayer(ICMP):
# ICMP 目标不可达错误类型3,被过滤
if ans.getlayer(ICMP).type == 3:
result = [300,port]
# ICMP 代码为1,2,3,9,10或13,被过滤
elif ans.getlayer(ICMP).type in [1,2,3,9,10,13]:
result = [300, port]
# 返回TCP包
elif ans.haslayer(TCP):
# 验证为RST包,说明端口关闭
if ans.getlayer(TCP).flags == 0x14:
result = [500,port]
except Exception as e:
print(e)
return result
测试结果如下:
同样,Xmas-Tree扫描仍然只能用于粗略估计开放列表,以及估计操作系统类型。
ACK 扫描与上文中提到的扫描目的不同,其不用于发现端口状态,而用于发现服务器上状态防火墙的存在情况。它的结果只能说明端口是否被过滤。
其判断逻辑主要依赖以下两种情况:
目标状态防火墙关闭:请求方发送带有 ACK 标识和端口号的数据包给服务器。响应方返回一个带有 RST 标识的 TCP 数据包。
目标状态防火墙开启:求方发送带有 ACK 标识和端口号的数据包给服务器,响应方没有任何回应,或者返回ICMP 错误类型3且代码为1,2,3,9,10或13的数据包。
由于ACK扫描用于判断状态防火墙,需要重新设计判断逻辑,核心函数如下:
# 防火墙发现-ACK扫描
def FirewallIsUp_ACK(ip, port, timesout = 3):
result = [500,port]
try:
# 发送一个ACK包
ans = sr1(IP(dst=ip)/
TCP(dport=port,flags="A"),
timeout=timesout,verbose=False)
# 回显判断
# 无响应,说明过滤器开启
if ans == None:
result = [300,port]
# 返回ICMP包
elif ans.haslayer(ICMP):
# ICMP 目标不可达错误类型3,被过滤
if ans.getlayer(ICMP).type == 3:
result = [300,port]
# ICMP 代码为1,2,3,9,10或13,被过滤
elif ans.getlayer(ICMP).type in [1,2,3,9,10,13]:
result = [300, port]
# 返回TCP包
elif ans.haslayer(TCP):
# 验证为RST包,说明防火墙开启
if ans.getlayer(TCP).flags == 0x14:
result = [500,port]
except Exception as e:
print(e)
return result
将其封装至多线程类中,注意保护临界区:
# 线程锁:
threadLock = threading.Lock()
resultList = []
# 防火墙判断线程类
class FireWallScan(threading.Thread):
def __init__(self,ip,port,mode,timeout = 3):
threading.Thread.__init__(self)
self.port = port
self.ip = ip
self.timeout = timeout
self.mode = mode
def run(self):
result = FirewallIsUp_ACK(self.ip, self.port, self.timeout)
if result[0] == 500:
# 临界区:
threadLock.acquire()
resultList.append([result[1],'FireWall is down'])
threadLock.release()
else:
# 临界区:
threadLock.acquire()
resultList.append([result[1], 'FireWall is up'])
threadLock.release()
接下来编写主函数:
# 判断防火墙主函数
def JudgeFireWall(ip,start_port,end_port,mode = 1,timeout = 3):
portlist = range(start_port,end_port + 1)
threads = []
results = []
try:
for port in portlist:
thread = FireWallScan(ip,port,mode,timeout)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
except Exception as e:
print(e)
for result in resultList:
results.append(result)
return results
部分测试结果如下:
再次声明,ACK 扫描不能发现端口是否处于开启或关闭状态,只能用于判断状态防火墙的存在与否。
扫描的流程类似于 ACK 扫描,向服务端发送带有 ACK 标识的数据包。TCP 窗口扫描会检查收到的 RST 数据包中的窗口大小,通过窗口值判断端口开放情况,其判断过程如下:
目标端口开放时:请求方发送一个带有 ACK 标识和端口号的 TCP 数据包,收到的RST响应窗口值大于零。
目标端口关闭时:请求方发送一个带有 ACK 标识和端口号的 TCP 数据包,收到的RST响应窗口值等于零。
根据这个原理,我们来编写窗口扫描函数:
# 端口发现-窗口扫描
def PortIsUp_Window(ip, port, timesout = 3):
result = [500,port]
try:
# 发送一个PSH,FIN,URG包
ans = sr1(IP(dst=ip)/
TCP(dport=port,flags="A"),
timeout=timesout,verbose=False)
# 回显判断
# 无响应,说明端口关闭或过滤
if ans == None:
result = [300,port]
# 返回TCP包
elif ans.haslayer(TCP):
# 窗口大小为0,说明端口关闭
if ans.getlayer(TCP).window == 0:
result = [500,port]
# 窗口大小大于0,说明端口开放
elif ans.getlayer(TCP).window > 0:
result = [200,port]
except Exception as e:
print(e)
return result
测试一下扫描效果:
竟然没有输出?后台查看日志,发现开放端口的窗口值还真是0,由于初次握手包的大小很不稳定,TCP窗口扫描得到的结果同样不是很准确。
这种扫描方式也被称为空闲扫描或反向扫描。为了降低被检测到的机率,我们通常需要转嫁责任和风险,这时可以使用空闲扫描(Idle scan),让一个僵尸主机承担扫描任务。僵尸主机是指感染病毒,被黑客程序控制的网络设备。扫描过程中,由僵尸主机向目标主机发送SYN包,并回应SYN/ACK和RST。其扫描的主要步骤额如下:
1. 向僵尸主机发送SYN/ACK数据包,获得带有分片ID(IPID)的RST报文。
2. 发送使用僵尸主机IP地址的伪数据包给目标主机。
3. 若目标主机端口关闭,就会向僵尸主机响应RST报文。
若目标端口开放,会向僵尸主机响应SYN/ACK报文,
僵尸主机发现这个非法连接响应,并向目标主机发送RST报文,此时IPID号开始增长。
4. 通过向僵尸主机发送另一个SYN/ACK报文以退出上述循环
检查僵尸主机RST报文中的IPID是否每次增长2。
目标主机的RST每次增长1。
5. 重复上述步骤直到检测完所有的端口。
使用空闲扫描时需要注意,找一台TCP序列预测成功率高的僵尸主机,这个僵尸主机必须尽可能的空闲,比如说网络打印机,不仅存在着恒定的网络资源,而且很难预测它们的TCP序列,就使得僵尸扫描更具有隐蔽性。
由于使用到僵尸主机,这种方式就成了后渗透时期才需要在内网使用的手段,本文不再涉及。
相比TCP扫描的复杂,UDP协议使得它的扫描方式十分简单,只需要判断是否有响应即可判断端口状态。扫描过程如下图:
请求方发送一个带有端口号的 UDP 数据包,若回复了UDP数据包,则目标端口是开放的。根据这个原理,构建UDP的扫描函数显得十分简单:
# 端口发现-UDP扫描
def PortIsUp_UDP(ip,port,timesout = 3):
result = [500,port]
#发送UDP数据包
try:
ans = sr1(IP(dst=ip) /
UDP(dport=port),
timeout=timesout, verbose=False)
# 收到数据即证明端口开放
if ans != None:
if ans.haslayer(UDP):
result = [200,port]
except Exception as e:
print(e)
return result
找不到网络上开放的UDP主机,随便找一台内网主机试试:
可以看到,扫描出了主机DNS服务的两个端口,相对而言,UDP扫描比TCP简单得多。
在本篇文章中,我们列举了最常用的端口扫描方式,并主动避开了已有的扫描工具,自己手动实现了一个端口扫描器。期间使用了多线程方式加快扫描速度,用于定制数据包发送过程的Scapy有一定的难度,需要读者有一定的阅读文档能力。源码地址
在这些扫描函数的基础上,结合混淆、随机化、分布式系统、专家系统、优化算法等等方式,就得到了一个完整的端口扫描器。Nmap也正是采用了这样的思路,有兴趣的读者可以尝试着阅读Nmap源码以及Nmap文档进一步学习。
事实上,在日常漏扫系统的端口扫描编写中,我们可以直接使用现成的python-nmap模块,调用本机安装的Nmap来实现自己的扫描需求。这里列举一下上文中使用到扫描方式的命令:
TCP全连接扫描;:nmap -sT
SYN扫描:nmap -sS
TCP FIN扫描:nmap -sF
NULL扫描:nmap -sN
XMAS扫描:nmap -sX
TCP ACK扫描:nmap -sA
TCP窗口扫描:nmap -sW
Dump扫描:nmap -sI
UDP扫描:nmap -sU
在后文需要扫描速度和精度的应用场景中,我们将会用到它。