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.
251 lines
6.1 KiB
Go
251 lines
6.1 KiB
Go
package recon
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
const maxHTMLBytes = 512 * 1024
|
|
|
|
// fetchPageFn overrides HTTP fetches in tests (nil = live GET from server host).
|
|
var fetchPageFn func(rawURL string) (status int, body string, err error)
|
|
|
|
// Crawl fetches seed URL and same-origin linked paths up to depth and maxPages.
|
|
func Crawl(host string, port int, scheme string, seedPaths []string) (*CrawlReport, error) {
|
|
scheme = normalizeScheme(scheme, port)
|
|
if port <= 0 {
|
|
port = defaultPortForScheme(scheme)
|
|
}
|
|
base := fmt.Sprintf("%s://%s", scheme, joinHostPort(host, port))
|
|
seeds := seedPaths
|
|
if len(seeds) == 0 {
|
|
seeds = []string{"/"}
|
|
}
|
|
|
|
report := &CrawlReport{}
|
|
var uploadRaw []UploadHunterFinding
|
|
var jsQueue []string
|
|
jsSeen := map[string]bool{}
|
|
var headerSnaps []HTTPHeaderSnap
|
|
var htmlBodies []string
|
|
visited := map[string]bool{}
|
|
queue := []queuedURL{}
|
|
for _, p := range seeds {
|
|
abs, err := resolveSameOrigin(base, p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
queue = append(queue, queuedURL{url: abs, depth: 0})
|
|
}
|
|
|
|
for len(queue) > 0 && report.PagesFetched < DefaultCrawlMaxPages {
|
|
item := queue[0]
|
|
queue = queue[1:]
|
|
key := normalizeURLKey(item.url)
|
|
if visited[key] {
|
|
continue
|
|
}
|
|
visited[key] = true
|
|
|
|
status, body, headers, err := fetchPage(item.url)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
report.PagesFetched++
|
|
title := ""
|
|
if root, err := htmlParseTitle(body); err == nil {
|
|
title = root
|
|
}
|
|
report.Pages = append(report.Pages, PageFinding{URL: item.url, StatusCode: status, Title: 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)
|
|
report.FileInputs = append(report.FileInputs, files...)
|
|
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)...)
|
|
collectUploadJSAtDepth(base, item.url, body, item.depth, DefaultCrawlDepth, jsSeen, &jsQueue)
|
|
|
|
if item.depth >= DefaultCrawlDepth {
|
|
continue
|
|
}
|
|
for _, link := range extractLinks(body) {
|
|
abs, err := resolveSameOrigin(base, link)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !sameOrigin(base, abs) {
|
|
continue
|
|
}
|
|
lkey := normalizeURLKey(abs)
|
|
if !visited[lkey] {
|
|
queue = append(queue, queuedURL{url: abs, depth: item.depth + 1})
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
type queuedURL struct {
|
|
url string
|
|
depth int
|
|
}
|
|
|
|
func fetchPage(rawURL string) (int, string, map[string]string, error) {
|
|
if fetchPageFn != nil {
|
|
s,b,e:=fetchPageFn(rawURL); return s,b,nil,e
|
|
}
|
|
client := &http.Client{Timeout: DefaultPortDialTimeout}
|
|
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return 0, "", nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "AetherForge-Recon/1.0")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return 0, "", nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := readBodyLimited(resp.Body, maxHTMLBytes)
|
|
if err != nil {
|
|
return resp.StatusCode, "", nil, err
|
|
}
|
|
headers:=map[string]string{}
|
|
for k,v:=range resp.Header { if len(v)>0 { headers[k]=v[0] } }
|
|
return resp.StatusCode, body, headers, nil
|
|
}
|
|
|
|
func normalizeScheme(scheme string, port int) string {
|
|
scheme = strings.ToLower(strings.TrimSpace(scheme))
|
|
switch scheme {
|
|
case "http", "https":
|
|
return scheme
|
|
}
|
|
if port == 443 || port == 8443 {
|
|
return "https"
|
|
}
|
|
return "http"
|
|
}
|
|
|
|
func defaultPortForScheme(scheme string) int {
|
|
if scheme == "https" {
|
|
return 443
|
|
}
|
|
return 80
|
|
}
|
|
|
|
func joinHostPort(host string, port int) string {
|
|
if strings.Contains(host, ":") {
|
|
return host
|
|
}
|
|
return fmt.Sprintf("%s:%d", host, port)
|
|
}
|
|
|
|
func resolveSameOrigin(base, ref string) (string, error) {
|
|
baseURL, err := url.Parse(base)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
ref = strings.TrimSpace(ref)
|
|
if ref == "" {
|
|
return baseURL.String(), nil
|
|
}
|
|
refURL, err := url.Parse(ref)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return baseURL.ResolveReference(refURL).String(), nil
|
|
}
|
|
|
|
func sameOrigin(base, target string) bool {
|
|
b, err := url.Parse(base)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
t, err := url.Parse(target)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return strings.EqualFold(b.Scheme, t.Scheme) &&
|
|
strings.EqualFold(b.Hostname(), t.Hostname()) &&
|
|
b.Port() == t.Port()
|
|
}
|
|
|
|
func normalizeURLKey(raw string) string {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return strings.ToLower(strings.TrimSpace(raw))
|
|
}
|
|
u.Fragment = ""
|
|
return strings.ToLower(u.String())
|
|
}
|
|
|
|
func extractLinks(body string) []string {
|
|
root, err := htmlParseRoot(body)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var links []string
|
|
var walk func(*htmlNode)
|
|
walk = func(n *htmlNode) {
|
|
if n.tag == "a" {
|
|
if href := n.attr("href"); href != "" && !strings.HasPrefix(strings.ToLower(href), "javascript:") {
|
|
links = append(links, href)
|
|
}
|
|
}
|
|
for _, c := range n.children {
|
|
walk(c)
|
|
}
|
|
}
|
|
walk(root)
|
|
return links
|
|
}
|
|
|
|
func mergeCMS(existing, add []string) []string {
|
|
seen := map[string]bool{}
|
|
for _, tag := range existing {
|
|
seen[tag] = true
|
|
}
|
|
var out []string
|
|
out = append(out, existing...)
|
|
for _, tag := range add {
|
|
if tag == "" || seen[tag] {
|
|
continue
|
|
}
|
|
seen[tag] = true
|
|
out = append(out, tag)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// htmlParseRoot and htmlParseTitle are thin wrappers to avoid exporting html types in tests.
|
|
func htmlParseRoot(body string) (*htmlNode, error) {
|
|
root, err := parseHTMLTree(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return toHTMLNode(root), nil
|
|
}
|
|
|
|
func htmlParseTitle(body string) (string, error) {
|
|
root, err := parseHTMLTree(body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return extractTitle(root), nil
|
|
}
|