Skip to content

xscan 源码学习

date
2025-09-10 12:08:13

xscan 旧版本在知识星球开源了,看一看,主要看 XSS 检测逻辑

项目结构

xscan-1.2/
├── cmd/xscan/          # 主程序入口
├── core/               # 核心功能模块
│   ├── refxss/         # XSS检测核心
│   ├── spider/         # 爬虫模块
│   ├── parser/         # 解析器模块
│   ├── httpx/          # HTTP客户端
│   ├── queue/          # 队列管理
│   ├── stringset/      # 字符串集合处理
│   └── logger/         # 日志模块
├── go.mod              # Go模块依赖
└── readme.md           # 项目说明

源码学习

队列模块

core/queue core/queue/leveldb_queue.go => 基于 goleveldb 的数据存储 core/queue/queue.go => Go 实现队列数据结构 syndtr/goleveldb: LevelDB key/value database in Go. 一个键值数据库,基于文件实现存储 leveldb_queue.go 封装:

package queue  

import (  
    "github.com/syndtr/goleveldb/leveldb"  
    "sync")  

type LevelQueue struct {  
    Db   *leveldb.DB  
    lock sync.Mutex  
}  

func NewLevelQueue() (*LevelQueue, error) {  
    db, err := leveldb.OpenFile("./db/block.db", nil)  
    if err != nil {  
       return nil, err  
    }  
    return &LevelQueue{Db: db, lock: sync.Mutex{}}, nil  
}  

func (l *LevelQueue) Close() {  
    l.Db.Close()  
}  

func (l *LevelQueue) Push(v []byte) error {  
    return l.Db.Put(v, nil, nil)  
}  

func (l *LevelQueue) Len() int {  
    db := l.Db  
    iter := db.NewIterator(nil, nil)  
    index := 0  
    for iter.Next() {  
       index += 1  
    }  
    iter.Release()  
    return index  
}

爬虫模块

xscan 采用的是静态爬虫 + 第三方的 Wayback,和之前看过的 gospider 类似不过也有不同。

静态爬虫

core\spider\spider.go Start => feed => Visit => handleResult 主要逻辑在 feed() 中:

