Skip to content

projectdiscover之naabu 端口扫描器源码学习

字数
2408 字
阅读时间
12 分钟
更新日期
2/11/2021

ProjectDiscovery组织开源了很多自动化扫描的内部工具和研究,例如subfinder 被动子域名发现工具nuclei 基于模板的可配置快速扫描工具naabu 端口扫描器dnsprobe dns解析器httpx 多功能http工具包,它们都是基于Go语言编写,并且在实际渗透中有极大的作用。我非常喜欢这个组织开源的软件,它也是我学习Go语言的动力之一,所以计划写一个系列文章来研究下它们的代码。

介绍

naabu的项目地址是:https://github.com/projectdiscovery/naabu

几个特性:

  • 基于syn/connect两种模式扫描
  • 多种输入类型支持,包括HOST / IP / CIDR表示法。
  • 自动处理多个子域之间的重复主机
  • Stdinstdout 支持集成到工作流中
  • 易于使用的轻量级资源

naabu

▶ naabu -host hackerone.com

                  __
  ___  ___  ___ _/ /  __ __
 / _ \/ _ \/ _ \/ _ \/ // /
/_//_/\_,_/\_,_/_.__/\_,_/ v2.0.3

    projectdiscovery.io

[WRN] Use with caution. You are responsible for your actions
[WRN] Developers assume no liability and are not responsible for any misuse or damage.
[INF] Running SYN scan with root privileges
[INF] Found 4 ports on host hackerone.com (104.16.100.52)
hackerone.com:80
hackerone.com:443
hackerone.com:8443
hackerone.com:8080

扫描方式

扫描相关的代码在 v2/pkg/scan目录

cdn check

顾名思义,跟踪一下,发现cdn检查调用的是github.com/projectdiscovery/cdncheck中的项目。

通过接口获取一些CDN的ip段,判断ip是否在这些ip段中

go
// scrapeCloudflare scrapes cloudflare firewall's CIDR ranges from their API
func scrapeCloudflare(httpClient *http.Client) ([]string, error) {
    resp, err := httpClient.Get("https://www.cloudflare.com/ips-v4")
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    data, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }
    body := string(data)

    cidrs := cidrRegex.FindAllString(body, -1)
    return cidrs, nil
}

// scrapeIncapsula scrapes incapsula firewall's CIDR ranges from their API
func scrapeIncapsula(httpClient *http.Client) ([]string, error) {
    req, err := http.NewRequest(http.MethodPost, "https://my.incapsula.com/api/integration/v1/ips", strings.NewReader("resp_format=text"))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

    resp, err := httpClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    data, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }
    body := string(data)

    cidrs := cidrRegex.FindAllString(body, -1)
    return cidrs, nil
}

// scrapeAkamai scrapes akamai firewall's CIDR ranges from ipinfo
func scrapeAkamai(httpClient *http.Client) ([]string, error) {
    resp, err := httpClient.Get("https://ipinfo.io/AS12222")
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    data, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }
    body := string(data)

    cidrs := cidrRegex.FindAllString(body, -1)
    return cidrs, nil
}

// scrapeSucuri scrapes sucuri firewall's CIDR ranges from ipinfo
func scrapeSucuri(httpClient *http.Client) ([]string, error) {
    resp, err := httpClient.Get("https://ipinfo.io/AS30148")
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    data, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }
    body := string(data)

    cidrs := cidrRegex.FindAllString(body, -1)
    return cidrs, nil
}

func scrapeProjectDiscovery(httpClient *http.Client) ([]string, error) {
    resp, err := httpClient.Get("https://cdn.projectdiscovery.io/cdn/cdn-ips")
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    data, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }
    body := string(data)

    cidrs := cidrRegex.FindAllString(body, -1)
    return cidrs, nil
}

connect扫描

naabu的connect扫描就是简单的建立一个tcp连接

go
// ConnectVerify is used to verify if ports are accurate using a connect request
func (s *Scanner) ConnectVerify(host string, ports map[int]struct{}) map[int]struct{} {
    for port := range ports {
        conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), s.timeout)
        if err != nil {
            delete(ports, port)
            continue
        }
        gologger.Debugf("Validated active port %d on %s\n", port, host)
        conn.Close()
    }
    return ports
}

