用 Golang 编写一个像 Arjun 那样的参数爆破工具

这次 Chat 带各位骚年用 Golang 语言手把手实现一个简单的工具,这个工具的作用是找到能将用户的输入反射到页面的有效参数,这些参数可能会是潜在的反射 XSS 漏洞所在点,找出参数以后,可以将输出作为 XSS 扫描工具输入, 进一步确认漏洞是否存在。如果骚年想了解怎么用 Golang 写小工具,那么这次 Chat 很适合你。

在本场 Chat 中,会讲到如下内容:

  • 这个工具的实现思路
  • Golang 正则表达式,协程,信道相关知识点
  • 逐步解释如何编写这个工具
  • 一些使用 Golang 的会遇到的坑

前言

这篇 chat 带各位骚年手把手实现一个简单的工具,这个工具的作用是找到能将用户的输入反射到页面的有效参数, 这些参数可能会是潜在的反射 xss 漏洞所在点,找出参数以后,可以将输出作为 xss 扫描工具输入, 进一步确认漏洞所在。

基本原理

这个工具是用 golang 重写了 Arjun 这个工具,所以大部分逻辑基本和这个工具一致,主要 不一样的地方是 Arjun 后面的判断用了二分法,而我是直接每个参数在测试一次。

现在说一下大概逻辑,先直接发出一个对 URL 的请求,并保存响应体和响应码,这些数据用来和下面的请求返回的数据进行比较,如果在响应中找到任何 HTML 表单,js 变量定义,将从中提取参数名,并将其添加到参数名列表中以进行进一步检查。

随机生成的参数名添加到该 URL, 然后向这个 URL 发起第二个请求,以确定 Web 应用程序针对不存在的参数的行为,这里很重要, 因为有些应用哪怕不存在这个参数都会将其反射到页面。保存响应体和响应码,以便在以后的步骤中进行比较。

然后将这些特征与第一个请求进行比较,以防止误报。加载了数万个参数名字典,保存到字符串切片中,然后对切片分组,每组 100 个参数名,这样 100 个参数名添加到一个 URL 一起发送请求, 可以减少请求数量,为了加快检测速度,每发一个 HTTP 请求都会创建一个协程用来发这个请求。

将这些请求的响应与以前的数据进行比较,将引起响应变化的组的每个参数名添加到一个信道, 从信道取出每个参数名,对每个参数名分别发起一次请求,如果是有效参数,将参数名称被标记为有效,再传入另一个信道。

初始化和读取参数字典

现在我们来具体实现一下代码。代码我上传到了 github,可以一边对比 github 的代码一边看文章。

https://github.com/liqiye-cmd/canshu

首先我们要能够从命令行解析参数,如果这个功能要我们自己实现,真的是让人脑壳疼的操作,所以我们用 go 标准库中提供的一个叫做 flag 的包。定义 flags 有两种常见的方式,第一种 flag.Xxx(),其中 Xxx 可为 Int,String,Bool 等,然后返回一个相应类型的指针,如:var url = flag.Int("u", "", "目标网址"); 第二种方法就是将 flag 绑定到一个变量上,flag.XxxVar(), 如 flag.StringVar(&url, "u", "", "目标网址")第一个参数是接收 url 的实际值,第二个参数表明 flag 名称为 u, 第三个参数 表示 url 的默认值为空字符串,第四个参数是 url 的提示信息。在我们的工具里面采用第二种方法。

    var urls []string       flag.BoolVar(&details, "v", false, "输出详情")    var path string    flag.StringVar(&path, "f", "./params.txt", "设置参数字典")    var url string    flag.StringVar(&url, "u", "", "目标网址")    flag.Parse()

然后我们需要读取 url, 这里分为两种情况,如果从命令行设置了-u XXX.com, 表明是要处理单个 url; 如果这个参数没有设置,那么就会从输入流读取 url 列表,方便对这些 url 列表批量处理。为了格式更加统一一点,无论是单个 url 还是 url 列表,最后都会传入一个字符串切片当中。

if url != "" {        urls = []string{url}    } else {        sc := bufio.NewScanner(os.Stdin)        for sc.Scan() {            urls = append(urls, sc.Text())        }        if err := sc.Err(); err != nil && details {            fmt.Fprintf(os.Stderr, " 从输入流读取参数失败: %s\n ", err)        }    }

以输入流的方式更加方便工具之间互相调用,以一个工具的输入流作为另一个工具的输出流,比如 子域名工具 | 测活工具 | canshu | xss 检测工具,通过管道的方式将输出传给下一个工具。然后我们需要一个参数字典,用来爆破参数,这个参数字典从文件读取,ReadLine 传入的参数的是文件所在的路径,作用就是将文件的内容读取出来,以换行符作为分隔符,存入一个字符串切片,然后返回这个字符串切片。

paramFromFile, err := ReadLine(path)    if err != nil && details{        fmt.Fprintf(os.Stderr, " 从参数字典读取参数失败: %s\n ", au.Red(err))    }

ReadLine 的代码很简单, 可以自己看看,这里就不浪费篇幅贴出来了。