func (c *Crawler) feed() {  
    var wg sync.WaitGroup  
    c.feedChain = make(chan SpiderOutput)  
    // 1. 多协程调用 Visit 进行链接爬取
    p, _ := ants.NewPoolWithFunc(c.processNum, func(i interface{}) {  
       s := i.(SpiderOutput)  
       c.Visit(s)  
       wg.Done()  
    })  
    defer p.Release()  
    // 2. 启动一个协程把 Visit 拿出来放到 feedChain 中, 当数据到达 10w 就停止
    go func() {  
       maxSize := 100000  
       index := 0  
       db := c.queue.Db  
       iter := db.NewIterator(nil, nil)  
       for iter.Next() {  
          if index > maxSize {  
             break  
          }  
          v := Decode(iter.Key())  
          c.feedChain <- v  
          db.Delete(iter.Key(), nil)  
          index += 1  
       }  
       iter.Release()  
       close(c.feedChain)  
    }()  
    // 3. 调用回调函数处理拿出来的链接  
    for r := range c.feedChain {  
       n := r  
       if c.callback != nil {  
          c.callback(n)  
       }  
       wg.Add(1)  
       _ = p.Invoke(n)  
    }  
    time.Sleep(time.Second * 5)  
    wg.Wait()  
}
就是一个不断深入爬取的过程。 然后看 Visit 的逻辑:
func (c *Crawler) Visit(p SpiderOutput) {  
    c.limitSecond.Take()  
    // 1. 发起请求  
    output := p.Output  
    req, err := c.hp.NewRequest(p.Method, output, nil, nil)  
    if err != nil {  
       logrus.WithError(err).Debugln()  
       return  
    }  
    referer := req.Header.Get("Referer")  
    if referer == "" {  
       req.Header.Set("Referer", p.Input)  
    }  
    resp, err := c.hp.Do(req)  
    if err != nil {  
       logrus.WithError(err).Debugf("spider请求失败:%s", req.URL.String())  
       return  
    }  
    // 2. 处理响应 => 提取新的链接  
    c.handleResult(resp)  
}
handleResult 逻辑可以看出 1.2 的静态爬虫比较简陋,只是 href 和表单,和 gospider 有差距。
func (c *Crawler) handleResult(nResp *httpx.Response) {  
    doc, err := goquery.NewDocumentFromReader(strings.NewReader(nResp.Text))  
    if err != nil {  
       logrus.WithError(err).Warningf("goquery解析失败")  
       return  
    }  

    // 寻找href  
    reqUrl := nResp.Req.URL  
    doc.Find("[href]").Each(func(i int, s *goquery.Selection) {  
       href, ok := s.Attr("href")  
       if ok {  
          urlString := UrlJoin(reqUrl, href)  
          if c.filterUrl(urlString, httpx.GET) {  
             return  
          }  
          out := SpiderOutput{  
             Input:  reqUrl.String(),  
             Source: "href",  
             Output: urlString,  
             Header: header2map(nResp.Req.Header),  
             Method: httpx.GET,  
             Body:   "",  
          }  
          c.queue.Push(Encode(out))  
       }  
    })  

    //form action  
    doc.Find("form[action]").Each(func(i int, s *goquery.Selection) {  
       action, ok := s.Attr("action")  
       method, ok2 := s.Attr("method")  
       if !ok2 {  
          method = "GET"  
       }  
       method = strings.ToUpper(method)  
       if ok {  
          action_url := UrlJoin(nResp.Req.URL, action)  
          if c.filterUrl(action_url, method) {  
             return  
          }  
          p := url.Values{}  
          s.Find("input[name]").Each(func(i int, selection *goquery.Selection) {  
             name, exist := selection.Attr("name")  
             v, exist2 := selection.Attr("value")  
             if !exist {  
                return  
             }  
             if !exist2 {  
                v = core.RandomStr(4)  
             }  
             p.Set(name, v)  
          })  
          nResp.Req.Header.Set("Content-Type", "application/x-www-form-urlencoded")  
          out := SpiderOutput{  
             Input:  reqUrl.String(),  
             Source: "form",  
             Output: action_url,  
             Header: header2map(nResp.Req.Header),  
             Method: method,  
             Body:   p.Encode(),  
          }  
          c.queue.Push(Encode(out))  
       }  
    })  
不过整体的爬虫队列是基于 leveldb 文件存储实现的,而不是都在内存中,这个可以借鉴。 handleResult 获取到的链接都封装为 SpiderOutput 然后放到队列中,feed() [2] 处的协程会拿出来再丢给回调函数。 回调函数在入口处定义, 把数据封装好再提交到携程池中继续爬取,直到 10w:
func spiderCallback(out spider.SpiderOutput) {  
    logrus.WithField("URL", out.Output).WithField("Input", out.Input).Debugln()  
    var postdata []byte  
    if out.Body != "" {  
       postdata = []byte(out.Body)  
    }  
    p := refxss.Parameter{  
       Url:     out.Output,  
       Method:  out.Method,  
       POST:    postdata,  
       Headers: out.Header,  
    }  
    wg.Add()  
    _ = xssPool.Invoke(p)  
}

第三方

core/spider/source.go 中定义了通过 web.archive.org 来获取链接,不过好像并没有使用:

func getWaybackURLs(domain string) ([]string, error) {  

    res, err := http.Get(  
       fmt.Sprintf("http://web.archive.org/cdx/search/cdx?url=%s/*&output=json&collapse=urlkey", domain),  
    )  
    if err != nil {  
       return []string{}, err  
    }  
    raw, err := ioutil.ReadAll(res.Body)  
    res.Body.Close()  
    if err != nil {  
       return []string{}, err  
    }  
    var wrapper [][]string  
    err = json.Unmarshal(raw, &wrapper)  
    out := make([]string, 0, len(wrapper))  
    skip := true  
    for _, urls := range wrapper {  
       // The first item is always just the string "original",  
       // so we should skip the first item       if skip {  
          skip = false  
          continue  
       }  
       out = append(out, urls[2])  
    }  
    return out, nil  
}