syn扫描

syn扫描只能在unix操作系统上运行,如果是windows系统,会切换到connect扫描。

syn扫描的原理是只用发一个syn包,节省发包时间,而完整的tcp需要进行三次握手。

image-20191003223330374

获取空闲端口

初始化时,获取空闲端口,并监听这个端口

go
import github.com/phayes/freeport

func NewScannerUnix(scanner *Scanner) error {
    rawPort, err := freeport.GetFreePort()
    if err != nil {
        return err
    }
    scanner.listenPort = rawPort

    tcpConn, err := net.ListenIP("ip4:tcp", &net.IPAddr{IP: net.ParseIP(fmt.Sprintf("0.0.0.0:%d", rawPort))})
    if err != nil {
        return err
    }
    scanner.tcpPacketlistener = tcpConn

    var handlers Handlers
    scanner.handlers = handlers

    scanner.tcpChan = make(chan *PkgResult, chanSize)
    scanner.tcpPacketSend = make(chan *PkgSend, packetSendSize)
    return nil
}

监听网卡

获取网卡名称

image-20210211205335461

SetupHandlerUnix 监听网卡

go
const (
    maxRetries     = 10
    sendDelayMsec  = 10
    chanSize       = 1000
    packetSendSize = 2500
    snaplen        = 65536
    readtimeout    = 1500
)

func SetupHandlerUnix(s *Scanner, interfaceName string) error {
    inactive, err := pcap.NewInactiveHandle(interfaceName)
    if err != nil {
        return err
    }

    err = inactive.SetSnapLen(snaplen)
    if err != nil {
        return err
    }

    readTimeout := time.Duration(readtimeout) * time.Millisecond
    if err = inactive.SetTimeout(readTimeout); err != nil {
        s.CleanupHandlers()
        return err
    }
    err = inactive.SetImmediateMode(true)
    if err != nil {
        return err
    }

    handlers := s.handlers.(Handlers)
    handlers.Inactive = append(handlers.Inactive, inactive)

    handle, err := inactive.Activate()
    if err != nil {
        s.CleanupHandlers()
        return err
    }

    handlers.Active = append(handlers.Active, handle)

    // Strict BPF filter
    // + Packets coming from target ip
    // + Destination port equals to sender socket source port
    err = handle.SetBPFFilter(fmt.Sprintf("tcp and dst port %d and tcp[13]=18", s.listenPort))
    if err != nil {
        s.CleanupHandlers()
        return err
    }
    s.handlers = handlers

    return nil
}

从网卡中过滤数据包 tcp and dst port %d and tcp[13]=18

%d 即第一步获取的空闲端口,tcp[13]=18 即tcp的第十三位偏移的值为18,即仅抓取 TCP SYN标记的数据包。

监听数据

通过pcap监听数据

go
func TCPReadWorkerPCAPUnix(s *Scanner) {
    defer s.CleanupHandlers()

    var wgread sync.WaitGroup

    handlers := s.handlers.(Handlers)

    for _, handler := range handlers.Active {
        wgread.Add(1)
        go func(handler *pcap.Handle) {
            defer wgread.Done()

            var (
                eth layers.Ethernet
                ip4 layers.IPv4
                tcp layers.TCP
            )

            // Interfaces with MAC (Physical + Virtualized)
            parserMac := gopacket.NewDecodingLayerParser(layers.LayerTypeEthernet, ð, &ip4, &tcp)
            // Interfaces without MAC (TUN/TAP)
            parserNoMac := gopacket.NewDecodingLayerParser(layers.LayerTypeIPv4, &ip4, &tcp)

            var parsers []*gopacket.DecodingLayerParser
            parsers = append(parsers, parserMac, parserNoMac)

            decoded := []gopacket.LayerType{}

            for {
                data, _, err := handler.ReadPacketData()
                if err == io.EOF {
                    break
                } else if err != nil {
                    continue
                }

                for _, parser := range parsers {
                    if err := parser.DecodeLayers(data, &decoded); err != nil {
                        continue
                    }
                    for _, layerType := range decoded {
                        if layerType == layers.LayerTypeTCP {
                            if !s.IPRanger.Contains(ip4.SrcIP.String()) {
                                gologger.Debugf("Discarding TCP packet from non target ip %s\n", ip4.SrcIP.String())
                                continue
                            }

                            // We consider only incoming packets
                            if tcp.DstPort != layers.TCPPort(s.listenPort) {
                                continue
                            } else if tcp.SYN && tcp.ACK {
                                s.tcpChan <- &PkgResult{ip: ip4.SrcIP.String(), port: int(tcp.SrcPort)}
                            }
                        }
                    }
                }
            }
        }(handler)
    }

    wgread.Wait()
}