判断随机字符串生成的参数名是否会反射到页面。

我们利用一个 for 循环把 urls 这个字符串切片里面保存的 url 一个一个地提取出来处理。

for _, url := range urls {    处理 URL}

回忆一下前面提到的基本原理,先直接一个发出对 URL 的请求。

    firstResponse, _, err := httpGet(url)    if err != nil && details{        fmt.Fprintf(os.Stderr, " http 请求失败 : %s\n ", au.Red(err))    }

httpGet 的实现如下,传入一个代表 url 的字符串, 返回包含响应内容的字节切片和响应码。

func httpGet(url string) ([]byte, int, error) {    res, err := http.Get(url)    if nil != res {        defer res.Body.Close()    }    if err != nil {        return []byte{}, 0, err    }    raw, err := ioutil.ReadAll(res.Body)    if err != nil {        return []byte{}, 0, err    }    return raw, res.StatusCode, nil}

整个代码是很简单的, 但是这里需要注意一点,这里的 if nil != res 判断一定要加上,不然返回一个空响应的时候,res 是 nil, res.Body 会报错。然后生成一个随机字符串,用这个字符串表示参数名(理论来说这样随机生成的参数名是绝对不是有效的参数名)

    originalFuzz := RandomString(6)    originalResponse, originalCode, err := httpGet(url + "?" + RandomString(8) + "=" + originalFuzz)    if err != nil && details{        fmt.Fprintf(os.Stderr, " http 请求失败 : %s\n ", au.Red(err))    }

将这个随机参数名的参数值在页面的反射次数记录下来*如果有的话)

reflections := strings.Count(string(originalResponse), originalFuzz)

然后我们会对比两次请求响应体的字节数,响应体去除 html 标签后的纯文本字节数, 根据这两次比较来设置 factors 这个散列的值,如果为假,表示哪怕是随机生成的一个无效的参数也会引起页面的变化。 newLength := len(originalResponse) plainText := removeTags(string(originalResponse)) plainTextLength := len(plainText) if details { fmt.Printf("%s %d\n", au.Magenta("内容长度:"), au.Green(newLength)) fmt.Printf("%s %d\n", au.Magenta("去除标签以后的内容长度:"), au.Green(plainTextLength)) } factors = make(map[string]bool) factors["sameHTML"] = false factors["samePlainText"] = false

    if len(firstResponse) == newLength {        factors["sameHTML"] = true    }    if len(removeTags(string(firstResponse))) == plainTextLength {        factors["samePlainText"] = true    };

从 HTML 页面提取参数名

然后我们从 HTML 页面提取参数名,这些参数名会和参数字典里面的参数名合并,然后进行进一步检测。

    paramFromHtml := heuristic(firstResponse)

让我们看一下 heuristic 的实现,首先提取 js 变量 作为参数名。https://brutelogic.com.br/xss.php这个 xss 靶场返回下面的内容,

很好,现在我们要把 c1,c2,c3,c4,c5 这个 5 个参数名提取出来,我们利用正则表达式可以做到这个,正则表达式简单来说就是描述了一种字符串匹配的模式,可以用来检查一个串是否含有某种子串、将匹配的子串替换或者从某个串中取出符合某个条件的子串等。go 语言的正则表达式和其他语言的正则表达式规则都是一样的,只是调用的函数不同而已, 要想使用 go 语言的正则表达式功能,可以使用 go 语言的 regexp 包。

regexp.MustCompile(var(\s+)?([a-zA-Z0-9\-\_]+)(\s+)?=).FindAllStringSubmatch(string(response), -1)

