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.
This commit is contained in:
61
server/internal/recon/admin_surface.go
Normal file
61
server/internal/recon/admin_surface.go
Normal file
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,7 +92,7 @@ func LocalRelayScan(req RelayScanRequest) (*RelayScanReport, error) {
|
|||||||
return &RelayScanReport{
|
return &RelayScanReport{
|
||||||
Host: host, ScannedAt: time.Now().UTC(), LocalReachable: true, ScannedVia: "server",
|
Host: host, ScannedAt: time.Now().UTC(), LocalReachable: true, ScannedVia: "server",
|
||||||
Ports: ports, UDPHints: udp, PathTracerHints: PathTracerHintsFromUDP(udp),
|
Ports: ports, UDPHints: udp, PathTracerHints: PathTracerHintsFromUDP(udp),
|
||||||
Recommendations: BuildRecommendations(ports, nil, nil, shell.Host, false),
|
Recommendations: BuildRecommendations(ports, nil, nil, host, false),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ func runOwnedTargetScan(req ScanRequest, scanID string, emit StreamEmit) (*ScanR
|
|||||||
if !opts.SkipPorts {
|
if !opts.SkipPorts {
|
||||||
ports := scanPortsList(host, portsToScan)
|
ports := scanPortsList(host, portsToScan)
|
||||||
report.Ports = ports
|
report.Ports = ports
|
||||||
|
for _, p := range ports {
|
||||||
|
emitReconPort(emit, scanID, host, p)
|
||||||
|
}
|
||||||
report.Banners = GrabBanners(host, ports)
|
report.Banners = GrabBanners(host, ports)
|
||||||
}
|
}
|
||||||
if shouldCrawlProfile(req, uxProfile, report.Ports, opts) {
|
if shouldCrawlProfile(req, uxProfile, report.Ports, opts) {
|
||||||
@@ -259,6 +262,7 @@ func crawlWithOptions(host string, port int, scheme string, seedPaths []string,
|
|||||||
report.PagesFetched++
|
report.PagesFetched++
|
||||||
title, _ := htmlParseTitle(body)
|
title, _ := htmlParseTitle(body)
|
||||||
report.Pages = append(report.Pages, PageFinding{URL: item.url, StatusCode: status, Title: title})
|
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}) }
|
if len(headers) > 0 { headerSnaps = append(headerSnaps, HTTPHeaderSnap{URL: item.url, Headers: headers}) }
|
||||||
htmlBodies = append(htmlBodies, body)
|
htmlBodies = append(htmlBodies, body)
|
||||||
files, multi, fields, pageScore, cms := ParseHTML(item.url, 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.URLFields = append(report.URLFields, fields...)
|
||||||
report.SSRFScore += pageScore
|
report.SSRFScore += pageScore
|
||||||
report.CMSFingerprints = mergeCMS(report.CMSFingerprints, cms)
|
report.CMSFingerprints = mergeCMS(report.CMSFingerprints, cms)
|
||||||
|
emitReconFindings(emit, scanID, host, files, multi, fields)
|
||||||
if item.depth >= maxDepth { continue }
|
if item.depth >= maxDepth { continue }
|
||||||
for _, link := range extractLinks(body) {
|
for _, link := range extractLinks(body) {
|
||||||
abs, err := resolveSameOrigin(base, link)
|
abs, err := resolveSameOrigin(base, link)
|
||||||
@@ -280,6 +285,47 @@ func crawlWithOptions(host string, port int, scheme string, seedPaths []string,
|
|||||||
return report, nil
|
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 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 {
|
func DiffReports(prev, cur *ScanReport) *ReconScanDiff {
|
||||||
if cur == nil { return nil }
|
if cur == nil { return nil }
|
||||||
|
|||||||
252
server/internal/recon/upload_hunter.go
Normal file
252
server/internal/recon/upload_hunter.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user