Add recon form fingerprint library and SSRF canary mode.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Score crawl inputs by SSRF-prone field names and register ping-back canaries for operator paste confirmation.
This commit is contained in:
17
server/internal/recon/canary.go
Normal file
17
server/internal/recon/canary.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package recon
|
||||
|
||||
import "strings"
|
||||
|
||||
func BuildSSRfCanaryURL(base, scanID string) string {
|
||||
base = strings.TrimSpace(base)
|
||||
if base == "" {
|
||||
base = "https://localhost"
|
||||
}
|
||||
if strings.HasPrefix(base, "http://") {
|
||||
base = "https://" + strings.TrimPrefix(base, "http://")
|
||||
}
|
||||
if !strings.HasPrefix(base, "https://") {
|
||||
base = "https://" + base
|
||||
}
|
||||
return strings.TrimRight(base, "/") + "/recon/ping/" + scanID
|
||||
}
|
||||
@@ -67,6 +67,7 @@ func Crawl(host string, port int, scheme string, seedPaths []string) (*CrawlRepo
|
||||
report.MultipartForms = append(report.MultipartForms, multi...)
|
||||
report.URLFields = append(report.URLFields, fields...)
|
||||
report.SSRFScore += pageScore
|
||||
AppendPageFingerprints(report, item.url, body)
|
||||
report.CMSFingerprints = mergeCMS(report.CMSFingerprints, cms)
|
||||
uploadRaw = append(uploadRaw, collectUploadFromPage(item.url, files, multi)...)
|
||||
uploadRaw = append(uploadRaw, detectDragDropZones(item.url, body)...)
|
||||
|
||||
104
server/internal/recon/fingerprint.go
Normal file
104
server/internal/recon/fingerprint.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package recon
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var formFingerprintKeywords = []struct {
|
||||
keyword string
|
||||
weight int
|
||||
}{
|
||||
{"url", 12}, {"src", 10}, {"href", 10}, {"callback", 14}, {"redirect", 13},
|
||||
{"avatar", 8}, {"import", 11}, {"feed", 9}, {"screenshot", 12}, {"pdf", 10}, {"proxy", 11},
|
||||
}
|
||||
|
||||
func ScoreFormFieldFingerprint(name, id, placeholder string) (score int, matches []string) {
|
||||
joined := strings.ToLower(strings.Join([]string{name, id, placeholder}, " "))
|
||||
seen := map[string]bool{}
|
||||
for _, kw := range formFingerprintKeywords {
|
||||
if strings.Contains(joined, kw.keyword) {
|
||||
score += kw.weight
|
||||
if !seen[kw.keyword] {
|
||||
seen[kw.keyword] = true
|
||||
matches = append(matches, kw.keyword)
|
||||
}
|
||||
}
|
||||
}
|
||||
return score, matches
|
||||
}
|
||||
|
||||
func ExtractFormFieldFingerprints(pageURL, body string) []FormFieldFingerprint {
|
||||
root, err := html.Parse(strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []FormFieldFingerprint
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && (n.Data == "input" || n.Data == "textarea" || n.Data == "select") {
|
||||
name := fingerprintAttr(n, "name")
|
||||
id := fingerprintAttr(n, "id")
|
||||
placeholder := fingerprintAttr(n, "placeholder")
|
||||
score, matches := ScoreFormFieldFingerprint(name, id, placeholder)
|
||||
if score > 0 {
|
||||
out = append(out, FormFieldFingerprint{
|
||||
PageURL: pageURL, Name: name, ID: id, Placeholder: placeholder,
|
||||
Score: score, Matches: matches,
|
||||
})
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Score != out[j].Score {
|
||||
return out[i].Score > out[j].Score
|
||||
}
|
||||
if out[i].Name != out[j].Name {
|
||||
return out[i].Name < out[j].Name
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func fingerprintAttr(n *html.Node, key string) string {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == key {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TopFormFieldFingerprint(fields []FormFieldFingerprint) *FormFieldFingerprint {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
top := fields[0]
|
||||
return &top
|
||||
}
|
||||
|
||||
func ApplyCanaryPasteTarget(fields []FormFieldFingerprint, canaryURL string) {
|
||||
for i := range fields {
|
||||
fields[i].PasteTarget = canaryURL
|
||||
}
|
||||
}
|
||||
|
||||
func AppendPageFingerprints(report *CrawlReport, pageURL, body string) {
|
||||
if report == nil {
|
||||
return
|
||||
}
|
||||
fps := ExtractFormFieldFingerprints(pageURL, body)
|
||||
report.FingerprintFields = append(report.FingerprintFields, fps...)
|
||||
for _, fp := range fps {
|
||||
if fp.Score >= 10 {
|
||||
report.SSRFScore += fp.Score / 5
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,7 @@ func parseURLFieldFromNamed(pageURL, name, id, placeholder string) *URLFieldFind
|
||||
return &URLFieldFinding{
|
||||
PageURL: pageURL,
|
||||
Name: label,
|
||||
ID: id,
|
||||
Type: "text",
|
||||
Hint: hint,
|
||||
}
|
||||
|
||||
@@ -315,3 +315,19 @@ func TestCloudMetadataProfileSuggestsSSM(t *testing.T) {
|
||||
t.Fatal()
|
||||
}
|
||||
func containsInt(a []int,w int) bool { for _,n:=range a { if n==w {return true} }; return false }
|
||||
|
||||
|
||||
func TestScoreFormFieldFingerprintKeywords(t *testing.T) {
|
||||
score, matches := ScoreFormFieldFingerprint("callback_url", "", "")
|
||||
if score < 14 || !containsStr(matches, "callback") { t.Fatalf("score=%d matches=%v", score, matches) }
|
||||
}
|
||||
|
||||
func TestExtractFormFieldFingerprintsExactNameID(t *testing.T) {
|
||||
fps := ExtractFormFieldFingerprints("http://lab/", `<input name="avatar_url" id="userAvatar">`)
|
||||
if len(fps) == 0 || fps[0].Name != "avatar_url" || fps[0].ID != "userAvatar" { t.Fatalf("%+v", fps) }
|
||||
}
|
||||
|
||||
func TestBuildSSRfCanaryURLForcesHTTPS(t *testing.T) {
|
||||
u := BuildSSRfCanaryURL("http://public.example.com", "abc-123")
|
||||
if !strings.HasPrefix(u, "https://") || !strings.Contains(u, "/recon/ping/abc-123") { t.Fatalf("url=%q", u) }
|
||||
}
|
||||
|
||||
@@ -184,6 +184,9 @@ func runOwnedTargetScan(req ScanRequest, scanID string, emit StreamEmit) (*ScanR
|
||||
if !opts.SkipPorts {
|
||||
ports := scanPortsList(host, portsToScan)
|
||||
report.Ports = ports
|
||||
for _, p := range ports {
|
||||
emitReconPort(emit, scanID, host, p)
|
||||
}
|
||||
report.Banners = GrabBanners(host, ports)
|
||||
}
|
||||
if shouldCrawlProfile(req, uxProfile, report.Ports, opts) {
|
||||
@@ -264,6 +267,7 @@ func crawlWithOptions(host string, port int, scheme string, seedPaths []string,
|
||||
report.PagesFetched++
|
||||
title, _ := htmlParseTitle(body)
|
||||
report.Pages = append(report.Pages, PageFinding{URL: item.url, StatusCode: status, Title: title})
|
||||
emitReconPage(emit, scanID, host, item.url, status, title)
|
||||
if len(headers) > 0 { headerSnaps = append(headerSnaps, HTTPHeaderSnap{URL: item.url, Headers: headers}) }
|
||||
htmlBodies = append(htmlBodies, body)
|
||||
files, multi, fields, pageScore, cms := ParseHTML(item.url, body)
|
||||
@@ -271,7 +275,9 @@ func crawlWithOptions(host string, port int, scheme string, seedPaths []string,
|
||||
report.MultipartForms = append(report.MultipartForms, multi...)
|
||||
report.URLFields = append(report.URLFields, fields...)
|
||||
report.SSRFScore += pageScore
|
||||
AppendPageFingerprints(report, item.url, body)
|
||||
report.CMSFingerprints = mergeCMS(report.CMSFingerprints, cms)
|
||||
emitReconFindings(emit, scanID, host, files, multi, fields)
|
||||
uploadRaw = append(uploadRaw, collectUploadFromPage(item.url, files, multi)...)
|
||||
uploadRaw = append(uploadRaw, detectDragDropZones(item.url, body)...)
|
||||
collectUploadJSAtDepth(base, item.url, body, item.depth, maxDepth, jsSeen, &jsQueue)
|
||||
@@ -289,14 +295,38 @@ func crawlWithOptions(host string, port int, scheme string, seedPaths []string,
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func emitReconPort(emit StreamEmit, scanID, host string, p PortResult) {
|
||||
if emit == nil { return }
|
||||
emit("recon_port", map[string]interface{}{"scan_id": scanID, "host": host, "port": p.Port, "open": p.Open})
|
||||
}
|
||||
func emitReconPage(emit StreamEmit, scanID, host, pageURL string, status int, title string) {
|
||||
if emit == nil { return }
|
||||
emit("recon_page", map[string]interface{}{"scan_id": scanID, "host": host, "url": pageURL, "status_code": status, "title": title})
|
||||
}
|
||||
func emitReconFindings(emit StreamEmit, scanID, host string, files, multi []FormFinding, fields []URLFieldFinding) {
|
||||
if emit == nil { return }
|
||||
for _, f := range files { emit("recon_finding", map[string]interface{}{"scan_id": scanID, "host": host, "kind": "file_input", "finding": f}) }
|
||||
for _, f := range multi { emit("recon_finding", map[string]interface{}{"scan_id": scanID, "host": host, "kind": "multipart_form", "finding": f}) }
|
||||
for _, f := range fields { emit("recon_finding", map[string]interface{}{"scan_id": scanID, "host": host, "kind": "url_field", "finding": f}) }
|
||||
}
|
||||
func formFindingKey(f FormFinding) string { return f.PageURL + "|" + f.Action + "|" + strings.Join(f.Fields, ",") }
|
||||
func collectFormFindings(r *ScanReport) []FormFinding {
|
||||
if r == nil || r.Crawl == nil { return nil }
|
||||
out := append([]FormFinding{}, r.Crawl.FileInputs...)
|
||||
return append(out, r.Crawl.MultipartForms...)
|
||||
}
|
||||
|
||||
func OpenPorts(ports []PortResult) []int { var o []int; for _, p := range ports { if p.Open { o = append(o, p.Port) } }; return o }
|
||||
func DiffReports(prev, cur *ScanReport) *ReconScanDiff {
|
||||
if cur == nil { return nil }
|
||||
d := &ReconScanDiff{}
|
||||
if prev == nil { d.NewPorts = OpenPorts(cur.Ports); return d }
|
||||
if prev == nil { d.NewPorts = OpenPorts(cur.Ports); d.NewForms = collectFormFindings(cur); return d }
|
||||
po := map[int]bool{}
|
||||
for _, p := range prev.Ports { if p.Open { po[p.Port] = true } }
|
||||
for _, p := range cur.Ports { if p.Open && !po[p.Port] { d.NewPorts = append(d.NewPorts, p.Port) } }
|
||||
seen := map[string]bool{}
|
||||
for _, f := range collectFormFindings(prev) { seen[formFindingKey(f)] = true }
|
||||
for _, f := range collectFormFindings(cur) { if !seen[formFindingKey(f)] { d.NewForms = append(d.NewForms, f) } }
|
||||
return d
|
||||
}
|
||||
func BuildHistory(rows []*ScanReport) []ReconHistoryEntry {
|
||||
|
||||
@@ -11,12 +11,13 @@ const (
|
||||
)
|
||||
|
||||
type ScanRequest struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Profiles []string `json:"profiles,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Profiles []string `json:"profiles,omitempty"`
|
||||
SSRFCanary bool `json:"ssrf_canary,omitempty"`
|
||||
}
|
||||
|
||||
type PortResult struct {
|
||||
@@ -62,10 +63,31 @@ type FormFinding struct {
|
||||
type URLFieldFinding struct {
|
||||
PageURL string `json:"page_url"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
|
||||
type FormFieldFingerprint struct {
|
||||
PageURL string `json:"page_url"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Score int `json:"score"`
|
||||
Matches []string `json:"matches,omitempty"`
|
||||
PasteTarget string `json:"paste_target,omitempty"`
|
||||
}
|
||||
|
||||
type SSRFCanaryInfo struct {
|
||||
ScanID string `json:"scan_id"`
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
PasteTarget string `json:"paste_target"`
|
||||
PasteFieldName string `json:"paste_field_name,omitempty"`
|
||||
PasteFieldID string `json:"paste_field_id,omitempty"`
|
||||
HitAt *time.Time `json:"hit_at,omitempty"`
|
||||
}
|
||||
|
||||
type UploadHunterFinding struct {
|
||||
PageURL string `json:"page_url"`
|
||||
Target string `json:"target,omitempty"`
|
||||
@@ -90,15 +112,16 @@ type PageFinding struct {
|
||||
}
|
||||
|
||||
type CrawlReport struct {
|
||||
PagesFetched int `json:"pages_fetched"`
|
||||
Pages []PageFinding `json:"pages,omitempty"`
|
||||
FileInputs []FormFinding `json:"file_inputs,omitempty"`
|
||||
MultipartForms []FormFinding `json:"multipart_forms,omitempty"`
|
||||
URLFields []URLFieldFinding `json:"url_fields,omitempty"`
|
||||
SSRFScore int `json:"ssrf_score"`
|
||||
CMSFingerprints []string `json:"cms_fingerprints,omitempty"`
|
||||
Stack []StackEntry `json:"stack,omitempty"`
|
||||
UploadHunter []UploadHunterFinding `json:"upload_hunter,omitempty"`
|
||||
PagesFetched int `json:"pages_fetched"`
|
||||
Pages []PageFinding `json:"pages,omitempty"`
|
||||
FileInputs []FormFinding `json:"file_inputs,omitempty"`
|
||||
MultipartForms []FormFinding `json:"multipart_forms,omitempty"`
|
||||
URLFields []URLFieldFinding `json:"url_fields,omitempty"`
|
||||
FingerprintFields []FormFieldFingerprint `json:"fingerprint_fields,omitempty"`
|
||||
SSRFScore int `json:"ssrf_score"`
|
||||
CMSFingerprints []string `json:"cms_fingerprints,omitempty"`
|
||||
Stack []StackEntry `json:"stack,omitempty"`
|
||||
UploadHunter []UploadHunterFinding `json:"upload_hunter,omitempty"`
|
||||
}
|
||||
|
||||
type DeployRecommendation struct {
|
||||
@@ -141,4 +164,5 @@ type ScanReport struct {
|
||||
PathTracerHints []string `json:"path_tracer_hints,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Recommendations []DeployRecommendation `json:"recommendations,omitempty"`
|
||||
Canary *SSRFCanaryInfo `json:"canary,omitempty"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user