Golang实现一个简单的端口扫描器
0x00 前言
这学期有一门名为网络安全理论与技术
的课,实验相较其他课程还是比较硬核且有趣的,借着这个机会练练golang开发,同时弥补了一些一直比较模糊的概念以及使用方法,例如golang的协程、信道、并发控制等。
写完实验报告直接复制过来了。。可能会有些错误,如果看到哪个我的理解有偏差或者不对,欢迎评论指出~
github: https://github.com/AnneviLL/PortHub
0x01 编写一个简单的端口扫描器
Golang 语言专门提供了net包用于网络编程,我们可以很容易的通过Golang net包中的 DialTimeout 方法实现基于TCP连接的端口扫描。其原型如下:
我们只需要提供三个参数:网络类型(TCP),连接地址(ip:port),超时时间。
若连接成功,该函数的返回值error为 nil。若连接失败,则err!=nil
通过这个函数,可以很方便的判断一个远端网络地址是否能够接收TCP连接。
0x02 协程的使用以及并发的控制
Golang语言的一个特点就是原生支持 协程
,该特性体现在了关键字go中。在golang中,所有的程序都运行在goroutine上,比如 main函数就运行在一个goroutine
上,我们将这个goroutine
称作程序的主协程。
而通过go 关键字,我们能够开启一个新的协程,可以称作子协程。每一个协程之间都是并发执行的,因此可以很方便的利用这个特性进行并发编程。
在我写的代码中,通过控制器 controller
来控制这种并发的行为。具体如下:
在这段代码中,可以看到,首先对ip数组以及port数组做了一个循环遍历,以便能够将每一个ip和port传入扫描函数 StartScanTask。同时使用go
关键字使得每一次循环启用一个协程,从而实现高并发扫描ip+port。
这里循环开启了ip*port
个协程,虽然协程极为轻量,占用极小,但是由于端口扫描是一个网络请求,过多的并发会占用大量的socket,容易造成本地端口资源耗尽,因此不得不对并发数进行一个限制。
golang中的channel信道能够很好的符合我们对于并发控制的需求。因此,我定义了一个int类型的带有缓冲区的channel,当这个缓冲区被写满后,程序将会被阻塞,直到有一个消费者从缓冲区中取出一个1时,程序将会继续。所以我在循环中让每启动一个任务都向信道中写入一个1,代表当前启动了一个协程,当信道写满时,便能不再继续创建协程。而在并发体中,对于每一个端口的扫描结束后,都会从信道中取出一个1,代表这一个协程已经结束。利用这样的方式,便可以成功的控制住goroutine的数量。
同时这里使用了sync.waitGroup,目的是避免主协程先于子协程退出,造成程序不能够完整的运行。
0x03 后端数据存储及处理
在后端数据的存储以及处理上,由于想要后端对数据进行一段时间的存储,而非扫描完后就消失,我选择了redis作为数据的暂存区以及缓冲区。
redis作为一个支持高并发的nosql数据库,非常适合在这种需要存储的数据结构简单、快速存取的情景。
在该程序中,使用了redis
的集合(set)作为主要的数据结构,利用集合元素的唯一性,能够省下许多后端的判断工作。在每一个IP的扫描任务开启前,首先将该ip作为key,running
作为值存入redis中,表明该ip正在被扫描。然后对IP进行ICMP存活探测,若存活,则送入端口扫描任务中,若该IP初步探测不存活,则从redis中删去该ip,同时忽略该ip的扫描。
在扫描任务时,会向全局变量Alive []string 中写入对应ip的存活端口。同时在控制器中对该全局变量进行轮询。
每次轮询结果都存入redis中,从而实现了实时端口结果的存入。
同时,对于数据的查询,首先通过redis的keys *
命令获取所有的集合名称 (ip),再去查询这些 ip的值(开放端口),通过字典将这些数据组成json
,并且向前端返回。
0x04 实现图形化界面
由于选择了Golang作为开发语言,而Golang的GUI编程极为残疾,但是golang的web框架却是比较成熟,因此选择将端口扫描程序写成web服务的方式获取扫描数据以及展示扫描结果。
在本次实验中,我选择了gin框架作为web框架,gin框架是一款轻量级的golang http服务框架,适合做轻量级应用的开发,快速且代码简洁。前端则选择了bootstrap + vue.js 进行开发。
后端代码关键部分如下:
首先是路由定义,一个web服务不可缺少的就是路由,这里定义了两个模板路由:/
、/result
,这两个路由分别对应了主页和结果页。同时定义了两个关键api:createPortScanTask
和 getResult
,通过这两个api,能够利用web框架启动端口扫描任务以及获取端口扫描结果。
前后端之间的数据通信则采用了JSON的方式,因此前端发送的数据需要通过JSON的反序列化成为golang的结构体才能被我们在程序中所利用。同样的,我们后端返回给前端的数据也需要进行JSON序列化后再返回给前端。
对于前端的处理,html以及前端样式部分代码则采用了bootstrap框架,写了一个比较简单的交互界面,同时利用vue.js对数据进行一些封装和处理,利用axios进行请求的发送与接受。
上图为对前端数据的处理,首先利用正则表达式匹配用户输入的字符串,从而获取相关的信息, 接着构建后端可识别的json信息并返回。
上图代码即为向后端发送数据,并且取得响应的相关代码。
上图代码则为前端向后端发起结果查询的核心代码,通过异步函数getResult对后端发起GET请求,获取JSON格式的结果。接着通过对象的深拷贝对完整结果进行一个暂存。为了避免数据过多造成前端显示混乱,因此在数据层面对数据的大小长度做了限制,最后将获取到的结果返回给Vue的全局变量 this.res以便模板能够直接获取到该数据。
上图代码则是在Vue生命周期的created阶段通过setTimeout的方式对后端数据进行轮询操作,以便能够实时的更新扫描到的端口信息,同时这个操作也是需要异步进行的。
上述代码则是前端对于数据如何展示的最后处理部分,通过vue的 v-for v-if 关键字对数据进行遍历显示,达到目标效果。
0x05 一些问题
- redis并发问题 在调式操作redis相关的代码时,总是会出现报错,提示redis存在并发错误的情况。 经过查询相关的资料,得知redis在处理网络请求时是单线程的,因此单个redis连接一次只能够处理一个网络请求,而在我的程序中,可能会存在前端获取数据的同时,后端正在向redis中写入的情况,这种情况下,redis可能会出现缓冲区中的数据和读取的数据大小不匹配的这种并发不安全状态。我的第一想法是采用互斥锁的方式对redis进行操作,但是这样显得有些复杂。于是采用了另一种方法:redis连接池,可以同时打开多个redis连接,每个连接处理一个redis网络请求,这样就能够很好的避免了上述状况的出现。
- socket文件最大打开数量以及最大连接数问题 在调试过程中,经常会出现类似 too many open files 等一些错误。我们知道,在unix操作系统中,所有的东西都是文件,包括socket,而操作系统对文件的最大打开数量会进行限制,因此猜想该错误是由于并发数量过大,造成socket连接数过多而又未及时关闭造成的。经过进一步的排查,在代码中找到了几处没有及时关闭连接的地方,修复后,情况缓解了不少,但是在扫描大量端口时(如 1-65535)这样的全端口时,偶尔还是会出现此种情况。对于这种情况,则可能是由于本地端口资源耗尽的情况造成的,我们知道,端口的最大值规定为65535,在不考虑端口复用的情况下,我们只能够打开这么多的TCP连接。而我们的扫描器是基于TCP的,因此每一次扫描会随机选取一个本地端口与远端服务器进行握手通信,当同时进行握手的连接超出65535后,就会发生错误。 但是为什么我们在每一个扫描任务的结束都对连接进行了关闭,仍然会出现这种情况呢?由于TCP连接在关闭时,并非直接关闭,而是会进入一个等待阶段,之后才会正式的关闭,因此并发过高会造成等待阶段的连接堆积,从而还是有可能出现本地端口被耗尽的情况。