mirror of
https://github.com/Vyntral/god-eye.git
synced 2026-05-22 23:59:41 +02:00
3a4c230aa7
Complete architectural overhaul. Replaces the v0.1 monolithic scanner with an event-driven pipeline of auto-registered modules. Foundation (internal/): - eventbus: typed pub/sub, 20 event types, race-safe, drop counter - module: registry with phase-based selection - store: thread-safe host store with per-host locks + deep-copy reads - pipeline: coordinator with phase barriers + panic recovery - config: 5 scan profiles + 3 AI tiers + YAML loader + auto-discovery Modules (26 auto-registered across 6 phases): - Discovery: passive (26 sources), bruteforce, recursive, AXFR, GitHub dorks, CT streaming, permutation, reverse DNS, vhost, ASN, supply chain (npm + PyPI) - Enrichment: HTTP probe + tech fingerprint + TLS appliance ID, ports - Analysis: security checks, takeover (110+ sigs), cloud, JavaScript, GraphQL, JWT, headers (OWASP), HTTP smuggling, AI cascade, Nuclei - Reporting: TXT/JSON/CSV writer + AI scan brief AI layer (internal/ai/ + internal/modules/ai/): - Three profiles: lean (16 GB), balanced (32 GB MoE), heavy (64 GB) - Six event-driven handlers: CVE, JS file, HTTP response, secret filter, multi-agent vuln enrichment, anomaly + executive report - Content-hash cache dedups Ollama calls across hosts - Auto-pull of missing models via /api/pull with streaming progress - End-of-scan AI SCAN BRIEF in terminal with top chains + next actions Nuclei compat layer (internal/nucleitpl/): - Executes ~13k community templates (HTTP subset) - Auto-download of nuclei-templates ZIP to ~/.god-eye/nuclei-templates - Scope filter rejects off-host templates (eliminates OSINT FPs) Operations: - Interactive wizard (internal/wizard/) — zero-flag launch - LivePrinter (internal/tui/) — colorized event stream - Diff engine + scheduler (internal/diff, internal/scheduler) for continuous ASM monitoring with webhook alerts - Proxy support (internal/proxyconf/): http / https / socks5 / socks5h + basic auth Fixes #1 — native SOCKS5 / Tor compatibility via --proxy flag. 185 unit tests across 15 packages, all race-detector clean.
228 lines
7.0 KiB
Go
228 lines
7.0 KiB
Go
// Package smuggling detects HTTP request smuggling (CL.TE and TE.CL
|
|
// variants) by sending ambiguous Content-Length / Transfer-Encoding
|
|
// combinations and timing-analyzing the responses.
|
|
//
|
|
// This is the non-destructive timing variant: we send a request crafted
|
|
// so that CL.TE or TE.CL parsing desync would cause the server to hold
|
|
// the connection waiting for more bytes, while the correct interpretation
|
|
// returns immediately. Large response time delta ⇒ likely smuggling.
|
|
//
|
|
// We do NOT attempt to actually smuggle follow-up requests — that could
|
|
// affect other users. This is safe for authorized testing.
|
|
package smuggling
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"god-eye/internal/eventbus"
|
|
"god-eye/internal/module"
|
|
"god-eye/internal/store"
|
|
)
|
|
|
|
const ModuleName = "vuln.http-smuggling"
|
|
|
|
type smModule struct{}
|
|
|
|
func Register() { module.Register(&smModule{}) }
|
|
|
|
func (*smModule) Name() string { return ModuleName }
|
|
func (*smModule) Phase() module.Phase { return module.PhaseAnalysis }
|
|
func (*smModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventHTTPProbed} }
|
|
func (*smModule) Produces() []eventbus.EventType {
|
|
return []eventbus.EventType{eventbus.EventVulnerability}
|
|
}
|
|
|
|
// Opt-in: timing-based testing is slower and can be noisy. Bugbounty profile enables it.
|
|
func (*smModule) DefaultEnabled() bool { return false }
|
|
|
|
func (*smModule) Run(mctx module.Context) error {
|
|
if !mctx.Config.Bool("smuggling_scan", false) {
|
|
return nil
|
|
}
|
|
timeout := mctx.Config.Int("timeout", 10)
|
|
|
|
processed := make(map[string]struct{})
|
|
var mu sync.Mutex
|
|
shouldProcess := func(host string) bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if _, ok := processed[host]; ok {
|
|
return false
|
|
}
|
|
processed[host] = struct{}{}
|
|
return true
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
for _, h := range mctx.Store.All(mctx.Ctx) {
|
|
if h == nil || h.StatusCode == 0 {
|
|
continue
|
|
}
|
|
if !shouldProcess(h.Subdomain) {
|
|
continue
|
|
}
|
|
host := h.Subdomain
|
|
wg.Add(1)
|
|
go func() { defer wg.Done(); probe(mctx, host, timeout) }()
|
|
}
|
|
|
|
sub := mctx.Bus.Subscribe(eventbus.EventHTTPProbed, func(_ context.Context, e eventbus.Event) {
|
|
ev, ok := e.(eventbus.HTTPProbed)
|
|
if !ok || ev.StatusCode == 0 {
|
|
return
|
|
}
|
|
host := ev.Meta().Target
|
|
if !shouldProcess(host) {
|
|
return
|
|
}
|
|
wg.Add(1)
|
|
go func() { defer wg.Done(); probe(mctx, host, timeout) }()
|
|
})
|
|
defer sub.Unsubscribe()
|
|
|
|
select {
|
|
case <-time.After(500 * time.Millisecond):
|
|
case <-mctx.Ctx.Done():
|
|
}
|
|
|
|
wg.Wait()
|
|
return nil
|
|
}
|
|
|
|
func probe(mctx module.Context, host string, timeoutSec int) {
|
|
timeout := time.Duration(timeoutSec) * time.Second
|
|
|
|
// Baseline: normal request, measure response time.
|
|
baseline, err := sendRequest(host, baselineRequest(host), timeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// CL.TE probe: Content-Length says more data coming, TE: chunked says "last chunk now".
|
|
// Vulnerable servers that read TE first return quickly; non-vulnerable
|
|
// servers that read CL wait for more bytes and hit the read timeout.
|
|
cltePayload := clteRequest(host)
|
|
clte, _ := sendRequest(host, cltePayload, timeout)
|
|
|
|
// TE.CL probe: reversed — server reads CL first (ignoring chunked), payload is poisoned.
|
|
teclPayload := teclRequest(host)
|
|
tecl, _ := sendRequest(host, teclPayload, timeout)
|
|
|
|
// Heuristic: if either probe hangs (duration >= timeout * 0.8) and baseline
|
|
// returned fast, it's a likely desync.
|
|
threshold := time.Duration(float64(timeout) * 0.8)
|
|
fastEnough := baseline.duration < timeout/3
|
|
|
|
if fastEnough && clte.duration > threshold {
|
|
emit(mctx, host, "CL.TE", "CL.TE HTTP Request Smuggling candidate", clte)
|
|
}
|
|
if fastEnough && tecl.duration > threshold {
|
|
emit(mctx, host, "TE.CL", "TE.CL HTTP Request Smuggling candidate", tecl)
|
|
}
|
|
}
|
|
|
|
type probeResult struct {
|
|
duration time.Duration
|
|
response string
|
|
}
|
|
|
|
func baselineRequest(host string) string {
|
|
return "GET / HTTP/1.1\r\n" +
|
|
"Host: " + host + "\r\n" +
|
|
"User-Agent: god-eye-v2\r\n" +
|
|
"Connection: close\r\n" +
|
|
"\r\n"
|
|
}
|
|
|
|
// clteRequest crafts a CL.TE probe: the chunked body declares "0\r\n\r\n"
|
|
// which is the last chunk. If the server honors TE: chunked, the request
|
|
// completes immediately. If it honors Content-Length (say, 4), it waits for
|
|
// 4 more bytes.
|
|
func clteRequest(host string) string {
|
|
body := "0\r\n\r\n"
|
|
return fmt.Sprintf("POST / HTTP/1.1\r\n"+
|
|
"Host: %s\r\n"+
|
|
"User-Agent: god-eye-v2\r\n"+
|
|
"Content-Length: %d\r\n"+
|
|
"Transfer-Encoding: chunked\r\n"+
|
|
"Connection: close\r\n"+
|
|
"\r\n%s", host, 4, body) // CL=4 mismatches chunked body length
|
|
}
|
|
|
|
// teclRequest: TE: chunked, body ends with a chunk that declares non-zero
|
|
// remaining — CL says "done", TE says "more coming". Opposite desync.
|
|
func teclRequest(host string) string {
|
|
body := "12\r\n" +
|
|
"GPOST / HTTP/1.1\r\n" +
|
|
"\r\n0\r\n\r\n"
|
|
return fmt.Sprintf("POST / HTTP/1.1\r\n"+
|
|
"Host: %s\r\n"+
|
|
"User-Agent: god-eye-v2\r\n"+
|
|
"Content-Length: 3\r\n"+
|
|
"Transfer-Encoding: chunked\r\n"+
|
|
"Connection: close\r\n"+
|
|
"\r\n%s", host, body)
|
|
}
|
|
|
|
// sendRequest opens a raw TCP/TLS connection, writes raw HTTP bytes, and
|
|
// returns the time until the first response line is read (or timeout).
|
|
func sendRequest(host, payload string, timeout time.Duration) (probeResult, error) {
|
|
dialer := &net.Dialer{Timeout: timeout}
|
|
conn, err := tls.DialWithDialer(dialer, "tcp", host+":443", &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
ServerName: host,
|
|
})
|
|
if err != nil {
|
|
return probeResult{}, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
_ = conn.SetDeadline(time.Now().Add(timeout))
|
|
|
|
start := time.Now()
|
|
if _, err := conn.Write([]byte(payload)); err != nil {
|
|
return probeResult{duration: time.Since(start)}, err
|
|
}
|
|
|
|
br := bufio.NewReader(conn)
|
|
line, err := br.ReadString('\n')
|
|
return probeResult{duration: time.Since(start), response: line}, err
|
|
}
|
|
|
|
func emit(mctx module.Context, host, kind, title string, r probeResult) {
|
|
now := time.Now()
|
|
_ = mctx.Store.Upsert(mctx.Ctx, host, func(h *store.Host) {
|
|
h.Vulnerabilities = append(h.Vulnerabilities, store.Vulnerability{
|
|
ID: "http-smuggling-" + strings.ToLower(kind),
|
|
Title: title,
|
|
Description: kind + " desync candidate based on response-time delta (" + r.duration.String() + ").",
|
|
Severity: string(eventbus.SeverityHigh),
|
|
URL: "https://" + host,
|
|
Evidence: strings.TrimSpace(r.response),
|
|
Remediation: "Ensure front-end and back-end parse Content-Length and Transfer-Encoding identically. Reject requests with both headers.",
|
|
OWASP: "A06:2021-Vulnerable and Outdated Components",
|
|
FoundAt: now,
|
|
})
|
|
})
|
|
mctx.Bus.Publish(mctx.Ctx, eventbus.VulnerabilityFound{
|
|
EventMeta: eventbus.EventMeta{At: now, Source: ModuleName, Target: host},
|
|
ID: "http-smuggling-" + strings.ToLower(kind),
|
|
Title: title,
|
|
Description: "Timing-based " + kind + " desync candidate.",
|
|
Severity: eventbus.SeverityHigh,
|
|
URL: "https://" + host,
|
|
Evidence: strings.TrimSpace(r.response),
|
|
Remediation: "Align CL/TE parsing between front-end and back-end.",
|
|
OWASP: "A06:2021-Vulnerable and Outdated Components",
|
|
})
|
|
}
|
|
|