开源工具分析-Fscan

本文首发于先知社区

今天思考了一下,web狗安身立命的技能主要是三个:渗透、安全开发、审计,开发貌似也是极其重要的,就像演艺圈一样必须得有自己拿得出手的一个作品,所以决定入门一下安全开发,从扫描器开始入手,扫描器的标杆应该就是fscan了,所以试试读一下fscan的源码,尝试写出比fscan更好的扫描器。

https://github.com/shadow1ng/fscan

fscan是面向过程编写的,先看一下目录结构,主要就是

common:放一些公用模块,比如参数解析,代理,配置

plugins:应该是最核心的目录,扫描器的主体,其中scanner.go文件负责了框架的调度流程

webscan:貌似是另开一个目录写的web扫描,实现了基于yml格式的web扫描指纹插件

image-20240805101701254

入口函数

func main() {
    start := time.Now()
    var Info common.HostInfo
    common.Flag(&Info)
    common.Parse(&Info)
    Plugins.Scan(Info)
    t := time.Now().Sub(start)
    fmt.Printf("[*] 扫描结束,耗时: %s\n", t)
}
  • common.Flag:从命令行获取输入的参数,并根据参数准备程序运行的方式
  • common.Parse:解析输入的内容,如从文件中读取主机,将主机范围转化为主机切片
  • Plugins.Scan:开始进行扫描

参数解析

涉及入口函数里的

common.Flag(&Info)
common.Parse(&Info)

FLAG用到了flag库,将命令行输入的参数保存到内存中,也设置了一些默认值啥的,保存完了以后parse进行解析

func Parse(Info *HostInfo) {
	ParseUser()
	ParsePass(Info)
	ParseInput(Info)
	ParseScantype(Info)
}

解析的流程就是这四个函数,前两个对输入的用户名密码进行解析,后面解析输入的一堆参数,去一下重,把添加的数据和默认数据组合一下啥的,至于ParseScantype,是检查采用的模块,就是很简单的用啥模块就用switch去选择哪个端口

scan

初始化

到最重要的scan模块了

首先解析一下host,这一步依旧是参数解析,解析一下输入的参数,去一下重啥的

Hosts, err := common.ParseIP(info.Host, common.HostFile, common.NoHosts)

接着初始化一个http客户端,不知道有啥用

lib.Inithttp()

继续初始化

var ch = make(chan struct{}, common.Threads)

make 函数用于创建一个新的通道。

chan struct{}: 定义了一个通道,通道的元素类型是 struct{}。在这里,struct{} 是一个空的结构体,通常用于作为信号的占位符,不携带任何数据。

common.Threads: 通道的缓冲区大小。这意味着通道可以同时容纳 common.Threads 个信号。如果缓冲区满,则发送操作会被阻塞,直到有空间可用。

var wg = sync.WaitGroup{}

sync.WaitGroup{}:

  • sync.WaitGroup 是一个用于等待一组操作完成的同步原语。
  • sync.WaitGroup 提供了三个主要方法:Add(增加计数)、Done(减少计数)和 Wait(等待计数变为零)。
web := strconv.Itoa(common.PORTList["web"])
ms17010 := strconv.Itoa(common.PORTList["ms17010"])

从映射中获取并转换端口

common.PORTList:

  • PORTList 是一个映射,其键是 string 类型,值是 int 类型。

  • strconv.Itoa 是一个用于将整数转换为字符串的函数。

进入扫描

到了这么if差不多就正式进入扫描了

if len(Hosts) > 0 || len(common.HostPort) > 0

首先是判断是否进行存活扫描,并打印存活主机的数量。

if common.NoPing == false && len(Hosts) > 1 || common.Scantype == "icmp" {
			Hosts = CheckLive(Hosts, common.Ping)
			fmt.Println("[*] Icmp alive hosts len is:", len(Hosts))
		}

探活

先来看一下fscan是怎么探活的

第一部分

chanHosts := make(chan string, len(hostslist))
	go func() {
		for ip := range chanHosts {
			if _, ok := ExistHosts[ip]; !ok && IsContain(hostslist, ip) {
				ExistHosts[ip] = struct{}{}
				if common.Silent == false {
					if Ping == false {
						fmt.Printf("(icmp) Target %-15s is alive\n", ip)
					} else {
						fmt.Printf("(ping) Target %-15s is alive\n", ip)
					}
				}
				AliveHosts = append(AliveHosts, ip)
			}
			livewg.Done()
		}
	}()

chanHosts := make(chan string, len(hostslist))这里先创建了一个信道用于传递ip

range chanHosts: 这个 range 语句会从 chanHosts 通道中接收数据,直到通道关闭为止。每次从通道中接收到一个数据项,ip 变量会被赋值为通道中的数据。

Goroutine: 异步执行主机存活状态的处理。它从通道中读取 IP 地址,并检查是否已存在。如果主机存活且不在 ExistHosts 中,则将其添加到 AliveHosts 列表中,并打印相关信息。

选择检测方法:

if Ping == true {
		//使用ping探测
		RunPing(hostslist, chanHosts)
	} else {
		//优先尝试监听本地icmp,批量探测
		conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
		if err == nil {
			RunIcmp1(hostslist, conn, chanHosts)
		} else {
			common.LogError(err)
			//尝试无监听icmp探测
			fmt.Println("trying RunIcmp2")
			conn, err := net.DialTimeout("ip4:icmp", "127.0.0.1", 3*time.Second)
			defer func() {
				if conn != nil {
					conn.Close()
				}
			}()
			if err == nil {
				RunIcmp2(hostslist, chanHosts)
			} else {
				common.LogError(err)
				//使用ping探测
				fmt.Println("The current user permissions unable to send icmp packets")
				fmt.Println("start ping")
				RunPing(hostslist, chanHosts)
			}
		}
	}

ps:ping 实际上就是一种 ICMP 探测。

