github.com/chainreactors/fingers@v1.2.1/xray/xray.go (about)

     1  // Package xray implements a fingerprint engine based on converted xray POCs.
     2  //
     3  // Unlike fingerprinthub which matches all templates against one response,
     4  // the xray engine matches each template's request independently:
     5  //   - WebMatch (passive): only matches requests targeting path "/"
     6  //   - HTTPActiveMatch (active): sends each request to its specified path,
     7  //     caching responses by path to avoid duplicate requests
     8  package xray
     9  
    10  import (
    11  	"bytes"
    12  	"encoding/json"
    13  	"fmt"
    14  	"io/ioutil"
    15  	"net/http"
    16  	"strings"
    17  	"sync"
    18  
    19  	"github.com/chainreactors/fingers/common"
    20  	"github.com/chainreactors/fingers/resources"
    21  	"github.com/chainreactors/logs"
    22  	"github.com/chainreactors/neutron/operators"
    23  	"github.com/chainreactors/neutron/protocols"
    24  	nhttp "github.com/chainreactors/neutron/protocols/http"
    25  	"github.com/chainreactors/neutron/templates"
    26  	"github.com/chainreactors/utils/httputils"
    27  	"gopkg.in/yaml.v3"
    28  )
    29  
    30  const FrameFromXray common.From = common.From(20)
    31  
    32  func init() {
    33  	common.FrameFromMap[FrameFromXray] = "xray"
    34  }
    35  
    36  // XrayEngine implements fingerprint matching using converted xray POC templates.
    37  type XrayEngine struct {
    38  	templates       []*templates.Template
    39  	executerOptions *protocols.ExecuterOptions
    40  
    41  	// CaseInsensitive 控制匹配时是否忽略大小写(默认 true)。
    42  	CaseInsensitive bool
    43  }
    44  
    45  // NewXrayEngine creates a new xray fingerprint engine from gzipped JSON data.
    46  func NewXrayEngine(webData []byte) (*XrayEngine, error) {
    47  	engine := &XrayEngine{
    48  		CaseInsensitive: true,
    49  		executerOptions: &protocols.ExecuterOptions{
    50  			Options: &protocols.Options{Timeout: 10},
    51  		},
    52  	}
    53  
    54  	// xray data is not embedded; an empty engine is valid (templates are
    55  	// supplied later via the Provider layer).
    56  	if len(webData) == 0 {
    57  		return engine, nil
    58  	}
    59  
    60  	var rawTemplates []map[string]interface{}
    61  	if err := resources.UnmarshalData(webData, &rawTemplates); err != nil {
    62  		return nil, fmt.Errorf("unmarshal xray fingerprints: %w", err)
    63  	}
    64  
    65  	loaded, errs := engine.loadTemplates(rawTemplates)
    66  	if len(errs) > 0 && len(errs) < 10 {
    67  		for _, e := range errs {
    68  			logs.Log.Warn(e)
    69  		}
    70  	}
    71  
    72  	logs.Log.Infof("resources type=fingerprints source=xray templates=%d", loaded)
    73  	return engine, nil
    74  }
    75  
    76  func (e *XrayEngine) loadTemplates(data []map[string]interface{}) (int, []error) {
    77  	var loaded int
    78  	var errs []error
    79  
    80  	for _, raw := range data {
    81  		yb, err := yaml.Marshal(raw)
    82  		if err != nil {
    83  			errs = append(errs, fmt.Errorf("marshal: %w", err))
    84  			continue
    85  		}
    86  		// templates.Load parses the (normally pre-converted) neutron template.
    87  		// Raw xray POCs are only auto-converted if the application opts in by
    88  		// importing github.com/chainreactors/neutron/convert, which registers
    89  		// the xray converter; without it, this package pulls in no conversion
    90  		// dependency and simply parses neutron-format templates.
    91  		tmpl, err := templates.Load(yb)
    92  		if err != nil {
    93  			errs = append(errs, fmt.Errorf("load: %w", err))
    94  			continue
    95  		}
    96  		if err := e.compileTemplate(tmpl); err != nil {
    97  			for _, req := range tmpl.GetRequests() {
    98  				if compileErr := (&req.Operators).Compile(); compileErr != nil {
    99  					continue
   100  				}
   101  				req.CompiledOperators = &req.Operators
   102  			}
   103  		}
   104  		if tmpl.GetRequests() != nil {
   105  			e.templates = append(e.templates, tmpl)
   106  			loaded++
   107  		}
   108  	}
   109  	return loaded, errs
   110  }
   111  
   112  func (e *XrayEngine) compileTemplate(tmpl *templates.Template) error {
   113  	if e.CaseInsensitive {
   114  		for _, req := range tmpl.GetRequests() {
   115  			for _, matcher := range req.Matchers {
   116  				if matcher.Type == "word" {
   117  					matcher.CaseInsensitive = true
   118  				}
   119  			}
   120  		}
   121  	}
   122  	return tmpl.Compile(e.executerOptions)
   123  }
   124  
   125  // ---------------------------------------------------------------------------
   126  // EngineImpl interface
   127  // ---------------------------------------------------------------------------
   128  
   129  func (e *XrayEngine) Name() string                            { return "xray" }
   130  func (e *XrayEngine) Len() int                                { return len(e.templates) }
   131  func (e *XrayEngine) Compile() error                          { return nil }
   132  func (e *XrayEngine) Capability() common.EngineCapability {
   133  	return common.EngineCapability{SupportWeb: true, SupportService: false}
   134  }
   135  
   136  // WebMatch performs passive fingerprint matching against an HTTP response.
   137  // Only requests targeting path "/" are matched (other paths require active probing).
   138  func (e *XrayEngine) WebMatch(content []byte) common.Frameworks {
   139  	resp := httputils.NewResponseWithRaw(content)
   140  	if resp == nil {
   141  		return make(common.Frameworks)
   142  	}
   143  
   144  	bodyStr := string(httputils.ReadBody(resp))
   145  	if e.CaseInsensitive {
   146  		bodyStr = strings.ToLower(bodyStr)
   147  	}
   148  	event := e.buildEvent(resp, bodyStr, len(content))
   149  	frames := make(common.Frameworks)
   150  
   151  	for _, tmpl := range e.templates {
   152  		if e.matchTemplatePassive(tmpl, event) {
   153  			frames.Add(e.newFramework(tmpl))
   154  		}
   155  	}
   156  	return frames
   157  }
   158  
   159  // matchTemplatePassive checks if ANY root-path request in the template matches.
   160  // Only requests with path "/" or "{{BaseURL}}/" are evaluated in passive mode.
   161  func (e *XrayEngine) matchTemplatePassive(tmpl *templates.Template, event protocols.InternalEvent) bool {
   162  	for _, req := range tmpl.GetRequests() {
   163  		if !isRootPath(req) {
   164  			continue
   165  		}
   166  		if req.CompiledOperators == nil || len(req.CompiledOperators.Matchers) == 0 {
   167  			continue
   168  		}
   169  		if matchRequest(req, event) {
   170  			return true
   171  		}
   172  	}
   173  	return false
   174  }
   175  
   176  func isRootPath(req *nhttp.Request) bool {
   177  	if len(req.Path) == 0 {
   178  		return true
   179  	}
   180  	for _, p := range req.Path {
   181  		cleaned := strings.TrimPrefix(p, "{{BaseURL}}")
   182  		cleaned = strings.TrimSuffix(cleaned, "/")
   183  		if cleaned == "" || cleaned == "/" {
   184  			return true
   185  		}
   186  	}
   187  	return false
   188  }
   189  
   190  func matchRequest(req *nhttp.Request, event protocols.InternalEvent) bool {
   191  	cond := strings.ToLower(strings.TrimSpace(req.CompiledOperators.MatchersCondition))
   192  	if cond == "" {
   193  		cond = "or"
   194  	}
   195  
   196  	anyMatched, allMatched := false, true
   197  	for _, matcher := range req.CompiledOperators.Matchers {
   198  		ok, _ := req.Match(event, matcher)
   199  		if ok {
   200  			anyMatched = true
   201  		} else {
   202  			allMatched = false
   203  		}
   204  	}
   205  	if cond == "and" {
   206  		return allMatched && len(req.CompiledOperators.Matchers) > 0
   207  	}
   208  	return anyMatched
   209  }
   210  
   211  func (e *XrayEngine) buildEvent(resp *http.Response, body string, contentLength int) protocols.InternalEvent {
   212  	event := make(protocols.InternalEvent)
   213  	event["body"] = body
   214  	event["status_code"] = resp.StatusCode
   215  	event["content_length"] = contentLength
   216  
   217  	var hdrBuilder strings.Builder
   218  	for k, vals := range resp.Header {
   219  		joined := strings.Join(vals, " ")
   220  		norm := strings.ToLower(strings.Replace(strings.TrimSpace(k), "-", "_", -1))
   221  		if e.CaseInsensitive {
   222  			joined = strings.ToLower(joined)
   223  		}
   224  		event[norm] = joined
   225  		hdrBuilder.WriteString(norm)
   226  		hdrBuilder.WriteString(": ")
   227  		hdrBuilder.WriteString(joined)
   228  		hdrBuilder.WriteString("\n")
   229  	}
   230  	event["all_headers"] = hdrBuilder.String()
   231  	event["header"] = hdrBuilder.String()
   232  	return event
   233  }
   234  
   235  func (e *XrayEngine) newFramework(tmpl *templates.Template) *common.Framework {
   236  	name := tmpl.Info.Name
   237  	if name == "" {
   238  		name = tmpl.Id
   239  	}
   240  	frame := common.NewFramework(name, FrameFromXray)
   241  	if tmpl.Info.Metadata != nil {
   242  		if vendor, ok := tmpl.Info.Metadata["vendor"].(string); ok {
   243  			frame.Attributes.Vendor = vendor
   244  		}
   245  		if product, ok := tmpl.Info.Metadata["product"].(string); ok {
   246  			frame.Attributes.Product = product
   247  		}
   248  	}
   249  	return frame
   250  }
   251  
   252  // ServiceMatch is not supported by the xray engine.
   253  func (e *XrayEngine) ServiceMatch(host, portStr string, level int, sender common.ServiceSender, callback common.ServiceCallback) *common.ServiceResult {
   254  	return nil
   255  }
   256  
   257  // ---------------------------------------------------------------------------
   258  // Active matching with per-request dispatch and path-level caching
   259  // ---------------------------------------------------------------------------
   260  
   261  // cachedTransport caches HTTP responses by request path to avoid duplicate requests.
   262  type cachedTransport struct {
   263  	transport http.RoundTripper
   264  	cache     map[string]*cachedResp
   265  	mu        sync.Mutex
   266  }
   267  
   268  type cachedResp struct {
   269  	resp *http.Response
   270  	body []byte
   271  }
   272  
   273  func (c *cachedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
   274  	key := req.URL.Path
   275  	if key == "" {
   276  		key = "/"
   277  	}
   278  
   279  	c.mu.Lock()
   280  	if cached, ok := c.cache[key]; ok {
   281  		c.mu.Unlock()
   282  		resp := *cached.resp
   283  		resp.Body = ioutil.NopCloser(bytes.NewReader(cached.body))
   284  		resp.Request = req
   285  		return &resp, nil
   286  	}
   287  	c.mu.Unlock()
   288  
   289  	resp, err := c.transport.RoundTrip(req)
   290  	if err != nil {
   291  		return nil, err
   292  	}
   293  
   294  	bodyBytes, err := ioutil.ReadAll(resp.Body)
   295  	resp.Body.Close()
   296  	if err != nil {
   297  		return nil, err
   298  	}
   299  
   300  	cr := *resp
   301  	cr.Body = nil
   302  	c.mu.Lock()
   303  	c.cache[key] = &cachedResp{resp: &cr, body: bodyBytes}
   304  	c.mu.Unlock()
   305  
   306  	resp.Body = ioutil.NopCloser(bytes.NewReader(bodyBytes))
   307  	return resp, nil
   308  }
   309  
   310  // HTTPActiveMatch sends per-template per-request probes with path-level caching.
   311  func (e *XrayEngine) HTTPActiveMatch(baseURL string, level int, transport http.RoundTripper, callback func(*common.Framework, *common.Vuln)) (common.Frameworks, common.Vulns) {
   312  	if baseURL == "" || transport == nil {
   313  		return nil, nil
   314  	}
   315  
   316  	allFrameworks := make(common.Frameworks)
   317  	ct := &cachedTransport{transport: transport, cache: make(map[string]*cachedResp)}
   318  	client := &http.Client{Transport: ct}
   319  	for _, tmpl := range e.templates {
   320  		if len(tmpl.RequestsHTTP) == 0 {
   321  			continue
   322  		}
   323  
   324  		// Pass the per-call client through execution instead of stashing it on the
   325  		// shared template's request objects — concurrent calls must not mutate the
   326  		// shared compiled templates.
   327  		result, err := tmpl.ExecuteWithClient(baseURL, nil, client)
   328  		if err == nil && result != nil && result.Matched {
   329  			frame := e.newFramework(tmpl)
   330  			allFrameworks.Add(frame)
   331  			if callback != nil {
   332  				callback(frame, nil)
   333  			}
   334  		}
   335  	}
   336  	return allFrameworks, nil
   337  }
   338  
   339  // LoadFromJSON loads templates from a raw JSON byte slice (for testing).
   340  func (e *XrayEngine) LoadFromJSON(data []byte) error {
   341  	var raw []map[string]interface{}
   342  	if err := json.Unmarshal(data, &raw); err != nil {
   343  		return err
   344  	}
   345  	loaded, _ := e.loadTemplates(raw)
   346  	_ = loaded
   347  	return nil
   348  }
   349  
   350  // GetTemplateMatchersForRequest returns the set of matchers for a template,
   351  // for a specific request (by index).
   352  func GetTemplateMatchersForRequest(tmpl *templates.Template, reqIndex int) []*operators.Matcher {
   353  	reqs := tmpl.GetRequests()
   354  	if reqIndex < 0 || reqIndex >= len(reqs) {
   355  		return nil
   356  	}
   357  	req := reqs[reqIndex]
   358  	if req.CompiledOperators == nil {
   359  		return nil
   360  	}
   361  	return req.CompiledOperators.Matchers
   362  }