如果dstport为我们监听的端口,并且标志位是 syn+ack,就将端口和ip加入到结果中。

发送数据包

核心内容是从之前监听的tcp发送。

go
// SendAsyncPkg sends a single packet to a port
func (s *Scanner) SendAsyncPkg(ip string, port int, pkgFlag PkgFlag) {
    // Construct all the network layers we need.
    ip4 := layers.IPv4{
        SrcIP:    s.SourceIP,
        DstIP:    net.ParseIP(ip),
        Version:  4,
        TTL:      255,
        Protocol: layers.IPProtocolTCP,
    }
    tcpOption := layers.TCPOption{
        OptionType:   layers.TCPOptionKindMSS,
        OptionLength: 4,
        OptionData:   []byte{0x05, 0xB4},
    }

    tcp := layers.TCP{
        SrcPort: layers.TCPPort(s.listenPort),
        DstPort: layers.TCPPort(port),
        Window:  1024,
        Seq:     s.tcpsequencer.Next(),
        Options: []layers.TCPOption{tcpOption},
    }

    if pkgFlag == SYN {
        tcp.SYN = true
    } else if pkgFlag == ACK {
        tcp.ACK = true
    }

    err := tcp.SetNetworkLayerForChecksum(&ip4)
    if err != nil {
        if s.debug {
            gologger.Debugf("Can not set network layer for %s:%d port: %s\n", ip, port, err)
        }
    } else {
        err = s.send(ip, s.tcpPacketlistener, &tcp)
        if err != nil {
            if s.debug {
                gologger.Debugf("Can not send packet to %s:%d port: %s\n", ip, port, err)
            }
        }
    }
}


// send sends the given layers as a single packet on the network.
func (s *Scanner) send(destIP string, conn net.PacketConn, l ...gopacket.SerializableLayer) error {
    buf := gopacket.NewSerializeBuffer()
    if err := gopacket.SerializeLayers(buf, s.serializeOptions, l...); err != nil {
        return err
    }

    var (
        retries int
        err     error
    )

send:
    if retries >= maxRetries {
        return err
    }
    _, err = conn.WriteTo(buf.Bytes(), &net.IPAddr{IP: net.ParseIP(destIP)})
    if err != nil {
        retries++
        // introduce a small delay to allow the network interface to flush the queue
        time.Sleep(time.Duration(sendDelayMsec) * time.Millisecond)
        goto send
    }
    return err
}

其他

修改ulimit

大多数类UNIX操作系统(包括Linux和macOS)在每个进程和每个用户的基础上提供了系统资源的限制和控制(如线程,文件和网络连接)的方法。 这些“ulimits”阻止单个用户使用太多系统资源。

go
import (
    _ "github.com/projectdiscovery/fdmax/autofdmax"
)

修改ulimit,只针对unix系统

fdmax.go

go
// +build !windows

package fdmax

import (
    "runtime"

    "golang.org/x/sys/unix"
)

const (
    UnixMax uint64 = 999999
    OSXMax  uint64 = 24576
)

type Limits struct {
    Current uint64
    Max     uint64
}

func Get() (*Limits, error) {
    var rLimit unix.Rlimit
    err := unix.Getrlimit(unix.RLIMIT_NOFILE, &rLimit)
    if err != nil {
        return nil, err
    }

    return &Limits{Current: uint64(rLimit.Cur), Max: uint64(rLimit.Max)}, nil
}