ping 是一个现成的工具,直接使用,简单易用。

ping可以看作是ICMP探活的一个子集或特例。ICMP探活提供了更多的可能性和灵活性

这里的优势:

  • 直接使用 ICMP 包通常比调用系统的 ping 命令消耗更少的资源
  • 代码会先尝试需要较高权限的方法(如 ICMP 监听),如果失败则退回到可能需要较低权限的方法。

看一下ping的具体实现:

func RunPing(hostslist []string, chanHosts chan string) {
	var wg sync.WaitGroup
	limiter := make(chan struct{}, 50)
	for _, host := range hostslist {
		wg.Add(1)
		limiter <- struct{}{}
		go func(host string) {
			if ExecCommandPing(host) {
				livewg.Add(1)
				chanHosts <- host
			}
			<-limiter
			wg.Done()
		}(host)
	}
	wg.Wait()
}

使用一个容量为 50 的 channel(limiter)来限制同时进行的 ping 操作数量,其实就是==信号量==。struct{}{} 是一个空结构体,不占用任何内存空间,所以它经常被用作信号的载体。<- 操作符在这里表示向 channel 发送数据。limiter <- struct{}{}limiter channel 发送一个空结构体。如果 channel 已满(即当前已有 50 个并发操作在进行),这个操作会阻塞,直到 channel 有空位。<-limiter这个操作的含义是:“从 limiter channel 中取出一个值”。如果 channel 为空,这个操作会阻塞直到有数据可取。

这是一个demo

package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

func main() {
	// 创建一个容量为3的limiter
	limiter := make(chan struct{}, 3)
	var wg sync.WaitGroup

	// 模拟10个任务
	for i := 1; i <= 10; i++ {
		wg.Add(1)
		go func(taskID int) {
			defer wg.Done()
			fmt.Printf("Task %d is waiting to start\n", taskID)
			// 占用一个槽位
			limiter <- struct{}{}
			fmt.Printf("Task %d has started\n", taskID)
			// 模拟任务执行
			time.Sleep(time.Duration(rand.Intn(5)) * time.Second)
			// 释放槽位
			<-limiter
			fmt.Printf("Task %d has finished\n", taskID)
		}(i)
	}
	wg.Wait()
	fmt.Println("All tasks completed")
}

使用 sync.WaitGroup(wg)来确保所有 goroutines 完成后才结束函数。

ExecCommandPing其实就是调用系统命令了,chanHosts <- host把存活的host放到上面定义好的chan里

func ExecCommandPing(ip string) bool {
	var command *exec.Cmd
	switch runtime.GOOS {
	case "windows":
		command = exec.Command("cmd", "/c", "ping -n 1 -w 1 "+ip+" && echo true || echo false") //ping -c 1 -i 0.5 -t 4 -W 2 -w 5 "+ip+" >/dev/null && echo true || echo false"
	case "darwin":
		command = exec.Command("/bin/bash", "-c", "ping -c 1 -W 1 "+ip+" && echo true || echo false") //ping -c 1 -i 0.5 -t 4 -W 2 -w 5 "+ip+" >/dev/null && echo true || echo false"
	default: //linux
		command = exec.Command("/bin/bash", "-c", "ping -c 1 -w 1 "+ip+" && echo true || echo false") //ping -c 1 -i 0.5 -t 4 -W 2 -w 5 "+ip+" >/dev/null && echo true || echo false"
	}
	outinfo := bytes.Buffer{}
	command.Stdout = &outinfo
	err := command.Start()
	if err != nil {
		return false
	}
	if err = command.Wait(); err != nil {
		return false
	} else {
		if strings.Contains(outinfo.String(), "true") && strings.Count(outinfo.String(), ip) > 2 {
			return true
		} else {
			return false
		}
	}
}

再来看icmp探活的实现,就不是调用系统执行命令了,感觉这样效率应该会高一些,坏处就是需要自己去写实现网络通信的一些处理代码

func icmpalive(host string) bool {
	startTime := time.Now()
	conn, err := net.DialTimeout("ip4:icmp", host, 6*time.Second)
	if err != nil {
		return false
	}
	defer conn.Close()
	if err := conn.SetDeadline(startTime.Add(6 * time.Second)); err != nil {
		return false
	}
	msg := makemsg(host)
	if _, err := conn.Write(msg); err != nil {
		return false
	}

	receive := make([]byte, 60)
	if _, err := conn.Read(receive); err != nil {
		return false
	}

	return true
}

等待所有探测完成并关闭通道:

livewg.Wait()
close(chanHosts)

livewg.Wait(): 等待所有 Goroutine 完成。

close(chanHosts): 关闭通道,表示探测完成。

最后处理探测结果返回数据

端口扫描

我们继续看scanner.go,这里就是各种端口扫描的类型。注意这里各种端口扫描的参数Hosts就是上面的探活的结果

这里调用的函数位于portscan.go文件

if common.Scantype == "webonly" || common.Scantype == "webpoc" {
			AlivePorts = NoPortScan(Hosts, common.Ports)
		} else if common.Scantype == "hostname" {
			common.Ports = "139"
			AlivePorts = NoPortScan(Hosts, common.Ports)
		} else if len(Hosts) > 0 {
			AlivePorts = PortScan(Hosts, common.Ports, common.Timeout)
			fmt.Println("[*] alive ports len is:", len(AlivePorts))
			if common.Scantype == "portscan" {
				common.LogWG.Wait()
				return
			}
		}

如果扫描类型是 “webonly” 或 “webpoc”:

  • 使用 NoPortScan 函数,可能是为了快速检查web服务,而不进行完整的端口扫描。

如果扫描类型是 “hostname”:

  • 将端口设置为 “139”(通常用于NetBIOS会话服务)。
  • 同样使用 NoPortScan 函数。

