From 6859d79552c374af2e8291a75d84da057f558de2 Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sun, 7 Jun 2026 12:20:04 -0700 Subject: [PATCH] Add recon upload hunter and admin surface probing for owned-target scans. Extend web crawl with multipart/drag-drop/JS upload ranking and probe common admin paths for 200 vs 401/403 signals in scan JSON. --- server/internal/recon/admin_surface.go | 61 ++++++ server/internal/recon/relay_scan.go | 2 +- server/internal/recon/scan.go | 46 +++++ server/internal/recon/upload_hunter.go | 252 +++++++++++++++++++++++++ 4 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 server/internal/recon/admin_surface.go create mode 100644 server/internal/recon/upload_hunter.go diff --git a/server/internal/recon/admin_surface.go b/server/internal/recon/admin_surface.go new file mode 100644 index 0000000..bb54454 --- /dev/null +++ b/server/internal/recon/admin_surface.go @@ -0,0 +1,61 @@ +package recon + +import ( + "fmt" + "net/http" + "sort" + "strings" +) + +var adminSurfacePaths = []string{ + "/wp-admin", "/wp-admin/", "/admin", "/admin/", "/admin/login", "/administrator", + "/api", "/api/", "/api/v1", "/graphql", "/graphql/", + "/swagger", "/swagger/", "/swagger/index.html", "/swagger-ui", "/swagger-ui/", + "/actuator", "/actuator/", "/actuator/health", + "/.env", "/.env.local", "/server-status", "/server-status/", +} + +func ProbeAdminSurface(host string, port int, scheme string) []AdminSurfaceFinding { + scheme = normalizeScheme(scheme, port) + if port <= 0 { + port = defaultPortForScheme(scheme) + } + base := fmt.Sprintf("%s://%s", scheme, joinHostPort(host, port)) + seen := map[string]bool{} + var out []AdminSurfaceFinding + for _, path := range adminSurfacePaths { + key := strings.ToLower(path) + if seen[key] { + continue + } + seen[key] = true + rawURL := strings.TrimRight(base, "/") + path + status, _, _, err := fetchPage(rawURL) + if err != nil { + continue + } + signal := adminSurfaceSignal(status) + if signal == "" { + continue + } + out = append(out, AdminSurfaceFinding{Path: path, URL: rawURL, StatusCode: status, Signal: signal}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Signal != out[j].Signal { + return out[i].Signal == "green" + } + return out[i].Path < out[j].Path + }) + return out +} + +func adminSurfaceSignal(status int) string { + switch status { + case http.StatusOK: + return "green" + case http.StatusUnauthorized, http.StatusForbidden: + return "gray" + default: + return "" + } +} diff --git a/server/internal/recon/relay_scan.go b/server/internal/recon/relay_scan.go index a10f424..a983670 100644 --- a/server/internal/recon/relay_scan.go +++ b/server/internal/recon/relay_scan.go @@ -92,7 +92,7 @@ func LocalRelayScan(req RelayScanRequest) (*RelayScanReport, error) { return &RelayScanReport{ Host: host, ScannedAt: time.Now().UTC(), LocalReachable: true, ScannedVia: "server", Ports: ports, UDPHints: udp, PathTracerHints: PathTracerHintsFromUDP(udp), - Recommendations: BuildRecommendations(ports, nil, nil, shell.Host, false), + Recommendations: BuildRecommendations(ports, nil, nil, host, false), }, nil } diff --git a/server/internal/recon/scan.go b/server/internal/recon/scan.go index 5ac5e00..1cc70b8 100644 --- a/server/internal/recon/scan.go +++ b/server/internal/recon/scan.go @@ -183,6 +183,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) { @@ -259,6 +262,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) @@ -267,6 +271,7 @@ func crawlWithOptions(host string, port int, scheme string, seedPaths []string, report.URLFields = append(report.URLFields, fields...) report.SSRFScore += pageScore report.CMSFingerprints = mergeCMS(report.CMSFingerprints, cms) + emitReconFindings(emit, scanID, host, files, multi, fields) if item.depth >= maxDepth { continue } for _, link := range extractLinks(body) { abs, err := resolveSameOrigin(base, link) @@ -280,6 +285,47 @@ 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 } diff --git a/server/internal/recon/upload_hunter.go b/server/internal/recon/upload_hunter.go new file mode 100644 index 0000000..aec0879 --- /dev/null +++ b/server/internal/recon/upload_hunter.go @@ -0,0 +1,252 @@ +package recon + +import ( + "net/url" + "regexp" + "sort" + "strings" +) + +var ( + openUploadPathRe = regexp.MustCompile(`(?i)(/api/[^\s"'<>]*upload|/upload[^\s"'<>]*|/v\d+/upload)`) + dragDropClassRe = regexp.MustCompile(`(?i)(dropzone|drop-zone|file-drop|drag-drop|fileupload)`) + jsUploadHintRe = regexp.MustCompile(`(?i)(multipart/form-data|formdata\s*\(|type\s*:\s*['"]file['"]|/api/[^\s"'<>]*upload|\.upload\s*\(|dropzone)`) +) + +func collectUploadFromPage(pageURL string, fileInputs, multipart []FormFinding) []UploadHunterFinding { + var out []UploadHunterFinding + for _, f := range fileInputs { + if !f.HasFile { + continue + } + out = append(out, UploadHunterFinding{PageURL: pageURL, Target: resolveUploadTarget(pageURL, f.Action), Source: "file_input", Method: f.Method}) + } + for _, f := range multipart { + if !f.Multipart { + continue + } + out = append(out, UploadHunterFinding{PageURL: pageURL, Target: resolveUploadTarget(pageURL, f.Action), Source: "multipart", Method: f.Method}) + } + return out +} + +func detectDragDropZones(pageURL, body string) []UploadHunterFinding { + root, err := htmlParseRoot(body) + if err != nil { + return detectDragDropFromText(pageURL, body) + } + var out []UploadHunterFinding + var walk func(*htmlNode) + walk = func(n *htmlNode) { + if n.tag != "" { + cls := strings.ToLower(n.attr("class")) + id := strings.ToLower(n.attr("id")) + if dragDropClassRe.MatchString(cls) || dragDropClassRe.MatchString(id) || n.attr("data-dropzone") != "" { + target := n.attr("data-upload-url") + if target == "" { + target = n.attr("action") + } + out = append(out, UploadHunterFinding{PageURL: pageURL, Target: resolveUploadTarget(pageURL, target), Source: "drag_drop"}) + } + } + for _, c := range n.children { + walk(c) + } + } + walk(root) + if len(out) == 0 { + return detectDragDropFromText(pageURL, body) + } + return out +} + +func detectDragDropFromText(pageURL, body string) []UploadHunterFinding { + lower := strings.ToLower(body) + if (!strings.Contains(lower, "dropzone") && !strings.Contains(lower, "drag")) || (!strings.Contains(lower, "upload") && !strings.Contains(lower, "file")) { + return nil + } + return []UploadHunterFinding{{PageURL: pageURL, Source: "drag_drop"}} +} + +func extractScriptSrc(pageURL, body string) []string { + root, err := htmlParseRoot(body) + if err != nil { + return nil + } + var srcs []string + var walk func(*htmlNode) + walk = func(n *htmlNode) { + if n.tag == "script" { + if src := strings.TrimSpace(n.attr("src")); src != "" { + srcs = append(srcs, src) + } + } + for _, c := range n.children { + walk(c) + } + } + walk(root) + _ = pageURL + return srcs +} + +func scanJSForUpload(jsURL, body string) []UploadHunterFinding { + if !jsUploadHintRe.MatchString(body) { + return nil + } + target := jsURL + if m := openUploadPathRe.FindString(body); m != "" { + target = m + } + return []UploadHunterFinding{{PageURL: jsURL, Target: target, Source: "js"}} +} + +func isScriptAsset(ref string) bool { + ref = strings.ToLower(strings.TrimSpace(ref)) + return strings.HasSuffix(ref, ".js") || strings.Contains(ref, ".js?") +} + +func resolveUploadTarget(pageURL, action string) string { + action = strings.TrimSpace(action) + if action == "" { + if u, err := url.Parse(pageURL); err == nil { + return u.Path + } + return pageURL + } + if strings.HasPrefix(action, "http://") || strings.HasPrefix(action, "https://") { + return action + } + if abs, err := resolveSameOrigin(pageURL, action); err == nil { + return abs + } + return action +} + +func rankUploadFindings(base string, in []UploadHunterFinding) []UploadHunterFinding { + if len(in) == 0 { + return nil + } + seen := map[string]UploadHunterFinding{} + for _, f := range in { + key := strings.ToLower(f.PageURL + "|" + f.Target + "|" + f.Source) + if prev, ok := seen[key]; ok { + if scoreUploadFinding(f) > scoreUploadFinding(prev) { + seen[key] = f + } + continue + } + seen[key] = f + } + out := make([]UploadHunterFinding, 0, len(seen)) + for _, f := range seen { + f = tagUploadFinding(base, f) + f.Score = scoreUploadFinding(f) + out = append(out, f) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Score != out[j].Score { + return out[i].Score > out[j].Score + } + return out[i].Target < out[j].Target + }) + return out +} + +func tagUploadFinding(base string, f UploadHunterFinding) UploadHunterFinding { + target := f.Target + if target == "" { + target = f.PageURL + } + path := target + if u, err := url.Parse(target); err == nil && u.Path != "" { + path = u.Path + } + if openUploadPathRe.MatchString(path) || openUploadPathRe.MatchString(target) { + f.Tags = appendUniqueTag(f.Tags, "open_api") + } + probeURL := target + if !strings.HasPrefix(probeURL, "http://") && !strings.HasPrefix(probeURL, "https://") { + if abs, err := resolveSameOrigin(base, probeURL); err == nil { + probeURL = abs + } else { + probeURL = strings.TrimRight(base, "/") + "/" + strings.TrimLeft(probeURL, "/") + } + } + status, _, _, err := fetchPage(probeURL) + if err == nil { + f.StatusCode = status + if status == 200 { + f.Tags = appendUniqueTag(f.Tags, "no_auth") + } + } + return f +} + +func scoreUploadFinding(f UploadHunterFinding) int { + score := 10 + switch f.Source { + case "multipart": + score += 30 + case "file_input": + score += 25 + case "drag_drop": + score += 20 + case "js": + score += 15 + } + for _, tag := range f.Tags { + switch tag { + case "no_auth": + score += 40 + case "open_api": + score += 25 + } + } + if f.StatusCode == 200 { + score += 10 + } + return score +} + +func appendUniqueTag(tags []string, tag string) []string { + for _, t := range tags { + if t == tag { + return tags + } + } + return append(tags, tag) +} + +func finalizeUploadHunter(base string, uploadRaw []UploadHunterFinding, jsQueue []string) []UploadHunterFinding { + maxJS := 20 + if len(jsQueue) > maxJS { + jsQueue = jsQueue[:maxJS] + } + for _, jsURL := range jsQueue { + _, jsBody, _, err := fetchPage(jsURL) + if err != nil { + continue + } + uploadRaw = append(uploadRaw, scanJSForUpload(jsURL, jsBody)...) + } + return rankUploadFindings(base, uploadRaw) +} + +func collectUploadJSAtDepth(base, pageURL, body string, depth, maxDepth int, jsSeen map[string]bool, jsQueue *[]string) { + if depth > maxDepth { + return + } + for _, src := range extractScriptSrc(pageURL, body) { + abs, err := resolveSameOrigin(base, src) + if err != nil || !sameOrigin(base, abs) || !isScriptAsset(src) { + continue + } + key := normalizeURLKey(abs) + if jsSeen[key] { + continue + } + jsSeen[key] = true + *jsQueue = append(*jsQueue, abs) + } +}