扫描器解析日记之目标探测

admin 2026-05-31 04:14:05 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文以Fscan扫描器为例,详细分析了目标探测模块的实现逻辑,包括IP解析、存活检测机制。文章指出Fscan在A段扫描时采用随机策略可能遗漏资产,并提供了改进方案实现完整扫描。同时分析了ICMP依赖可能导致漏报的问题,提出通过TCP连接检测作为补充方案。文章还解析了代码中的并发处理、代理配置等关键技术点,为安全工具开发提供了实践参考。 综合评分: 89 文章分类: 漏洞分析,安全工具,WEB安全,技术标准


cover_image

扫描器解析日记之目标探测

原创

Qiu Qiu

稻草人安全团队

2024年10月9日 12:17 湖南

在小说阅读器读本章

去阅读

开发和代码是作为一名安全人员不可或缺的能力,而我们现在学习就可以以前人开发的工具入手,学习其代码逻辑,设计理念等等。从而写出更好的属于自己的工具。

本篇文章以几款扫描器为例,分析其前期对目标探测的模块进行入手学习。

Fscan

在读取完各种参数后,进入到解析ip中

若传入的不是文件且包含端口的ip,则先分割出ip和port然后丢入 ParseIPs进行解析,如果没有携带端口则直接进入ParseIPs,若是文件则进行文件处理后再解析,所以我们跟进到 ParseIPs

1. func ParseIP(host string, filename string, nohosts ...string) (hosts []string, err error) {
2. if filename == "" && strings.Contains(host, ":") {
3. //192.168.0.0/16:80
4. hostport := strings.Split(host, ":")
5. if len(hostport) == 2 {
6. host = hostport[0]
7. hosts = ParseIPs(host)
8. Ports = hostport[1]
9. }
10. } else {
11. hosts = ParseIPs(host)
12. if filename != "" {
13. var filehost []string
14. filehost, _ = Readipfile(filename)
15. hosts = append(hosts, filehost...)
16. }
17. }
18. //nohosts不扫描的ip
19. //..篇幅省略
20. //去重
21. hosts = RemoveDuplicate(hosts)

23. if len(hosts) == 0 && len(HostPort) == 0 && host != "" && filename != "" {
24. err = ParseIPErr
25. }
26. return
27. }

主要是这个对于A段扫描时处理不够完善,fscan为了避免扫描过多的ip采用了随机扫描的方式,如果用户就是需要扫描整个/8网段,则可能会遗漏,我们可以对其修改如下

1. func parseIP(ip string) []string {
2. reg := regexp.MustCompile(`[a-zA-Z]+`)
3. switch {
4. case ip == "192":
5. return parseIP("192.168.0.0/8")
6. case ip == "172":
7. return parseIP("172.16.0.0/12")
8. case ip == "10":
9. return parseIP("10.0.0.0/8")
10. // 扫描/8时,只扫网关和随机IP,避免扫描过多IP
11. case strings.HasSuffix(ip, "/8"):
12. return parseIP8(ip)
13. //解析 /24 /16 /8 /xxx 等
14. case strings.Contains(ip, "/"):
15. return parseIP2(ip)
16. //可能是域名,用lookup获取ip
17. case reg.MatchString(ip):
18. //  _, err := net.LookupHost(ip)
19. //  if err != nil {
20. //      return nil
21. //  }
22. return []string{ip}
23. //192.168.1.1-192.168.1.100
24. case strings.Contains(ip, "-"):
25. return parseIP1(ip)
26. //处理单个ip
27. default:
28. testIP := net.ParseIP(ip)
29. if testIP == nil {
30. return nil
31. }
32. return []string{ip}
33. }
34. }

这里我直接就参考dddd的写法改写了

1. func parseIP8(ip string) []string {
2. var AllIP []string
3. for _, i := range CIDRToIP(ip) {
4. AllIP = append(AllIP, i.String())
5. }
6. return AllIP
7. }

9. func CIDRToIP(cidr string) (IPs []net.IP) {
10. _, network, _ := net.ParseCIDR(cidr)
11. first := FirstIP(network)
12. last := LastIP(network)
13. return pairsToIP(first, last)
14. }

16. func FirstIP(network *net.IPNet) net.IP {
17. return network.IP
18. }

20. func LastIP(network *net.IPNet) net.IP {
21. firstIP := FirstIP(network)
22. mask, _ := network.Mask.Size()
23. size := math.Pow(2, float64(32-mask))
24. lastIP := toIP(toInt(firstIP) + uint32(size) - 1)
25. return net.ParseIP(lastIP)
26. }

28. func toIP(i uint32) string {
29. buf := bytes.NewBuffer([]byte{})
30. _ = binary.Write(buf, binary.BigEndian, i)
31. b := buf.Bytes()
32. return fmt.Sprintf("%v.%v.%v.%v", b[0], b[1], b[2], b[3])
33. }

35. func toInt(ip net.IP) uint32 {
36. var buf = []byte(ip)
37. if len(buf) > 12 {
38. buf = buf[12:]
39. }
40. buffer := bytes.NewBuffer(buf)
41. var i uint32
42. _ = binary.Read(buffer, binary.BigEndian, &i)
43. return i
44. }

46. func pairsToIP(ip1, ip2 net.IP) (IPs []net.IP) {
47. start := toInt(ip1)
48. end := toInt(ip2)
49. for i := start; i <= end; i++ {
50. IPs = append(IPs, net.ParseIP(toIP(i)))
51. }
52. return IPs
53. }

效果如下

接着就是初始化一些http客户端的参数

web poc的线程数 ThreadsNum、代理类型 DownProxy 和超时时间 Timeout。主要目的是配置一个 http.Client 实例,在进行 HTTP 请求时使用指定的代理和超时设置

如果noping参数为false,或者扫描参数为icmp则进行 CheckLive的存活探测

common.LogWG.Wait()这个是日志同步,等待所有日志记录操作完成。确保所有日志记录操作都已完成,避免日志丢失。

监听一个通道( chanHosts)以接收IP地址。当接收到一个IP地址时,它检查该IP是否不在 ExistHosts映射中,并且是否在 hostslist中。如果两个条件都为真,则将IP添加到 ExistHostsAliveHosts中,并打印一条消息。

这里利用一个 ExistHosts[ip]=struct{}{}

利用Go语言的映射(map)特性来实现一个集合(set)的功能。由于映射中的值可以是任意类型,而这里使用的是空结构体 struct{}{},所以实际上我们并不关心值本身,而是关心键(即IP地址)是否存在。

通过这种方式,我们可以快速地检查一个IP地址是否已经存在于 ExistHosts 中,如果存在,则不需要再次添加。同时,由于空结构体不占用任何内存空间,所以这种做法也非常节省内存。

1. func CheckLive(hostslist []string, Ping bool) []string {
2. //创建一个缓冲通道,容量为 hostslist 的长度
3. chanHosts := make(chan string, len(hostslist))
4. go func() {
5. for ip := range chanHosts {
6. if _, ok := ExistHosts[ip]; !ok && IsContain(hostslist, ip) {
7. ExistHosts[ip] = struct{}{}
8. if common.Silent == false {
9. if Ping == false {
10. fmt.Printf("(icmp) Target %-15s is alive\n", ip)
11. } else {
12. fmt.Printf("(ping) Target %-15s is alive\n", ip)
13. }
14. }
15. AliveHosts = append(AliveHosts, ip)
16. }
17. livewg.Done()
18. }
19. }()

下面就是选择用ping还是icmp进行探测

默认ping参数为false,所以优先尝试监听本地icmp, 进入 RunIcmp1

这个函数主要逻辑就是

  1. 遍历 hostslist切片中的每个IP地址。
  2. 对于每个IP地址,发送一个ICMP回显请求到该IP地址。
  3. 在后台运行一个goroutine,监听ICMP回显应答。
  4. 如果收到ICMP回显应答,表示目标IP地址存活,将其添加到 AliveHosts列表中。
  5. 等待一段时间,如果 AliveHosts列表中的IP地址数量与 hostslist中的IP地址数量相匹配,则表示所有IP地址都已探测完成。

若是权限不够的话则会选择使用 RunPing进行探测

具体实现在 ExecCommandPing函数

根据回显,返回 true 则 ping 成功,否则返回 false

1. func RunPing(hostslist []string, chanHosts chan string) {
2. var wg sync.WaitGroup
3. limiter := make(chan struct{}, 50)
4. for _, host := range hostslist {
5. wg.Add(1)
6. limiter <- struct{}{}
7. go func(host string) {
8. if ExecCommandPing(host) {
9. livewg.Add(1)
10. chanHosts <- host
11. }
12. <-limiter
13. wg.Done()
14. }(host)
15. }
16. wg.Wait()
17. }

在某些环境配置中会有 echo1>/proc/sys/net/ipv4/icmp_echo_ignore_all 这种就不会返回任何ICMP的响应。而fscan默认的扫描策略强依赖于ICMP协议,所以有可能在一些情况漏掉部分资产。

具体实现,通过建立一个TCP的连接,如果连接成功则会记录一个成功消息并将主机地址发送到一个通道。

1. func PortConnect(addr Addr, respondingHosts chan<- string, adjustedTimeout int64, wg *sync.WaitGroup) {
2. host, port := addr.ip, addr.port
3. conn, err := common.WrapperTcpWithTimeout("tcp4", fmt.Sprintf("%s:%v", host, port), time.Duration(adjustedTimeout)*time.Second)
4. if err == nil {
5. defer conn.Close()
6. address := host + ":" + strconv.Itoa(port)
7. result := fmt.Sprintf("%s open", address)
8. common.LogSuccess(result)
9. wg.Add(1)
10. respondingHosts <- address
11. }

WrapperTcpWithTimeout实现了一个TCP的包装器

1. func WrapperTcpWithTimeout(network, address string, timeout time.Duration) (net.Conn, error) {
2. d := &net.Dialer{Timeout: timeout}
3. return WrapperTCP(network, address, d)
4. }

6. func WrapperTCP(network, address string, forward *net.Dialer) (net.Conn, error) {
7. //get conn
8. var conn net.Conn
9. if Socks5Proxy == "" {
10. var err error
11. conn, err = forward.Dial(network, address)
12. if err != nil {
13. return nil, err
14. }
15. } else {
16. dailer, err := Socks5Dailer(forward)
17. if err != nil {
18. return nil, err
19. }
20. conn, err = dailer.Dial(network, address)
21. if err != nil {
22. return nil, err
23. }
24. }
25. return conn, nil

27. }

GoGo

看完了fscan的探测逻辑,接下来可以看看其他工具的这部分的相关逻辑,这里拿dddd以及gogo为例

直接跟进到gogo的默认直接扫描逻辑

跟进 plugin.Dispatch(result)

1. func Dispatch(result *pkg.Result) {
2. defer func() {
3. if err := recover(); err != nil {
4. logs.Log.Errorf("scan %s unexcept error, %v", result.GetTarget(), err)
5. panic(err)
6. }
7. }()
8. atomic.AddInt32(&RunOpt.Sum, 1)
9. if result.Port == "137" || result.Port == "nbt" {
10. nbtScan(result)
11. return
12. } else if result.Port == "135" || result.Port == "wmi" {
13. wmiScan(result)
14. return
15. } else if result.Port == "oxid" {
16. oxidScan(result)
17. return
18. } else if result.Port == "icmp" || result.Port == "ping" {
19. icmpScan(result)
20. return
21. } else if result.Port == "snmp" || result.Port == "161" {
22. snmpScan(result)
23. return
24. } else if result.Port == "445" || result.Port == "smb" {
25. smbScan(result)
26. if RunOpt.Exploit == "ms17010" {
27. ms17010Scan(result)
28. } else if RunOpt.Exploit == "smbghost" || RunOpt.Exploit == "cve-2020-0796" {
29. smbGhostScan(result)
30. } else if RunOpt.Exploit == "auto" || RunOpt.Exploit == "smb" {
31. ms17010Scan(result)
32. smbGhostScan(result)
33. }
34. return
35. } else if result.Port == "mssqlntlm" {
36. mssqlScan(result)
37. return
38. } else if result.Port == "winrm" {
39. winrmScan(result)
40. return
41. } else {
42. initScan(result)
43. }
44. ....
45. ...

可以看到,它对一些特定端口服务针对性的做了一些定制化的扫描,比如135(wmi)、161(snmp), 一般大部分情况来说是是不会有其他情况占用的。

跟进到默认的initScan

1. func initScan(result *pkg.Result) {
2. var bs []byte
3. target := result.GetTarget()
4. if pkg.ProxyUrl != nil && strings.HasPrefix(pkg.ProxyUrl.Scheme, "http") {
5. // 如果是http代理, 则使用http库代替socket
6. conn := result.GetHttpConn(RunOpt.Delay)
7. resp, err := pkg.HTTPGet(conn, "http://"+target)
8. if err != nil {
9. return
10. }
11. if err != nil {
12. result.Err = err
13. return
14. }
15. result.Open = true
16. pkg.CollectHttpResponse(result, resp)
17. } else {
18. defer func() {
19. // 如果进行了各种探测依旧为tcp协议, 则收集tcp端口状态
20. if result.Protocol == "tcp" {
21. if result.Err != nil {
22. result.Error = result.Err.Error()
23. if RunOpt.Debug {
24. result.ErrStat = handleError(result.Err)
25. }
26. }
27. }
28. }()
29. conn, err := pkg.NewSocket("tcp", target, RunOpt.Delay)
30. if err != nil {
31. result.Err = err
32. return
33. }
34. defer conn.Close()
35. result.Open = true

37. // 启发式扫描探测直接返回不需要后续处理
38. if result.SmartProbe {
39. return
40. }
41. result.Status = "open"

43. bs, err = conn.Read(RunOpt.Delay)
44. if err != nil {
45. senddataStr := fmt.Sprintf("GET /%s HTTP/1.1\r\nHost: %s\r\n\r\n", result.Uri, target)
46. bs, err = conn.Request([]byte(senddataStr), DefaultMaxSize)
47. if err != nil {
48. result.Err = err
49. }
50. }
51. pkg.CollectSocketResponse(result, bs)
52. }

54. //所有30x,400,以及非http协议的开放端口都送到http包尝试获取更多信息
55. if result.Status == "400" || result.Protocol == "tcp" || (strings.HasPrefix(result.Status, "3") && bytes.Contains(result.Content, []byte("location: https"))) {
56. systemHttp(result, "https")
57. } else if strings.HasPrefix(result.Status, "3") {
58. systemHttp(result, "http")
59. }

61. return
62. }

这里不关注代理功能先,我们看默认是进入了pkg.NewSocket进行探测,封装了一个Socket的结构体,使用了go自带的net库实现的TCP的连接

1. func NewSocket(network, target string, delay int) (*Socket, error) {
2. s := &Socket{
3. Timeout: time.Duration(delay) * time.Second,
4. }
5. var conn net.Conn
6. var err error
7. if ProxyDialTimeout != nil {
8. conn, err = ProxyDialTimeout(network, target, s.Timeout)
9. } else {
10. conn, err = net.DialTimeout(network, target, s.Timeout)
11. }
12. if err != nil {
13. return nil, err
14. }

16. s.Conn = conn
17. return s, nil
18. }

20. type Socket struct {
21. Conn    net.Conn
22. Count   int
23. Timeout time.Duration
24. }

最后还会将所有的30x,400,以及非http协议的开放端口都送到http包尝试获取更多信息。

可以发现,gogo的话其实为了尽可能的优化体积以及兼容性,绝大部分功能都是采用go自带库的进行实现,对性能的占用也能达到一个比较好的效果。

其设计理念和细节值得我们去慢慢学习。

Dddd

dddd呢则是更偏向于外网的扫描器,我们也是重点就看他的主要扫描逻辑

1. // 端口扫描
2. if len(ips) > 0 {
3. if !structs.GlobalConfig.SkipHostDiscovery {
4. var ICMPAlive []string
5. // ICMP 探测存活
6. if !structs.GlobalConfig.NoICMPPing {
7. ICMPAlive = common.CheckLive(ips, false)
8. }

10. // TCP 探测存活
11. var TCPAlive []string
12. if structs.GlobalConfig.TCPPing {
13. // 获取没有存活的进行探测
14. var uncheck []string
15. for _, ip := range ips {
16. index := utils.GetItemInArray(ICMPAlive, ip)
17. if index == -1 {
18. uncheck = append(uncheck, ip)
19. }
20. }
21. gologger.Info().Msg("TCP存活探测")
22. common.PortScan = false
23. tcpAliveIPPort := common.PortScanTCP(uncheck, "80,443,3389,445,22",
24. structs.GlobalConfig.NoPortString,
25. structs.GlobalConfig.TCPPortScanTimeout)
26. for _, tIPPort := range tcpAliveIPPort {
27. t := strings.Split(tIPPort, ":")
28. TCPAlive = append(TCPAlive, t[0])
29. }
30. }

首先是进行ICMP的探测存活,包括后面的TCP探测端口扫描,跟进之后发现其实实现代码大致是相同的

但是他这里比fscan多了一种SYN的扫描,是调用masscan进行SYN端口扫描

后面对结果经过处理后,调用Httpx进行获取相关ip的http响应

总结

其前期的探测逻辑也就到此为止,后续都是Web扫描,漏洞扫描,目录爆破等等功能,我们留到后面的文章进行分析。

本篇主要是对一些扫描器的代码进行阅读分析,并且从中发现一些借鉴学习的点或者其中一些不足之处,能够进行相关优化的地方,为想要以后自己开发扫描器的师傅提供一点学习的思路。站在巨人的肩膀上走的更远。

参考

https://chainreactors.github.io/wiki/gogo

https://github.com/shadow1ng/fscan

https://github.com/SleepingBag945/dddd

https://xz.aliyun.com/t/15318


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:稻草人安全团队 Qiu Qiu《扫描器解析日记之目标探测》

评论:0   参与:  0