对于其他扫描类型(如果主机列表不为空):

  • 使用 PortScan 函数进行完整的端口扫描。
  • 打印出活跃端口的数量。
  • 如果扫描类型是 “portscan”,则等待日志写入完成后直接返回。

先看下noport,也就是webonly和hostname的情况

webonly会有一堆端口,hostname只有139

probePorts := common.ParsePort(ports)
noPorts := common.ParsePort(common.NoPorts)

ParsePort函数是用来解析端口字符串并返回一个整数切片,common.NoPorts是要排除的端口

这一段是去除掉不扫描的端口

if len(noPorts) > 0 {
		temp := map[int]struct{}{}
		for _, port := range probePorts {
			temp[port] = struct{}{}
		}

		for _, port := range noPorts {
			delete(temp, port)
		}

		var newDatas []int
		for port, _ := range temp {
			newDatas = append(newDatas, port)
		}
		probePorts = newDatas
		sort.Ints(probePorts)
	}

map[int]struct{}{} 是 Go 语言中的一种语法,用于创建和初始化一个空的 map。在这个 map 中,键的类型是 int,值的类型是 struct{},多出来的{} 用于初始化数据结构。在这它用于初始化一个空的 map

最后就是得到全部要扫描的ip和端口的组合,好家伙,这里意思就是直接默认给出的端口存在了,不扫了

for _, port := range probePorts {
		for _, host := range hostslist {
			address := host + ":" + strconv.Itoa(port)
			AliveAddress = append(AliveAddress, address)
		}
	}

再来看PortScan

PortScan(Hosts, common.Ports, common.Timeout)

先来看准备部分,这里又出现了上面的解析端口以及去掉不需要扫描的端口的部分,感觉代码有点冗余

var AliveAddress []string
	probePorts := common.ParsePort(ports)
	if len(probePorts) == 0 {
		fmt.Printf("[-] parse port %s error, please check your port format\n", ports)
		return AliveAddress
	}
	noPorts := common.ParsePort(common.NoPorts)
	if len(noPorts) > 0 {
		temp := map[int]struct{}{}
		for _, port := range probePorts {
			temp[port] = struct{}{}
		}

		for _, port := range noPorts {
			delete(temp, port)
		}

		var newDatas []int
		for port := range temp {
			newDatas = append(newDatas, port)
		}
		probePorts = newDatas
		sort.Ints(probePorts)
	}

然后就是真正的端口扫描部分

workers := common.Threads
	Addrs := make(chan Addr, 100)
	results := make(chan string, 100)
	var wg sync.WaitGroup

	//接收结果
	go func() {
		for found := range results {
			AliveAddress = append(AliveAddress, found)
			wg.Done()
		}
	}()

	//多线程扫描
	for i := 0; i < workers; i++ {
		go func() {
			for addr := range Addrs {
				PortConnect(addr, results, timeout, &wg)
				wg.Done()
			}
		}()
	}

	//添加扫描目标
	for _, port := range probePorts {
		for _, host := range hostslist {
			wg.Add(1)
			Addrs <- Addr{host, port}
		}
	}
	wg.Wait()
	close(Addrs)
	close(results)
	return AliveAddress

按逻辑来说首先应该是添加扫描目标

这里也就是组合端口和ip,wg.Add(1)设置等待向创建的Addrs添加一条数据,Addrs容量是100,所以这里只能同时扫100个ip:port的组合

//添加扫描目标
	for _, port := range probePorts {
		for _, host := range hostslist {
			wg.Add(1)
			Addrs <- Addr{host, port}
		}
	}

然后是开扫,从Addrs拿一个数据调用PortConnect

//多线程扫描
	for i := 0; i < workers; i++ {
		go func() {
			for addr := range Addrs {
				PortConnect(addr, results, timeout, &wg)
				wg.Done()
			}
		}()
	}

看一下PortConnect,这算是fscan端口扫描的核心代码了吧

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

用的其实还是go的net包,后面我去研究了一下端口扫描器的实现,貌似用net包就可以了,此外net包还有很多其他的功能可以让我开发其他的安全攻击

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

不过这里我没看到在哪里把结果传给results这个信道的

最后就是接收结果,遍历results信道添加扫到的结果

//接收结果
	go func() {
		for found := range results {
			AliveAddress = append(AliveAddress, found)
			wg.Done()
		}
	}()

然后这里不是很理解sync.WaitGroup咋用的,gpt写了个demo,感觉可以理解为wg.Add相当于给这个线程打个标记,结束了就执行wg.Done,然后sync.WaitGroup会一直监视有没有打了标记但是还没执行Done方法的(还没结束),就会一直等着

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	var wg sync.WaitGroup

	// 要启动的 goroutine 数量
	numGoroutines := 3

	// 为每个 goroutine 增加计数
	for i := 1; i <= numGoroutines; i++ {
		wg.Add(1) // 计数器加 1
		go func(id int) {
			defer wg.Done() // 在 goroutine 结束时将计数器减 1

			fmt.Printf("Goroutine %d is starting\n", id)
			// 模拟工作
			time.Sleep(time.Second * time.Duration(id))
			fmt.Printf("Goroutine %d is done\n", id)
		}(i)
	}

	// 等待所有 goroutine 完成
	wg.Wait()
	fmt.Println("All goroutines have completed")
}

vulscan

端口的处理完毕,继续来看scanner.go,注释里说这里开始进入vulscan了

这里是根据不同情况对AddScan的调用

var severports []string //severports := []string{"21","22","135"."445","1433","3306","5432","6379","9200","11211","27017"...}
		for _, port := range common.PORTList {
			severports = append(severports, strconv.Itoa(port))
		}
		fmt.Println("start vulscan")
		for _, targetIP := range AlivePorts {
			info.Host, info.Ports = strings.Split(targetIP, ":")[0], strings.Split(targetIP, ":")[1]
			if common.Scantype == "all" || common.Scantype == "main" {
				switch {
				case info.Ports == "135":
					AddScan(info.Ports, info, &ch, &wg) //findnet
					if common.IsWmi {
						AddScan("1000005", info, &ch, &wg) //wmiexec
					}
				case info.Ports == "445":
					AddScan(ms17010, info, &ch, &wg) //ms17010
					//AddScan(info.Ports, info, ch, &wg)  //smb
					//AddScan("1000002", info, ch, &wg) //smbghost
				case info.Ports == "9000":
					AddScan(web, info, &ch, &wg)        //http
					AddScan(info.Ports, info, &ch, &wg) //fcgiscan
				case IsContain(severports, info.Ports):
					AddScan(info.Ports, info, &ch, &wg) //plugins scan
				default:
					AddScan(web, info, &ch, &wg) //webtitle
				}
			} else {
				scantype := strconv.Itoa(common.PORTList[common.Scantype])
				AddScan(scantype, info, &ch, &wg)
			}
		}

感觉这一块的代码写的很丑啊

主要来看addscan

func AddScan(scantype string, info common.HostInfo, ch *chan struct{}, wg *sync.WaitGroup) {
	*ch <- struct{}{}
	wg.Add(1)
	go func() {
		Mutex.Lock()
		common.Num += 1
		Mutex.Unlock()
		ScanFunc(&scantype, &info)
		Mutex.Lock()
		common.End += 1
		Mutex.Unlock()
		wg.Done()
		<-*ch
	}()
}

这里的锁操作又得补习一下了,然后这个函数调用的就是scanfunc,是动态调用插件的

func ScanFunc(name *string, info *common.HostInfo) {
	defer func() {
		if err := recover(); err != nil {
			fmt.Printf("[-] %v:%v scan error: %v\n", info.Host, info.Ports, err)
		}
	}()
	f := reflect.ValueOf(PluginList[*name])
	in := []reflect.Value{reflect.ValueOf(info)}
	f.Call(in)
}

ScanFunc 函数根据给定的插件名称动态调用相应的插件函数。它使用 reflect 包来实现动态函数调用,并在调用过程中处理可能发生的错误。

deferrecover:

  • 使用 deferrecover 处理函数调用中的异常。这样可以确保在函数发生异常时,能够输出错误信息,而不是让程序崩溃。

reflect.ValueOf(PluginList[\*name]):

  • reflect.ValueOf 用于获取 PluginList 中对应插件名称的函数值。

  • f.Call(in) 动态调用 PluginList 中的函数。in 是一个 []reflect.Value 切片,包含了要传递给函数的参数(在这里是 info)。

来看一下fscan是怎么调用的,来看PluginList,是一个在base.go中定义的map。

这里就是简单的把端口和插件名(go文件的名称)进行对应,然后进行调用,这里我不禁疑问,要是该服务用的不是默认端口咋办?

var PluginList = map[string]interface{}{
	"21":      FtpScan,
	"22":      SshScan,
	"135":     Findnet,
	"139":     NetBIOS,
	"445":     SmbScan,
	"1433":    MssqlScan,
	"1521":    OracleScan,
	"3306":    MysqlScan,
	"3389":    RdpScan,
	"5432":    PostgresScan,
	"6379":    RedisScan,
	"9000":    FcgiScan,
	"11211":   MemcachedScan,
	"27017":   MongodbScan,
	"1000001": MS17010,
	"1000002": SmbGhost,
	"1000003": WebTitle,
	"1000004": SmbScan2,
	"1000005": WmiExec,
}

然后就是去实现它的各种插件了,这里也能知道要是自己想拓展它的功能就需要到这个PluginList加自己的规则,然后写一个go文件。

web的扫描应该还涉及对yaml格式的poc的调用

读fscan剩下的任务应该还有

  • scanner.go最后这里动态调用机制的实现,包括线程锁,信号量啥的,看看怎么实现的
  • 重点看一下对web的扫描,debug一下看看流程,看看怎么去匹配指纹(icohash咋算),怎么解析、调用poc,怎么抓取和分析web
  • 大致看一下各种插件的实现
  • 看一下代理怎么实现的

这里再补习一下go的线程池的实现

这个例子很清晰了,可以看到work是用来处理输入信号和输出信号的地方,可以用for循环的方式启动3个worker,每个 worker 都在一个独立的 goroutine 中运行,这实际上就是启动了 3 个并发线程。

然后就是一个循环结构来发送信号(任务),往信道里发,goroutine中启动的worker会自动处理,然后再启动一个循环结构接收返回值

package main

import "fmt"
import "time"

// 这里是 worker,我们将并发执行多个 worker。
// worker 将从 `jobs` 通道接收任务,并且通过 `results` 发送对应的结果。
// 我们将让每个任务间隔 1s 来模仿一个耗时的任务。
func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        fmt.Println("worker", id, "processing job", j)
        time.Sleep(time.Second)
        results <- j * 2
    }
}

func main() {

    // 为了使用 worker 线程池并且收集他们的结果,我们需要 2 个通道。
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    // 这里启动了 3 个 worker,初始是阻塞的,因为还没有传递任务。
    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    // 这里我们发送 9 个 `jobs`,然后 `close` 这些通道
    // 来表示这些就是所有的任务了。
    for j := 1; j <= 9; j++ {
        jobs <- j
    }
    close(jobs)

    // 最后,我们收集所有这些任务的返回值。
    for a := 1; a <= 9; a++ {
        <-results
    }
}

机制实现

再来看第一个问题

scanner.go最后这里动态调用机制的实现,包括线程锁,信号量啥的,看看怎么实现的

下面的ch和wg早已定义

var ch = make(chan struct{}, common.Threads)
var wg = sync.WaitGroup{}

动态调用机制

  • 使用 reflect 包动态调用函数。
  • 代码中的 ScanFunc 函数演示了如何通过反射动态调用不同的扫描插件。
