开源工具分析-Fscan
本文首发于先知社区
今天思考了一下,web狗安身立命的技能主要是三个:渗透、安全开发、审计,开发貌似也是极其重要的,就像演艺圈一样必须得有自己拿得出手的一个作品,所以决定入门一下安全开发,从扫描器开始入手,扫描器的标杆应该就是fscan了,所以试试读一下fscan的源码,尝试写出比fscan更好的扫描器。
https://github.com/shadow1ng/fscan
fscan是面向过程编写的,先看一下目录结构,主要就是
common:放一些公用模块,比如参数解析,代理,配置
plugins:应该是最核心的目录,扫描器的主体,其中scanner.go文件负责了框架的调度流程
webscan:貌似是另开一个目录写的web扫描,实现了基于yml格式的web扫描指纹插件
入口函数
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
包来实现动态函数调用,并在调用过程中处理可能发生的错误。
defer
和 recover
:
- 使用
defer
和recover
处理函数调用中的异常。这样可以确保在函数发生异常时,能够输出错误信息,而不是让程序崩溃。
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
动态调用该函数。
defer
和 recover
用于捕获并处理运行时错误,防止程序崩溃。
线程锁(Mutex):
sync.Mutex
用于保护对共享资源(如common.Num
和common.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.Num
和 common.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
调用的条件有三个:
调用ScanFunc
这里10003对应的:
"web": 1000003,
"webonly": 1000003,
"webpoc": 1000003,
但是这个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")
}
这里又补习了go导包的一个知识
我新定义了一个hello包,他放在根目录貌似不行,根目录的go文件都是Main包的,需要新建一个目录
go.mod是这样的
导包是这样的
我这里写的demo也是调不进去,可能是因为我的go版本有点老?但是应该可以根据调用的模块名和函数名去打断点吧
打了一些断点,执行顺序大概是这几个文件
来看下执行的流程
我用vulhub本地搭了一个tp5的漏洞,看看他是怎么执行,怎么调yaml的
先跑一下
然后来debug
配置如下
这里不知道它动态调用怎么调的,调试不进去,就姑且一切从这个webtitle函数开始看
这里没有指定m参数,Scantype默认是all,进入WebTitle函数
规范数据
进入geturl函数,这里flag默认为1
进入函数以后看注释有对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函数
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如下
把body塞进checkdata
flag!=2,第一次请求,先检查编码保证解析,gettitle提取标题,读取 Content-Length
字段拿到长度
resp.Location()
获取响应中的重定向 URL(如果存在)。如果获取成功(err1
为 nil
),将重定向 URL 转换为字符串并赋值给 reurl
。
使用 fmt.Sprintf
格式化一个结果字符串,包含网页 URL、HTTP 状态码、内容长度和网页标题。如果 reurl
不为空(表示存在重定向 URL),将其添加到结果字符串中。
最后如果有跳转的话就返回跳转的url,没有就返回空,此外返回checkdata
然后我们就又回到了GOWebTitle这个函数,可见这个函数并不只是拿title
有跳转的话就再次进入我们刚出来的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"
}
}
至此我们又回到刚一开始的函数
进入WebScan.InfoCheck
进行指纹匹配
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)
//}
}
不过感觉他这里有点迷,tp都认不出来,不过又回去看了下靶机确实是啥也没有,不过我这里改了下他就认识了
试了下命中的是这一条规则,这里我的body里有ThinkPHP
{"ThinkPHP", "headers", "(ThinkPHP)"},
我自己加了条规则改了下代码就能匹配上了,看了下fscan匹配body的指纹才三条,不知道是不是bug,感觉我这么改才是符合逻辑的
{"ThinkPHPtest", "body", "(ThinkPHP)"},
接下来进入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函数
传入的Info如下
先初始化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的库,先读进内存
看一下lib.LoadPoc
加载一个 POC 文件,并将其解析成 Poc
结构体,返回p,err
解析出来就是这样的
把他们放进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 ""
}
接下来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的扫描就结束了
插件实现
大致看一下各种插件的实现
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一下看看代理怎么走的
先跑一下证明没问题
调试了一下,调用顺序应该是这样的
scanner.go刚开始的时候会进行初始化http,进去看看
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参数是在这里被解析的
跟进去看一下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包的了解,后面都得去了解