Add recon upload hunter and admin surface probing for owned-target scans.
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
Extend web crawl with multipart/drag-drop/JS upload ranking and probe common admin paths for 200 vs 401/403 signals in scan JSON.
This commit is contained in:
@@ -8,11 +8,29 @@ import (
|
||||
)
|
||||
|
||||
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/",
|
||||
"/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 {
|
||||
@@ -21,6 +39,7 @@ func ProbeAdminSurface(host string, port int, scheme string) []AdminSurfaceFindi
|
||||
port = defaultPortForScheme(scheme)
|
||||
}
|
||||
base := fmt.Sprintf("%s://%s", scheme, joinHostPort(host, port))
|
||||
|
||||
seen := map[string]bool{}
|
||||
var out []AdminSurfaceFinding
|
||||
for _, path := range adminSurfacePaths {
|
||||
@@ -29,6 +48,7 @@ func ProbeAdminSurface(host string, port int, scheme string) []AdminSurfaceFindi
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
rawURL := strings.TrimRight(base, "/") + path
|
||||
status, _, _, err := fetchPage(rawURL)
|
||||
if err != nil {
|
||||
@@ -38,8 +58,14 @@ func ProbeAdminSurface(host string, port int, scheme string) []AdminSurfaceFindi
|
||||
if signal == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, AdminSurfaceFinding{Path: path, URL: rawURL, StatusCode: status, Signal: signal})
|
||||
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"
|
||||
|
||||
@@ -25,6 +25,9 @@ func Crawl(host string, port int, scheme string, seedPaths []string) (*CrawlRepo
|
||||
}
|
||||
|
||||
report := &CrawlReport{}
|
||||
var uploadRaw []UploadHunterFinding
|
||||
var jsQueue []string
|
||||
jsSeen := map[string]bool{}
|
||||
var headerSnaps []HTTPHeaderSnap
|
||||
var htmlBodies []string
|
||||
visited := map[string]bool{}
|
||||
@@ -65,6 +68,9 @@ func Crawl(host string, port int, scheme string, seedPaths []string) (*CrawlRepo
|
||||
report.URLFields = append(report.URLFields, fields...)
|
||||
report.SSRFScore += pageScore
|
||||
report.CMSFingerprints = mergeCMS(report.CMSFingerprints, cms)
|
||||
uploadRaw = append(uploadRaw, collectUploadFromPage(item.url, files, multi)...)
|
||||
uploadRaw = append(uploadRaw, detectDragDropZones(item.url, body)...)
|
||||
collectUploadJSAtDepth(base, item.url, body, item.depth, DefaultCrawlDepth, jsSeen, &jsQueue)
|
||||
|
||||
if item.depth >= DefaultCrawlDepth {
|
||||
continue
|
||||
@@ -89,6 +95,7 @@ func Crawl(host string, port int, scheme string, seedPaths []string) (*CrawlRepo
|
||||
}
|
||||
report.CMSFingerprints = mergeCMS(nil, report.CMSFingerprints)
|
||||
report.Stack = BuildStack(headerSnaps, htmlBodies)
|
||||
report.UploadHunter = finalizeUploadHunter(base, uploadRaw, jsQueue)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +162,116 @@ func TestNormalizeOwnedHostRejectsEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestUploadHunterMultipartAndJS(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/":
|
||||
w.Write([]byte(`<html><body><form action="/api/upload" enctype="multipart/form-data"><input type="file"></form><script src="/static/upload.js"></script></body></html>`))
|
||||
case "/static/upload.js":
|
||||
w.Write([]byte(`fetch("/api/upload")`))
|
||||
case "/api/upload":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
u, _ := url.Parse(srv.URL)
|
||||
port := 80
|
||||
if p := u.Port(); p != "" {
|
||||
port = atoi(p)
|
||||
}
|
||||
SetFetchPageHook(func(rawURL string) (int, string, error) {
|
||||
resp, err := http.Get(rawURL)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := readBodyLimited(resp.Body, maxHTMLBytes)
|
||||
return resp.StatusCode, body, nil
|
||||
})
|
||||
t.Cleanup(func() { SetFetchPageHook(nil) })
|
||||
report, err := Crawl(u.Hostname(), port, u.Scheme, []string{"/"})
|
||||
if err != nil || len(report.UploadHunter) == 0 {
|
||||
t.Fatalf("err=%v hunter=%+v", err, report.UploadHunter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeAdminSurfaceSignals(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/admin", "/admin/":
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
case "/swagger", "/swagger/":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
u, _ := url.Parse(srv.URL)
|
||||
port := 80
|
||||
if p := u.Port(); p != "" {
|
||||
port = atoi(p)
|
||||
}
|
||||
SetFetchPageHook(func(rawURL string) (int, string, error) {
|
||||
resp, err := http.Get(rawURL)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := readBodyLimited(resp.Body, maxHTMLBytes)
|
||||
return resp.StatusCode, body, nil
|
||||
})
|
||||
t.Cleanup(func() { SetFetchPageHook(nil) })
|
||||
if len(ProbeAdminSurface(u.Hostname(), port, u.Scheme)) == 0 {
|
||||
t.Fatal("expected admin surface")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanReportIncludesAdminSurfaceJSON(t *testing.T) {
|
||||
SetPortDialHook(func(host string, port int, _ time.Duration) bool { return port == 80 })
|
||||
t.Cleanup(func() { SetPortDialHook(nil) })
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/":
|
||||
w.Write([]byte(`<html><form enctype="multipart/form-data" action="/api/upload"><input type="file"></form></html>`))
|
||||
case "/api/upload":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/admin", "/admin/":
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
u, _ := url.Parse(srv.URL)
|
||||
port := 80
|
||||
if p := u.Port(); p != "" {
|
||||
port = atoi(p)
|
||||
}
|
||||
SetFetchPageHook(func(rawURL string) (int, string, error) {
|
||||
resp, err := http.Get(rawURL)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := readBodyLimited(resp.Body, maxHTMLBytes)
|
||||
return resp.StatusCode, body, nil
|
||||
})
|
||||
t.Cleanup(func() { SetFetchPageHook(nil) })
|
||||
report, err := Scan(ScanRequest{Host: u.Hostname(), Port: port, Scheme: u.Scheme})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(report)
|
||||
s := string(raw)
|
||||
if !strings.Contains(s, `"admin_surface"`) || !strings.Contains(s, `"upload_hunter"`) {
|
||||
t.Fatalf("json=%s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
|
||||
@@ -34,6 +34,7 @@ func scanLegacy(req ScanRequest) (*ScanReport, error) {
|
||||
if err == nil && crawl != nil {
|
||||
report.Crawl = crawl
|
||||
}
|
||||
report.AdminSurface = ProbeAdminSurface(host, req.Port, req.Scheme)
|
||||
}
|
||||
report.Recommendations = BuildRecommendations(ports, report.Crawl, nil, host, false)
|
||||
return report, nil
|
||||
@@ -127,7 +128,7 @@ func BuildRecommendations(ports []PortResult, crawl *CrawlReport, stack []StackE
|
||||
}
|
||||
|
||||
if crawl != nil {
|
||||
if len(crawl.FileInputs) > 0 || len(crawl.MultipartForms) > 0 {
|
||||
if len(crawl.FileInputs) > 0 || len(crawl.MultipartForms) > 0 || len(crawl.UploadHunter) > 0 {
|
||||
recs = append(recs, DeployRecommendation{
|
||||
Lane: "stage_fetch",
|
||||
Reason: "Multipart or file-upload form — stage_fetch manifest staging",
|
||||
@@ -183,9 +184,6 @@ 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) {
|
||||
@@ -194,6 +192,7 @@ func runOwnedTargetScan(req ScanRequest, scanID string, emit StreamEmit) (*ScanR
|
||||
report.Crawl = crawl
|
||||
report.Stack = MergeStack(crawl.Stack)
|
||||
}
|
||||
report.AdminSurface = ProbeAdminSurface(host, req.Port, req.Scheme)
|
||||
}
|
||||
report.DeployKitLane = SuggestDeployKitLane(report.Stack)
|
||||
cloudMeta := containsPortProfile(profilesUsed, PortProfileCloudMetadata)
|
||||
@@ -245,6 +244,9 @@ func crawlWithOptions(host string, port int, scheme string, seedPaths []string,
|
||||
if maxPages <= 0 { maxPages = DefaultCrawlMaxPages }
|
||||
if maxDepth < 0 { maxDepth = DefaultCrawlDepth }
|
||||
report := &CrawlReport{}
|
||||
var uploadRaw []UploadHunterFinding
|
||||
var jsQueue []string
|
||||
jsSeen := map[string]bool{}
|
||||
var headerSnaps []HTTPHeaderSnap
|
||||
var htmlBodies []string
|
||||
visited := map[string]bool{}
|
||||
@@ -262,7 +264,6 @@ 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 +272,9 @@ 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)
|
||||
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)
|
||||
if item.depth >= maxDepth { continue }
|
||||
for _, link := range extractLinks(body) {
|
||||
abs, err := resolveSameOrigin(base, link)
|
||||
@@ -282,50 +285,10 @@ func crawlWithOptions(host string, port int, scheme string, seedPaths []string,
|
||||
if report.SSRFScore > 100 { report.SSRFScore = 100 }
|
||||
report.CMSFingerprints = mergeCMS(nil, report.CMSFingerprints)
|
||||
report.Stack = BuildStack(headerSnaps, htmlBodies)
|
||||
report.UploadHunter = finalizeUploadHunter(base, uploadRaw, jsQueue)
|
||||
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 }
|
||||
|
||||
@@ -19,13 +19,23 @@ func collectUploadFromPage(pageURL string, fileInputs, multipart []FormFinding)
|
||||
if !f.HasFile {
|
||||
continue
|
||||
}
|
||||
out = append(out, UploadHunterFinding{PageURL: pageURL, Target: resolveUploadTarget(pageURL, f.Action), Source: "file_input", Method: f.Method})
|
||||
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})
|
||||
out = append(out, UploadHunterFinding{
|
||||
PageURL: pageURL,
|
||||
Target: resolveUploadTarget(pageURL, f.Action),
|
||||
Source: "multipart",
|
||||
Method: f.Method,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -41,12 +51,17 @@ func detectDragDropZones(pageURL, body string) []UploadHunterFinding {
|
||||
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") != "" {
|
||||
dropAttr := strings.ToLower(n.attr("data-dropzone"))
|
||||
if dragDropClassRe.MatchString(cls) || dragDropClassRe.MatchString(id) || dropAttr != "" {
|
||||
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"})
|
||||
out = append(out, UploadHunterFinding{
|
||||
PageURL: pageURL,
|
||||
Target: resolveUploadTarget(pageURL, target),
|
||||
Source: "drag_drop",
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, c := range n.children {
|
||||
@@ -62,10 +77,16 @@ func detectDragDropZones(pageURL, body string) []UploadHunterFinding {
|
||||
|
||||
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")) {
|
||||
if !strings.Contains(lower, "dropzone") && !strings.Contains(lower, "drag") {
|
||||
return nil
|
||||
}
|
||||
return []UploadHunterFinding{{PageURL: pageURL, Source: "drag_drop"}}
|
||||
if !strings.Contains(lower, "upload") && !strings.Contains(lower, "file") {
|
||||
return nil
|
||||
}
|
||||
return []UploadHunterFinding{{
|
||||
PageURL: pageURL,
|
||||
Source: "drag_drop",
|
||||
}}
|
||||
}
|
||||
|
||||
func extractScriptSrc(pageURL, body string) []string {
|
||||
@@ -98,7 +119,11 @@ func scanJSForUpload(jsURL, body string) []UploadHunterFinding {
|
||||
if m := openUploadPathRe.FindString(body); m != "" {
|
||||
target = m
|
||||
}
|
||||
return []UploadHunterFinding{{PageURL: jsURL, Target: target, Source: "js"}}
|
||||
return []UploadHunterFinding{{
|
||||
PageURL: jsURL,
|
||||
Target: target,
|
||||
Source: "js",
|
||||
}}
|
||||
}
|
||||
|
||||
func isScriptAsset(ref string) bool {
|
||||
|
||||
Reference in New Issue
Block a user