func ScanFunc(name *string, info *common.HostInfo) {
    defer func() {
        if err := recover(); err != nil {
            fmt.Printf("[-] %v:%v scan error: %v\n", info.Host, info.Ports, err)
        }
    }()
    f := reflect.ValueOf(PluginList[*name])
    in := []reflect.Value{reflect.ValueOf(info)}
    f.Call(in)
}

ScanFunc 函数使用 reflect 包的 ValueOf 方法获取 PluginList 中对应名称的函数。

reflect.ValueOf 获取到函数的反射值后,通过 f.Call 动态调用该函数。

deferrecover 用于捕获并处理运行时错误,防止程序崩溃。

线程锁(Mutex)

  • sync.Mutex 用于保护对共享资源(如 common.Numcommon.End)的访问,确保线程安全。
  • AddScan 函数中,Mutex.Lock()Mutex.Unlock() 确保在对这些共享资源进行读写操作时不会发生竞争条件。
var Mutex = &sync.Mutex{}

func AddScan(scantype string, info common.HostInfo, ch *chan struct{}, wg *sync.WaitGroup) {
    *ch <- struct{}{}
    wg.Add(1)
    go func() {
        Mutex.Lock()
        common.Num += 1
        Mutex.Unlock()
        ScanFunc(&scantype, &info)
        Mutex.Lock()
        common.End += 1
        Mutex.Unlock()
        wg.Done()
        <-*ch
    }()
}

Mutex 是一个全局的互斥锁,用于保护对共享资源的访问。

AddScan 中,Mutex.Lock()Mutex.Unlock() 确保对 common.Numcommon.End 的增减操作是线程安全的。

信号量(Channel)

  • ch 通道用于限制并发执行的扫描任务的数量。
  • 每当启动一个新的扫描任务时,AddScan 函数会向 ch 发送一个空结构体,表示有一个新的任务在进行中。
  • 扫描任务完成后,会从 ch 中接收一个值,表示一个任务已经完成。
func AddScan(scantype string, info common.HostInfo, ch *chan struct{}, wg *sync.WaitGroup) {
    *ch <- struct{}{}
    wg.Add(1)
    go func() {
        // 执行扫描任务
        wg.Done()
        <-*ch
    }()
}

ch 是一个通道,用于控制并发任务的数量。每个任务启动时,都会向 ch 发送一个信号,表示有一个新的任务在进行中。

当任务完成时,会从 ch 中接收一个信号,表示任务已经完成。

这可以用于限制同时运行的任务数量,避免系统过载。

web扫描

再来看第二个问题

重点看一下对web的扫描,debug一下看看流程,看看怎么去匹配指纹(icohash咋算),怎么解析、调用poc,怎么抓取和分析web

调用的条件有三个:

image-20240806101644699

调用ScanFunc

这里10003对应的:

"web":         1000003,
"webonly":     1000003,
"webpoc":      1000003,

image-20240806102606926

但是这个call一直调试不进去

f := reflect.ValueOf(PluginList[*name])
	in := []reflect.Value{reflect.ValueOf(info)}
	f.Call(in)

不太懂go的反射,这是一个简单的demo

package main

import (
	"fmt"
	"reflect"
)

func Hello(name string) {
	fmt.Println("Hello,", name)
}

func main() {
	f := reflect.ValueOf(Hello)
	params := []reflect.Value{reflect.ValueOf("World")}
	f.Call(params) // 调用 Hello("World")
}

image-20240806104412216

这里又补习了go导包的一个知识

我新定义了一个hello包,他放在根目录貌似不行,根目录的go文件都是Main包的,需要新建一个目录

image-20240806105928710

go.mod是这样的

image-20240806110044145

导包是这样的

image-20240806110115804

我这里写的demo也是调不进去,可能是因为我的go版本有点老?但是应该可以根据调用的模块名和函数名去打断点吧

打了一些断点,执行顺序大概是这几个文件

image-20240806110652918

来看下执行的流程

我用vulhub本地搭了一个tp5的漏洞,看看他是怎么执行,怎么调yaml的

先跑一下

image-20240806143150797

然后来debug

配置如下

image-20240806143721954

这里不知道它动态调用怎么调的,调试不进去,就姑且一切从这个webtitle函数开始看

image-20240806143416067

这里没有指定m参数,Scantype默认是all,进入WebTitle函数

image-20240806143602360

规范数据

image-20240806143833069

进入geturl函数,这里flag默认为1

image-20240806144020098

进入函数以后看注释有对flag的解释

//flag 1 first try
//flag 2 /favicon.ico
//flag 3 302
//flag 4 400 -> https

这里我们是第一次请求,这一块应该就是go进行http请求的方式,设置好req发出去,他这里都是写死的了

req, err := http.NewRequest("GET", Url, nil)
	if err != nil {
		return err, "", CheckData
	}
	req.Header.Set("User-agent", common.UserAgent)
	req.Header.Set("Accept", common.Accept)
	req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
	if common.Cookie != "" {
		req.Header.Set("Cookie", common.Cookie)
	}
	//if common.Pocinfo.Cookie != "" {
	//	req.Header.Set("Cookie", "rememberMe=1;"+common.Pocinfo.Cookie)
	//} else {
	//	req.Header.Set("Cookie", "rememberMe=1")
	//}
	req.Header.Set("Connection", "close")
	var client *http.Client
	if flag == 1 {
		client = lib.ClientNoRedirect
	} else {
		client = lib.Client
	}

	resp, err := client.Do(req)

后面对接收到的响应包进行处理,进入getRespBody函数

image-20240806144543026

func getRespBody(oResp *http.Response) ([]byte, error) {
	var body []byte
	if oResp.Header.Get("Content-Encoding") == "gzip" {
		gr, err := gzip.NewReader(oResp.Body)
		if err != nil {
			return nil, err
		}
		defer gr.Close()
		for {
			buf := make([]byte, 1024)
			n, err := gr.Read(buf)
			if err != nil && err != io.EOF {
				return nil, err
			}
			if n == 0 {
				break
			}
			body = append(body, buf...)
		}
	} else {
		raw, err := io.ReadAll(oResp.Body)
		if err != nil {
			return nil, err
		}
		body = raw
	}
	return body, nil
}

读取 HTTP 响应体,并处理可能的 gzip 压缩,没有压缩就直接返回

body如下

image-20240806144917432

把body塞进checkdata

flag!=2,第一次请求,先检查编码保证解析,gettitle提取标题,读取 Content-Length 字段拿到长度

resp.Location() 获取响应中的重定向 URL(如果存在)。如果获取成功(err1nil),将重定向 URL 转换为字符串并赋值给 reurl

使用 fmt.Sprintf 格式化一个结果字符串,包含网页 URL、HTTP 状态码、内容长度和网页标题。如果 reurl 不为空(表示存在重定向 URL),将其添加到结果字符串中。

image-20240806145229128

最后如果有跳转的话就返回跳转的url,没有就返回空,此外返回checkdata

image-20240806145730918

然后我们就又回到了GOWebTitle这个函数,可见这个函数并不只是拿title

image-20240806145824174

有跳转的话就再次进入我们刚出来的geturl方法,flag为3,这里访问图标(flag为2)被注释了,我们在调回去看一眼

	//有跳转
	if strings.Contains(result, "://") {
		info.Url = result
		err, result, CheckData = geturl(info, 3, CheckData)
		if err != nil {
			return
		}
	}

	if result == "https" && !strings.HasPrefix(info.Url, "https://") {
		info.Url = strings.Replace(info.Url, "http://", "https://", 1)
		err, result, CheckData = geturl(info, 1, CheckData)
		//有跳转
		if strings.Contains(result, "://") {
			info.Url = result
			err, _, CheckData = geturl(info, 3, CheckData)
			if err != nil {
				return
			}
		}
	}
	//是否访问图标
	//err, _, CheckData = geturl(info, 2, CheckData)
	if err != nil {
		return
	}
	return
}

代码就一小段,计算ico hash不在这里?

if flag == 2 {
		URL, err := url.Parse(Url)
		if err == nil {
			Url = fmt.Sprintf("%s://%s/favicon.ico", URL.Scheme, URL.Host)
		} else {
			Url += "/favicon.ico"
		}
	}

至此我们又回到刚一开始的函数

image-20240806150715253

进入WebScan.InfoCheck

image-20240806150926926

进行指纹匹配

for _, data := range *CheckData {
   for _, rule := range info.RuleDatas {
      if rule.Type == "code" {
         matched, _ = regexp.MatchString(rule.Rule, string(data.Body))
      } else {
         matched, _ = regexp.MatchString(rule.Rule, data.Headers)
      }
      if matched == true {
         infoname = append(infoname, rule.Name)
      }
   }
   //flag, name := CalcMd5(data.Body)

   //if flag == true {
   // infoname = append(infoname, name)
   //}
}

image-20240806151109597

不过感觉他这里有点迷,tp都认不出来,不过又回去看了下靶机确实是啥也没有,不过我这里改了下他就认识了

image-20240806152137853

试了下命中的是这一条规则,这里我的body里有ThinkPHP

{"ThinkPHP", "headers", "(ThinkPHP)"},

我自己加了条规则改了下代码就能匹配上了,看了下fscan匹配body的指纹才三条,不知道是不是bug,感觉我这么改才是符合逻辑的

{"ThinkPHPtest", "body", "(ThinkPHP)"},

image-20240806153423726

接下来进入removeDuplicateElement函数去重一下指纹名称

func removeDuplicateElement(languages []string) []string {
	result := make([]string, 0, len(languages))
	temp := map[string]struct{}{}
	for _, item := range languages {
		if _, ok := temp[item]; !ok {
			temp[item] = struct{}{}
			result = append(result, item)
		}
	}
	return result
}

至此又回到WebTitle函数,我们没有指定NoPoc,所以在进入Poc扫描,也就是WebScan.WebScan函数

image-20240806155030547

传入的Info如下

image-20240806155306563

先初始化poc,然后对数据进行处理,后面for循环读取之前拿到的指纹,给pocinfo.PocName赋值,执行Execute(pocinfo),我们一个个来看

func WebScan(info *common.HostInfo) {
	once.Do(initpoc)
	var pocinfo = common.Pocinfo
	buf := strings.Split(info.Url, "/")
	pocinfo.Target = strings.Join(buf[:3], "/")

	if pocinfo.PocName != "" {
		Execute(pocinfo)
	} else {
		for _, infostr := range info.Infostr {
			pocinfo.PocName = lib.CheckInfoPoc(infostr)
			Execute(pocinfo)
		}
	}
}

once.Do(initpoc) 是 Go 语言中用于实现单次初始化操作的一个常见模式。这是通过 sync.Once 类型来实现的。sync.Once 确保某个操作在整个程序运行期间只执行一次,即使有多个 goroutine 试图执行这个操作。

先来看看是怎么初始化poc的

func initpoc() {
	if common.PocPath == "" {
		entries, err := Pocs.ReadDir("pocs")
		if err != nil {
			fmt.Printf("[-] init poc error: %v", err)
			return
		}
		for _, one := range entries {
			path := one.Name()
			if strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml") {
				if poc, _ := lib.LoadPoc(path, Pocs); poc != nil {
					AllPocs = append(AllPocs, poc)
				}
			}
		}
	} else {
		fmt.Println("[+] load poc from " + common.PocPath)
		err := filepath.Walk(common.PocPath,
			func(path string, info os.FileInfo, err error) error {
				if err != nil || info == nil {
					return err
				}
				if !info.IsDir() {
					if strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml") {
						poc, _ := lib.LoadPocbyPath(path)
						if poc != nil {
							AllPocs = append(AllPocs, poc)
						}
					}
				}
				return nil
			})
		if err != nil {
			fmt.Printf("[-] init poc error: %v", err)
		}
	}
}

首先if判断,没指定poc就用默认的那个poc文件夹的

Pocs.ReadDir这应该也是go的一个解析yaml的库,先读进内存

image-20240806160447208

看一下lib.LoadPoc

加载一个 POC 文件,并将其解析成 Poc 结构体,返回p,err

image-20240806160824970

解析出来就是这样的

image-20240806161035334

把他们放进AllPocs

这里如果是指定了自定义的poc就直接执行,否则就进入下面的for循环一个个匹配

if pocinfo.PocName != "" {
		Execute(pocinfo)
	} else {
		for _, infostr := range info.Infostr {
			pocinfo.PocName = lib.CheckInfoPoc(infostr)
			Execute(pocinfo)
		}
	}

来看一下CheckInfoPoc,它传入的是匹配到的指纹的名字

strings.Contains是检查后面的在不在前面的字符串里,所以我新加的thinkphptest被匹配到了

func CheckInfoPoc(infostr string) string {
	for _, poc := range info.PocDatas {
		if strings.Contains(infostr, poc.Name) {
			return poc.Alias
		}
	}
	return ""
}

image-20240806161940055

接下来Execute(pocinfo)

func Execute(PocInfo common.PocInfo) {
	req, err := http.NewRequest("GET", PocInfo.Target, nil)
	if err != nil {
		errlog := fmt.Sprintf("[-] webpocinit %v %v", PocInfo.Target, err)
		common.LogError(errlog)
		return
	}
	req.Header.Set("User-agent", common.UserAgent)
	req.Header.Set("Accept", common.Accept)
	req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
	if common.Cookie != "" {
		req.Header.Set("Cookie", common.Cookie)
	}
	pocs := filterPoc(PocInfo.PocName)
	lib.CheckMultiPoc(req, pocs, common.PocNum)
}

common.PocInfo 是一个结构体,包以目标 URL和POC 名称

常规的创建req,设置请求头,然后 filterPoc和CheckMultiPoc

先来看前者

func filterPoc(pocname string) (pocs []*lib.Poc) {
	if pocname == "" {
		return AllPocs
	}
	for _, poc := range AllPocs {
		if strings.Contains(poc.Name, pocname) {
			pocs = append(pocs, poc)
		}
	}
	return
}

其实就是查找包含了这个名称的poc返回一个列表

再来看后者

func CheckMultiPoc(req *http.Request, pocs []*Poc, workers int) {
	tasks := make(chan Task)
	var wg sync.WaitGroup
	for i := 0; i < workers; i++ {
		go func() {
			for task := range tasks {
				isVul, _, name := executePoc(task.Req, task.Poc)
				if isVul {
					result := fmt.Sprintf("[+] PocScan %s %s %s", task.Req.URL, task.Poc.Name, name)
					common.LogSuccess(result)
				}
				wg.Done()
			}
		}()
	}
	for _, poc := range pocs {
		task := Task{
			Req: req,
			Poc: poc,
		}
		wg.Add(1)
		tasks <- task
	}
	wg.Wait()
	close(tasks)
}

主要还是俩部分,一个是创建一堆worker线程等着执行poc(从通道tasks里读取),一个是循环给通道传入poc,传一个就add(1),而执行poc的那边结束一个就执行一个done

最后结束

那么这里还剩我们要看的应该是最后一个函数executePoc

真的是老长了,其实无非就是根据poc里面写好的东西去发包而已

不过感觉这块函数对一个扫描器来说是非常核心的,这块函数的功能决定poc可以怎么写,有多灵活的功能等,如果自己开发扫描器这里是一个很重点的地方