解析模块

html

core/parser/html/parser.go 结构体定义:

1
2
3
4
5
6
type ResponsePosition struct {  
    Type    int  
    TagName string  // 标签名称
    Content string  // 标签之间的文本
    Attrs   []html.Attribute  // 标签的属性列表 
}
ParseHtml 解析 HTML 返回 ResponsePosition 列表,主要使用到 html.NewTokenizer 进行解析。 文档地址:html package - golang.org/x/net/html - Go Packages
1
2
3
4
5
6
7
8
9
const (
    ErrorToken TokenType = iota // 错误 token
    TextToken                   // 文本内容
    StartTagToken               // 开始标签(如 <div>)
    EndTagToken                 // 结束标签(如 </div>)
    SelfClosingTagToken         // 自闭合标签(如 <img/>)
    CommentToken                // HTML 注释
    DoctypeToken                // DOCTYPE 声明
)
ParseHtml 函数用来解析整个 HTML 把 HTML 转换为 ResponsePosition 的格式 :
func ParseHtml(body io.Reader) []ResponsePosition {  
    z := html.NewTokenizer(body)  
    var positions []ResponsePosition  
    var tokenizer []ResponsePosition  
labelType:  
    for {  
       tt := z.Next()  
       switch tt {  
       case html.ErrorToken:  
          break labelType  
       case html.SelfClosingTagToken:  
          tk := z.Token()  
          // 原始文本  
          raw := string(z.Raw())  
          // 标签属性  
          attrs := tk.Attr  
          for i, attr := range attrs {  
             // 属性值不为空  
             if attr.Val == "" {  
                continue  
             }  
             // 属性名 => class="j-lazy"             found := attr.Key + "="  
             // 寻找位置  
             kIndex := strings.Index(raw, found)  
             if kIndex != -1 {  
                vIndex := core.IndexAt(raw, attr.Val, kIndex) - 1  
                if vIndex > -1 && (raw[vIndex] == 34 || raw[vIndex] == 39) {  
                   attr.Val = raw[vIndex : vIndex+2+len(attr.Val)]  
                }  
             }  
             attrs[i] = attr  
          }  
          current := ResponsePosition{  
             Type:    NULL,  
             TagName: tk.Data,  
             Content: "",  
             Attrs:   attrs,  
          }  
          positions = append(positions, current)  
       case html.StartTagToken:  
          tk := z.Token()  
          raw := string(z.Raw())  
          attrs := tk.Attr  
          for i, attr := range attrs {  
             if attr.Val == "" {  
                continue  
             }  
             found := attr.Key + "="  
             kIndex := strings.Index(raw, found)  
             if kIndex != -1 {  
                vIndex := core.IndexAt(raw, attr.Val, kIndex) - 1  
                if vIndex > -1 && (raw[vIndex] == 34 || raw[vIndex] == 39) {  
                   attr.Val = raw[vIndex : vIndex+2+len(attr.Val)]  
                }  
             }  
             attrs[i] = attr  
          }  
          current := ResponsePosition{  
             Type:    NULL,  
             TagName: tk.Data,  
             Content: "",  
             Attrs:   attrs,  
          }  
          positions = append(positions, current)  
       case html.TextToken:  
          if len(positions) > 0 {  
             positions[len(positions)-1].Content += string(z.Raw())  
          }  
       case html.EndTagToken:  
          if len(positions) > 0 {  
             end := len(positions) - 1  
             current := positions[end]  
             tokenizer = append(tokenizer, current)  
             positions = positions[:end]  
          }  
       case html.CommentToken:  
          tk := z.Token()  
          current := ResponsePosition{  
             Type:    NULL,  
             TagName: "comment",  
             Content: tk.Data,  
             Attrs:   nil,  
          }  
          tokenizer = append(tokenizer, current)  
       }  
    }  
    for len(positions) > 0 {  
       end := len(positions) - 1  
       current := positions[end]  
       tokenizer = append(tokenizer, current)  
       positions = positions[:end]  
    }  
    return tokenizer  
}
SearchInputInResponse 方法用于获取报告知道输入的 ResponsePosition 列表: ```go func SearchInputInResponse(input string, body io.Reader) ([]ResponsePosition, error) {
tokenizer := ParseHtml(body)
var ret []ResponsePosition
for , p := range tokenizer {
// check TEXT
if strings.Contains(p.Content, input) {
if p.TagName == "comment" {
ret = append(ret, ResponsePosition{
Type: Comment,
TagName: p.TagName,
Content: p.Content,
Attrs: p.Attrs,
})
} else {
ret = append(ret, ResponsePosition{
Type: Text,
TagName: p.TagName,
Content: p.Content,
Attrs: p.Attrs,
})
}
}
// check tagname
if strings.Contains(p.TagName, input) {
ret = append(ret, ResponsePosition{
Type: Tag,
TagName: p.TagName,
Content: p.Content,
Attrs: p.Attrs,
})
}
// check Style
if strings.ToLower(p.TagName) == "style" && strings.Contains(p.Content, input) {
ret = append(ret, ResponsePosition{
Type: Style,
TagName: p.TagName,
Content: p.Content,
Attrs: p.Attrs,
})
}
// check Attibute
for
, attr := range p.Attrs {
if strings.Contains(attr.Key, input) {
ret = append(ret, ResponsePosition{
Type: AttibuteKey,
TagName: p.TagName,
Content: p.Content,
Attrs: p.Attrs,
})
}

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
      if strings.Contains(attr.Val, input) {  
         ret = append(ret, ResponsePosition{  
            Type:    AttibuteValue,  
            TagName: p.TagName,  
            Content: p.Content,  
            Attrs:   p.Attrs,  
         })  
      }  
   }  
}  
return ret, nil

} ```