大概解释一下,首先要匹配 var,()标记一个子表达式的开始和结束位置。子表达式可以获取供以后使用,()里面是\s+,\s 表示匹配任何空白字符,包括空格、制表符、换页符等等等等等等,+表示匹配前面的子表达式一次或多次。\s+合在一起意思就是匹配一个空格或者多个空格,?的意思是匹配前面的子表达式零次或一次。很好,(\s+)?就是表示可以匹配一个或多个空格,也可以完全不匹配空格,var c1=xxxxx, 这样可以, varc1 这样也可以, 有点沙雕,因为我后面写(?:name|id)(\s+)?=(\s+)?["']?([a-zA-Z0-9\-\_]+)["\']? ,最开始写错了,它 name=xxxx 是不匹配的,只匹配 name = xxxx, 后面才改成这样子,然后我顺手复制粘贴代码,后面发现这样有点多余,因为实际定义变量的时候不可能是 varc1=xxx 这样, 不过也懒得改回来了。[] 是定义匹配的字符范围, [a-zA-Z0-9-_] 表示匹配英文字符和数字以及下划线。其实这就是变量名了,我们要把它提取出来作为参数名,也就是前面提到的 c1,c2,c3,c4,c5。

out = append(out, jsvar[2])

注意这个 2,这表示第二个子表达式。0 表示整个表达式, 1 表示第一个第一个表达式,也就是第一个括号那坨(\s+),2 表示第一个第二个表达式,也就是第二个括号那坨([a-zA-Z0-9\-\_]+),后面的代码都大同小异,这里就不啰嗦浪费篇幅了。

给参数分组以减少请求数

下面这行代码将页面提取到的参数和来自参数字典的参数合并,赋值给paramSlice

paramSlice := append(paramFromHtml, paramFromFile...)

然后对参数进行分组。

paramCheck := splitArray(paramSlice, int64(len(paramSlice)/100))、

splitArray 是我从网上复制粘贴过来的,随便试了一下发现凑合着能用,这里要注意两个地方就行,首先是传入的参数,它这个函数它自己实现它希望你传入的参数是你要分成多少组,不过我希望的是直接告诉函数每组要分成多少个,所以大家都退一步,所以我传入的参数就变成了 int64(len(paramSlice)/100);还有一点就是它的返回值的第一个元素是一个空切片,这就很搞笑,然后我直接 return segmens[1:] 不要第一个元素。

确定有效参数

前面说了,100 个参数作为一组, 然后和一个 URL 一起发出请求,那么如果这次请求引起了某些内容变化,那么就说明那个有效参数在这个组里面,这个组的所有参数都认为是可疑的有效参数,需要进行进一步测试。 把 paramCheck 每个组提取处理,按组对这些参数。

for _, params := range paramCheck {    处理 params , params 是一个字符串切片, 包含 100 个参数}

协程是 go 语言中的轻量级线程实现,由 go 运行时管理。在一个函数调用前加上 go 关键字,这次调用就会在一个新的协程中并发执行。当被调用的函数返回时,这个协程也自动结束。需要注意的是,如果这个函数有返回值,那么这个返回值会直接被丢弃。下面的代码启动了一个协程。

go func(params []string) {    启动一个协程, 将 params 传进来。}(params) 

然后在 func(params []string) {} 里面调用了 quickBruter 这个函数,

temp := quickBruter(params, originalResponse, originalCode, reflections, url)

这个函数的逻辑很简单, 就是看看传进来的参数有没有引起响应差异,响应码是否和原来一样,factors["sameHTML"]为真是响应长度是否一样, factors["samePlainText"] 为真时响应长度是否一样,以及最粗暴的方法,看看反射次数是否一样。如果不一样,表示传入的参数里面可能存在有效参数,然后返回你传入的 params。如果上面的 4 种判断全部一样,没有引起响应差异,就表示没有有效参数,返回一个空的字符串切片。信道是 go 语言在语言级别提供的协程间的通信方式,我们可以使用信道在多个协程之间传递消息。信道是类型相关的,一个信道只能传递一种类型的值,这个类型需要在声明信道时指定,比如foundParamsTemp := make(chan string) 这行定义信道的代码。简单来说,你可以认为 channel 是一个管道或者先进先出队列。 看看下面的代码。

if len(temp) != 0 {    for _, t := range temp {          foundParamsTemp <- t    }}

然后还要一些要注意的地方,当上面调用的协程运行结束的时候, 我们希望能够把 foundParamsTemp 这个信道关闭,启动了一个协程来完成这个操作。

go func() {    foundParamsTempWG.Wait()    close(foundParamsTemp)}()

我们希望close(foundParamsTemp)在上面调用的协程完成运行后再调用,所以我们需要WaitGroup 锁这玩意,一个WaitGroup 锁用来等待一个协程合集结束。调用foundParamsTempWG.Add(1)方法设置需要等待的协程数量,然后运行每一个协程当其结束时调用foundParamsTempWG.Done()方法。foundParamsTempWG.Wait()能够阻塞这个协程,这样用来执行close(foundParamsTemp) 的协程会被阻塞直到前面所有的协程完成。

后面的代码和前面的基本一样,区别就是不是以 100 个参数为一组,而是一个参数为一组, 检测每一个的参数。

for param := range foundParamsTemp {    一些处理逻辑}

最后把有效的的参数收集起来(如果有的话), 整个代码的核心逻辑已经解释清楚了,如果还有啥不理解的,可以自己去看看代码。这个工具因为写着玩的,其实有很多乱七八糟的缺点,Arjun 在确定某个组存在有效参数的时候,然后会采用二分法进行进一步测试,这样可以减少请求数,而我这里是确定这个组存在有效参数,直接把这个组的参数都试一次。然后就是不能直接传入带参数的 url, 比如https://xxxx.com?a=1&b=2. 还有就是只支持 get 请求,以及不能设置超时,如果各位骚年有兴趣, 可以自己改进一下这个工具。

运行效果:

用 Golang 编写一个像 Arjun 那样的参数爆破工具_第1张图片也支持静默模式输出:在这里插入图片描述可以看到, 运行速度很快。

参考资料:

https://github.com/s0md3v/Arjunhttps://golang.org/pkg/

阅读全文: http://gitbook.cn/gitchat/activity/5ec27c25a941c3774193e9e4

您还可以下载 CSDN 旗下精品原创内容社区 GitChat App ,阅读更多 GitChat 专享技术内容哦。

FtooAtPSkEJwnW-9xkCLqSTRpBKX

你可能感兴趣的:(用 Golang 编写一个像 Arjun 那样的参数爆破工具)