func executePoc(oReq *http.Request, p *Poc) (bool, error, string) {
	c := NewEnvOption()
	c.UpdateCompileOptions(p.Set)
	if len(p.Sets) > 0 {
		var setMap StrMap
		for _, item := range p.Sets {
			if len(item.Value) > 0 {
				setMap = append(setMap, StrItem{item.Key, item.Value[0]})
			} else {
				setMap = append(setMap, StrItem{item.Key, ""})
			}
		}
		c.UpdateCompileOptions(setMap)
	}
	env, err := NewEnv(&c)
	if err != nil {
		fmt.Printf("[-] %s environment creation error: %s\n", p.Name, err)
		return false, err, ""
	}
	req, err := ParseRequest(oReq)
	if err != nil {
		fmt.Printf("[-] %s ParseRequest error: %s\n", p.Name, err)
		return false, err, ""
	}
	variableMap := make(map[string]interface{})
	defer func() { variableMap = nil }()
	variableMap["request"] = req
	for _, item := range p.Set {
		k, expression := item.Key, item.Value
		if expression == "newReverse()" {
			if !common.DnsLog {
				return false, nil, ""
			}
			variableMap[k] = newReverse()
			continue
		}
		err, _ = evalset(env, variableMap, k, expression)
		if err != nil {
			fmt.Printf("[-] %s evalset error: %v\n", p.Name, err)
		}
	}
	success := false
	//爆破模式,比如tomcat弱口令
	if len(p.Sets) > 0 {
		success, err = clusterpoc(oReq, p, variableMap, req, env)
		return success, nil, ""
	}

	DealWithRule := func(rule Rules) (bool, error) {
		Headers := cloneMap(rule.Headers)
		var (
			flag, ok bool
		)
		for k1, v1 := range variableMap {
			_, isMap := v1.(map[string]string)
			if isMap {
				continue
			}
			value := fmt.Sprintf("%v", v1)
			for k2, v2 := range Headers {
				if !strings.Contains(v2, "{{"+k1+"}}") {
					continue
				}
				Headers[k2] = strings.ReplaceAll(v2, "{{"+k1+"}}", value)
			}
			rule.Path = strings.ReplaceAll(rule.Path, "{{"+k1+"}}", value)
			rule.Body = strings.ReplaceAll(rule.Body, "{{"+k1+"}}", value)
		}

		if oReq.URL.Path != "" && oReq.URL.Path != "/" {
			req.Url.Path = fmt.Sprint(oReq.URL.Path, rule.Path)
		} else {
			req.Url.Path = rule.Path
		}
		// 某些poc没有区分path和query,需要处理
		req.Url.Path = strings.ReplaceAll(req.Url.Path, " ", "%20")
		//req.Url.Path = strings.ReplaceAll(req.Url.Path, "+", "%20")

		newRequest, err := http.NewRequest(rule.Method, fmt.Sprintf("%s://%s%s", req.Url.Scheme, req.Url.Host, string([]rune(req.Url.Path))), strings.NewReader(rule.Body))
		if err != nil {
			//fmt.Println("[-] newRequest error: ",err)
			return false, err
		}
		newRequest.Header = oReq.Header.Clone()
		for k, v := range Headers {
			newRequest.Header.Set(k, v)
		}
		Headers = nil
		resp, err := DoRequest(newRequest, rule.FollowRedirects)
		newRequest = nil
		if err != nil {
			return false, err
		}
		variableMap["response"] = resp
		// 先判断响应页面是否匹配search规则
		if rule.Search != "" {
			result := doSearch(rule.Search, GetHeader(resp.Headers)+string(resp.Body))
			if len(result) > 0 { // 正则匹配成功
				for k, v := range result {
					variableMap[k] = v
				}
			} else {
				return false, nil
			}
		}
		out, err := Evaluate(env, rule.Expression, variableMap)
		if err != nil {
			return false, err
		}
		//如果false不继续执行后续rule
		// 如果最后一步执行失败,就算前面成功了最终依旧是失败
		flag, ok = out.Value().(bool)
		if !ok {
			flag = false
		}
		return flag, nil
	}

	DealWithRules := func(rules []Rules) bool {
		successFlag := false
		for _, rule := range rules {
			flag, err := DealWithRule(rule)
			if err != nil || !flag { //如果false不继续执行后续rule
				successFlag = false // 如果其中一步为flag,则直接break
				break
			}
			successFlag = true
		}
		return successFlag
	}

	if len(p.Rules) > 0 {
		success = DealWithRules(p.Rules)
	} else {
		for _, item := range p.Groups {
			name, rules := item.Key, item.Value
			success = DealWithRules(rules)
			if success {
				return success, nil, name
			}
		}
	}

	return success, nil, ""
}

最后又回到了WebTitle,差不多到这里对web的扫描就结束了

image-20240806163415850

插件实现

大致看一下各种插件的实现

wmiexec

**github.com/C-Sto/goWMIExec/pkg/wmiexec**这是第三方包,用于执行 WMI 命令。

核心代码如下

cfg, err1 := wmiexec.NewExecConfig(username, password, hash, domain, target, clientHostname, true, nil, nil)
execer := wmiexec.NewExecer(cfgIn)
err = execer.RPCConnect()

然后就是考虑怎么进行判断

暂时不看了

代理实现

代理怎么实现?

我本地搞了个正向代理,模拟机还是那个tp的洞,debug一下看看代理怎么走的

先跑一下证明没问题

image-20240807095327660

调试了一下,调用顺序应该是这样的

scanner.go刚开始的时候会进行初始化http,进去看看

image-20240807100801991

net.Dialer 是 Go 语言标准库 net 包中的一个结构体,用于创建网络连接。它允许你配置和定制网络连接的各种参数,比如连接超时、保持连接活动等。

package main

import (
	"context"
	"fmt"
	"net"
	"time"
)

func main() {
	dialer := &net.Dialer{
		Timeout:   5 * time.Second, // 设置连接超时时间为 5 秒
		KeepAlive: 30 * time.Second, // 设置 Keep-Alive 时间为 30 秒
	}

	conn, err := dialer.Dial("tcp", "example.com:80")
	if err != nil {
		fmt.Println("Error:", err)
		return
	}
	defer conn.Close()

	fmt.Println("Connected to example.com:80")
}

定位到InitHttpClient函数

可以看到我们传的socks5参数是在这里被解析的

image-20240807101016714

跟进去看一下common.Socks5Dailer函数

func Socks5Dailer(forward *net.Dialer) (proxy.Dialer, error) {
	u, err := url.Parse(Socks5Proxy)
	if err != nil {
		return nil, err
	}
	if strings.ToLower(u.Scheme) != "socks5" {
		return nil, errors.New("Only support socks5")
	}
	address := u.Host
	var auth proxy.Auth
	var dailer proxy.Dialer
	if u.User.String() != "" {
		auth = proxy.Auth{}
		auth.User = u.User.Username()
		password, _ := u.User.Password()
		auth.Password = password
		dailer, err = proxy.SOCKS5("tcp", address, &auth, forward)
	} else {
		dailer, err = proxy.SOCKS5("tcp", address, nil, forward)
	}

	if err != nil {
		return nil, err
	}
	return dailer, nil
}

用于创建并返回一个 SOCKS5 代理的拨号器(proxy.Dialer)

代码很简单,最后想执行的无非是这一句dailer, err = proxy.SOCKS5("tcp", address, nil, forward)

它调用了net包的sokcs5方法,并且fscan只支持socks5,这里也是一个可以优化的点?

这里其实都是提前设置好client(Dialer),然后等发包的时候执行client.do()

其实我不是很了解go网络编程的细节,主要还是对net包的了解,后面都得去了解

image-20240807104128058

0%