JS

https://zhaomenghuan.js.org/blog/js-ast-principle-reveals.html 使用 github.com/tdewolff/parse/js 做 AST go func ParseJs2AstType(src string) ([]*AstType, error) { input := parse.NewInputString(src) l := js.NewLexer(input) var ret []*AstType for { tt, text := l.Next() switch tt { case js.ErrorToken: if l.Err() != io.EOF { err := CreateJsParserError(input, l.Err()) return ret, err } return ret, nil default: n := AstType{ Type: tt, Content: string(text), } ret = append(ret, &n) } } }

SearchInputInScript 主要是收集这几种类型: - IdentifierToken:标识符(变量名、函数名、属性名等) - StringToken:字符串,比如 let s = "hello" 中的 "hello" - CommentToken:注释内容

这里用到了 fallthroughgoswitch case 不需要显式的跳出,默认会跳出,fallthrough 的作用就是不跳出,继续向下。不过这里就这 3 个感觉也没必要这么写,可能是方便后续扩充。

func SearchInputInScript(input string, script string) ([]*AstType, error) {  
    astTokens, err := ParseJs2AstType(script)  
    if err != nil {  
       return nil, err  
    }  
    var ret []*AstType  
    for _, token := range astTokens {  
       if !strings.Contains(token.Content, input) {  
          continue  
       }  
       switch token.Type {  
       case js.IdentifierToken:  
          fallthrough  
       case js.StringToken:  
          fallthrough  
       case js.CommentToken:  
          n := *token  
          ret = append(ret, &n)  
       //case js.TemplateToken:  
       default:  
          err = CreateTokenNotFound(token.Type, token.Content)  
          return ret, err  
       }  
    }  
    return ret, nil  
}

变量收集,对应着隐藏参数收集,就是从 HTML 中收集一些静态爬虫没有收集到的:

var regx = regexp.MustCompile(`^[\w\d-_]+$`)

