Add recon form fingerprint library and SSRF canary mode.
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:
AetherForge
2026-06-07 12:23:11 -07:00
parent 251bdfa1ac
commit 49c24e611c
19 changed files with 1410 additions and 78 deletions

View 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
}

View File

@@ -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)...)

View 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
}
}
}

View File

@@ -128,6 +128,7 @@ func parseURLFieldFromNamed(pageURL, name, id, placeholder string) *URLFieldFind
return &URLFieldFinding{
PageURL: pageURL,
Name: label,
ID: id,
Type: "text",
Hint: hint,
}

View File

@@ -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) }
}

View File

@@ -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 {

View File

@@ -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"`
}