func Set(maxLimit uint64) error {
    var rLimit unix.Rlimit
    rLimit.Max = maxLimit

    rLimit.Cur = maxLimit
    // https://github.com/golang/go/issues/30401
    if runtime.GOOS == "darwin" && rLimit.Cur > OSXMax {
        rLimit.Cur = OSXMax
    }

    return unix.Setrlimit(unix.RLIMIT_NOFILE, &rLimit)
}

随机IP PICK

go
import "github.com/projectdiscovery/ipranger"

ipranger 实现就是来自masscan的随机化地址扫描算法

https://paper.seebug.org/1052 写过

随机化地址扫描

在读取地址后,如果进行顺序扫描,伪代码如下

c
> for (i = 0; i < range; i++) {
    scan(i);
}

但是考虑到有的网段可能对扫描进行检测从而封掉整个网段,顺序扫描效率是较低的,所以需要将地址进行随机的打乱,用算法描述就是设计一个打乱数组的算法,Masscan是设计了一个加密算法,伪代码如下

c
> range = ip_count * port_count;
for (i = 0; i < range; i++) {
    x = encrypt(i);
    ip   = pick(addresses, x / port_count);
    port = pick(ports,     x % port_count);
    scan(ip, port);
}

随机种子就是i的值,这种加密算法能够建立一种一一对应的映射关系,即在[1…range]的区间内通过i来生成[1…range]内不重复的随机数。同时如果中断了扫描,只需要记住i的值就能重新启动,在分布式上也可以根据i来进行。

  • 如果对这个加密算法感兴趣可以看 Ciphers with Arbitrary Finite Domains 这篇论文。

可缓存的hashmap

ipranger中使用了github.com/projectdiscovery/hmap/store/hybrid

看了下代码,是一个带缓存功能的hashmap,也带有超时时间。

所有添加的目标(ip)会加入到缓存中,让我想到ksubdomain中也有实现类似的功能,不过是在内存中进行,导致目标很多的时候内存操作会有点问题。如果用这个库应该可以解决这个问题 。

总结

naabu的代码架构很清晰,一个文件完成一个功能,通过看文件名就知道这个实现了什么功能,所以看代码的时候很轻松。

  1. 但是从代码来看,naabu只是实现了在linux上的syn,在Windows上会使用三次握手的tcp连接(基于pcap,可以实现在windows上组合tcp发包的,但naabu没有实现)。

  2. naabu的目标添加是先循环读取目标一遍,如果目标cidr地址很大,会造成很多内存占用(虽然也会有硬盘缓存),如果边读取边发送就没有这种烦恼,但naabu不是这样的。

  3. naabu的重试次数,不是对某个ip:port的发送失败的重试,是对所有目标的重试。。

naabu还不是心中完美的扫描器 - =

撰写

布局切换

调整 VitePress 的布局样式,以适配不同的阅读习惯和屏幕环境。

全部展开
使侧边栏和内容区域占据整个屏幕的全部宽度。
全部展开,但侧边栏宽度可调
侧边栏宽度可调,但内容区域宽度不变,调整后的侧边栏将可以占据整个屏幕的最大宽度。
全部展开,且侧边栏和内容区域宽度均可调
侧边栏宽度可调,但内容区域宽度不变,调整后的侧边栏将可以占据整个屏幕的最大宽度。
原始宽度
原始的 VitePress 默认布局宽度

页面最大宽度

调整 VitePress 布局中页面的宽度,以适配不同的阅读习惯和屏幕环境。

调整页面最大宽度
一个可调整的滑块,用于选择和自定义页面最大宽度。

内容最大宽度

调整 VitePress 布局中内容区域的宽度,以适配不同的阅读习惯和屏幕环境。

调整内容最大宽度
一个可调整的滑块,用于选择和自定义内容最大宽度。

聚光灯

支持在正文中高亮当前鼠标悬停的行和元素,以优化阅读和专注困难的用户的阅读体验。

ON开启
开启聚光灯。
OFF关闭
关闭聚光灯。

聚光灯样式

调整聚光灯的样式。

置于底部
在当前鼠标悬停的元素下方添加一个纯色背景以突出显示当前鼠标悬停的位置。
置于侧边
在当前鼠标悬停的元素旁边添加一条固定的纯色线以突出显示当前鼠标悬停的位置。