浅析端口扫描原理
2021-05-27 22:05:00
端口扫描
文章首发于:安全客
自觉对于端口扫描只是浅显的停留在nmap的几条命令,因此花了点时间了解了一下端口扫描器是如何运作的,并且使用python写了一个简单的端口扫描器,笔者目的不在于替代nmap等主流端扫的作用,只是为了更深入地了解端口扫描器运作的原理。
本文以nmap中常见的扫描技术的原理展开叙述。
端口
每一个ip地址可以有2^16=65535个端口,其中分成了两类,一类是TCP,另一类是UDP;TCP是面向连接的,可靠的字节流服务,UDP是面向非连接的,不可靠的数据报服务;在实现端口扫描器时需要针对这两种分别进行扫描。
每一个端口只能被用于一个服务,例如常见的80端口就被用于挂载http服务,3306则是mysql,而nmap中对于端口的定义存在着六种状态:
- open(开放的)
- close(关闭的)
- filtered(被过滤的)
- unfiltered(未被过滤的)
- open|filtered(开放或者被过滤的)
- closed|filtered(关闭或者被过滤的)
对于这6种状态我在后文中会陆续提到;端口扫描器要做的就是扫出各个端口的状态,并且探测到存活的端口中存在着的服务。
TCP
因为扫描TCP端口时需要用到TCP的知识,因此,在此之前有必要再复习一下TCP协议的那些事,也就是TCP的三次握手:
- 第一次握手:
客户端主动发送SYN给服务端,SYN序列号假设为J(此时服务器是被动接受)。
- 第二次握手
服务端接受到客户端发送的SYN(J)后,发送一个SYN(J 1)和ACK(K)给客户端。
- 第三次握手
客户端接受到新的SYN(K)和ACK(J 1)后,也发送一个ACK(K 1)给服务端,此时连接建立,双方可以进行通信。
而发送TCP包的连接状态是由一个名为标志位(flags)来决定的,它存在一下几种:
- F : FIN - 结束; 结束会话
- S : SYN - 同步; 表示开始会话请求
- R : RST - 复位;中断一个连接
- P : PUSH - 推送; 数据包立即发送
- A : ACK - 应答
- U : URG - 紧急
- E : ECE - 显式拥塞提醒回应
- W : CWR - 拥塞窗口减少
UDP
关于UDP的话就不多说拉,只需要知道它是一个无连接协议,因此也是一种不可靠的协议,因为你并不清楚你发的数据是否到达目标。
TCP SYN SCAN
对应于nmap中的-sS。
关于SYN前面稍稍提了一下,那么下面对此细节进行展开。
SYN扫描也称为半连接扫描,那么说到这里顺嘴提一下SYN FLOOD,它是利用了TCP协议的缺陷,在客户端伪造源地址发送SYN给服务端时,服务端会响应该SYN并且发送SYN跟ACK包,但因为客户端地址是伪造的,真实请求的客户端不认为该包是其发送的,因此不再响应从服务端从发送来的包,服务端收不到响应会进行重试3-5次并且等待一个SYN Time(一般30秒-2分钟)后,丢弃这个连接,这一过程会消耗服务端的资源,
而当大量伪造源地址的TCP请求使用此种方式去向服务端发送SYN包时,服务端会因为资源的大量消耗导致请求缓慢,此时用户无法正常使用服务。
回到端扫,项目采用的是scapy,那么用它来发送一个SYN包应该怎么做?
找到其文档会发现它已经直截了当地给出了答案:
代码语言:javascript复制sr1(IP(dst="101.132.132.179")/TCP(dport=80,flags="S"))
这里flag="S"也就是将标志位置为SYN,说明此时发送的是SYN,而收到的内容:
代码语言:javascript复制Begin emission:
Finished sending 1 packets.
...*
Received 4 packets, got 1 answers, remaining 0 packets
<IP version=4 ihl=5 tos=0x4 len=44 id=0 flags=DF frag=0 ttl=48 proto=tcp chksum=0x743c src=101.132.132.179 dst=192.168.43.172 |<TCP sport=http dport=ftp_data seq=1048042036 ack=1 dataofs=6 reserved=0 flags=SA window=29200 chksum=0x36f7 urgptr=0 options=[('MSS', 1318)] |>>
发现flags=SA,即SYN ACK,那么如何判断端口是否开放?
文档中给出的方法是判断响应包是否具有SA标识,有则默认端口开放,但是对于我们来说仅仅到这一步并不太满意,会发现nmap文档中早已给出答案:
它发送一个SYN报文, 就像您真的要打开一个连接,然后等待响应。 SYN/ACK表示端口在监听 (开放),而 RST (复位)表示没有监听者。如果数次重发后仍没响应, 该端口就被标记为被过滤。如果收到ICMP不可到达错误 (类型3,代码1,2,3,9,10,或者13),该端口也被标记为被过滤。
概括一下可以分成三种状态,open,closed,filtered,而我们很容易冲描述中判断出对应的行为应该给与什么样的状态:
行为 | 状态 |
---|---|
数次重发未响应 | filtered |
收到ICMP不可达错误 | filtered |
SYN/ACK | open |
RST | closed |
对于判断响应是否具有TCP层 or ICMP层,scapy中也贴心的给出了相应的函数(getlayer和haslayer),而在收到SYN-ACK报文后要做的是发送RST报文中断连接而非传统TCP连接中的ACK报文,而当收到RA时表示端口关闭。
这里的RA也可以用16进制表示为0x14,其算法为:
URG=0,ACK=1,PSH=0,RST=1、SYN=0、FIN=0
010100=0x14
一个demo:
代码语言:javascript复制def tcp_syn_scan(host,port):
send = sr1(IP(dst=host) / TCP(dport=port, flags="S"),retry=-2, timeout=2, verbose=0)
if (send is None):
# filtered
pass
elif send.haslayer(TCP):
if send.getlayer(TCP).flags == 0x12:#SA
sr1(IP(dst=host) / TCP(dport=port, flags="R"), timeout=2, verbose=0)
print("[ ] %s %d Open" % (host, port))
elif send.getlayer(TCP).flags == 0x14: #RA
# closed
pass
elif send.haslayer(ICMP):
if send.getlayer(TCP).type == 3 and send.getlayer(TCP).code in [1, 2, 3, 9, 10, 13]:
# filtered
pass
TCP CONNECT SCAN
对应于nmap中的-sT。
相对于SYN的半连接扫描,TCP CONNECT SCAN又称为全连接扫描,顾名思义这种扫描方式需要与目标建立完整的TCP连接,前面讲SYN扫描时说到了最后发送的是一个RST报文,其是为了躲避防火墙的检测,而在全连接扫描中则会留下记录,全连接扫描可以说是SYN扫描的下位选择,因为他们得到的信息相同,只有在SYN扫描不可用的情况下才会选择全连接扫描。
那么如果端口不开放,服务端同样返回的是一个RST报文,同样的RA。
因为基本上与SYN扫描类似,区别就是需要建立完整的TCP连接,只需改动一行,将R改成AR:
代码语言:javascript复制sr1(IP(dst=host) / TCP(dport=port, flags="AR"), timeout=2, verbose=0)
TCP Null,FIN,and Xmas扫描
nmap中将这三类扫描置为一类,看看文档中对于这一类扫描的描述:
如果扫描系统遵循该RFC,当端口关闭时,任何不包含SYN,RST,或者ACK位的报文会导致 一个RST返回,而当端口开放时,应该没有任何响应。只要不包含SYN,RST,或者ACK, 任何其它三种(FIN,PSH,and URG)的组合都行
因为三类扫描实际上原理相似,因此只着重讲一个。
首先看到nmap中对于这三类扫描的flags值:
代码语言:javascript复制case XMAS_SCAN: pspec->pd.tcp.flags = TH_FIN|TH_URG|TH_PUSH; break;
case NULL_SCAN: pspec->pd.tcp.flags = 0; break;
case FIN_SCAN: pspec->pd.tcp.flags = TH_FIN; break;
并且它们的回显置为了无回显:
代码语言:javascript复制noresp_open_scan = true;
很明显这三种扫描在行为上是一致的,当扫描到端口关闭时服务端会给出一个RST,而当端口开放时会得到一个未响应的结果(因为不设置SYN,RST,或者ACK位的报文发送到开放端口时服务端会丢弃该报文),那么就有如下行为/状态表:
行为 | 状态 |
---|---|
未响应 | Open/filtered |
返回RST | Closed |
ICMP不可达错误 | filtered |
这种扫描我在扫描本地时准确率不低,但扫描服务器时因为服务器大部分端口都是filtered,且它没能识别出来是open还是filtered,当然了其优点是比SYN扫描更为隐蔽,且能够躲过一些无状态防火墙和报文过滤路由器。
TCP Null
对应于nmap中的-sN。
Null扫描,不设置任何标志位(tcp标志头是0),那么做个测试,将flags位置空看看会得到什么结果?
当尝试扫描一个开放的端口时,会没有响应;当将该端口关闭后再次扫描,会得到如下结果:
代码语言:javascript复制###[ TCP ]###
sport = opsession_prxy
dport = ftp_data
seq = 0
ack = 0
dataofs = 5
reserved = 0
flags = RA
window = 0
chksum = 0xfe1c
urgptr = 0
options = []
demo:
代码语言:javascript复制def tcp_null_scan(host,port):
send = sr1(IP(dst=host) / TCP(dport=port, flags=0x00), timeout=10, verbose=0)
if (send is None):
print("[ ] %s %d 33[92m Open/Filtered 33[0m" % (host, port))
elif send.haslayer(TCP):
if send.getlayer(TCP).flags == 0x14:
# closed
pass
elif send.haslayer(ICMP):
if send.getlayer(TCP).type == 3 and send.getlayer(TCP).code in [1, 2, 3, 9, 10, 13]:
# filtered
pass
FIN SCAN
对应于nmap中的-sF。
与null的差异就是只设置TCP FIN标志位。
其实与null无甚差异,因此扫描代码也只需要将flags位置为F:
代码语言:javascript复制sr1(IP(dst=host)/TCP(dport=port,flags="F"),timeout=10,verbose=0)
Xmans SCAN
对应于nmap中的-sX。
根据nmap源码中给出也就是设置FINURGPUSH三个标志位,其余遵循先前的行为状态规则。
代码语言:javascript复制sr1(IP(dst=host)/TCP(dport=port,flags="FUP"),timeout=10,verbose=0)
TCP ACK SCAN
对应于nmap中的-sA。
这种扫描技术无法确定端口的开放性,因为它做的就是发送一个ACK报文,但服务端在收到该报文时,无论端口是否开放(open/closed),只要未被过滤,都会返回一个RST报文,将它们认为是unfiltered,而当扫描一个端口未得到响应时则认为是filtered,同样的ICMP不可达也认为是filtered。
咋一看貌似无法用于确定端口的状态,也确实如此,这种扫描技术实际上是用于发现防火墙的规则,确定它们是有状态的还是无状态的,并且得到filtered的端口。
其行为状态表如下:
行为 | 状态 |
---|---|
收到RST报文 | unfiltered(open/closed) |
未响应 | filtered |
ICMP不可达 | filtered |
只设置ACK标志位即可。
demo:
代码语言:javascript复制def tcp_ack_scan(host,port):
send = sr1(IP(dst=host) / TCP(dport=port, flags="A"), timeout=10, verbose=0)
if (send is None):
# filtered
pass
elif send.haslayer(TCP):
if send.getlayer(TCP).flags == 0x04:
print("[ ] %s %d 33[92m Unfiltered 33[0m" % (host, port))
elif send.haslayer(ICMP):
if send.getlayer(TCP).type == 3 and send.getlayer(TCP).code in [1, 2, 3, 9, 10, 13]:
# filtered
pass
TCP WINDOW SCAN
对应于nmap中的-sW。
同上一条的ACK扫描一般,窗口扫描也是发送一个ACK报文,但它能够区别出端口的开放(open/closed);现在尝试发送一个ACK报文到目标开放端口,查看一下返回内容:
代码语言:javascript复制###[ TCP ]###
sport = http
dport = ftp_data
seq = 2170377956
ack = 1
dataofs = 6
reserved = 0
flags = SA
window = 29200
chksum = 0x7761
urgptr = 0
options = [('MSS', 1318)]
有一个名为window的字段,这在前面是被忽略的一个字段,现在再次发送ACK报文到目标关闭的端口,会发现该值为0,因此窗口扫描技术是可以根据window值是正值 or 0来判断目标端口是open还是closed。
当然在大部分情况因为系统不支持的原因,得到的只是一个无响应或者是window值为0,从而令所有端口为filtered或者closed;例如用nmap去扫描的话得到的结果是所有端口都为filtered,而我本地发送报文到本地开放端口往往得到的也是一个window为0的结果,并且nmap文档中也提到了该扫描偶尔会得到一个相反的结果,如扫描1000个端口仅有3个端口关闭,此时这3个端口可能才是开放的端口。
其行为/状态表如下:
行为 | 状态 |
---|---|
window>0 | open |
window=0 | closed |
未响应 | Filtered |
ICMP不可达 | filtered |
demo:
代码语言:javascript复制def tcp_window_scan(host,port):
send = sr1(IP(dst=host) / TCP(dport=port, flags="A"), timeout=2, verbose=0)
if type(send) == None:
# filtered
pass
elif send.haslayout(TCP):
if send.getlayer(TCP).window > 0:
print("[ ] %s %d 33[92m Open 33[0m" % (host, port))
elif send.getlayer(TCP).window == 0:
# closed
elif send.haslayer(ICMP):
if send.getlayer(TCP).type == 3 and send.getlayer(TCP).code in [1, 2, 3, 9, 10, 13]:
# filtered
pass
TCP Maimon SCAN
对应于nmap中的-sM。
该扫描技术本人没使用过,也是看nmap文档才了解到,首先该扫描技术与前面提到的Null,FIN,Xmas扫描一般,可以看到nmap中也是将它与这三种扫描技术归到同一类:
代码语言:javascript复制case FIN_SCAN:
case XMAS_SCAN:
case MAIMON_SCAN:
case NULL_SCAN:
noresp_open_scan = true;
然而nmap中给它设置的标志位是TH_FIN|TH_ACK,而据文档所述,在RFC 793 (TCP)标准下无论端口开放还是关闭都应该响应RST给这样的探测,然而在许多基于BSD的系统中,如果端口开放,它只是丢弃该探测报文而非响应。
那么其行为/状态表与前面提到的Null等三项扫描技术一致。
代码语言:javascript复制send = sr1(IP(dst=host) / TCP(dport=port, flags="AF"), timeout=2, verbose=0)
UDP SCAN
对应于nmap中的-sU。
显然UDP扫描相较于TCP扫描而言使用的较少,就我个人来说我也不习惯去扫描UDP端口,而例如常见的DNS,SNMP,和DHCP (注册的端口是53,161/162,和67/68)同样也是一些不可忽略的端口,在某些情况下会有些用处。
同样的在nmap文档中给出了说明:
UDP扫描发送空的(没有数据)UDP报头到每个目标端口。 如果返回ICMP端口不可到达错误(类型3,代码3), 该端口是
closed
(关闭的)。 其它ICMP不可到达错误(类型3, 代码1,2,9,10,或者13)表明该端口是filtered
(被过滤的)。 偶尔地,某服务会响应一个UDP报文,证明该端口是open
(开放的)。 如果几次重试后还没有响应,该端口就被认为是open|filtered
(开放|被过滤的)。 这意味着该端口可能是开放的,也可能包过滤器正在封锁通信。
那么整理一下大概是如下:
行为 | 状态 |
---|---|
ICMP端口不可到达错误(类型3,代码3) | closed |
ICMP不可到达错误(类型3, 代码1,2,9,10,或者13) | filtered |
响应 | open |
重试未响应 | open|filtered |
那么UDP不受欢迎的原因一个在于目前主流的服务大部分基于TCP,而其次便在于UDP扫描的特殊性,在一次扫描过后通常无法得到一个正确的响应,偶尔某服务会返回一个UDP报文说明该端口开放,因此需要进行多次探测;对于ICMP不可到达错误,需要知道的是一般主机在默认情况下限制ICMP端口不可到达消息,如一秒限制发送一条不可达消息。
可以看一下分别对本机的53端口跟一个不存在UDP服务的端口发送UDP报文查看它们的区别。
Open:
代码语言:javascript复制###[ UDP ]###
sport = domain
dport = domain
len = 8
chksum = 0x172
closed:
代码语言:javascript复制###[ ICMP ]###
type = dest-unreach
code = port-unreachable
chksum = 0xfc44
reserved = 0
length = 0
nexthopmtu= 0
###[ UDP in ICMP ]###
sport = domain
dport = ntp
len = 8
chksum = 0x0
一如TCP扫描一般,ICMP可以用getlayer(ICMP).type和code来获取到它们对应的状态,那么同样给出一个demo:
代码语言:javascript复制def udp_scan(host,port):
send = sr1(IP(dst=host) / UDP(dport=port), retry=-2, timeout=2, verbose=0)
if (send is None):
# filtered
pass
elif send.haslayer(ICMP):
if send.getlayer(ICMP).type == 3:
if send.getlayer(ICMP).code in [1, 2, 9, 10, 13]:
# filtered
pass
elif send.getlayer(ICMP).code == 3:
# closed
pass
elif send.haslayer(UDP):
print("[ ] %s U%d 33[92m Open 33[0m" % (host, port))
IP SCAN
对应于nmap中的-sO。
严格来说这并不是端口扫描,IP SCAN所得到的结果是目标机器支持的IP协议 (TCP,ICMP,IGMP,等等)。
协议扫描扫的不是端口,它是在IP协议域的8位上循环,仅发送IP报文,而不是前面使用到的TCP or UDP报文,同时报文头(除了流行的TCP、ICMP、UDP协议外)会是空的,根据nmap中说的是如果得到对应协议的响应则将该协议标记为open, ICMP协议不可到达 错误(类型 3,代号 2) 导致协议被标记为 closed
。其它ICMP不可到达协议(类型 3,代号 1,3,9,10,或者13) 导致协议被标记为 filtered
(虽然同时他们证明ICMP是 open
)。如果重试之后仍没有收到响应, 该协议就被标记为open|filtered。
scapy中可以简单的使用IP来发送默认的256个协议。
代码语言:javascript复制ans,unans = sr(IP(dst=host,proto=(0,255))/"SCAPY")
ans.show()
//0000 127.0.0.1 > 127.0.0.1 ip / Raw ==> 127.0.0.1 > 127.0.0.1 ip / Raw
版本探测
对应于nmap的-sV。
同协议探测,版本探测严格来讲不在端口扫描的范围内,但却也互相关联,尽管在探测到常见的80端口,3306端口等等会第一时间辨认出运行在其上的服务,然而有些人的想法是难以猜透的,在10086端口放个web服务也不是不可能的,总会有奇奇怪怪的人在一些奇奇怪怪的端口运行着一些常见的服务。
在扫描到开放的端口后,版本探测所做的就是识别出这些端口对应的服务,在nmap中的nmap-service-probes文件中存放着不同服务的探测报文和解析识别响应的匹配表达式,例如使用nmap进行版本探测,会得到如下结果:
代码语言:javascript复制PORT STATE SERVICE VERSION
80/tcp open http nginx 1.14.0 (Ubuntu)
443/tcp open ssl/http nginx 1.14.0 (Ubuntu)
对于版本探测的实现相对简单的方式就是通过无阻塞套接字和探查,然后对响应进行匹配以期得到一个正确的探测结果,在使用nmap探测一个nc监听的端口时会发现收到了一个GET / HTTP/1.0
。
那么同样可以使用python的socket发送它来得到服务响应。
代码语言:javascript复制ip = "ip"
port = 80
PROBE = 'GET / HTTP/1.0rnrn'
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
result = sock.connect_ex((ip, port))
if result == 0:
try:
sock.sendall(PROBE.encode())
response = sock.recv(256)
if response:
print(response)
except ConnectionResetError:
pass
else:
pass
sock.close()
//b'HTTP/1.1 200 OKrnServer: nginx/1.14.0 (Ubuntu)rnDate: Mon
只需要一个合理的正则便够匹配出正确的服务和版本信息。
Reference
https://xz.aliyun.com/t/5376#toc-24
https://nmap.org/man/zh/
https://www.osgeo.cn/scapy/
https://www.imooc.com/article/286803
本文原创于HhhM的博客,转载请标明出处。