分析
该扫描器为命令行模式,命令行使用的是第三方cli库,主文件代码如下:
func main() {
app := cli.NewApp()
app.Name = "PostScan"
app.Author = "afa"
app.Version = "2021/07/23"
app.Usage = "tcp syn/connect port scanner"
app.Commands = []cli.Command{cmd.Scan}
app.Flags = append(app.Flags, cmd.Scan.Flags...)
err := app.Run(os.Args)
_ = err
}
cli.Command是一个结构体,接收命令行参数的内容并进行处理,这里为了main文件简介,结构清晰,Command代码单独摘了出来,跟进到cmd文件夹下,查看Scan的定义:
var Scan = cli.Command{
Name: "scan",
Usage: "start to scan port",
Description: "start to scan port",
Action: util.Scan,
Flags: []cli.Flag{
stringFlag("iplist, i", "", "ip list"),
stringFlag("port, p", "", "port list"),
stringFlag("mode, m", "", "scan mode"),
intFlag("timeout, t", 3, "timeout"),
intFlag("concurrency, c", 1000, "concurrency"),
},
}
需要注意的是Action和Flags的定义,Flags是一个切片类型的结构体,命令显示帮助信息显示的就是这块内容,Flag中的每个参数,也有自己的名称、默认值和描述,所以这里相当于是结构体嵌套了一层结构体,stringFlag和intFlag形式:
func stringFlag(name, value, usage string) cli.StringFlag {
return cli.StringFlag{
Name: name,
Value: value,
Usage: usage,
}
}
func intFlag(name string, value int, usage string) cli.IntFlag {
return cli.IntFlag{
Name: name,
Value: value,
Usage: usage,
}
}
个人感觉基本涉及命令行操作的,多个参数的,都可以参考这种写法。回到Scan中的Action参数,调用的是Util下的Scan,定位到Util下:
func Scan(ctx *cli.Context) error {
if ctx.IsSet("iplist") {
vars.Host = ctx.String("iplist")
}
if ctx.IsSet("port") {
vars.Port = ctx.String("port")
}
if ctx.IsSet("mode") {
vars.Mode = ctx.String("mode")
}
if ctx.IsSet("timeout") {
vars.Timeout = ctx.Int("timeout")
}
if ctx.IsSet("concurrency") {
vars.ThreadNum = ctx.Int("concurrency")
}
ips, err := GetIpList(vars.Host)
ports, err := GetPorts(vars.Port)
tasks, n := scanner.GenerateTask(ips, ports)
_ = n
scanner.AssigningTasks(tasks)
scanner.PrintResult()
return err
}
前部分是判断命令行内容是否包含指定参数值,包含的话就存到变量中,这个变量在vars下保存,如果没有指定,可以看到vars有设置默认的:
var (
ThreadNum = 5000
Result *sync.Map
Host string
Port = "80,443,8080"
Mode = "syn"
Timeout = 2
)
func init() {
Result = &sync.Map{}
}
但是Host没设置,也就意味着如果直接命令跟一个scan运行的话,程序没获取到IP,也没有设置默认IP,可能没有任何提示,这里大家感兴趣后面可以添加提示。
回到Util.Scan下半部分,也就是扫描器的核心部分,作用是获取命令行相关参数,然后调用扫描函数进行扫描并把结果返回,先大概看下流程:
ips, err := GetIpList(vars.Host)
ports, err := GetPorts(vars.Port)
tasks, n := scanner.GenerateTask(ips, ports)
_ = n
scanner.AssigningTasks(tasks)
scanner.PrintResult()
上面共调用了GetIpList、GetPorts、GenerateTask、AssigningTasks、PrintResult五个函数,因为涉及到扫描器核心代码处理,所以下面添加了一些注释,方便理解:
首先是GetIpList函数,定位该函数:
// 功能:解析IP函数
// 接收一个字符串,返回net.IP型切片
func GetIpList(ips string) ([]net.IP, error) {
// iprange.ParseList为第三方库,可接收同Nmap一样的IP格式
addressList, err := iprange.ParseList(ips)
if err != nil {
return nil, err
}
// 将IP以切片形式返回
list := addressList.Expand()
return list, err
}
该函数会接收一个字符串,然后调用第三方库iprange来处理,返回一个切片,iprange可处理的ip形式和nmap很像,支持c、b段,基本nmap支持的,这里都可以使用。
然后是GetPorts函数,用来获取端口:
// 功能:解析端口函数
// 接收一个字符串,返回Int型切片
func GetPorts(selection string) ([]int, error) {
ports := []int{}
if selection == "" {
return ports, nil
}
// 将字符串以逗号进行分割
ranges := strings.Split(selection, ",")
// 分割后进行遍历
for _, r := range ranges {
// 去除首尾空格
r = strings.TrimSpace(r)
// 如果包含-符号,则进行分割处理,将分割后的字符串转换为数字,并进行遍历
if strings.Contains(r, "-") {
parts := strings.Split(r, "-")
if len(parts) != 2 {
return nil, fmt.Errorf("Invalid port selection segment:'%s'", r)
}
p1, err := strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("Invalid port number:'%s'", parts[0])
}
p2, err := strconv.Atoi(parts[1])
if err != nil {
return nil, fmt.Errorf("Invalid port number:'%s'", parts[1])
}
if p1 > p2 {
return nil, fmt.Errorf("Invalid port range:%d - %d", p1, p2)
}
for i := p1; i <= p2; i++ {
ports = append(ports, i)
}
// 如果不包含-,则直接转换为数字
} else {
if port, err := strconv.Atoi(r); err != nil {
return nil, fmt.Errorf("Invalid port numver:'%s'", r)
} else {
ports = append(ports, port)
}
}
}
return ports, nil
}
这个函数代码看着相对多点,因为支持的端口形式是自己手动写的来处理的,多个端口的情况下,逗号和杠都可以识别分析。
随后是GenerateTask函数,该函数在scanner包下定义:
// 接收IP列表和端口列表,进行组合,返回一个map型的切片
func GenerateTask(ipList []net.IP, ports []int) ([]map[string]int, int) {
tasks := make([]map[string]int, 0)
// 相当于把所有端口在每一个IP上都组合一遍,形成一个Map
for _, ip := range ipList {
for _, port := range ports {
ipPort := map[string]int{ip.String(): port}
tasks = append(tasks, ipPort)
}
}
return tasks, len(tasks)
}
该函数相当于把分析好的IP和端口排列组合一下,返回的是一个切片型的map,而返回的切片传给了AssigningTasks函数,该函数会对任务按线程数平均分割,具体看下面注释:
// 分割任务函数,接收一个Map型切片
func AssigningTasks(tasks []map[string]int) {
// 任务总数除以线程数,线程数在var包中可自定义,这里是5000
// 任务数按线程数平均分一下,目的就是多线程执行任务,提高扫描速度,相当于分了个批次,每个批次都会单独运行
scanBatch := len(tasks) / vars.ThreadNum
for i := 0; i < scanBatch; i++ {
// 给每个批次分配任务,传给RunTask函数执行,这里分配任务和那种分页查询的原理一样
curTask := tasks[vars.ThreadNum*i : vars.ThreadNum*(i+1)]
RunTask(curTask)
}
// 如果上面批次没有除尽,则把剩余的任务再一块打包给RunTask运行
if len(tasks)%vars.ThreadNum > 0 {
lastTasks := tasks[vars.ThreadNum*scanBatch:]
RunTask(lastTasks)
}
}
线程数在vars中定义的默认为5000,也可以通过参数concurrency来指定。分割函数平均分配后会传给RunTask函数去执行,定位RunTask:
// RunTask函数运行任务时,对协程控制不够细,像上面线程数5000,任务传进去后,直接for循环所有,相当于瞬间开启了5000个协程去扫描探测
// 每个任务都是瞬间开启大量协程,容易瞬间将服务器CPU占满
// 对RunTask进行改进,使用通道的方式实现,将任务不断的发送到通道,扫描探测不断的去通道拿IP和端口
func RunTask2(tasks []map[string]int) {
// 这里计数器使用指针形式,因为在另一个Scan函数中用到了wg
wg := &sync.WaitGroup{}
taskChan := make(chan map[string]int, vars.ThreadNum*2)
// 开启ThreadNum个协程,每个协程都去调用Scan,接收一个通道
for i := 0; i < vars.ThreadNum; i++ {
go Scan(taskChan, wg)
}
// 每个任务都传入到taskChan通道中
for _, task := range tasks {
wg.Add(1)
taskChan <- task
}
close(taskChan)
wg.Wait()
}
RunTask通过计数器sync.WaitGroup来进行并发配置,将任务不断的传入通道中,并通过Scan不断从通道中取,Scan函数定义如下:
// 扫描函数,接收一个通道
func Scan(taskChan chan map[string]int, wg *sync.WaitGroup) {
// 遍历通道
for task := range taskChan {
for ip, port := range task {
if strings.ToLower(vars.Mode) == "syn" {
err := SaveResult(SynScan(ip, port))
_ = err
} else {
err := SaveResult(Connect(ip, port))
_ = err
}
wg.Done()
}
}
}
Scan函数中调用了SaveResult来进行结果保存:
// 保存结果函数,接收IP、端口、conerr
func SaveResult(ip string, port int, err error) error {
if err != nil {
return err
}
// Result由sync.Map定义,和Map类似,但如果是多线程,自带Map是不安全的,所以需要sync.Map
// Load方法用来获取值,只有通过Store存储的,Load才能获取到
v, ok := vars.Result.Load(ip)
// 如果没有获取到为false,就直接执行else部分,存储下端口,注意端口是以切片形式存储的,键为IP地址
if ok {
// 如果为true,说明已经存储了某个端口了,这时需要把v,也就是获取到的端口转换为int型切片,其实默认就是不转也行
ports, ok1 := v.([]int)
if ok1 {
// 把新端口添加到端口切片中
ports = append(ports, port)
// 重新存储端口切片
vars.Result.Store(ip, ports)
}
} else {
ports := make([]int, 0)
ports = append(ports, port)
vars.Result.Store(ip, ports)
}
return err
}
这里注意的是SaveResult函数并没有调用PrintResult函数,PrintResult之所以能打印出结果在于SaveResult通过sync.Map进行了保存,PrintResult通过sync.Map获取:
// 打印结果
func PrintResult() {
// sync.Map的Range方法用来遍历Store存储的内容
vars.Result.Range(func(key, value interface{}) bool {
fmt.Printf("ip:%vn", key)
fmt.Printf("ports:%vn", value)
fmt.Println(strings.Repeat("-", 100))
return true
})
}
上面就是扫描器大概一个过程,在Scan函数中,通过mode模式会调用Connect和SynScan,Connect为全连接的tcp扫描,SynScan为半连接扫描,只发syn标识,根据返回标识来进行判断。
定位到Connect函数:
// 连接函数,接收一个IP和端口,返回IP、端口、连接和错误信息
func Connect(ip string, port int) (string, int, error) {
// 使用net.DialTimeout的tcp进行连接
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%v:%v", ip, port), time.Duration(vars.Timeout)*time.Second)
defer func() {
// conn不等于nil,说明连接成功,返回了信息,所以需要close关闭
if conn != nil {
_ = conn.Close()
}
}()
return ip, port, err
}
通过net.DialTimeout来进行的连接,查看SynScan函数:
// 半开放扫描方式,接收目标ip和端口
func SynScan(dstIp string, dstPort int) (string, int, error) {
// 调用localIPPort函数,获取本地ip和端口
srcIp, srcPort, err := localIPPort(net.ParseIP(dstIp))
// lookupIP获取主机的ip,如果是域名则会解析其a记录
dstAddrs, err := net.LookupIP(dstIp)
if err != nil {
return dstIp, 0, err
}
// 截至到这里,上面代码作用就是获取下本地ip和端口以及远程的ip和端口
// ip用4字节表示,如果是ipv4,则原样返回,如果是ipv6,则截取指定的4个字节
dstip := dstAddrs[0].To4()
// layers.TCPPort是一个uint16类型
var dstport layers.TCPPort
dstport = layers.TCPPort(dstPort)
srcport := layers.TCPPort(srcPort)
// ip头定义,网络层
ip := &layers.IPv4{
SrcIP: srcIp,
DstIP: dstip,
Protocol: layers.IPProtocolTCP,
}
// tcp头定义,传输层
tcp := &layers.TCP{
SrcPort: srcport,
DstPort: dstport,
SYN: true,
}
// 对ip包进行包装,进行tcp包校验
err = tcp.SetNetworkLayerForChecksum(ip)
// 下面代码相当于按需求新建了一个数据包
// 新建一个缓冲区实例
buf := gopacket.NewSerializeBuffer()
// 配置实例
opts := gopacket.SerializeOptions{
ComputeChecksums: true,
FixLengths: true,
}
// 将所有层写入缓冲区,进行相互包装
if err := gopacket.SerializeLayers(buf, opts, tcp); err != nil {
return dstIp, 0, err
}
// 监听所有ipv4地址
conn, err := net.ListenPacket("ip4:tcp", "0.0.0.0")
if err != nil {
return dstIp, 0, err
}
defer conn.Close()
// 将缓冲区中组好的数据包写到dstip中,也就是目标ip。作用可理解为向目标ip发送组好的数据包
if _, err := conn.WriteTo(buf.Bytes(), &net.IPAddr{IP: dstip}); err != nil {
return dstIp, 0, err
}
// 设置连接的最大等待时间
if err := conn.SetDeadline(time.Now().Add(time.Duration(vars.Timeout) * time.Second)); err != nil {
return dstIp, 0, err
}
// 下面for循环中代码相当于读取返回包内容,判断是否为syn-ack,如果是,则端口开放
for {
b := make([]byte, 4096)
// 从连接中读取内容,相当于读取返回的包
n, addr, err := conn.ReadFrom(b)
if err != nil {
return dstIp, 0, err
} else if addr.String() == dstip.String() {
// 创建一个新的packet对象
packet := gopacket.NewPacket(b[:n], layers.LayerTypeTCP, gopacket.Default)
// 获取包中的tcp层
if tcpLayer := packet.Layer(layers.LayerTypeTCP); tcpLayer != nil {
tcp, _ := tcpLayer.(*layers.TCP)
if tcp.DstPort == srcport {
// 返回syn和ack,代表端口开发
if tcp.SYN && tcp.ACK {
return dstIp, dstPort, err
} else {
return dstIp, 0, err
}
}
}
}
}
}
不赘述了,具体可以参考注释部分,它里面调用了另一个localIPPort自己写的函数,作用是获取本地的IP地址,定义如下:
// 该函数用来获取本地的IP地址,接收参数是目标IP,返回的是本地的IP、端口
func localIPPort(dstip net.IP) (net.IP, int, error) {
// 将接收的IP进行解析,返回的是udpaddr类型,实际上相当于传进来的IP又返回去了,不过进行了类型转换,接收的是net.IP,返回的是UDPAddr
serverAddr, err := net.ResolveUDPAddr("udp", dstip.String()+"54321")
if err != nil {
return nil, 0, err
}
// 连接服务器,laddr为本地ip,这里写nil,serverAddr是远端的ip,是一个UDPAddr类型
if con, err := net.DialUDP("udp", nil, serverAddr); err == nil {
// LocalAddr方法用来从连接con中获取本地的IP地址,这里的本地IP指的是局域网地址,而不是广域网
if udpaddr, ok := con.LocalAddr().(*net.UDPAddr); ok {
return udpaddr.IP, udpaddr.Port, nil
}
}
return nil, -1, err
}
以上就是大体的过程。
总结
Go本身支持的高并发和跨平台编译,很适合做扫描器,外网信息探测可以使用,像内网的Linux或Win主机,如果要求指定时间,比如6个小时要扫完整个C段、B段,甚至全内网,那么Go写的扫描器还是很有优势的。
本文始发于微信公众号(aFa攻防实验室):Go扫描器分析
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论