安全开发02:fscan源码解析-下
m*隅 发表于 广东 安全工具 664浏览 · 2024-08-07 03:22

这里再补习一下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包的了解,后面都得去了解

0 条评论
某人
表情
可输入 255
目录