Go扫描器分析

  • A+
所属分类:安全开发

Go扫描器分析


介绍


代码参考白帽子安全实战中的扫描器章节,参考地址:

https://github.com/p0p0p0/sec-dev-in-action-src


分析


该扫描器为命令行模式,命令行使用的是第三方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)_ = nscanner.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、端口、conerrfunc 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扫描器分析

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: