安全开发01:fscan源码解析-上
m*隅 发表于 广东 安全工具 1013浏览 · 2024-08-06 08:47

今天思考了一下,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
  • 大致看一下各种插件的实现
  • 看一下代理怎么实现的
0 条评论
某人
表情
可输入 255

没有评论

目录