func (w *varWalker) Enter(n js.INode) js.IVisitor {  
    switch n := n.(type) {  
    // 变量声明  
    case *js.VarDecl:  
       for i := range n.List {  
          v, ok := n.List[i].Binding.(*js.Var)  
          if !ok {  
             continue  
          }  
          // 匹配正则  
          strV := string(v.Data)  
          if regx.MatchString(strV) {  
             w.vars = append(w.vars, strV)  
          }  
       }  
    // 对象表达式  
    case *js.ObjectExpr:  
       for _, p := range n.List {  
          if p.Name != nil {  
             v := p.Name.Literal.String()  
             if regx.MatchString(v) {  
                w.vars = append(w.vars, v)  
             }  
          }  
       }  
    }  

    return w  
}

XSS检测模块

HTML

每个函数对应不同的 XSS 检测方法: checkHtml html标签未转义,可攻击: 1. flag 生成 2. payload 生成 prefix<flag> 3. 发起请求 4. 搜索 flaghtml 中的位置 5. 如果 flag 在标签名里面则判断存在 => 之前的 payload 的 flag 就是一个标签

checkStyle ie下可造成xss: - payloadexpression(a(%s)) => expression 用于在 IE 中的 CSS 样式中镶嵌 JS 代码 - 如果位置是样式,并且内容包含 expression 表达式则判断存在

checkTagValue 引号可被闭合,可使用其他事件造成xss - 这次检索的是标签里面属性中存在的 XSS - payloadfmt.Sprintf("%s%s", prefix, flag) => 这里的前缀其实就是引号啥的 - 如果属性的 key 中包含 flag 就判断存在 => 之前是的属性值位置,现在通过引号进行闭合,变成了 key 的位置。a="payload" => a="" flag

代码都差不多:

func (r *RefXss) checkHtml(target Parameter, position string, k string, prefix string) {  
    flag := r.generateFlag()  
    payload := fmt.Sprintf("%s<%s>", prefix, flag)  
    req, resp, err := r.request(target, position, k, payload)  
    if err != nil {  
       logrus.WithError(err).Debugln("请求失败[checkHtml]")  
       return  
    }  
    positions, err := html.SearchInputInResponse(flag, strings.NewReader(resp.Text))  
    if err != nil {  
       logrus.WithError(err).Debugln("html语义解析失败")  
       return  
    }  
    for _, p := range positions {  
       if p.Type == html.Tag && strings.Contains(p.TagName, flag) {  
          lines := htmlInLine(resp.Text, payload)  
          item := Item{  
             Req:            req,  
             Resp:           resp,  
             XssType:        "html",  
             Desc:           "html标签未转义,可攻击",  
             Position:       position,  
             Parameter:      k,  
             Payload:        fmt.Sprintf("%s=%s", k, payload),  
             SuggestPayload: fmt.Sprintf("%s=%s", k, prefix+"<svg onload=alert(1)>//"),  
             Line:           lines,  
          }  
          r.result.Items = append(r.result.Items, item)  
       }  
    }  
}

JS

checkJsString JS 逃逸 应该是在 script 里面的变量什么的可控的场景 1. payload := fmt.Sprintf("%s-%s-%s", quote, flag, quote) 2. HTML 解析,获取 script 标签的内容(JS代码) 3. 解析这些 JS 代码获取语法树 4. 如果 ast.TypeIdentifierToken 并且内容中有 flag可以使用quote逃逸引号 5. 如果没有逃逸成功,进行转义测试 fmt.Sprintf("\\%s%s", quote, flag) 1. JS AST 解析失败,表明使用转义符破坏原有语义 2. 解析成功,和 [4] 处类似的检测方式

checkJsComment 可以使用%s逃逸注释 - 就是测试 /**/// 注释

func (r *RefXss) checkJsComment(target Parameter, position string, k string, s string) {  
    flag := r.generateFlag()  
    var payload string  
    var truePayload string  
    if s == "//" {  
       payload = fmt.Sprintf("\n%s//", flag)  
       truePayload = fmt.Sprintf("\n%s//", "alert(1)")  
    } else if s == "/*" {  
       payload = fmt.Sprintf("*/%s/*", flag)  
       truePayload = fmt.Sprintf("\n%s//", "alert(1)")  
    }  

    req, resp, err := r.request(target, position, k, payload)  
    if err != nil {  
       logrus.WithError(err).Debugln("请求失败 [checkJsComment]")  
       return  
    }  
    positions, err := html.SearchInputInResponse(flag, strings.NewReader(resp.Text))  
    sources := []string{}  
    for _, p := range positions {  
       if strings.ToLower(p.TagName) == "script" {  
          sources = append(sources, p.Content)  
       }  
    }  
    content := strings.Join(sources, "\n")  
    asts, err := js.SearchInputInScript(flag, content)  
    if err != nil {  
       logrus.WithError(err).Debugln("ast解析失败")  
       return  
    }  
    lines := htmlInLine(content, flag)  
    for _, ast := range asts {  
       if ast.Type == js2.IdentifierToken && strings.Contains(ast.Content, flag) {  
          item := Item{  
             Req:            req,  
             Resp:           resp,  
             XssType:        "Js CommentToken",  
             Desc:           fmt.Sprintf("可以使用%s逃逸注释", truePayload),  
             Position:       position,  
             Parameter:      k,  
             Payload:        fmt.Sprintf("%s=%s", k, payload),  
             SuggestPayload: fmt.Sprintf("%s=%s", k, truePayload),  
             Line:           lines,  
          }  
          r.result.Items = append(r.result.Items, item)  
          return  
       }  
    }  
}

XSS

request 模块:针对对应位置设置对应的请求参数,请求用的 projectdiscovery 的库

func (r *RefXss) request(target Parameter, position string, k string, v string) (*retryablehttp.Request, *httpx.Response, error) {  
    req, err := r.hp.NewRequest(target.Method, target.Url, target.POST, target.Headers)  
    if err != nil {  
       return nil, nil, err  
    }  
    switch position {  
    case InjectGetParameter:  
       query := req.URL.Query()  
       query.Set(k, v)  
       req.URL.RawQuery = query.Encode()  
    case InjectPostParameter:  
       vs, err := url.ParseQuery(string(target.POST))  
       if err != nil {  
          return nil, nil, err  
       }  
       vs.Set(k, v)  
       req.Body = ioutil.NopCloser(strings.NewReader(vs.Encode()))  
       req, err = retryablehttp.FromRequest(req.Request)  
       if err != nil {  
          return nil, nil, err  
       }  
    case InjectCookieParameter:  
       cookies := req.Cookies()  
       for i, vv := range cookies {  
          key := vv.Name  
          if key == k {  
             cookies[i].Value = v  
          }  
       }  
       req.Header.Set("Cookie", EncodeCookies(cookies))  

    case InjectUriParameter:  
       paths := req.URL.Path  
       splits := strings.Split(paths, "/")  
       kValue, _ := strconv.Atoi(k)  
       s := splits[kValue]  
       last := strings.LastIndex(s, ".")  
       // 对路径中含点的做特殊处理  
       if last > -1 {  
          v = v + s[last:]  
       }  
       splits[kValue] = v  
       req.URL.Path = strings.Join(splits, "/")  
    case InjectHeaderParameter:  
       req.Header.Set(k, v)  
    }  
    resp, err := r.hp.Do(req)  
    if err != nil {  
       return nil, nil, err  
    }  
    return req, resp, nil  
}
扫描模块: 实际并没有调用,而是使用 ScanWithHiddenParameter。 1. 隐藏参数收集 1. HTML 获取 input 的 name 2. JS AST 获取变量再配合正则进行过滤 2. 添加隐藏参数,调用 handle 进行扫描,只检测 GET 3. 检测其他位置的 XSS ( POST, COOKIE, HEADER, URI )

总结

AST 抽象语法树 XSS