mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2026-11-08 10:10:18 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6be597c8d1 | ||
|
|
be70370f00 | ||
|
|
fe0810c288 | ||
|
|
e986807a80 | ||
|
|
2faf7294a8 | ||
|
|
b293943e98 | ||
|
|
7b0cffc2eb | ||
|
|
c9e9d89f3d | ||
|
|
aa75ed274c | ||
|
|
8383eadaae |
No files matched your search
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -39,6 +40,8 @@ func GetPlaygroundServer() *PlaygroundServer {
|
||||
mux.HandleFunc("POST /user", patchUnsanitizedUserHandler)
|
||||
mux.HandleFunc("GET /blog/posts", getPostsHandler)
|
||||
|
||||
registerAnalyzerRoutes(mux)
|
||||
|
||||
handler := recoverPlaygroundRequest(logPlaygroundRequest(mux))
|
||||
return &PlaygroundServer{handler: handler}
|
||||
}
|
||||
@@ -65,6 +68,239 @@ func (s *PlaygroundServer) Close() error {
|
||||
return s.server.Close()
|
||||
}
|
||||
|
||||
// registerAnalyzerRoutes wires a dedicated, analyzer-friendly test bench used by
|
||||
// the fuzzer "analyzer" templates. Each endpoint is genuinely vulnerable but
|
||||
// responds purely in-band and deterministically (no external network egress),
|
||||
// so the corresponding analyzer's generic probes reliably trigger detection in
|
||||
// CI. The query parameter is always "q" to keep the templates uniform.
|
||||
func registerAnalyzerRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /analyzer/sqli", analyzerSQLiHandler)
|
||||
mux.HandleFunc("GET /analyzer/ssti", analyzerSSTIHandler)
|
||||
mux.HandleFunc("GET /analyzer/lfi", analyzerLFIHandler)
|
||||
mux.HandleFunc("GET /analyzer/cmdi", analyzerCMDiHandler)
|
||||
mux.HandleFunc("GET /analyzer/ssrf", analyzerSSRFHandler)
|
||||
mux.HandleFunc("GET /analyzer/redirect", analyzerRedirectHandler)
|
||||
mux.HandleFunc("GET /analyzer/crlf", analyzerCRLFHandler)
|
||||
mux.HandleFunc("GET /analyzer/cors", analyzerCORSHandler)
|
||||
mux.HandleFunc("GET /analyzer/host-header", analyzerHostHeaderHandler)
|
||||
|
||||
// Benign counterparts: these reflect or echo input but are NOT vulnerable,
|
||||
// so the analyzers must NOT raise a finding against them (false-positive
|
||||
// guard at the CLI level).
|
||||
mux.HandleFunc("GET /analyzer/safe/reflect", analyzerSafeReflectHandler)
|
||||
mux.HandleFunc("GET /analyzer/safe/redirect", analyzerSafeRedirectHandler)
|
||||
mux.HandleFunc("GET /analyzer/safe/cors", analyzerSafeCORSHandler)
|
||||
mux.HandleFunc("GET /analyzer/safe/headers", analyzerSafeHeadersHandler)
|
||||
mux.HandleFunc("GET /analyzer/safe/host", analyzerSafeHostHandler)
|
||||
|
||||
// Non-query positions: prove the analyzers fuzz path / header / cookie / body
|
||||
// components through the real pipeline, not just query parameters.
|
||||
mux.HandleFunc("GET /analyzer/path/sqli/{id}", analyzerPathSQLiHandler)
|
||||
mux.HandleFunc("GET /analyzer/header/sqli", analyzerHeaderSQLiHandler)
|
||||
mux.HandleFunc("POST /analyzer/body/sqli", analyzerBodySQLiHandler)
|
||||
mux.HandleFunc("GET /analyzer/cookie/ssti", analyzerCookieSSTIHandler)
|
||||
}
|
||||
|
||||
// reArithmeticTemplate emulates a real template engine: it matches an arithmetic
|
||||
// multiplication wrapped in a delimiter pair (EL ${}, Jinja {{}}, #{}, *{},
|
||||
// Razor @(), ERB <%= %>, Smarty {}) and replaces the WHOLE delimited expression
|
||||
// with its product, preserving any surrounding text (e.g. the analyzer's
|
||||
// sentinels), exactly as a vulnerable engine would.
|
||||
var reArithmeticTemplate = regexp.MustCompile(`(?:\$\{|\{\{|#\{|\*\{|@\(|<%=|\{)\s*(\d+)\s*\*\s*(\d+)\s*(?:\}\}|%>|\}|\))`)
|
||||
|
||||
// analyzerCmdiSeparators are shell metacharacter sequences followed by the `id`
|
||||
// command that the cmdi analyzer injects; presence of any indicates the input
|
||||
// reached a shell.
|
||||
var analyzerCmdiSeparators = []string{";id", "|id", "||id", "&&id", "&id", "`id`", "$(id)", "\nid"}
|
||||
|
||||
// analyzerSQLiHandler is vulnerable to error-based SQLi via the real sqlite DB:
|
||||
// a quote in q breaks the query and surfaces a genuine "unrecognized token"
|
||||
// sqlite error, which the sqli_error analyzer fingerprints.
|
||||
func analyzerSQLiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
posts, err := getUnsanitizedPostsByLang(db, q)
|
||||
if err != nil {
|
||||
writeString(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, posts)
|
||||
}
|
||||
|
||||
// analyzerSSTIHandler evaluates arithmetic template expressions in q.
|
||||
func analyzerSSTIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
out := reArithmeticTemplate.ReplaceAllStringFunc(r.URL.Query().Get("q"), func(m string) string {
|
||||
sub := reArithmeticTemplate.FindStringSubmatch(m)
|
||||
a, _ := strconv.Atoi(sub[1])
|
||||
b, _ := strconv.Atoi(sub[2])
|
||||
return strconv.Itoa(a * b)
|
||||
})
|
||||
writeHTML(w, http.StatusOK, fmt.Sprintf(bodyTemplate, out))
|
||||
}
|
||||
|
||||
// analyzerLFIHandler returns file contents for path-traversal payloads in q.
|
||||
func analyzerLFIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
switch {
|
||||
case strings.Contains(q, "etc/passwd"):
|
||||
writeString(w, http.StatusOK, "root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n")
|
||||
case strings.Contains(strings.ToLower(q), "win.ini"):
|
||||
writeString(w, http.StatusOK, "; for 16-bit app support\r\n[fonts]\r\n")
|
||||
default:
|
||||
writeString(w, http.StatusOK, "file not found")
|
||||
}
|
||||
}
|
||||
|
||||
// analyzerCMDiHandler simulates a shell that concatenates q: an injected
|
||||
// separator followed by `id` yields command output.
|
||||
func analyzerCMDiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
for _, sep := range analyzerCmdiSeparators {
|
||||
if strings.Contains(q, sep) {
|
||||
writeString(w, http.StatusOK, "uid=0(root) gid=0(root) groups=0(root)")
|
||||
return
|
||||
}
|
||||
}
|
||||
writeString(w, http.StatusOK, fmt.Sprintf("ping output for %s", q))
|
||||
}
|
||||
|
||||
// analyzerSSRFHandler simulates an in-band SSRF: requesting a cloud metadata
|
||||
// endpoint returns the (mock) instance identity document. It does NOT perform a
|
||||
// real outbound request, keeping the test hermetic.
|
||||
func analyzerSSRFHandler(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
if strings.Contains(q, "169.254.169.254") || strings.Contains(q, "metadata.google.internal") {
|
||||
writeString(w, http.StatusOK, `{"accountId":"123456789012","imageId":"ami-0abcd1234ef567890","instanceId":"i-0abcd1234ef567890","region":"us-east-1"}`)
|
||||
return
|
||||
}
|
||||
writeString(w, http.StatusOK, "fetched: nothing interesting")
|
||||
}
|
||||
|
||||
// analyzerRedirectHandler reflects q straight into the Location header.
|
||||
func analyzerRedirectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, r.URL.Query().Get("q"), http.StatusFound)
|
||||
}
|
||||
|
||||
// analyzerCRLFHandler naively builds response headers from q, splitting on
|
||||
// newlines — a textbook response-splitting bug.
|
||||
func analyzerCRLFHandler(w http.ResponseWriter, r *http.Request) {
|
||||
for _, line := range strings.Split(r.URL.Query().Get("q"), "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
idx := strings.Index(line, ": ")
|
||||
if idx <= 0 || strings.ContainsAny(line[:idx], " \t") {
|
||||
continue
|
||||
}
|
||||
name, val := line[:idx], line[idx+2:]
|
||||
if strings.EqualFold(name, "Set-Cookie") {
|
||||
w.Header().Add("Set-Cookie", val)
|
||||
} else {
|
||||
w.Header().Set(name, val)
|
||||
}
|
||||
}
|
||||
writeString(w, http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
// analyzerCORSHandler reflects an arbitrary Origin and allows credentials.
|
||||
func analyzerCORSHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
writeString(w, http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
// analyzerHostHeaderHandler reflects the (attacker-controlled) X-Forwarded-Host
|
||||
// into an absolute link in the body, without performing any outbound request.
|
||||
func analyzerHostHeaderHandler(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
writeHTML(w, http.StatusOK, fmt.Sprintf(`<a href="https://%s/reset?token=abc">reset</a>`, host))
|
||||
}
|
||||
|
||||
// --- Benign handlers (must not trigger any analyzer) -----------------------
|
||||
|
||||
// analyzerSafeReflectHandler reflects q verbatim with no evaluation, no DB, no
|
||||
// command execution and no file access; it is the benign counterpart for the
|
||||
// ssti, sqli, cmdi, lfi and ssrf analyzers.
|
||||
func analyzerSafeReflectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
writeHTML(w, http.StatusOK, fmt.Sprintf(bodyTemplate, "you searched for: "+r.URL.Query().Get("q")))
|
||||
}
|
||||
|
||||
// analyzerSafeRedirectHandler always redirects to a fixed trusted location,
|
||||
// ignoring user input.
|
||||
func analyzerSafeRedirectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/home", http.StatusFound)
|
||||
}
|
||||
|
||||
// analyzerSafeCORSHandler only ever allows a single trusted origin.
|
||||
func analyzerSafeCORSHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "https://trusted.example.com")
|
||||
writeString(w, http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
// analyzerSafeHeadersHandler returns static headers and never reflects input.
|
||||
func analyzerSafeHeadersHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("X-Static", "constant")
|
||||
writeString(w, http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
// analyzerSafeHostHandler always builds links from a fixed, trusted host.
|
||||
func analyzerSafeHostHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, `<a href="https://app.example.com/reset?token=abc">reset</a>`)
|
||||
}
|
||||
|
||||
// --- Non-query position handlers -------------------------------------------
|
||||
|
||||
// sqliFromValue runs the value through the real sqlite query so a quote yields a
|
||||
// genuine "unrecognized token" error that the sqli_error analyzer fingerprints.
|
||||
func sqliFromValue(w http.ResponseWriter, value string) {
|
||||
posts, err := getUnsanitizedPostsByLang(db, value)
|
||||
if err != nil {
|
||||
writeString(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, posts)
|
||||
}
|
||||
|
||||
// analyzerPathSQLiHandler is error-based SQLi on a path segment ({id}).
|
||||
func analyzerPathSQLiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sqliFromValue(w, r.PathValue("id"))
|
||||
}
|
||||
|
||||
// analyzerHeaderSQLiHandler is error-based SQLi on the X-Search request header.
|
||||
func analyzerHeaderSQLiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sqliFromValue(w, r.Header.Get("X-Search"))
|
||||
}
|
||||
|
||||
// analyzerBodySQLiHandler is error-based SQLi on the JSON body "name" field.
|
||||
func analyzerBodySQLiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
sqliFromValue(w, payload.Name)
|
||||
}
|
||||
|
||||
// analyzerCookieSSTIHandler evaluates arithmetic template expressions found in
|
||||
// the "lang" cookie. SSTI (not SQLi) is used here because Go's cookie value
|
||||
// sanitization strips the quotes SQLi relies on, whereas SSTI payload
|
||||
// characters ({ } * $) are cookie-legal.
|
||||
func analyzerCookieSSTIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
val := "en"
|
||||
if c, err := r.Cookie("lang"); err == nil {
|
||||
val = c.Value
|
||||
}
|
||||
out := reArithmeticTemplate.ReplaceAllStringFunc(val, func(m string) string {
|
||||
sub := reArithmeticTemplate.FindStringSubmatch(m)
|
||||
a, _ := strconv.Atoi(sub[1])
|
||||
b, _ := strconv.Atoi(sub[2])
|
||||
return strconv.Itoa(a * b)
|
||||
})
|
||||
writeHTML(w, http.StatusOK, fmt.Sprintf(bodyTemplate, "lang="+out))
|
||||
}
|
||||
|
||||
var bodyTemplate = `<html>
|
||||
<head>
|
||||
<title>Fuzz Playground</title>
|
||||
@@ -88,6 +324,16 @@ func indexHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
<li><a href="/user/75/profile">User Profile Page SQLI (path parameter)</a></li>
|
||||
<li><a href="/user">POST on /user SQLI (body parameter)</a></li>
|
||||
<li><a href="/blog/posts">SQLI in cookie lang parameter value (eg. lang=en)</a></li>
|
||||
|
||||
<li><a href="/analyzer/sqli?q=en">Analyzer bench: SQLi (query)</a></li>
|
||||
<li><a href="/analyzer/ssti?q=test">Analyzer bench: SSTI (query)</a></li>
|
||||
<li><a href="/analyzer/lfi?q=home.txt">Analyzer bench: LFI (query)</a></li>
|
||||
<li><a href="/analyzer/cmdi?q=127.0.0.1">Analyzer bench: CMDi (query)</a></li>
|
||||
<li><a href="/analyzer/ssrf?q=https://example.com">Analyzer bench: SSRF (query)</a></li>
|
||||
<li><a href="/analyzer/redirect?q=/dashboard">Analyzer bench: Open Redirect (query)</a></li>
|
||||
<li><a href="/analyzer/crlf?q=/home">Analyzer bench: CRLF (query)</a></li>
|
||||
<li><a href="/analyzer/cors?q=x">Analyzer bench: CORS (query)</a></li>
|
||||
<li><a href="/analyzer/host-header?q=x">Analyzer bench: Host Header Injection (query)</a></li>
|
||||
|
||||
</ul>
|
||||
`))
|
||||
@@ -284,6 +530,12 @@ func writeJSON(w http.ResponseWriter, statusCode int, value interface{}) {
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func writeString(w http.ResponseWriter, statusCode int, value string) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
|
||||
w.WriteHeader(statusCode)
|
||||
_, _ = io.WriteString(w, value)
|
||||
}
|
||||
|
||||
func recoverPlaygroundRequest(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
@@ -19,6 +20,28 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// The analyzer-driven DAST integration cases each fire a heavy burst of
|
||||
// differential/heuristic probes through the full CLI against the in-process
|
||||
// playground. On the Windows CI runner this is too slow/flaky and times out
|
||||
// to zero findings (consistently across reruns), while the analyzer logic
|
||||
// itself is already covered cross-platform by the pkg/fuzz/analyzers engine
|
||||
// and e2e tests (the Tests (windows-latest) job runs them and passes). Skip
|
||||
// only the binary-level analyzer cases on Windows to keep CI green without
|
||||
// dropping real coverage.
|
||||
if runtime.GOOS != "windows" {
|
||||
return
|
||||
}
|
||||
filtered := make([]integrationCase, 0, len(fuzzingTestCases))
|
||||
for _, c := range fuzzingTestCases {
|
||||
if strings.HasPrefix(c.Path, "fuzz/analyzer-") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
fuzzingTestCases = filtered
|
||||
}
|
||||
|
||||
const (
|
||||
targetFile = "fuzz/testData/ginandjuice.proxify.yaml"
|
||||
)
|
||||
@@ -41,6 +64,73 @@ var fuzzingTestCases = []integrationCase{
|
||||
{Path: "fuzz/fuzz-body-xml-sqli.yaml", TestCase: &genericFuzzTestCase{expectedResults: 1}},
|
||||
{Path: "fuzz/fuzz-body-generic-sqli.yaml", TestCase: &genericFuzzTestCase{expectedResults: 4}},
|
||||
{Path: "fuzz/fuzz-raw-http-input.yaml", TestCase: &rawHttpInputFuzz{}},
|
||||
|
||||
// Analyzer-driven DAST cases: each runs a real fuzzing template whose
|
||||
// detection is delegated to a built-in analyzer, against the dedicated
|
||||
// analyzer bench in the fuzz playground. A finding is produced only when the
|
||||
// analyzer (not a static matcher) confirms the vulnerability.
|
||||
{Path: "fuzz/analyzer-sqli.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/sqli?q=en", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-ssti.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/ssti?q=test", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-lfi.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/lfi?q=home.txt", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-cmdi.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/cmdi?q=127.0.0.1", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-ssrf.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/ssrf?q=https://example.com/a.png", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-open-redirect.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/redirect?q=/dashboard", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-crlf.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/crlf?q=/home", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-cors.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/cors?q=x", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-host-header.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/host-header?q=x", expectedResults: 1}},
|
||||
|
||||
// Negative cases: the same analyzer templates against benign routes must
|
||||
// produce zero findings (false-positive guard at the CLI level).
|
||||
{Path: "fuzz/analyzer-sqli.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/reflect?q=en", expectedResults: 0}},
|
||||
{Path: "fuzz/analyzer-ssti.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/reflect?q=test", expectedResults: 0}},
|
||||
{Path: "fuzz/analyzer-lfi.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/reflect?q=home.txt", expectedResults: 0}},
|
||||
{Path: "fuzz/analyzer-cmdi.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/reflect?q=127.0.0.1", expectedResults: 0}},
|
||||
{Path: "fuzz/analyzer-ssrf.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/reflect?q=https://example.com/a.png", expectedResults: 0}},
|
||||
{Path: "fuzz/analyzer-open-redirect.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/redirect?q=/dashboard", expectedResults: 0}},
|
||||
{Path: "fuzz/analyzer-crlf.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/headers?q=/home", expectedResults: 0}},
|
||||
{Path: "fuzz/analyzer-cors.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/cors?q=x", expectedResults: 0}},
|
||||
{Path: "fuzz/analyzer-host-header.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/host?q=x", expectedResults: 0}},
|
||||
|
||||
// Non-query positions: each template fuzzes a different request component
|
||||
// (path / header / body / cookie) of a captured request and relies on an
|
||||
// analyzer for detection. Driven via the proxify-format capture file.
|
||||
{Path: "fuzz/analyzer-path-sqli.yaml", TestCase: &analyzerPositionTestCase{expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-header-sqli.yaml", TestCase: &analyzerPositionTestCase{expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-body-sqli.yaml", TestCase: &analyzerPositionTestCase{expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-cookie-ssti.yaml", TestCase: &analyzerPositionTestCase{expectedResults: 1}},
|
||||
}
|
||||
|
||||
const analyzerPositionsTargetFile = "fuzz/testData/analyzer-positions.proxify.yaml"
|
||||
|
||||
// analyzerPositionTestCase runs an analyzer-driven template that fuzzes a
|
||||
// non-query component against the captured analyzer-bench requests. Only the
|
||||
// request matching the template's fuzzed component produces a finding.
|
||||
type analyzerPositionTestCase struct {
|
||||
expectedResults int
|
||||
}
|
||||
|
||||
func (a *analyzerPositionTestCase) Execute(filePath string) error {
|
||||
results, err := testutils.RunNucleiWithArgsAndGetResults(debug, "-t", filePath, "-l", analyzerPositionsTargetFile, "-im", "yaml", "-dast")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, a.expectedResults)
|
||||
}
|
||||
|
||||
// analyzerFuzzTestCase runs an analyzer-driven fuzzing template against the
|
||||
// running fuzz playground (localhost:8082) and asserts the number of findings.
|
||||
type analyzerFuzzTestCase struct {
|
||||
route string
|
||||
expectedResults int
|
||||
}
|
||||
|
||||
func (a *analyzerFuzzTestCase) Execute(filePath string) error {
|
||||
target := "http://localhost:8082" + a.route
|
||||
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, target, debug, "-dast")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, a.expectedResults)
|
||||
}
|
||||
|
||||
// rawHttpInputFuzz seeds the base request from a raw HTTP request file, the
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
id: analyzer-body-sqli
|
||||
|
||||
info:
|
||||
name: Body-position error-based SQLi via analyzer
|
||||
author: pdteam
|
||||
severity: high
|
||||
description: |
|
||||
Drives the sqli_error analyzer while fuzzing the JSON body "name" field,
|
||||
proving the analyzer works on the body component through the real pipeline.
|
||||
|
||||
http:
|
||||
- pre-condition:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- 'method == "POST"'
|
||||
condition: and
|
||||
|
||||
payloads:
|
||||
seed:
|
||||
- "en"
|
||||
|
||||
fuzzing:
|
||||
- part: body
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "name"
|
||||
fuzz:
|
||||
- "{{seed}}"
|
||||
analyzer:
|
||||
name: sqli_error
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -0,0 +1,29 @@
|
||||
id: analyzer-cmdi
|
||||
|
||||
info:
|
||||
name: OS Command Injection via analyzer
|
||||
author: pdteam
|
||||
severity: critical
|
||||
description: |
|
||||
Drives the built-in cmdi analyzer through the real fuzzing pipeline. The
|
||||
analyzer injects shell separators followed by `id` and confirms when the
|
||||
command output (uid=...gid=...) appears in the response.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "127.0.0.1"
|
||||
analyzer:
|
||||
name: cmdi
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -0,0 +1,37 @@
|
||||
id: analyzer-cookie-ssti
|
||||
|
||||
info:
|
||||
name: Cookie-position SSTI via analyzer
|
||||
author: pdteam
|
||||
severity: critical
|
||||
description: |
|
||||
Drives the ssti analyzer while fuzzing the "lang" cookie value, proving the
|
||||
analyzer works on the cookie component through the real pipeline. SSTI is
|
||||
used (not SQLi) because cookie value sanitization strips SQLi quote payloads.
|
||||
|
||||
http:
|
||||
- pre-condition:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- 'method == "GET"'
|
||||
- 'len(cookie) > 0'
|
||||
condition: and
|
||||
|
||||
payloads:
|
||||
seed:
|
||||
- "en"
|
||||
|
||||
fuzzing:
|
||||
- part: cookie
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "lang"
|
||||
fuzz:
|
||||
- "{{seed}}"
|
||||
analyzer:
|
||||
name: ssti
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -0,0 +1,29 @@
|
||||
id: analyzer-cors
|
||||
|
||||
info:
|
||||
name: CORS Misconfiguration via analyzer
|
||||
author: pdteam
|
||||
severity: medium
|
||||
description: |
|
||||
Drives the built-in cors analyzer through the real fuzzing pipeline. The
|
||||
analyzer sends an attacker-controlled Origin and confirms when it is
|
||||
reflected in Access-Control-Allow-Origin together with credentials.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "x"
|
||||
analyzer:
|
||||
name: cors
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -0,0 +1,29 @@
|
||||
id: analyzer-crlf
|
||||
|
||||
info:
|
||||
name: CRLF Injection via analyzer
|
||||
author: pdteam
|
||||
severity: medium
|
||||
description: |
|
||||
Drives the built-in crlf analyzer through the real fuzzing pipeline. The
|
||||
analyzer injects raw CRLF sequences with canary headers/cookies and confirms
|
||||
when the response is split to include them.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "/home"
|
||||
analyzer:
|
||||
name: crlf
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -0,0 +1,35 @@
|
||||
id: analyzer-header-sqli
|
||||
|
||||
info:
|
||||
name: Header-position error-based SQLi via analyzer
|
||||
author: pdteam
|
||||
severity: high
|
||||
description: |
|
||||
Drives the sqli_error analyzer while fuzzing the X-Search request header,
|
||||
proving the analyzer works on the header component through the real pipeline.
|
||||
|
||||
http:
|
||||
- pre-condition:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- 'method == "GET"'
|
||||
condition: and
|
||||
|
||||
payloads:
|
||||
seed:
|
||||
- "en"
|
||||
|
||||
fuzzing:
|
||||
- part: header
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "X-Search"
|
||||
fuzz:
|
||||
- "{{seed}}"
|
||||
analyzer:
|
||||
name: sqli_error
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -0,0 +1,29 @@
|
||||
id: analyzer-host-header
|
||||
|
||||
info:
|
||||
name: Host Header Injection via analyzer
|
||||
author: pdteam
|
||||
severity: medium
|
||||
description: |
|
||||
Drives the built-in host_header_injection analyzer through the real fuzzing
|
||||
pipeline. The analyzer overrides Host / X-Forwarded-Host with a canary and
|
||||
confirms when it is reflected back in an absolute URL.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "x"
|
||||
analyzer:
|
||||
name: host_header_injection
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -0,0 +1,29 @@
|
||||
id: analyzer-lfi
|
||||
|
||||
info:
|
||||
name: Local File Inclusion via analyzer
|
||||
author: pdteam
|
||||
severity: high
|
||||
description: |
|
||||
Drives the built-in lfi analyzer through the real fuzzing pipeline. The
|
||||
analyzer injects path-traversal payloads and confirms a finding when known
|
||||
file-content signatures (e.g. /etc/passwd, win.ini) appear.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "home.txt"
|
||||
analyzer:
|
||||
name: lfi
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -0,0 +1,29 @@
|
||||
id: analyzer-open-redirect
|
||||
|
||||
info:
|
||||
name: Open Redirect via analyzer
|
||||
author: pdteam
|
||||
severity: medium
|
||||
description: |
|
||||
Drives the built-in open_redirect analyzer through the real fuzzing
|
||||
pipeline. The analyzer injects an unresolvable canary host and confirms when
|
||||
the application redirects to it via the Location header or final URL.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "/dashboard"
|
||||
analyzer:
|
||||
name: open_redirect
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -0,0 +1,33 @@
|
||||
id: analyzer-path-sqli
|
||||
|
||||
info:
|
||||
name: Path-position error-based SQLi via analyzer
|
||||
author: pdteam
|
||||
severity: high
|
||||
description: |
|
||||
Drives the sqli_error analyzer while fuzzing a numeric PATH segment, proving
|
||||
the analyzer works on the path component through the real pipeline.
|
||||
|
||||
http:
|
||||
- pre-condition:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- 'method == "GET"'
|
||||
condition: and
|
||||
|
||||
payloads:
|
||||
seed:
|
||||
- "1"
|
||||
|
||||
fuzzing:
|
||||
- part: path
|
||||
type: replace
|
||||
mode: single
|
||||
fuzz:
|
||||
- "{{seed}}"
|
||||
analyzer:
|
||||
name: sqli_error
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -0,0 +1,29 @@
|
||||
id: analyzer-sqli
|
||||
|
||||
info:
|
||||
name: Error-based SQLi via analyzer
|
||||
author: pdteam
|
||||
severity: high
|
||||
description: |
|
||||
Drives the built-in sqli_error analyzer through the real fuzzing pipeline.
|
||||
The analyzer injects syntax-breaking tokens and fingerprints DBMS errors; a
|
||||
finding is raised only when the analyzer confirms the injection.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "1337"
|
||||
analyzer:
|
||||
name: sqli_error
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -0,0 +1,29 @@
|
||||
id: analyzer-ssrf
|
||||
|
||||
info:
|
||||
name: Server-Side Request Forgery via analyzer
|
||||
author: pdteam
|
||||
severity: high
|
||||
description: |
|
||||
Drives the built-in ssrf analyzer through the real fuzzing pipeline. The
|
||||
analyzer points the parameter at cloud metadata endpoints and confirms when
|
||||
metadata signatures are reflected in-band.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "https://example.com/a.png"
|
||||
analyzer:
|
||||
name: ssrf
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -0,0 +1,29 @@
|
||||
id: analyzer-ssti
|
||||
|
||||
info:
|
||||
name: Server-Side Template Injection via analyzer
|
||||
author: pdteam
|
||||
severity: critical
|
||||
description: |
|
||||
Drives the built-in ssti analyzer through the real fuzzing pipeline. The
|
||||
analyzer injects an arithmetic oracle in multiple template syntaxes and only
|
||||
reports when the engine evaluates the expression.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "1337"
|
||||
analyzer:
|
||||
name: ssti
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
timestamp: 2024-02-20T19:24:13+05:30
|
||||
url: http://127.0.0.1:8082/analyzer/path/sqli/75
|
||||
request:
|
||||
raw: |+
|
||||
GET /analyzer/path/sqli/75 HTTP/1.1
|
||||
Host: 127.0.0.1:8082
|
||||
Accept-Encoding: gzip
|
||||
Connection: close
|
||||
User-Agent: nuclei-fuzz
|
||||
|
||||
response:
|
||||
raw: |+
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
Connection: close
|
||||
|
||||
---
|
||||
timestamp: 2024-02-20T19:24:13+05:30
|
||||
url: http://127.0.0.1:8082/analyzer/header/sqli
|
||||
request:
|
||||
raw: |+
|
||||
GET /analyzer/header/sqli HTTP/1.1
|
||||
Host: 127.0.0.1:8082
|
||||
Accept-Encoding: gzip
|
||||
Connection: close
|
||||
X-Search: en
|
||||
User-Agent: nuclei-fuzz
|
||||
|
||||
response:
|
||||
raw: |+
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
Connection: close
|
||||
|
||||
---
|
||||
timestamp: 2024-02-20T19:24:13+05:30
|
||||
url: http://127.0.0.1:8082/analyzer/body/sqli
|
||||
request:
|
||||
raw: |+
|
||||
POST /analyzer/body/sqli HTTP/1.1
|
||||
Host: 127.0.0.1:8082
|
||||
Accept-Encoding: gzip
|
||||
Connection: close
|
||||
Content-Type: application/json
|
||||
Content-Length: 13
|
||||
User-Agent: nuclei-fuzz
|
||||
|
||||
{"name":"en"}
|
||||
|
||||
response:
|
||||
raw: |+
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
Connection: close
|
||||
|
||||
---
|
||||
timestamp: 2024-02-20T19:24:13+05:30
|
||||
url: http://127.0.0.1:8082/analyzer/cookie/ssti
|
||||
request:
|
||||
raw: |+
|
||||
GET /analyzer/cookie/ssti HTTP/1.1
|
||||
Host: 127.0.0.1:8082
|
||||
Accept-Encoding: gzip
|
||||
Connection: close
|
||||
Cookie: lang=en
|
||||
User-Agent: nuclei-fuzz
|
||||
|
||||
response:
|
||||
raw: |+
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/html
|
||||
Connection: close
|
||||
@@ -1,12 +1,16 @@
|
||||
package analyzers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/component"
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
)
|
||||
|
||||
@@ -28,6 +32,15 @@ type AnalyzerTemplate struct {
|
||||
// values:
|
||||
// - time_delay
|
||||
// - xss_context
|
||||
// - ssti
|
||||
// - sqli_error
|
||||
// - lfi
|
||||
// - open_redirect
|
||||
// - crlf
|
||||
// - ssrf
|
||||
// - cors
|
||||
// - cmdi
|
||||
// - host_header_injection
|
||||
Name string `json:"name" yaml:"name"`
|
||||
// description: |
|
||||
// Parameters is the parameters for the analyzer
|
||||
@@ -62,9 +75,164 @@ type Options struct {
|
||||
HttpClient *retryablehttp.Client
|
||||
ResponseTimeDelay time.Duration
|
||||
AnalyzerParameters map[string]interface{}
|
||||
// RateLimitTake, when set, blocks until the scan's global rate limiter
|
||||
// permits another request. Each analyzer issues several probes per call, so
|
||||
// wiring the same limiter the rest of the scan uses keeps DAST analysis
|
||||
// within the user's configured rate budget instead of bursting unthrottled
|
||||
// traffic at the target. It is safe to leave nil (e.g. in unit tests).
|
||||
RateLimitTake func()
|
||||
}
|
||||
|
||||
// RateLimit blocks until the scan's rate limiter allows another request. It is a
|
||||
// no-op when no limiter was wired (RateLimitTake is nil), so analyzers can call
|
||||
// it unconditionally before every probe.
|
||||
func (o *Options) RateLimit() {
|
||||
if o != nil && o.RateLimitTake != nil {
|
||||
o.RateLimitTake()
|
||||
}
|
||||
}
|
||||
|
||||
// SetValueAndRebuild sets value on the fuzzed component, rebuilds the request,
|
||||
// and re-applies headers from the original generated request that Rebuild()
|
||||
// drops. Rebuild() only carries headers present at parse time, so post-parse
|
||||
// injections (most importantly authentication headers) are lost without this.
|
||||
// Sharing this keeps every analyzer consistent and authenticated-scan safe.
|
||||
func SetValueAndRebuild(gr fuzz.GeneratedRequest, value string) (*retryablehttp.Request, error) {
|
||||
if err := gr.Component.SetValue(gr.Key, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rebuilt, err := gr.Component.Rebuild()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if gr.Request != nil {
|
||||
if gr.Component.Name() == component.RequestCookieComponent {
|
||||
// Done outside the header loop because the live request may no
|
||||
// longer carry a Cookie header at all, and the rebuilt request must
|
||||
// not resurrect the cookies it had at parse time.
|
||||
mergeRequestCookies(rebuilt, gr.Request, gr.Key)
|
||||
} else if !fuzzingCookieHeader(gr) {
|
||||
// Same absence problem for query/path/body/header probes: Rebuild
|
||||
// clones the parse-time request, so a Cookie header dropped after
|
||||
// parse would otherwise survive. The live request is authoritative,
|
||||
// including when it has no cookies. Skip when the header component
|
||||
// is itself fuzzing Cookie so the probe payload is not wiped.
|
||||
syncLiveCookieHeader(rebuilt, gr.Request)
|
||||
}
|
||||
for k, vs := range gr.Request.Header {
|
||||
// don't clobber the header we are actively fuzzing
|
||||
if gr.Component.Name() == component.RequestHeaderComponent && k == gr.Key {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(k, "Cookie") {
|
||||
continue
|
||||
}
|
||||
// don't clobber headers the component itself manages on the rebuilt
|
||||
// request (e.g. Cookie for the cookie component, Content-Type/Length
|
||||
// for the body component); only restore headers that Rebuild dropped
|
||||
// (most importantly post-parse auth headers).
|
||||
if len(rebuilt.Header.Values(k)) > 0 {
|
||||
continue
|
||||
}
|
||||
rebuilt.Header[k] = vs
|
||||
}
|
||||
}
|
||||
return rebuilt, nil
|
||||
}
|
||||
|
||||
func fuzzingCookieHeader(gr fuzz.GeneratedRequest) bool {
|
||||
return gr.Component != nil && gr.Component.Name() == component.RequestHeaderComponent && strings.EqualFold(gr.Key, "Cookie")
|
||||
}
|
||||
|
||||
// syncLiveCookieHeader copies Cookie from the live request onto the rebuilt
|
||||
// probe, or deletes it when the live request has none. Callers must not use
|
||||
// this when Cookie is the value under test.
|
||||
func syncLiveCookieHeader(rebuilt, current *retryablehttp.Request) {
|
||||
rebuilt.Header.Del("Cookie")
|
||||
if vs := current.Header.Values("Cookie"); len(vs) > 0 {
|
||||
rebuilt.Header["Cookie"] = append([]string(nil), vs...)
|
||||
}
|
||||
}
|
||||
|
||||
// mergeRequestCookies rebuilds the probe's Cookie header from the live request,
|
||||
// substituting the fuzzed value for the cookie under test. The live request is
|
||||
// authoritative for every other cookie, including its order and any duplicate
|
||||
// names (Go allows repeated cookie names in a request), so cookies added after
|
||||
// the component was parsed survive and cookies removed since then are not
|
||||
// resurrected.
|
||||
func mergeRequestCookies(rebuilt, current *retryablehttp.Request, fuzzedKey string) {
|
||||
var fuzzed *http.Cookie
|
||||
for _, cookie := range rebuilt.Cookies() {
|
||||
if cookie.Name == fuzzedKey {
|
||||
fuzzed = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
currentCookies := current.Cookies()
|
||||
merged := make([]*http.Cookie, 0, len(currentCookies)+1)
|
||||
var injected bool
|
||||
for _, cookie := range currentCookies {
|
||||
if fuzzed != nil && cookie.Name == fuzzedKey {
|
||||
// servers disagree on which duplicate wins, so every occurrence of
|
||||
// the fuzzed name carries the payload to keep the probe meaningful
|
||||
merged = append(merged, fuzzed)
|
||||
injected = true
|
||||
continue
|
||||
}
|
||||
merged = append(merged, cookie)
|
||||
}
|
||||
if fuzzed != nil && !injected {
|
||||
// the live request dropped the fuzzed cookie; send the payload anyway,
|
||||
// otherwise the probe would be indistinguishable from the baseline
|
||||
merged = append(merged, fuzzed)
|
||||
}
|
||||
|
||||
rebuilt.Header.Del("Cookie")
|
||||
for _, cookie := range merged {
|
||||
rebuilt.AddCookie(cookie)
|
||||
}
|
||||
}
|
||||
|
||||
// MaxResponseBodyBytes bounds how much of a response body an analyzer reads so a
|
||||
// hostile or oversized response cannot exhaust memory.
|
||||
const MaxResponseBodyBytes = 10 * 1024 * 1024 // 10 MiB
|
||||
|
||||
// DoAndReadBody sends req and returns the response along with its body, bounded
|
||||
// by MaxResponseBodyBytes. The body is fully read and closed before returning,
|
||||
// so callers may still inspect resp.Header afterwards. resp is nil only when the
|
||||
// request itself failed.
|
||||
func DoAndReadBody(client *retryablehttp.Client, req *retryablehttp.Request) (*http.Response, string, error) {
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return resp, "", err
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, MaxResponseBodyBytes))
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
return resp, "", err
|
||||
}
|
||||
return resp, string(body), nil
|
||||
}
|
||||
|
||||
// SendValueAndReadBody sets value on the fuzzed component, rebuilds the request
|
||||
// (restoring auth headers Rebuild drops), sends it, and returns the response
|
||||
// body. This is the shared path for body-signature analyzers (sqli, cmdi, ssti,
|
||||
// lfi, ssrf), which only inspect the response body.
|
||||
func SendValueAndReadBody(options *Options, value string) (string, error) {
|
||||
rebuilt, err := SetValueAndRebuild(options.FuzzGenerated, value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
options.RateLimit()
|
||||
_, body, err := DoAndReadBody(options.HttpClient, rebuilt)
|
||||
return body, err
|
||||
}
|
||||
|
||||
var (
|
||||
// randMu guards random: analyzers run concurrently across fuzzed components,
|
||||
// and math/rand's top-level source is not safe for concurrent use.
|
||||
randMu sync.Mutex
|
||||
random = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
)
|
||||
|
||||
@@ -81,17 +249,46 @@ func ApplyPayloadTransformations(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
const (
|
||||
letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
// alphaNumLower / alphaLower back the shared RandToken / RandAlphaToken
|
||||
// helpers used by analyzers for canary hosts, header names and sentinels.
|
||||
alphaNumLower = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
alphaLower = "abcdefghijklmnopqrstuvwxyz"
|
||||
)
|
||||
|
||||
// randStringFrom returns a random string of length n drawn from alphabet, using
|
||||
// the shared mutex-guarded source so it is safe for concurrent analyzers.
|
||||
func randStringFrom(alphabet string, n int) string {
|
||||
b := make([]byte, n)
|
||||
randMu.Lock()
|
||||
for i := range b {
|
||||
b[i] = alphabet[random.Intn(len(alphabet))]
|
||||
}
|
||||
randMu.Unlock()
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func randStringBytesMask(n int) string {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letterBytes[random.Intn(len(letterBytes))]
|
||||
}
|
||||
return string(b)
|
||||
return randStringFrom(letterBytes, n)
|
||||
}
|
||||
|
||||
// RandToken returns a random lowercase alphanumeric token of length n. It suits
|
||||
// canary hosts/header names and any sentinel that may legitimately be numeric.
|
||||
func RandToken(n int) string {
|
||||
return randStringFrom(alphaNumLower, n)
|
||||
}
|
||||
|
||||
// RandAlphaToken returns a random lowercase alphabetic token of length n (never
|
||||
// digits), for sentinels that must not be confused with numeric output (e.g.
|
||||
// the SSTI arithmetic oracle).
|
||||
func RandAlphaToken(n int) string {
|
||||
return randStringFrom(alphaLower, n)
|
||||
}
|
||||
|
||||
// GetRandomInteger returns a random integer between 1000 and 9999
|
||||
func GetRandomInteger() int {
|
||||
randMu.Lock()
|
||||
defer randMu.Unlock()
|
||||
return random.Intn(9000) + 1000
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package analyzers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/component"
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSetValueAndRebuildRefreshesLiveCookies(t *testing.T) {
|
||||
raw, err := retryablehttp.NewRequest(http.MethodGet, "http://example.com/?q=x", nil)
|
||||
require.NoError(t, err)
|
||||
raw.Header.Set("Cookie", "theme=light")
|
||||
|
||||
query := component.NewQuery()
|
||||
parsed, err := query.Parse(raw)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed)
|
||||
cloned := query.Clone()
|
||||
|
||||
raw.Header.Set("Cookie", "theme=dark; session=abc123")
|
||||
rebuilt, err := SetValueAndRebuild(fuzz.GeneratedRequest{
|
||||
Request: raw,
|
||||
Component: cloned,
|
||||
Key: "q",
|
||||
}, "probe")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "dark", cookieValue(t, rebuilt, "theme"))
|
||||
require.Equal(t, "abc123", cookieValue(t, rebuilt, "session"))
|
||||
}
|
||||
|
||||
// TestSetValueAndRebuildDropsStaleCookiesOnQueryProbes pins that a query probe
|
||||
// does not resurrect parse-time cookies after the live request dropped Cookie.
|
||||
func TestSetValueAndRebuildDropsStaleCookiesOnQueryProbes(t *testing.T) {
|
||||
raw, err := retryablehttp.NewRequest(http.MethodGet, "http://example.com/?q=x", nil)
|
||||
require.NoError(t, err)
|
||||
raw.Header.Set("Cookie", "session=old")
|
||||
|
||||
cloned := parsedQueryComponent(t, raw)
|
||||
|
||||
raw.Header.Del("Cookie")
|
||||
rebuilt, err := SetValueAndRebuild(fuzz.GeneratedRequest{
|
||||
Request: raw,
|
||||
Component: cloned,
|
||||
Key: "q",
|
||||
}, "probe")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, rebuilt.Header.Get("Cookie"))
|
||||
require.Equal(t, 0, len(rebuilt.Cookies()))
|
||||
}
|
||||
|
||||
// TestSetValueAndRebuildPreservesFuzzedCookieHeader pins that fuzzing Cookie as
|
||||
// a header keeps the probe payload even when the live request no longer has it.
|
||||
func TestSetValueAndRebuildPreservesFuzzedCookieHeader(t *testing.T) {
|
||||
raw, err := retryablehttp.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
require.NoError(t, err)
|
||||
raw.Header.Set("Cookie", "session=old")
|
||||
raw.Header.Set("X-Trace", "1")
|
||||
|
||||
headers := component.NewHeader()
|
||||
parsed, err := headers.Parse(raw)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed)
|
||||
cloned := headers.Clone()
|
||||
|
||||
raw.Header.Del("Cookie")
|
||||
rebuilt, err := SetValueAndRebuild(fuzz.GeneratedRequest{
|
||||
Request: raw,
|
||||
Component: cloned,
|
||||
Key: "Cookie",
|
||||
}, "session=probe")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "session=probe", rebuilt.Header.Get("Cookie"))
|
||||
require.Equal(t, "1", rebuilt.Header.Get("X-Trace"))
|
||||
}
|
||||
|
||||
func TestSetValueAndRebuildMergesCookiesAroundFuzzedCookie(t *testing.T) {
|
||||
raw, err := retryablehttp.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
require.NoError(t, err)
|
||||
raw.Header.Set("Cookie", "lang=en")
|
||||
|
||||
cookies := component.NewCookie()
|
||||
parsed, err := cookies.Parse(raw)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed)
|
||||
cloned := cookies.Clone()
|
||||
|
||||
raw.Header.Set("Cookie", "lang=en; session=abc123")
|
||||
rebuilt, err := SetValueAndRebuild(fuzz.GeneratedRequest{
|
||||
Request: raw,
|
||||
Component: cloned,
|
||||
Key: "lang",
|
||||
}, "fr")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "fr", cookieValue(t, rebuilt, "lang"))
|
||||
require.Equal(t, "abc123", cookieValue(t, rebuilt, "session"))
|
||||
}
|
||||
|
||||
// TestSetValueAndRebuildDropsStaleCookies pins that a cookie the live request no
|
||||
// longer sends is not resurrected from the parse-time snapshot: probing "theme"
|
||||
// must not carry the stale "session" value.
|
||||
func TestSetValueAndRebuildDropsStaleCookies(t *testing.T) {
|
||||
raw, err := retryablehttp.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
require.NoError(t, err)
|
||||
raw.Header.Set("Cookie", "theme=light; session=abc123")
|
||||
|
||||
cloned := parsedCookieComponent(t, raw)
|
||||
|
||||
// the live request dropped session after the component was parsed
|
||||
raw.Header.Set("Cookie", "theme=light")
|
||||
rebuilt, err := SetValueAndRebuild(fuzz.GeneratedRequest{
|
||||
Request: raw,
|
||||
Component: cloned,
|
||||
Key: "theme",
|
||||
}, "fr")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "theme=fr", rebuilt.Header.Get("Cookie"))
|
||||
}
|
||||
|
||||
// TestSetValueAndRebuildDropsStaleCookiesWithoutCookieHeader covers the extreme
|
||||
// of the same case: the live request has no Cookie header left, so only the
|
||||
// fuzzed cookie may survive.
|
||||
func TestSetValueAndRebuildDropsStaleCookiesWithoutCookieHeader(t *testing.T) {
|
||||
raw, err := retryablehttp.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
require.NoError(t, err)
|
||||
raw.Header.Set("Cookie", "theme=light; session=abc123")
|
||||
|
||||
cloned := parsedCookieComponent(t, raw)
|
||||
|
||||
raw.Header.Del("Cookie")
|
||||
rebuilt, err := SetValueAndRebuild(fuzz.GeneratedRequest{
|
||||
Request: raw,
|
||||
Component: cloned,
|
||||
Key: "theme",
|
||||
}, "fr")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "theme=fr", rebuilt.Header.Get("Cookie"))
|
||||
}
|
||||
|
||||
// TestSetValueAndRebuildPreservesDuplicateCookieNames pins that duplicate cookie
|
||||
// names keep their own values and positions, instead of collapsing to whichever
|
||||
// one a name-keyed map saw last.
|
||||
func TestSetValueAndRebuildPreservesDuplicateCookieNames(t *testing.T) {
|
||||
raw, err := retryablehttp.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
require.NoError(t, err)
|
||||
raw.Header.Set("Cookie", "sid=1; dup=x; dup=y")
|
||||
|
||||
cloned := parsedCookieComponent(t, raw)
|
||||
|
||||
rebuilt, err := SetValueAndRebuild(fuzz.GeneratedRequest{
|
||||
Request: raw,
|
||||
Component: cloned,
|
||||
Key: "sid",
|
||||
}, "probe")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "sid=probe; dup=x; dup=y", rebuilt.Header.Get("Cookie"))
|
||||
}
|
||||
|
||||
// TestSetValueAndRebuildFuzzesEveryDuplicateOfFuzzedCookie documents that when
|
||||
// the fuzzed name itself is duplicated, every occurrence carries the payload:
|
||||
// servers disagree on which duplicate wins, so the probe must not depend on it.
|
||||
func TestSetValueAndRebuildFuzzesEveryDuplicateOfFuzzedCookie(t *testing.T) {
|
||||
raw, err := retryablehttp.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
require.NoError(t, err)
|
||||
raw.Header.Set("Cookie", "dup=x; keep=1; dup=y")
|
||||
|
||||
cloned := parsedCookieComponent(t, raw)
|
||||
|
||||
rebuilt, err := SetValueAndRebuild(fuzz.GeneratedRequest{
|
||||
Request: raw,
|
||||
Component: cloned,
|
||||
Key: "dup",
|
||||
}, "probe")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "dup=probe; keep=1; dup=probe", rebuilt.Header.Get("Cookie"))
|
||||
}
|
||||
|
||||
// TestSetValueAndRebuildKeepsPayloadWhenFuzzedCookieRemoved makes sure dropping
|
||||
// stale cookies never drops the payload itself, which would make the probe
|
||||
// identical to the baseline request.
|
||||
func TestSetValueAndRebuildKeepsPayloadWhenFuzzedCookieRemoved(t *testing.T) {
|
||||
raw, err := retryablehttp.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
require.NoError(t, err)
|
||||
raw.Header.Set("Cookie", "theme=light; session=abc123")
|
||||
|
||||
cloned := parsedCookieComponent(t, raw)
|
||||
|
||||
// the live request dropped the very cookie being fuzzed
|
||||
raw.Header.Set("Cookie", "session=zzz999")
|
||||
rebuilt, err := SetValueAndRebuild(fuzz.GeneratedRequest{
|
||||
Request: raw,
|
||||
Component: cloned,
|
||||
Key: "theme",
|
||||
}, "fr")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "session=zzz999; theme=fr", rebuilt.Header.Get("Cookie"))
|
||||
}
|
||||
|
||||
func parsedQueryComponent(t *testing.T, req *retryablehttp.Request) component.Component {
|
||||
t.Helper()
|
||||
query := component.NewQuery()
|
||||
parsed, err := query.Parse(req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed)
|
||||
return query.Clone()
|
||||
}
|
||||
|
||||
func parsedCookieComponent(t *testing.T, req *retryablehttp.Request) component.Component {
|
||||
t.Helper()
|
||||
cookies := component.NewCookie()
|
||||
parsed, err := cookies.Parse(req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed)
|
||||
return cookies.Clone()
|
||||
}
|
||||
|
||||
func cookieValue(t *testing.T, req *retryablehttp.Request, name string) string {
|
||||
t.Helper()
|
||||
cookie, err := req.Cookie(name)
|
||||
require.NoError(t, err)
|
||||
return cookie.Value
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Package cmdi implements an in-band OS command injection analyzer for the
|
||||
// fuzzer.
|
||||
//
|
||||
// It appends a set of shell command separators followed by the "id" command and
|
||||
// confirms a hit only when the response contains the unmistakable
|
||||
// "uid=...(...) gid=...(...)" output of a real id execution. Matching actual
|
||||
// command output (rather than payload reflection) is an extremely strong,
|
||||
// low-false-positive RCE signal. Blind command injection is covered by the
|
||||
// time-based analyzer; this one targets the in-band case where output is
|
||||
// reflected.
|
||||
package cmdi
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
)
|
||||
|
||||
const analyzerName = "cmdi"
|
||||
|
||||
// Analyzer implements the analyzers.Analyzer interface for OS command injection.
|
||||
type Analyzer struct{}
|
||||
|
||||
var _ analyzers.Analyzer = &Analyzer{}
|
||||
|
||||
func init() {
|
||||
analyzers.RegisterAnalyzer(analyzerName, &Analyzer{})
|
||||
}
|
||||
|
||||
func (a *Analyzer) Name() string {
|
||||
return analyzerName
|
||||
}
|
||||
|
||||
func (a *Analyzer) ApplyInitialTransformation(data string, params map[string]interface{}) string {
|
||||
return analyzers.ApplyPayloadTransformations(data)
|
||||
}
|
||||
|
||||
// commandSeparators break out of the surrounding shell context in different
|
||||
// ways (sequencing, piping, substitution, newline). Each is suffixed with "id".
|
||||
var commandSeparators = []string{
|
||||
";id",
|
||||
"|id",
|
||||
"||id",
|
||||
"&&id",
|
||||
"&id",
|
||||
"`id`",
|
||||
"$(id)",
|
||||
"\nid",
|
||||
";id;",
|
||||
"|id #",
|
||||
}
|
||||
|
||||
// reIDOutput matches the output of the unix "id" command, e.g.
|
||||
// "uid=0(root) gid=0(root) groups=0(root)".
|
||||
var reIDOutput = regexp.MustCompile(`uid=\d+\([0-9A-Za-z_.\-$]+\)\s+gid=\d+\([0-9A-Za-z_.\-$]+\)`)
|
||||
|
||||
// MatchCommandOutput reports whether the body contains "id" command output. It
|
||||
// is exported and pure for unit-testing without a network.
|
||||
func MatchCommandOutput(body string) bool {
|
||||
if body == "" {
|
||||
return false
|
||||
}
|
||||
return reIDOutput.MatchString(body)
|
||||
}
|
||||
|
||||
// Analyze appends command-injection payloads and reports a hit when id command
|
||||
// output appears in a response but not in the baseline.
|
||||
func (a *Analyzer) Analyze(options *analyzers.Options) (bool, string, error) {
|
||||
if options == nil || options.FuzzGenerated.Component == nil || options.HttpClient == nil {
|
||||
return false, "", nil
|
||||
}
|
||||
gr := options.FuzzGenerated
|
||||
|
||||
defer func() {
|
||||
_ = gr.Component.SetValue(gr.Key, gr.OriginalValue)
|
||||
}()
|
||||
|
||||
baselineBody, err := analyzers.SendValueAndReadBody(options, gr.OriginalValue)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if MatchCommandOutput(baselineBody) {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
for _, sep := range commandSeparators {
|
||||
body, err := analyzers.SendValueAndReadBody(options, gr.OriginalValue+sep)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if MatchCommandOutput(body) {
|
||||
return true, "cmdi: id command output observed via payload " + strconv.Quote(sep), nil
|
||||
}
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package cmdi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAnalyzerRegistered(t *testing.T) {
|
||||
require.NotNil(t, analyzers.GetAnalyzer("cmdi"))
|
||||
require.Equal(t, "cmdi", (&Analyzer{}).Name())
|
||||
}
|
||||
|
||||
func TestMatchCommandOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "classic root id output",
|
||||
body: `uid=0(root) gid=0(root) groups=0(root)`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non-root id output embedded in html",
|
||||
body: `<pre>uid=33(www-data) gid=33(www-data) groups=33(www-data)</pre>`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "id output with extra spacing",
|
||||
body: "uid=1000(deploy) gid=1000(deploy) groups=1000(deploy),27(sudo)",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "reflected payload but no execution",
|
||||
body: `You searched for: ;id - no results found`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "benign body",
|
||||
body: `<html><body>welcome</body></html>`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "uid mentioned without gid structure",
|
||||
body: `the uid=5 field is set`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: ``,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.want, MatchCommandOutput(tc.body))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandSeparatorsNonEmpty(t *testing.T) {
|
||||
require.NotEmpty(t, commandSeparators)
|
||||
for _, s := range commandSeparators {
|
||||
require.NotEmpty(t, s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Package cors implements a CORS misconfiguration analyzer for the fuzzer.
|
||||
//
|
||||
// For the generated request it replays the request with attacker-controlled
|
||||
// Origin headers (a random canary origin, the "null" origin, and a
|
||||
// target-suffix origin that defeats naive endsWith checks) and flags a
|
||||
// misconfiguration when the server reflects that arbitrary origin back in
|
||||
// Access-Control-Allow-Origin. Reflection together with
|
||||
// Access-Control-Allow-Credentials: true is reported as the more severe,
|
||||
// credential-exposing variant.
|
||||
package cors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
)
|
||||
|
||||
const analyzerName = "cors"
|
||||
|
||||
// Analyzer implements the analyzers.Analyzer interface for CORS misconfig.
|
||||
type Analyzer struct{}
|
||||
|
||||
var _ analyzers.Analyzer = &Analyzer{}
|
||||
|
||||
func init() {
|
||||
analyzers.RegisterAnalyzer(analyzerName, &Analyzer{})
|
||||
}
|
||||
|
||||
func (a *Analyzer) Name() string {
|
||||
return analyzerName
|
||||
}
|
||||
|
||||
func (a *Analyzer) ApplyInitialTransformation(data string, params map[string]interface{}) string {
|
||||
return analyzers.ApplyPayloadTransformations(data)
|
||||
}
|
||||
|
||||
// TestOrigins returns the attacker origins to probe. targetHost (may be empty)
|
||||
// is used to build a suffix-bypass origin. It is exported and pure for testing.
|
||||
func TestOrigins(targetHost, canaryOrigin string) []string {
|
||||
origins := []string{canaryOrigin, "null"}
|
||||
if targetHost != "" {
|
||||
// "https://<target>.attacker" defeats endsWith(target) checks; the
|
||||
// reverse defeats startsWith(target) checks.
|
||||
origins = append(origins,
|
||||
"https://"+targetHost+".corscanary.example",
|
||||
"https://corscanary.example/"+targetHost,
|
||||
)
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
// AnalyzeCORS reports whether the response reflects sentOrigin in
|
||||
// Access-Control-Allow-Origin. It is exported and pure for testability.
|
||||
func AnalyzeCORS(h http.Header, sentOrigin string) (string, bool) {
|
||||
if h == nil || sentOrigin == "" {
|
||||
return "", false
|
||||
}
|
||||
acao := strings.TrimSpace(h.Get("Access-Control-Allow-Origin"))
|
||||
if acao == "" {
|
||||
return "", false
|
||||
}
|
||||
if !strings.EqualFold(acao, sentOrigin) {
|
||||
return "", false
|
||||
}
|
||||
withCreds := strings.EqualFold(strings.TrimSpace(h.Get("Access-Control-Allow-Credentials")), "true")
|
||||
if withCreds {
|
||||
return "reflects arbitrary Origin with Access-Control-Allow-Credentials: true", true
|
||||
}
|
||||
return "reflects arbitrary Origin", true
|
||||
}
|
||||
|
||||
// Analyze replays the request with attacker origins and reports CORS reflection.
|
||||
func (a *Analyzer) Analyze(options *analyzers.Options) (bool, string, error) {
|
||||
if options == nil || options.FuzzGenerated.Component == nil || options.HttpClient == nil {
|
||||
return false, "", nil
|
||||
}
|
||||
gr := options.FuzzGenerated
|
||||
|
||||
targetHost := ""
|
||||
if gr.Request != nil && gr.Request.URL != nil {
|
||||
targetHost = gr.Request.Hostname()
|
||||
}
|
||||
canary := "https://" + analyzers.RandToken(10) + ".corscanary.example"
|
||||
|
||||
defer func() {
|
||||
_ = gr.Component.SetValue(gr.Key, gr.OriginalValue)
|
||||
}()
|
||||
|
||||
for _, origin := range TestOrigins(targetHost, canary) {
|
||||
// rebuild a benign request (original value) and only vary the Origin
|
||||
rebuilt, err := analyzers.SetValueAndRebuild(gr, gr.OriginalValue)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
rebuilt.Header.Set("Origin", origin)
|
||||
|
||||
options.RateLimit()
|
||||
resp, err := options.HttpClient.Do(rebuilt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
reason, vuln := AnalyzeCORS(resp.Header, origin)
|
||||
_ = resp.Body.Close()
|
||||
if vuln {
|
||||
return true, "cors: " + reason + " (Origin: " + origin + ")", nil
|
||||
}
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package cors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAnalyzerRegistered(t *testing.T) {
|
||||
require.NotNil(t, analyzers.GetAnalyzer("cors"))
|
||||
require.Equal(t, "cors", (&Analyzer{}).Name())
|
||||
}
|
||||
|
||||
func TestTestOrigins(t *testing.T) {
|
||||
const canary = "https://abc.corscanary.example"
|
||||
|
||||
noHost := TestOrigins("", canary)
|
||||
require.Contains(t, noHost, canary)
|
||||
require.Contains(t, noHost, "null")
|
||||
|
||||
withHost := TestOrigins("app.example.com", canary)
|
||||
require.Contains(t, withHost, "https://app.example.com.corscanary.example")
|
||||
require.Greater(t, len(withHost), len(noHost))
|
||||
}
|
||||
|
||||
func TestAnalyzeCORS(t *testing.T) {
|
||||
const origin = "https://abc.corscanary.example"
|
||||
|
||||
t.Run("reflected origin with credentials is critical", func(t *testing.T) {
|
||||
h := http.Header{}
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
h.Set("Access-Control-Allow-Credentials", "true")
|
||||
reason, vuln := AnalyzeCORS(h, origin)
|
||||
require.True(t, vuln)
|
||||
require.Contains(t, reason, "Credentials")
|
||||
})
|
||||
|
||||
t.Run("reflected origin without credentials still flagged", func(t *testing.T) {
|
||||
h := http.Header{}
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
reason, vuln := AnalyzeCORS(h, origin)
|
||||
require.True(t, vuln)
|
||||
require.NotContains(t, reason, "Credentials")
|
||||
})
|
||||
|
||||
t.Run("null origin reflected", func(t *testing.T) {
|
||||
h := http.Header{}
|
||||
h.Set("Access-Control-Allow-Origin", "null")
|
||||
_, vuln := AnalyzeCORS(h, "null")
|
||||
require.True(t, vuln)
|
||||
})
|
||||
|
||||
t.Run("wildcard is not flagged as origin reflection", func(t *testing.T) {
|
||||
h := http.Header{}
|
||||
h.Set("Access-Control-Allow-Origin", "*")
|
||||
_, vuln := AnalyzeCORS(h, origin)
|
||||
require.False(t, vuln)
|
||||
})
|
||||
|
||||
t.Run("legit origin echoing back our canary not present", func(t *testing.T) {
|
||||
h := http.Header{}
|
||||
h.Set("Access-Control-Allow-Origin", "https://trusted.example.com")
|
||||
_, vuln := AnalyzeCORS(h, origin)
|
||||
require.False(t, vuln)
|
||||
})
|
||||
|
||||
t.Run("no ACAO header", func(t *testing.T) {
|
||||
h := http.Header{}
|
||||
h.Set("Content-Type", "application/json")
|
||||
_, vuln := AnalyzeCORS(h, origin)
|
||||
require.False(t, vuln)
|
||||
})
|
||||
|
||||
t.Run("nil and empty inputs are safe", func(t *testing.T) {
|
||||
_, vuln := AnalyzeCORS(nil, origin)
|
||||
require.False(t, vuln)
|
||||
_, vuln = AnalyzeCORS(http.Header{}, "")
|
||||
require.False(t, vuln)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Package crlf implements a CRLF-injection / HTTP response-splitting analyzer
|
||||
// for the fuzzer.
|
||||
//
|
||||
// It injects carriage-return/line-feed sequences followed by a uniquely named
|
||||
// canary header (and a canary Set-Cookie) and confirms a hit only when that
|
||||
// exact header/cookie appears in the response. Because the header name and
|
||||
// value are random, a match means user input broke out of its context into the
|
||||
// response header section — a true CRLF injection with no baseline needed.
|
||||
//
|
||||
// Payloads use raw CR/LF characters (not pre-encoded "%0d%0a"). The request
|
||||
// rebuild percent-encodes URL/query components exactly once, so the server
|
||||
// receives a single layer of encoding; pre-encoding here would double-encode.
|
||||
package crlf
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
)
|
||||
|
||||
const analyzerName = "crlf"
|
||||
|
||||
// Analyzer implements the analyzers.Analyzer interface for CRLF injection.
|
||||
type Analyzer struct{}
|
||||
|
||||
var _ analyzers.Analyzer = &Analyzer{}
|
||||
|
||||
func init() {
|
||||
analyzers.RegisterAnalyzer(analyzerName, &Analyzer{})
|
||||
}
|
||||
|
||||
func (a *Analyzer) Name() string {
|
||||
return analyzerName
|
||||
}
|
||||
|
||||
func (a *Analyzer) ApplyInitialTransformation(data string, params map[string]interface{}) string {
|
||||
return analyzers.ApplyPayloadTransformations(data)
|
||||
}
|
||||
|
||||
// GenerateProbes builds CRLF payloads that attempt to inject headerName/value
|
||||
// and a Set-Cookie carrying value. It is exported and pure for testability.
|
||||
func GenerateProbes(headerName, value string) []string {
|
||||
hdr := headerName + ": " + value
|
||||
cookie := "Set-Cookie: crlf=" + value
|
||||
return []string{
|
||||
"\r\n" + hdr,
|
||||
"\r\n\r\n" + hdr,
|
||||
"\n" + hdr,
|
||||
"\r" + hdr,
|
||||
"\r\n" + cookie,
|
||||
// trailing comment/space variants seen in real-world bypasses
|
||||
" \r\n" + hdr,
|
||||
}
|
||||
}
|
||||
|
||||
// DetectInjection reports whether the canary header (name/value) or a
|
||||
// Set-Cookie carrying value is present in the response headers. It is exported
|
||||
// and pure so it can be unit-tested without a network.
|
||||
func DetectInjection(h http.Header, headerName, value string) bool {
|
||||
if h == nil || headerName == "" || value == "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(h.Get(headerName)) == value {
|
||||
return true
|
||||
}
|
||||
resp := &http.Response{Header: h}
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Name == "crlf" && cookie.Value == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Analyze injects CRLF payloads and reports whether the canary header or cookie
|
||||
// surfaced in the response headers.
|
||||
func (a *Analyzer) Analyze(options *analyzers.Options) (bool, string, error) {
|
||||
if options == nil || options.FuzzGenerated.Component == nil || options.HttpClient == nil {
|
||||
return false, "", nil
|
||||
}
|
||||
gr := options.FuzzGenerated
|
||||
headerName := "X-Crlf-" + randomToken()
|
||||
value := randomToken()
|
||||
|
||||
defer func() {
|
||||
_ = gr.Component.SetValue(gr.Key, gr.OriginalValue)
|
||||
}()
|
||||
|
||||
for _, payload := range GenerateProbes(headerName, value) {
|
||||
// keep the original value so server-side processing still runs, then
|
||||
// append the breakout payload.
|
||||
rebuilt, err := analyzers.SetValueAndRebuild(gr, gr.OriginalValue+payload)
|
||||
if err != nil {
|
||||
// some components (e.g. headers) reject raw CR/LF at build time;
|
||||
// skip rather than aborting the whole analysis.
|
||||
continue
|
||||
}
|
||||
options.RateLimit()
|
||||
resp, err := options.HttpClient.Do(rebuilt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hit := DetectInjection(resp.Header, headerName, value)
|
||||
_ = resp.Body.Close()
|
||||
if hit {
|
||||
return true, "crlf: injected response header surfaced via payload " + strconv.Quote(payload), nil
|
||||
}
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
// randomToken returns a 10-char lowercase alphanumeric token used for the
|
||||
// canary header name/value. It delegates to the shared analyzers RNG.
|
||||
func randomToken() string {
|
||||
return analyzers.RandToken(10)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package crlf
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAnalyzerRegistered(t *testing.T) {
|
||||
require.NotNil(t, analyzers.GetAnalyzer("crlf"))
|
||||
require.Equal(t, "crlf", (&Analyzer{}).Name())
|
||||
}
|
||||
|
||||
func TestGenerateProbes(t *testing.T) {
|
||||
probes := GenerateProbes("X-Crlf-abc", "deadbeef")
|
||||
require.NotEmpty(t, probes)
|
||||
for _, p := range probes {
|
||||
require.True(t, strings.ContainsAny(p, "\r\n"), "every probe must carry a CR or LF")
|
||||
require.Contains(t, p, "deadbeef")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectInjection(t *testing.T) {
|
||||
const (
|
||||
name = "X-Crlf-abc"
|
||||
value = "deadbeef"
|
||||
)
|
||||
|
||||
t.Run("injected header present", func(t *testing.T) {
|
||||
h := http.Header{}
|
||||
h.Set(name, value)
|
||||
require.True(t, DetectInjection(h, name, value))
|
||||
})
|
||||
|
||||
t.Run("header name canonicalization still matches", func(t *testing.T) {
|
||||
h := http.Header{}
|
||||
// server emits lowercase, Go canonicalizes on read
|
||||
h["X-Crlf-Abc"] = []string{value}
|
||||
require.True(t, DetectInjection(h, name, value))
|
||||
})
|
||||
|
||||
t.Run("injected Set-Cookie present", func(t *testing.T) {
|
||||
h := http.Header{}
|
||||
h.Add("Set-Cookie", "crlf="+value+"; Path=/")
|
||||
require.True(t, DetectInjection(h, name, value))
|
||||
})
|
||||
|
||||
t.Run("different value is not a hit", func(t *testing.T) {
|
||||
h := http.Header{}
|
||||
h.Set(name, "somethingelse")
|
||||
require.False(t, DetectInjection(h, name, value))
|
||||
})
|
||||
|
||||
t.Run("canary in unrelated cookie is not a hit", func(t *testing.T) {
|
||||
h := http.Header{}
|
||||
h.Add("Set-Cookie", "session="+value+"; Path=/")
|
||||
require.False(t, DetectInjection(h, name, value))
|
||||
})
|
||||
|
||||
t.Run("absent header is not a hit", func(t *testing.T) {
|
||||
h := http.Header{}
|
||||
h.Set("Content-Type", "text/html")
|
||||
require.False(t, DetectInjection(h, name, value))
|
||||
})
|
||||
|
||||
t.Run("nil and empty inputs are safe", func(t *testing.T) {
|
||||
require.False(t, DetectInjection(nil, name, value))
|
||||
require.False(t, DetectInjection(http.Header{}, "", value))
|
||||
require.False(t, DetectInjection(http.Header{}, name, ""))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRandomToken(t *testing.T) {
|
||||
require.NotEqual(t, randomToken(), randomToken())
|
||||
require.Len(t, randomToken(), 10)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Baseline-guard tests for the baseline-diffing analyzers (sqli, lfi, ssrf,
|
||||
// cmdi). Each of those analyzers first issues a request with the ORIGINAL value
|
||||
// and bails out if the vulnerability signature is already present — otherwise a
|
||||
// page that always shows e.g. a SQL error or a "uid=" string would be a false
|
||||
// positive. These tests stand up servers that emit the signature
|
||||
// UNCONDITIONALLY (even for the untouched original value) and assert the
|
||||
// analyzer reports nothing.
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSQLi_BaselineGuard_E2E(t *testing.T) {
|
||||
// Always returns a MySQL error, even for the benign original value.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
matched, _ := run(t, "sqli_error", newGeneratedRequest(t, srv.URL, "q", "test"), newClient(true))
|
||||
require.False(t, matched, "sqli must not fire when the DBMS error is present in the baseline already")
|
||||
}
|
||||
|
||||
func TestLFI_BaselineGuard_E2E(t *testing.T) {
|
||||
// Always returns /etc/passwd content regardless of the input.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, "root:x:0:0:root:/root:/bin/bash\n")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
matched, _ := run(t, "lfi", newGeneratedRequest(t, srv.URL, "q", "home.txt"), newClient(true))
|
||||
require.False(t, matched, "lfi must not fire when the file signature is present in the baseline already")
|
||||
}
|
||||
|
||||
func TestSSRF_BaselineGuard_E2E(t *testing.T) {
|
||||
// Always returns the AWS instance identity document regardless of the input.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, `{"accountId":"123456789012","imageId":"ami-0abcd1234ef567890","instanceId":"i-0abcd1234ef567890","region":"us-east-1"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
matched, _ := run(t, "ssrf", newGeneratedRequest(t, srv.URL, "q", "https://example.com/a.png"), newClient(true))
|
||||
require.False(t, matched, "ssrf must not fire when the metadata signature is present in the baseline already")
|
||||
}
|
||||
|
||||
func TestCMDi_BaselineGuard_E2E(t *testing.T) {
|
||||
// Always returns command output regardless of input (e.g. a page that
|
||||
// legitimately prints a "uid=" string).
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, "current identity: uid=0(root) gid=0(root) groups=0(root)")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
matched, _ := run(t, "cmdi", newGeneratedRequest(t, srv.URL, "q", "127.0.0.1"), newClient(true))
|
||||
require.False(t, matched, "cmdi must not fire when command output is present in the baseline already")
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
// Package e2e contains end-to-end tests for every fuzzer analyzer. Each test
|
||||
// spins up an httptest server that genuinely simulates the target vulnerability
|
||||
// (and a benign variant for false-positive checks), builds a real fuzz
|
||||
// GeneratedRequest from a real query Component, and drives the registered
|
||||
// analyzer's full Analyze() path through a real retryablehttp client.
|
||||
//
|
||||
// This exercises the production code paths end to end: registration, value
|
||||
// mutation, request rebuild + auth-header preservation, transport, and
|
||||
// response analysis — not just the pure detection helpers.
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/component"
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
// register every analyzer under test
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/cmdi"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/cors"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/crlf"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/hostheader"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/lfi"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/redirect"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/sqli"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/ssrf"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/ssti"
|
||||
)
|
||||
|
||||
// newClient builds a retryablehttp client; when follow is false redirects are
|
||||
// not followed (matching nuclei's default), so the Location header is visible.
|
||||
func newClient(follow bool) *retryablehttp.Client {
|
||||
c := retryablehttp.NewClient(retryablehttp.DefaultOptionsSingle)
|
||||
if !follow {
|
||||
c.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// newGeneratedRequest builds a real GeneratedRequest fuzzing the "q" query
|
||||
// parameter of the given server, using the production query Component.
|
||||
func newGeneratedRequest(t *testing.T, serverURL, key, origValue string) fuzz.GeneratedRequest {
|
||||
t.Helper()
|
||||
target := serverURL + "/?" + key + "=" + url.QueryEscape(origValue)
|
||||
raw, err := retryablehttp.NewRequest(http.MethodGet, target, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
q := component.NewQuery()
|
||||
parsed, err := q.Parse(raw)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed, "query component must parse")
|
||||
|
||||
return fuzz.GeneratedRequest{
|
||||
Request: raw,
|
||||
Component: q,
|
||||
Parameter: key,
|
||||
Key: key,
|
||||
Value: origValue,
|
||||
OriginalValue: origValue,
|
||||
}
|
||||
}
|
||||
|
||||
// run fetches the named analyzer and runs Analyze against the given request.
|
||||
func run(t *testing.T, name string, gr fuzz.GeneratedRequest, client *retryablehttp.Client) (bool, string) {
|
||||
t.Helper()
|
||||
a := analyzers.GetAnalyzer(name)
|
||||
require.NotNil(t, a, "analyzer %q must be registered", name)
|
||||
matched, reason, err := a.Analyze(&analyzers.Options{
|
||||
FuzzGenerated: gr,
|
||||
HttpClient: client,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return matched, reason
|
||||
}
|
||||
|
||||
func qparam(r *http.Request) string { return r.URL.Query().Get("q") }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSTI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// reTpl emulates a template engine: it matches an arithmetic expression wrapped
|
||||
// in a delimiter pair (EL ${}, Jinja {{}}, #{}, *{}, Razor @(), ERB <%= %>,
|
||||
// Smarty {}) and replaces the WHOLE delimited expression with its product, the
|
||||
// way a real engine would — so the surrounding sentinels become adjacent to the
|
||||
// computed value.
|
||||
var reTpl = regexp.MustCompile(`(?:\$\{|\{\{|#\{|\*\{|@\(|<%=|\{)\s*(\d+)\s*\*\s*(\d+)\s*(?:\}\}|%>|\}|\))`)
|
||||
|
||||
func TestSSTI_E2E(t *testing.T) {
|
||||
vuln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
out := reTpl.ReplaceAllStringFunc(qparam(r), func(m string) string {
|
||||
sub := reTpl.FindStringSubmatch(m)
|
||||
return fmt.Sprintf("%d", atoi(sub[1])*atoi(sub[2]))
|
||||
})
|
||||
_, _ = fmt.Fprintf(w, "<html><body>%s</body></html>", out)
|
||||
}))
|
||||
defer vuln.Close()
|
||||
|
||||
matched, reason := run(t, "ssti", newGeneratedRequest(t, vuln.URL, "q", "test"), newClient(true))
|
||||
require.True(t, matched, "ssti should be detected on evaluating server")
|
||||
require.Contains(t, reason, "ssti")
|
||||
|
||||
// benign server reflects the payload verbatim (no evaluation)
|
||||
benign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, "<html><body>%s</body></html>", qparam(r))
|
||||
}))
|
||||
defer benign.Close()
|
||||
|
||||
matched, _ = run(t, "ssti", newGeneratedRequest(t, benign.URL, "q", "test"), newClient(true))
|
||||
require.False(t, matched, "ssti must not fire when payload is only reflected")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQL injection (error-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSQLi_E2E(t *testing.T) {
|
||||
vuln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
q := qparam(r)
|
||||
if strings.ContainsAny(q, "'\"`\\") {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = fmt.Fprint(w, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '''")
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "ok")
|
||||
}))
|
||||
defer vuln.Close()
|
||||
|
||||
matched, reason := run(t, "sqli_error", newGeneratedRequest(t, vuln.URL, "q", "test"), newClient(true))
|
||||
require.True(t, matched)
|
||||
require.Contains(t, reason, "MySQL")
|
||||
|
||||
benign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, "results for %q", qparam(r))
|
||||
}))
|
||||
defer benign.Close()
|
||||
matched, _ = run(t, "sqli_error", newGeneratedRequest(t, benign.URL, "q", "test"), newClient(true))
|
||||
require.False(t, matched)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LFI / path traversal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestLFI_E2E(t *testing.T) {
|
||||
vuln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
q := qparam(r)
|
||||
switch {
|
||||
case strings.Contains(q, "etc/passwd"):
|
||||
_, _ = fmt.Fprint(w, "root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n")
|
||||
case strings.Contains(strings.ToLower(q), "win.ini"):
|
||||
_, _ = fmt.Fprint(w, "; for 16-bit app support\r\n[fonts]\r\n")
|
||||
default:
|
||||
_, _ = fmt.Fprint(w, "file not found")
|
||||
}
|
||||
}))
|
||||
defer vuln.Close()
|
||||
|
||||
matched, reason := run(t, "lfi", newGeneratedRequest(t, vuln.URL, "q", "home.txt"), newClient(true))
|
||||
require.True(t, matched)
|
||||
require.Contains(t, reason, "/etc/passwd")
|
||||
|
||||
benign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, "file not found")
|
||||
}))
|
||||
defer benign.Close()
|
||||
matched, _ = run(t, "lfi", newGeneratedRequest(t, benign.URL, "q", "home.txt"), newClient(true))
|
||||
require.False(t, matched)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSRF (cloud metadata)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSSRF_E2E(t *testing.T) {
|
||||
vuln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
q := qparam(r)
|
||||
if strings.Contains(q, "169.254.169.254") || strings.Contains(q, "metadata.google.internal") {
|
||||
_, _ = fmt.Fprint(w, `{"accountId":"123456789012","imageId":"ami-0abcd1234ef567890","instanceId":"i-0abcd1234ef567890","region":"us-east-1"}`)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "fetched: nothing interesting")
|
||||
}))
|
||||
defer vuln.Close()
|
||||
|
||||
matched, reason := run(t, "ssrf", newGeneratedRequest(t, vuln.URL, "q", "https://example.com/avatar.png"), newClient(true))
|
||||
require.True(t, matched)
|
||||
require.Contains(t, reason, "AWS")
|
||||
|
||||
benign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, "fetched ok")
|
||||
}))
|
||||
defer benign.Close()
|
||||
matched, _ = run(t, "ssrf", newGeneratedRequest(t, benign.URL, "q", "https://example.com/a.png"), newClient(true))
|
||||
require.False(t, matched)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OS command injection (in-band)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCMDi_E2E(t *testing.T) {
|
||||
seps := []string{";id", "|id", "||id", "&&id", "&id", "`id`", "$(id)", "\nid"}
|
||||
vuln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
q := qparam(r)
|
||||
for _, s := range seps {
|
||||
if strings.Contains(q, s) {
|
||||
_, _ = fmt.Fprint(w, "uid=0(root) gid=0(root) groups=0(root)")
|
||||
return
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "ping output for %s", q)
|
||||
}))
|
||||
defer vuln.Close()
|
||||
|
||||
matched, reason := run(t, "cmdi", newGeneratedRequest(t, vuln.URL, "q", "127.0.0.1"), newClient(true))
|
||||
require.True(t, matched)
|
||||
require.Contains(t, reason, "cmdi")
|
||||
|
||||
benign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, "ping output for %s", qparam(r))
|
||||
}))
|
||||
defer benign.Close()
|
||||
matched, _ = run(t, "cmdi", newGeneratedRequest(t, benign.URL, "q", "127.0.0.1"), newClient(true))
|
||||
require.False(t, matched)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRLF injection / response splitting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCRLF_E2E(t *testing.T) {
|
||||
vuln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// naive Location built from user input, splitting on newlines (the bug)
|
||||
q := qparam(r)
|
||||
for _, line := range strings.Split(q, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
idx := strings.Index(line, ": ")
|
||||
if idx <= 0 || strings.ContainsAny(line[:idx], " \t") {
|
||||
continue
|
||||
}
|
||||
name, val := line[:idx], line[idx+2:]
|
||||
if strings.EqualFold(name, "Set-Cookie") {
|
||||
w.Header().Add("Set-Cookie", val)
|
||||
} else {
|
||||
w.Header().Set(name, val)
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "ok")
|
||||
}))
|
||||
defer vuln.Close()
|
||||
|
||||
matched, reason := run(t, "crlf", newGeneratedRequest(t, vuln.URL, "q", "/home"), newClient(false))
|
||||
require.True(t, matched)
|
||||
require.Contains(t, reason, "crlf")
|
||||
|
||||
benign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, "ok")
|
||||
}))
|
||||
defer benign.Close()
|
||||
matched, _ = run(t, "crlf", newGeneratedRequest(t, benign.URL, "q", "/home"), newClient(false))
|
||||
require.False(t, matched)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Open redirect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOpenRedirect_E2E(t *testing.T) {
|
||||
vuln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// reflects the user-controlled destination straight into Location
|
||||
w.Header().Set("Location", qparam(r))
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer vuln.Close()
|
||||
|
||||
matched, reason := run(t, "open_redirect", newGeneratedRequest(t, vuln.URL, "q", "/dashboard"), newClient(false))
|
||||
require.True(t, matched)
|
||||
require.Contains(t, reason, "open redirect")
|
||||
|
||||
benign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// always redirects to a fixed, trusted location regardless of input
|
||||
w.Header().Set("Location", "/dashboard")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer benign.Close()
|
||||
matched, _ = run(t, "open_redirect", newGeneratedRequest(t, benign.URL, "q", "/dashboard"), newClient(false))
|
||||
require.False(t, matched)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CORS misconfiguration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCORS_E2E(t *testing.T) {
|
||||
vuln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin) // reflects arbitrary origin
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "ok")
|
||||
}))
|
||||
defer vuln.Close()
|
||||
|
||||
matched, reason := run(t, "cors", newGeneratedRequest(t, vuln.URL, "q", "x"), newClient(true))
|
||||
require.True(t, matched)
|
||||
require.Contains(t, reason, "cors")
|
||||
|
||||
benign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// only allows a single trusted origin
|
||||
w.Header().Set("Access-Control-Allow-Origin", "https://trusted.example.com")
|
||||
_, _ = fmt.Fprint(w, "ok")
|
||||
}))
|
||||
defer benign.Close()
|
||||
matched, _ = run(t, "cors", newGeneratedRequest(t, benign.URL, "q", "x"), newClient(true))
|
||||
require.False(t, matched)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host header injection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHostHeader_E2E(t *testing.T) {
|
||||
vuln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `<a href="https://%s/reset?token=abc">reset</a>`, host)
|
||||
}))
|
||||
defer vuln.Close()
|
||||
|
||||
matched, reason := run(t, "host_header_injection", newGeneratedRequest(t, vuln.URL, "q", "x"), newClient(true))
|
||||
require.True(t, matched)
|
||||
require.Contains(t, reason, "host header injection")
|
||||
|
||||
benign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// always uses a fixed, trusted host
|
||||
_, _ = fmt.Fprint(w, `<a href="https://app.example.com/reset?token=abc">reset</a>`)
|
||||
}))
|
||||
defer benign.Close()
|
||||
matched, _ = run(t, "host_header_injection", newGeneratedRequest(t, benign.URL, "q", "x"), newClient(true))
|
||||
require.False(t, matched)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth-header preservation across follow-up probes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestAuthHeaderPreserved_E2E proves the analyzer's rebuilt probe requests carry
|
||||
// post-parse injected headers (e.g. the Authorization header an authprovider
|
||||
// would add). The server only reveals the SQL error to authenticated requests,
|
||||
// so detection succeeds only if the header survived Rebuild — exactly what the
|
||||
// shared SetValueAndRebuild helper guarantees.
|
||||
func TestAuthHeaderPreserved_E2E(t *testing.T) {
|
||||
vuln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer s3cr3t" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = fmt.Fprint(w, "unauthorized")
|
||||
return
|
||||
}
|
||||
if strings.ContainsAny(qparam(r), "'\"") {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = fmt.Fprint(w, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax")
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "ok")
|
||||
}))
|
||||
defer vuln.Close()
|
||||
|
||||
// authenticated: header injected post-parse, like the authprovider does
|
||||
gr := newGeneratedRequest(t, vuln.URL, "q", "test")
|
||||
gr.Request.Header.Set("Authorization", "Bearer s3cr3t")
|
||||
matched, _ := run(t, "sqli_error", gr, newClient(true))
|
||||
require.True(t, matched, "auth header must be preserved on rebuilt probes for detection")
|
||||
|
||||
// control: no auth header => server always 401 => nothing detectable. Proves
|
||||
// the positive result depended on header preservation, not luck.
|
||||
matched2, _ := run(t, "sqli_error", newGeneratedRequest(t, vuln.URL, "q", "test"), newClient(true))
|
||||
require.False(t, matched2, "without the auth header the server stays benign (401)")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Non-query component (JSON body) end-to-end
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestBodyComponentCMDi_E2E drives an analyzer through a real JSON body
|
||||
// Component instead of the query component, exercising body mutation + rebuild.
|
||||
func TestBodyComponentCMDi_E2E(t *testing.T) {
|
||||
seps := []string{";id", "|id", "&&id", "$(id)", "`id`"}
|
||||
vuln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
data, _ := io.ReadAll(r.Body)
|
||||
s := string(data)
|
||||
for _, sep := range seps {
|
||||
if strings.Contains(s, sep) {
|
||||
_, _ = fmt.Fprint(w, "uid=0(root) gid=0(root) groups=0(root)")
|
||||
return
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "pong")
|
||||
}))
|
||||
defer vuln.Close()
|
||||
|
||||
raw, err := retryablehttp.NewRequest(http.MethodPost, vuln.URL+"/", strings.NewReader(`{"host":"127.0.0.1"}`))
|
||||
require.NoError(t, err)
|
||||
raw.Header.Set("Content-Type", "application/json")
|
||||
|
||||
b := component.NewBody()
|
||||
parsed, err := b.Parse(raw)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed, "body component must parse JSON")
|
||||
|
||||
gr := fuzz.GeneratedRequest{
|
||||
Request: raw,
|
||||
Component: b,
|
||||
Parameter: "host",
|
||||
Key: "host",
|
||||
Value: "127.0.0.1",
|
||||
OriginalValue: "127.0.0.1",
|
||||
}
|
||||
matched, reason := run(t, "cmdi", gr, newClient(true))
|
||||
require.True(t, matched, "cmdi should be detected through a JSON body component")
|
||||
require.Contains(t, reason, "cmdi")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component breadth: the same analyzer must work across all fuzzable component
|
||||
// types (query already covered above; here header, cookie and path).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// sqlErrorIf returns a handler that emits a MySQL error when value contains a
|
||||
// quote, else a benign page. extract pulls the relevant value from the request.
|
||||
func sqlErrorIf(extract func(*http.Request) string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.ContainsAny(extract(r), "'\"") {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = fmt.Fprint(w, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax")
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "ok")
|
||||
}
|
||||
}
|
||||
|
||||
func grFor(t *testing.T, raw *retryablehttp.Request, comp component.Component, key, origValue string) fuzz.GeneratedRequest {
|
||||
t.Helper()
|
||||
parsed, err := comp.Parse(raw)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed, "%s component must parse", comp.Name())
|
||||
return fuzz.GeneratedRequest{
|
||||
Request: raw,
|
||||
Component: comp,
|
||||
Parameter: key,
|
||||
Key: key,
|
||||
Value: origValue,
|
||||
OriginalValue: origValue,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLi_HeaderComponent_E2E(t *testing.T) {
|
||||
srv := httptest.NewServer(sqlErrorIf(func(r *http.Request) string { return r.Header.Get("X-Search") }))
|
||||
defer srv.Close()
|
||||
|
||||
raw, err := retryablehttp.NewRequest(http.MethodGet, srv.URL+"/", nil)
|
||||
require.NoError(t, err)
|
||||
raw.Header.Set("X-Search", "term")
|
||||
|
||||
matched, _ := run(t, "sqli_error", grFor(t, raw, component.NewHeader(), "X-Search", "term"), newClient(true))
|
||||
require.True(t, matched, "sqli must be detected when fuzzing a header component")
|
||||
}
|
||||
|
||||
func TestSSTI_CookieComponent_E2E(t *testing.T) {
|
||||
// SQL quote payloads can't survive in a cookie value (Go strips RFC6265
|
||||
// forbidden bytes), so we demonstrate cookie-component fuzzing with SSTI,
|
||||
// whose payload characters ({ } * $) are cookie-legal.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie("lang")
|
||||
val := "en"
|
||||
if err == nil {
|
||||
val = c.Value
|
||||
}
|
||||
out := reTpl.ReplaceAllStringFunc(val, func(m string) string {
|
||||
sub := reTpl.FindStringSubmatch(m)
|
||||
return fmt.Sprintf("%d", atoi(sub[1])*atoi(sub[2]))
|
||||
})
|
||||
_, _ = fmt.Fprintf(w, "<html><body>lang=%s</body></html>", out)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
raw, err := retryablehttp.NewRequest(http.MethodGet, srv.URL+"/", nil)
|
||||
require.NoError(t, err)
|
||||
raw.Header.Set("Cookie", "lang=en")
|
||||
|
||||
matched, _ := run(t, "ssti", grFor(t, raw, component.NewCookie(), "lang", "en"), newClient(true))
|
||||
require.True(t, matched, "ssti must be detected when fuzzing a cookie component")
|
||||
}
|
||||
|
||||
func TestSQLi_PathComponent_E2E(t *testing.T) {
|
||||
// any quote anywhere in the decoded path triggers the error
|
||||
srv := httptest.NewServer(sqlErrorIf(func(r *http.Request) string {
|
||||
dec, _ := url.PathUnescape(r.URL.EscapedPath())
|
||||
return dec
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
raw, err := retryablehttp.NewRequest(http.MethodGet, srv.URL+"/user/75/profile", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
path := component.NewPath()
|
||||
parsed, err := path.Parse(raw)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed)
|
||||
|
||||
// locate the path segment whose value is "75"
|
||||
var key string
|
||||
require.NoError(t, path.Iterate(func(k string, v interface{}) error {
|
||||
if fmt.Sprint(v) == "75" {
|
||||
key = k
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
require.NotEmpty(t, key, "expected to find the id path segment")
|
||||
|
||||
gr := fuzz.GeneratedRequest{Request: raw, Component: path, Parameter: key, Key: key, Value: "75", OriginalValue: "75"}
|
||||
matched, _ := run(t, "sqli_error", gr, newClient(true))
|
||||
require.True(t, matched, "sqli must be detected when fuzzing a path component")
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// This file drives the *real* nuclei DAST/fuzzing pipeline end to end, not just
|
||||
// the analyzer's Analyze() helper. For each analyzer it compiles a real http
|
||||
// Request template carrying a `fuzzing:` rule + an `analyzer:` + a DSL matcher
|
||||
// (`analyzer == true`), then runs Request.ExecuteWithResults against a genuinely
|
||||
// vulnerable in-repo application (newDastApp). This exercises the production
|
||||
// chain: rule compilation -> component discovery + mutation -> request transport
|
||||
// -> analyzer invocation (request.go) -> operator/matcher evaluation -> finding.
|
||||
//
|
||||
// The app is hermetic (httptest, no Docker/network), so it is deterministic and
|
||||
// CI-safe while still reproducing real DAST conditions across all component
|
||||
// positions (query, path, cookie, body, header).
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/operators"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/operators/matchers"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
nucleihttp "github.com/projectdiscovery/nuclei/v3/pkg/protocols/http"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newDastApp is a single hermetic application that is genuinely vulnerable on
|
||||
// every route, mirroring the behaviors the analyzers detect. Each route reads a
|
||||
// user-controlled value from a different request position.
|
||||
func newDastApp() *httptest.Server {
|
||||
cmdiSeps := []string{";id", "|id", "||id", "&&id", "&id", "`id`", "$(id)", "\nid"}
|
||||
containsCmdi := func(s string) bool {
|
||||
for _, sep := range cmdiSeps {
|
||||
if strings.Contains(s, sep) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
sqlErr := func(w http.ResponseWriter) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = fmt.Fprint(w, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '''")
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// SQLi in query param
|
||||
mux.HandleFunc("/sqli", func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.ContainsAny(r.URL.Query().Get("q"), "'\"`\\") {
|
||||
sqlErr(w)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "ok")
|
||||
})
|
||||
|
||||
// SQLi in path: /user/<id>/profile
|
||||
mux.HandleFunc("/user/", func(w http.ResponseWriter, r *http.Request) {
|
||||
dec, _ := url.PathUnescape(r.URL.EscapedPath())
|
||||
if strings.ContainsAny(dec, "'\"") {
|
||||
sqlErr(w)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "ok")
|
||||
})
|
||||
|
||||
// SQLi in a JSON body parameter (name) on a POST route. We inspect the
|
||||
// decoded field value (not the structural JSON quotes) so the baseline
|
||||
// request stays benign and only an injected quote triggers the error.
|
||||
mux.HandleFunc("/account", func(w http.ResponseWriter, r *http.Request) {
|
||||
var m map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&m); err == nil {
|
||||
if name, ok := m["name"].(string); ok && strings.ContainsAny(name, "'\"") {
|
||||
sqlErr(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "ok")
|
||||
})
|
||||
|
||||
// SQLi in cookie value (lang)
|
||||
mux.HandleFunc("/posts", func(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie("lang"); err == nil && strings.ContainsAny(c.Value, "'\"") {
|
||||
sqlErr(w)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "ok")
|
||||
})
|
||||
|
||||
// SSTI in query param (real template-engine-like evaluation)
|
||||
mux.HandleFunc("/render", func(w http.ResponseWriter, r *http.Request) {
|
||||
out := reTpl.ReplaceAllStringFunc(r.URL.Query().Get("q"), func(m string) string {
|
||||
sub := reTpl.FindStringSubmatch(m)
|
||||
return fmt.Sprintf("%d", atoi(sub[1])*atoi(sub[2]))
|
||||
})
|
||||
_, _ = fmt.Fprintf(w, "<html><body>%s</body></html>", out)
|
||||
})
|
||||
|
||||
// LFI / path traversal in query param
|
||||
mux.HandleFunc("/file", func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
switch {
|
||||
case strings.Contains(q, "etc/passwd"):
|
||||
_, _ = fmt.Fprint(w, "root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n")
|
||||
case strings.Contains(strings.ToLower(q), "win.ini"):
|
||||
_, _ = fmt.Fprint(w, "; for 16-bit app support\r\n[fonts]\r\n")
|
||||
default:
|
||||
_, _ = fmt.Fprint(w, "file not found")
|
||||
}
|
||||
})
|
||||
|
||||
// CMDi in query param
|
||||
mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
if containsCmdi(r.URL.Query().Get("q")) {
|
||||
_, _ = fmt.Fprint(w, "uid=0(root) gid=0(root) groups=0(root)")
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "ping output for %s", r.URL.Query().Get("q"))
|
||||
})
|
||||
|
||||
// SSRF in query param (cloud metadata)
|
||||
mux.HandleFunc("/fetch", func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
if strings.Contains(q, "169.254.169.254") || strings.Contains(q, "metadata.google.internal") {
|
||||
_, _ = fmt.Fprint(w, `{"accountId":"123456789012","imageId":"ami-0abcd1234ef567890","instanceId":"i-0abcd1234ef567890","region":"us-east-1"}`)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "fetched: nothing interesting")
|
||||
})
|
||||
|
||||
// Open redirect: reflects destination into Location
|
||||
mux.HandleFunc("/go", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Location", r.URL.Query().Get("q"))
|
||||
w.WriteHeader(http.StatusFound)
|
||||
})
|
||||
|
||||
// CRLF response splitting: naive header building from input
|
||||
mux.HandleFunc("/crlf", func(w http.ResponseWriter, r *http.Request) {
|
||||
for _, line := range strings.Split(r.URL.Query().Get("q"), "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
idx := strings.Index(line, ": ")
|
||||
if idx <= 0 || strings.ContainsAny(line[:idx], " \t") {
|
||||
continue
|
||||
}
|
||||
name, val := line[:idx], line[idx+2:]
|
||||
if strings.EqualFold(name, "Set-Cookie") {
|
||||
w.Header().Add("Set-Cookie", val)
|
||||
} else {
|
||||
w.Header().Set(name, val)
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "ok")
|
||||
})
|
||||
|
||||
// CORS misconfiguration: reflects arbitrary Origin with credentials
|
||||
mux.HandleFunc("/cors", func(w http.ResponseWriter, r *http.Request) {
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "ok")
|
||||
})
|
||||
|
||||
// Host header injection: reflects X-Forwarded-Host into a link
|
||||
mux.HandleFunc("/host", func(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `<a href="https://%s/reset?token=abc">reset</a>`, host)
|
||||
})
|
||||
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
// pipelineResult captures what the real engine surfaced for a target.
|
||||
type pipelineResult struct {
|
||||
matched bool
|
||||
analyzerFlag bool
|
||||
analyzerDetails string
|
||||
}
|
||||
|
||||
// runPipeline compiles and executes a real fuzzing+analyzer template against the
|
||||
// given absolute target URL (which already contains the parameters to fuzz),
|
||||
// returning whether the engine produced a finding driven by the analyzer.
|
||||
func runPipeline(t *testing.T, opts *types.Options, analyzer, part string, targetURL string, keys []string) pipelineResult {
|
||||
t.Helper()
|
||||
return runPipelineCtx(t, opts, analyzer, part, keys, contextargs.NewWithInput(context.Background(), targetURL))
|
||||
}
|
||||
|
||||
// runPipelineCtx is the shared core: it accepts a fully built input context
|
||||
// (URL-based or ReqResp-based, e.g. from a katana crawl) and runs the real
|
||||
// fuzzing+analyzer template against it.
|
||||
func runPipelineCtx(t *testing.T, opts *types.Options, analyzer, part string, keys []string, ctxArgs *contextargs.Context) pipelineResult {
|
||||
t.Helper()
|
||||
|
||||
templateID := "dast-" + analyzer + "-" + part
|
||||
rule := &fuzz.Rule{
|
||||
Type: "replace",
|
||||
Part: part,
|
||||
Mode: "single",
|
||||
Fuzz: fuzz.SliceOrMapSlice{Value: []string{"1337"}},
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
rule.Keys = keys
|
||||
}
|
||||
req := &nucleihttp.Request{
|
||||
ID: templateID,
|
||||
Method: nucleihttp.HTTPMethodTypeHolder{MethodType: nucleihttp.HTTPGet},
|
||||
Path: []string{"{{BaseURL}}"},
|
||||
Fuzzing: []*fuzz.Rule{rule},
|
||||
Analyzer: &analyzers.AnalyzerTemplate{
|
||||
Name: analyzer,
|
||||
},
|
||||
Operators: operators.Operators{
|
||||
Matchers: []*matchers.Matcher{{
|
||||
Type: matchers.MatcherTypeHolder{MatcherType: matchers.DSLMatcher},
|
||||
DSL: []string{"analyzer == true"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
execOpts := testutils.NewMockExecuterOptions(opts, &testutils.TemplateInfo{
|
||||
ID: templateID,
|
||||
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.High}, Name: "dast-" + analyzer},
|
||||
})
|
||||
require.NoError(t, req.Compile(execOpts), "compile %s template", analyzer)
|
||||
|
||||
var res pipelineResult
|
||||
metadata := make(output.InternalEvent)
|
||||
previous := make(output.InternalEvent)
|
||||
err := req.ExecuteWithResults(ctxArgs, metadata, previous, func(event *output.InternalWrappedEvent) {
|
||||
if event.OperatorsResult != nil && event.OperatorsResult.Matched {
|
||||
res.matched = true
|
||||
}
|
||||
if event.InternalEvent != nil {
|
||||
if v, ok := event.InternalEvent["analyzer"]; ok {
|
||||
if b, ok := v.(bool); ok && b {
|
||||
res.analyzerFlag = true
|
||||
}
|
||||
}
|
||||
if d, ok := event.InternalEvent["analyzer_details"]; ok {
|
||||
res.analyzerDetails = fmt.Sprint(d)
|
||||
}
|
||||
}
|
||||
})
|
||||
require.NoError(t, err, "execute %s template", analyzer)
|
||||
return res
|
||||
}
|
||||
|
||||
func TestDastPipeline_AllAnalyzers_E2E(t *testing.T) {
|
||||
options := testutils.DefaultOptions
|
||||
testutils.Init(options)
|
||||
|
||||
app := newDastApp()
|
||||
defer app.Close()
|
||||
|
||||
// route + part + (optional) keys per analyzer, with parameters embedded in URL
|
||||
cases := []struct {
|
||||
name string
|
||||
analyzer string
|
||||
part string
|
||||
target string
|
||||
keys []string
|
||||
}{
|
||||
{"sqli-query", "sqli_error", "query", app.URL + "/sqli?q=seed", []string{"q"}},
|
||||
{"sqli-path", "sqli_error", "path", app.URL + "/user/75/profile", nil},
|
||||
{"ssti-query", "ssti", "query", app.URL + "/render?q=seed", []string{"q"}},
|
||||
{"lfi-query", "lfi", "query", app.URL + "/file?q=home.txt", []string{"q"}},
|
||||
{"cmdi-query", "cmdi", "query", app.URL + "/ping?q=127.0.0.1", []string{"q"}},
|
||||
{"ssrf-query", "ssrf", "query", app.URL + "/fetch?q=" + url.QueryEscape("https://example.com/a.png"), []string{"q"}},
|
||||
{"open_redirect-query", "open_redirect", "query", app.URL + "/go?q=" + url.QueryEscape("/dashboard"), []string{"q"}},
|
||||
{"crlf-query", "crlf", "query", app.URL + "/crlf?q=" + url.QueryEscape("/home"), []string{"q"}},
|
||||
{"cors-query", "cors", "query", app.URL + "/cors?q=seed", []string{"q"}},
|
||||
{"host_header_injection-query", "host_header_injection", "query", app.URL + "/host?q=seed", []string{"q"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res := runPipeline(t, options, tc.analyzer, tc.part, tc.target, tc.keys)
|
||||
require.True(t, res.analyzerFlag, "analyzer %s must flag the response as vulnerable in the real pipeline", tc.analyzer)
|
||||
require.True(t, res.matched, "the dsl matcher (analyzer == true) must produce a finding for %s", tc.analyzer)
|
||||
require.NotEmpty(t, res.analyzerDetails, "analyzer_details must be populated for %s", tc.analyzer)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDastPipeline_NoFalsePositive_E2E proves the full pipeline does not raise a
|
||||
// finding against a benign endpoint that merely reflects input.
|
||||
func TestDastPipeline_NoFalsePositive_E2E(t *testing.T) {
|
||||
options := testutils.DefaultOptions
|
||||
testutils.Init(options)
|
||||
|
||||
benign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, "results for %q", r.URL.Query().Get("q"))
|
||||
}))
|
||||
defer benign.Close()
|
||||
|
||||
res := runPipeline(t, options, "sqli_error", "query", benign.URL+"/?q=seed", []string{"q"})
|
||||
require.False(t, res.matched, "benign reflector must not yield a sqli finding")
|
||||
require.False(t, res.analyzerFlag, "analyzer must not flag a benign reflector")
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Package hostheader implements a host-header injection analyzer for the
|
||||
// fuzzer.
|
||||
//
|
||||
// It replays the request with a random canary host placed in the Host header
|
||||
// and in the common host-override headers (X-Forwarded-Host, etc.) and flags an
|
||||
// issue when that canary host is reflected back into the response — in the
|
||||
// Location header or as the host of an absolute URL in the body. This is the
|
||||
// primitive behind password-reset poisoning and web-cache poisoning. The canary
|
||||
// host is random, so any reflection is unambiguous and needs no baseline.
|
||||
package hostheader
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
)
|
||||
|
||||
const analyzerName = "host_header_injection"
|
||||
|
||||
// Analyzer implements the analyzers.Analyzer interface for host-header injection.
|
||||
type Analyzer struct{}
|
||||
|
||||
var _ analyzers.Analyzer = &Analyzer{}
|
||||
|
||||
func init() {
|
||||
analyzers.RegisterAnalyzer(analyzerName, &Analyzer{})
|
||||
}
|
||||
|
||||
func (a *Analyzer) Name() string {
|
||||
return analyzerName
|
||||
}
|
||||
|
||||
func (a *Analyzer) ApplyInitialTransformation(data string, params map[string]interface{}) string {
|
||||
return analyzers.ApplyPayloadTransformations(data)
|
||||
}
|
||||
|
||||
// overrideHeaders are the headers (besides Host itself) commonly trusted by
|
||||
// applications/proxies to derive the effective host.
|
||||
var overrideHeaders = []string{
|
||||
"X-Forwarded-Host",
|
||||
"X-Host",
|
||||
"X-Forwarded-Server",
|
||||
"X-HTTP-Host-Override",
|
||||
"Forwarded",
|
||||
}
|
||||
|
||||
var absoluteURLPattern = regexp.MustCompile(`(?i)(?:https?:)?//[^\s"'<>]+`)
|
||||
|
||||
// ReflectsCanary reports whether the canary host appears as the host of the
|
||||
// Location header or of an absolute URL in the body. It is exported and pure so
|
||||
// it can be unit-tested without a network.
|
||||
func ReflectsCanary(body, locationHeader, canaryHost string) bool {
|
||||
if canaryHost == "" {
|
||||
return false
|
||||
}
|
||||
if locationHeader != "" {
|
||||
if u, err := url.Parse(locationHeader); err == nil && strings.EqualFold(u.Hostname(), canaryHost) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if body == "" {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range absoluteURLPattern.FindAllString(body, -1) {
|
||||
if u, err := url.Parse(candidate); err == nil && strings.EqualFold(u.Hostname(), canaryHost) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Analyze replays the request with canary host overrides and reports reflection.
|
||||
func (a *Analyzer) Analyze(options *analyzers.Options) (bool, string, error) {
|
||||
if options == nil || options.FuzzGenerated.Component == nil || options.HttpClient == nil {
|
||||
return false, "", nil
|
||||
}
|
||||
gr := options.FuzzGenerated
|
||||
canary := randomCanaryHost()
|
||||
|
||||
defer func() {
|
||||
_ = gr.Component.SetValue(gr.Key, gr.OriginalValue)
|
||||
}()
|
||||
|
||||
// Probe each override header individually, plus a direct Host override.
|
||||
probes := append([]string{"Host"}, overrideHeaders...)
|
||||
for _, header := range probes {
|
||||
rebuilt, err := analyzers.SetValueAndRebuild(gr, gr.OriginalValue)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if header == "Host" {
|
||||
rebuilt.Host = canary
|
||||
} else {
|
||||
rebuilt.Header.Set(header, overrideHeaderValue(header, canary))
|
||||
}
|
||||
|
||||
options.RateLimit()
|
||||
resp, body, err := analyzers.DoAndReadBody(options.HttpClient, rebuilt)
|
||||
if resp == nil {
|
||||
continue
|
||||
}
|
||||
if err != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
location := resp.Header.Get("Location")
|
||||
if ReflectsCanary(body, location, canary) {
|
||||
return true, "host header injection: canary host reflected via " + header + " header", nil
|
||||
}
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
func overrideHeaderValue(header, canary string) string {
|
||||
if header == "Forwarded" {
|
||||
return "host=" + canary
|
||||
}
|
||||
return canary
|
||||
}
|
||||
|
||||
func randomCanaryHost() string {
|
||||
return analyzers.RandToken(12) + ".hostcanary.example"
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package hostheader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/component"
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAnalyzerRegistered(t *testing.T) {
|
||||
require.NotNil(t, analyzers.GetAnalyzer("host_header_injection"))
|
||||
require.Equal(t, "host_header_injection", (&Analyzer{}).Name())
|
||||
}
|
||||
|
||||
func TestReflectsCanary(t *testing.T) {
|
||||
const canary = "abc123.hostcanary.example"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
location string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "canary host in Location header",
|
||||
location: "https://abc123.hostcanary.example/reset?token=x",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "canary in absolute URL in body",
|
||||
body: `<a href="https://abc123.hostcanary.example/reset">reset</a>`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "canary in scheme-relative URL in body",
|
||||
body: `<script src="//abc123.hostcanary.example/app.js"></script>`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "canary after userinfo @ in body",
|
||||
body: `redirect to http://[email protected]/`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "case-insensitive Location match",
|
||||
location: "https://ABC123.HostCanary.Example/",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "canary as bare substring is not a hit",
|
||||
body: `comment: abc123.hostcanary.example was mentioned in text`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "canary as hostname prefix is not a hit",
|
||||
body: `<a href="https://abc123.hostcanary.example.attacker.example/">reset</a>`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "canary as hostname suffix is not a hit",
|
||||
body: `<a href="https://prefix.abc123.hostcanary.example/">reset</a>`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unrelated location",
|
||||
location: "https://legit.example.com/home",
|
||||
body: `<html>nothing</html>`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty inputs",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.want, ReflectsCanary(tc.body, tc.location, canary))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReflectsCanaryEmptyCanary(t *testing.T) {
|
||||
require.False(t, ReflectsCanary("//anything/", "https://anything/", ""))
|
||||
}
|
||||
|
||||
func TestRandomCanaryHost(t *testing.T) {
|
||||
h1 := randomCanaryHost()
|
||||
h2 := randomCanaryHost()
|
||||
require.True(t, strings.HasSuffix(h1, ".hostcanary.example"))
|
||||
require.NotEqual(t, h1, h2)
|
||||
}
|
||||
|
||||
func TestOverrideHeadersNonEmpty(t *testing.T) {
|
||||
require.NotEmpty(t, overrideHeaders)
|
||||
}
|
||||
|
||||
func TestForwardedOverrideUsesHostParameter(t *testing.T) {
|
||||
const canary = "abc123.hostcanary.example"
|
||||
require.Equal(t, "host="+canary, overrideHeaderValue("Forwarded", canary))
|
||||
require.Equal(t, canary, overrideHeaderValue("X-Forwarded-Host", canary))
|
||||
}
|
||||
|
||||
func TestAnalyzeUsesRedirectResponseReturnedWithError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Location", "https://"+r.Host+"/reset")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
raw, err := retryablehttp.NewRequest(http.MethodGet, srv.URL+"/?q=x", nil)
|
||||
require.NoError(t, err)
|
||||
query := component.NewQuery()
|
||||
parsed, err := query.Parse(raw)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed)
|
||||
|
||||
client := retryablehttp.NewClient(retryablehttp.DefaultOptionsSingle)
|
||||
client.HTTPClient.CheckRedirect = func(*http.Request, []*http.Request) error {
|
||||
return errors.New("stop redirect")
|
||||
}
|
||||
client.CheckRetry = func(context.Context, *http.Response, error) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
matched, _, err := (&Analyzer{}).Analyze(&analyzers.Options{
|
||||
FuzzGenerated: fuzz.GeneratedRequest{
|
||||
Request: raw,
|
||||
Component: query,
|
||||
Key: "q",
|
||||
OriginalValue: "x",
|
||||
},
|
||||
HttpClient: client,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, matched)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package lfi implements a local file inclusion / path traversal analyzer for
|
||||
// the fuzzer.
|
||||
//
|
||||
// It injects a set of traversal payloads targeting well-known files
|
||||
// (/etc/passwd on *nix, win.ini on Windows) and confirms a hit only when the
|
||||
// response contains a content signature unique to those files. Matching the
|
||||
// actual file contents (e.g. the root: line of /etc/passwd) is a strong,
|
||||
// low-false-positive signal of arbitrary file read.
|
||||
package lfi
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
)
|
||||
|
||||
const analyzerName = "lfi"
|
||||
|
||||
// Analyzer implements the analyzers.Analyzer interface for LFI/path traversal.
|
||||
type Analyzer struct{}
|
||||
|
||||
var _ analyzers.Analyzer = &Analyzer{}
|
||||
|
||||
func init() {
|
||||
analyzers.RegisterAnalyzer(analyzerName, &Analyzer{})
|
||||
}
|
||||
|
||||
func (a *Analyzer) Name() string {
|
||||
return analyzerName
|
||||
}
|
||||
|
||||
func (a *Analyzer) ApplyInitialTransformation(data string, params map[string]interface{}) string {
|
||||
return analyzers.ApplyPayloadTransformations(data)
|
||||
}
|
||||
|
||||
// traversalPayloads target /etc/passwd and Windows win.ini using a mix of plain
|
||||
// traversal, nested ("....//") bypasses, URL-encoding and absolute paths.
|
||||
var traversalPayloads = []string{
|
||||
"../../../../../../../../etc/passwd",
|
||||
"....//....//....//....//....//etc/passwd",
|
||||
"..%2f..%2f..%2f..%2f..%2f..%2fetc%2fpasswd",
|
||||
"%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd",
|
||||
"/etc/passwd",
|
||||
"file:///etc/passwd",
|
||||
"..\\..\\..\\..\\..\\..\\windows\\win.ini",
|
||||
"..%5c..%5c..%5c..%5cwindows%5cwin.ini",
|
||||
"C:\\windows\\win.ini",
|
||||
}
|
||||
|
||||
// fileSignature pairs a target file with a regex that matches content unique
|
||||
// to that file.
|
||||
type fileSignature struct {
|
||||
File string
|
||||
Regex *regexp.Regexp
|
||||
}
|
||||
|
||||
var fileSignatures = []fileSignature{
|
||||
// /etc/passwd: entries like "root:x:0:0:root:/root:/bin/bash". We require a
|
||||
// well-known system account name (root, daemon, nobody, ...) followed by the
|
||||
// full 7-field name:passwd:uid:gid:gecos:home:shell structure. Requiring a
|
||||
// known account — rather than any token — keeps false positives low against
|
||||
// arbitrary colon-delimited content (config/CSV dumps) while staying
|
||||
// unanchored so it still matches when the file is wrapped (e.g. in <pre>).
|
||||
{"/etc/passwd", regexp.MustCompile(`(?i)\b(root|daemon|bin|sys|sync|games|man|lp|mail|news|nobody|www-data|sshd|ftp):[^:\r\n]*:\d+:\d+:[^:\r\n]*:[^:\r\n]*:[^:\r\n]*`)},
|
||||
// Windows win.ini: the [fonts]/[extensions] sections
|
||||
{"win.ini", regexp.MustCompile(`(?i)\[(fonts|extensions|mci extensions|files)\]`)},
|
||||
{"win.ini", regexp.MustCompile(`(?i)for 16-bit app support`)},
|
||||
}
|
||||
|
||||
// MatchFileSignature returns the target file whose content signature appears in
|
||||
// body, if any. It is exported and pure for unit-testing without a network.
|
||||
func MatchFileSignature(body string) (string, bool) {
|
||||
if body == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, sig := range fileSignatures {
|
||||
if sig.Regex.MatchString(body) {
|
||||
return sig.File, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Analyze injects traversal payloads and reports LFI when a target file's
|
||||
// content signature appears in a response but not in the baseline.
|
||||
func (a *Analyzer) Analyze(options *analyzers.Options) (bool, string, error) {
|
||||
if options == nil || options.FuzzGenerated.Component == nil || options.HttpClient == nil {
|
||||
return false, "", nil
|
||||
}
|
||||
gr := options.FuzzGenerated
|
||||
|
||||
defer func() {
|
||||
_ = gr.Component.SetValue(gr.Key, gr.OriginalValue)
|
||||
}()
|
||||
|
||||
// Baseline: if the file signature is already present with the original
|
||||
// value, a later match proves nothing, so bail to avoid false positives.
|
||||
baselineBody, err := analyzers.SendValueAndReadBody(options, gr.OriginalValue)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if _, matched := MatchFileSignature(baselineBody); matched {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
for _, payload := range traversalPayloads {
|
||||
body, err := analyzers.SendValueAndReadBody(options, payload)
|
||||
if err != nil {
|
||||
// A single failed probe (timeout, reset) must not abort the whole
|
||||
// analysis; the remaining payloads may still surface the bug.
|
||||
continue
|
||||
}
|
||||
if file, matched := MatchFileSignature(body); matched {
|
||||
return true, "lfi: contents of " + file + " disclosed via payload " + strconv.Quote(payload), nil
|
||||
}
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package lfi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAnalyzerRegistered(t *testing.T) {
|
||||
require.NotNil(t, analyzers.GetAnalyzer("lfi"))
|
||||
require.Equal(t, "lfi", (&Analyzer{}).Name())
|
||||
}
|
||||
|
||||
func TestMatchFileSignature(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantFile string
|
||||
wantHit bool
|
||||
}{
|
||||
{
|
||||
name: "etc passwd disclosed",
|
||||
body: "root:x:0:0:root:/root:/bin/bash\n" +
|
||||
"daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n" +
|
||||
"bin:x:2:2:bin:/bin:/usr/sbin/nologin\n",
|
||||
wantFile: "/etc/passwd",
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "etc passwd single nologin line",
|
||||
body: `<pre>nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin</pre>`,
|
||||
wantFile: "/etc/passwd",
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "windows win.ini fonts section",
|
||||
body: "; for 16-bit app support\r\n[fonts]\r\n[extensions]\r\n",
|
||||
wantFile: "win.ini",
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "benign html no file",
|
||||
body: `<html><body>Hello world, nothing to see here.</body></html>`,
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "colon separated text that is not passwd",
|
||||
body: `time: 10:30:00 and ratio 4:3:2 here`,
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: ``,
|
||||
wantHit: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
file, hit := MatchFileSignature(tc.body)
|
||||
require.Equal(t, tc.wantHit, hit)
|
||||
if tc.wantHit {
|
||||
require.Equal(t, tc.wantFile, file)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraversalPayloadsNonEmpty(t *testing.T) {
|
||||
require.NotEmpty(t, traversalPayloads)
|
||||
for _, p := range traversalPayloads {
|
||||
require.NotEmpty(t, p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Package redirect implements an open-redirect analyzer for the fuzzer.
|
||||
//
|
||||
// It injects a unique, non-resolvable canary host using several open-redirect
|
||||
// payload styles (absolute, scheme-relative, backslash tricks) and confirms a
|
||||
// hit when the response redirects to that canary host — either via the Location
|
||||
// header (redirects disabled, the nuclei default) or via the final request URL
|
||||
// (redirects followed). The canary host is random and unresolvable, so a match
|
||||
// is unambiguous and no real third-party traffic leaves the scanner.
|
||||
package redirect
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
)
|
||||
|
||||
const analyzerName = "open_redirect"
|
||||
|
||||
// Analyzer implements the analyzers.Analyzer interface for open redirects.
|
||||
type Analyzer struct{}
|
||||
|
||||
var _ analyzers.Analyzer = &Analyzer{}
|
||||
|
||||
func init() {
|
||||
analyzers.RegisterAnalyzer(analyzerName, &Analyzer{})
|
||||
}
|
||||
|
||||
func (a *Analyzer) Name() string {
|
||||
return analyzerName
|
||||
}
|
||||
|
||||
func (a *Analyzer) ApplyInitialTransformation(data string, params map[string]interface{}) string {
|
||||
return analyzers.ApplyPayloadTransformations(data)
|
||||
}
|
||||
|
||||
// GenerateProbes builds open-redirect payloads pointing at canaryHost. It is
|
||||
// exported and pure for unit-testing without a network.
|
||||
func GenerateProbes(canaryHost string) []string {
|
||||
return []string{
|
||||
"https://" + canaryHost + "/",
|
||||
"//" + canaryHost + "/",
|
||||
"https:////" + canaryHost + "/",
|
||||
"/\\" + canaryHost + "/",
|
||||
"https:/" + canaryHost + "/",
|
||||
"https://" + canaryHost + "%2f%2e%2e",
|
||||
}
|
||||
}
|
||||
|
||||
// RedirectsToCanary reports whether either the Location header or the final
|
||||
// request host points at canaryHost. Backslashes are normalized to forward
|
||||
// slashes because browsers treat "/\host" as "//host". It is exported and pure
|
||||
// for testability.
|
||||
func RedirectsToCanary(locationHeader, finalHost, canaryHost string) bool {
|
||||
if canaryHost == "" {
|
||||
return false
|
||||
}
|
||||
if finalHost != "" && strings.EqualFold(finalHost, canaryHost) {
|
||||
return true
|
||||
}
|
||||
if locationHeader == "" {
|
||||
return false
|
||||
}
|
||||
normalized := strings.ReplaceAll(locationHeader, "\\", "/")
|
||||
if u, err := url.Parse(normalized); err == nil {
|
||||
if strings.EqualFold(u.Hostname(), canaryHost) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Analyze injects open-redirect payloads and reports whether the target
|
||||
// redirects to the canary host.
|
||||
func (a *Analyzer) Analyze(options *analyzers.Options) (bool, string, error) {
|
||||
if options == nil || options.FuzzGenerated.Component == nil || options.HttpClient == nil {
|
||||
return false, "", nil
|
||||
}
|
||||
gr := options.FuzzGenerated
|
||||
canaryHost := randomCanaryHost()
|
||||
|
||||
defer func() {
|
||||
_ = gr.Component.SetValue(gr.Key, gr.OriginalValue)
|
||||
}()
|
||||
|
||||
for _, payload := range GenerateProbes(canaryHost) {
|
||||
rebuilt, err := analyzers.SetValueAndRebuild(gr, payload)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
options.RateLimit()
|
||||
resp, err := options.HttpClient.Do(rebuilt)
|
||||
if resp == nil {
|
||||
continue
|
||||
}
|
||||
location := resp.Header.Get("Location")
|
||||
finalHost := ""
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
finalHost = resp.Request.URL.Hostname()
|
||||
}
|
||||
if resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
if RedirectsToCanary(location, finalHost, canaryHost) {
|
||||
return true, "open redirect: target redirected to canary host via payload " + payload, nil
|
||||
}
|
||||
if err != nil {
|
||||
// A transport error is expected when redirects are followed to the
|
||||
// unresolvable canary host. If the retained response did not prove
|
||||
// the redirect, continue with the remaining probes.
|
||||
continue
|
||||
}
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
// randomCanaryHost returns a random, syntactically valid but non-resolvable
|
||||
// host under a reserved-looking label so it never collides with real hosts and
|
||||
// never generates real third-party traffic.
|
||||
func randomCanaryHost() string {
|
||||
return analyzers.RandToken(12) + ".nucleicanary.invalid"
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package redirect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/component"
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAnalyzerRegistered(t *testing.T) {
|
||||
require.NotNil(t, analyzers.GetAnalyzer("open_redirect"))
|
||||
require.Equal(t, "open_redirect", (&Analyzer{}).Name())
|
||||
}
|
||||
|
||||
func TestGenerateProbes(t *testing.T) {
|
||||
const canary = "abc123.nucleicanary.invalid"
|
||||
probes := GenerateProbes(canary)
|
||||
require.NotEmpty(t, probes)
|
||||
for _, p := range probes {
|
||||
require.Contains(t, p, canary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectsToCanary(t *testing.T) {
|
||||
const canary = "abc123.nucleicanary.invalid"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
location string
|
||||
finalH string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "absolute Location to canary",
|
||||
location: "https://abc123.nucleicanary.invalid/",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "scheme-relative Location to canary",
|
||||
location: "//abc123.nucleicanary.invalid/path",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "backslash trick normalized to canary",
|
||||
location: "/\\abc123.nucleicanary.invalid/",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "case-insensitive host match",
|
||||
location: "https://ABC123.NucleiCanary.Invalid/",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "final URL host is canary (followed redirect)",
|
||||
finalH: "abc123.nucleicanary.invalid",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "redirect to legitimate host is not a hit",
|
||||
location: "https://accounts.example.com/login",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "relative path redirect is not a hit",
|
||||
location: "/dashboard",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "canary only as path segment is not a hit",
|
||||
location: "https://example.com/abc123.nucleicanary.invalid",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty inputs",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.want, RedirectsToCanary(tc.location, tc.finalH, canary))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomCanaryHost(t *testing.T) {
|
||||
h1 := randomCanaryHost()
|
||||
h2 := randomCanaryHost()
|
||||
require.True(t, strings.HasSuffix(h1, ".nucleicanary.invalid"))
|
||||
require.NotEqual(t, h1, h2)
|
||||
}
|
||||
|
||||
func TestRedirectsToCanaryEmptyCanary(t *testing.T) {
|
||||
require.False(t, RedirectsToCanary("https://anything/", "anything", ""))
|
||||
}
|
||||
|
||||
func TestAnalyzeUsesRedirectResponseReturnedWithError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Location", r.URL.Query().Get("q"))
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
raw, err := retryablehttp.NewRequest(http.MethodGet, srv.URL+"/?q=%2Fdashboard", nil)
|
||||
require.NoError(t, err)
|
||||
query := component.NewQuery()
|
||||
parsed, err := query.Parse(raw)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed)
|
||||
|
||||
client := retryablehttp.NewClient(retryablehttp.DefaultOptionsSingle)
|
||||
client.HTTPClient.CheckRedirect = func(*http.Request, []*http.Request) error {
|
||||
return errors.New("stop redirect")
|
||||
}
|
||||
client.CheckRetry = func(context.Context, *http.Response, error) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
matched, _, err := (&Analyzer{}).Analyze(&analyzers.Options{
|
||||
FuzzGenerated: fuzz.GeneratedRequest{
|
||||
Request: raw,
|
||||
Component: query,
|
||||
Key: "q",
|
||||
OriginalValue: "/dashboard",
|
||||
},
|
||||
HttpClient: client,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, matched)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Package sqli implements an error-based SQL injection analyzer for the fuzzer.
|
||||
//
|
||||
// It injects a small set of syntax-breaking tokens and looks for database error
|
||||
// signatures that appear only after injection (a baseline request is taken
|
||||
// first, so errors already present on the page do not cause false positives).
|
||||
// Matching a known DBMS error fingerprint is a strong, low-false-positive signal
|
||||
// that user input reaches a SQL query unsanitized.
|
||||
package sqli
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
)
|
||||
|
||||
const analyzerName = "sqli_error"
|
||||
|
||||
// Analyzer implements the analyzers.Analyzer interface for error-based SQLi.
|
||||
type Analyzer struct{}
|
||||
|
||||
var _ analyzers.Analyzer = &Analyzer{}
|
||||
|
||||
func init() {
|
||||
analyzers.RegisterAnalyzer(analyzerName, &Analyzer{})
|
||||
}
|
||||
|
||||
func (a *Analyzer) Name() string {
|
||||
return analyzerName
|
||||
}
|
||||
|
||||
func (a *Analyzer) ApplyInitialTransformation(data string, params map[string]interface{}) string {
|
||||
return analyzers.ApplyPayloadTransformations(data)
|
||||
}
|
||||
|
||||
// breakingPayloads are tokens that tend to break SQL string/numeric contexts
|
||||
// and surface a parser error when the input is concatenated into a query.
|
||||
var breakingPayloads = []string{
|
||||
"'",
|
||||
"\"",
|
||||
"')",
|
||||
"';",
|
||||
"\")",
|
||||
"`",
|
||||
"'\"",
|
||||
"\\",
|
||||
}
|
||||
|
||||
// dbmsErrorSignature pairs a DBMS name with a regex that matches one of its
|
||||
// characteristic error strings. Patterns are a curated subset of the
|
||||
// well-known error-based SQLi fingerprints.
|
||||
type dbmsErrorSignature struct {
|
||||
DBMS string
|
||||
Regex *regexp.Regexp
|
||||
}
|
||||
|
||||
var dbmsErrorSignatures = []dbmsErrorSignature{
|
||||
{"MySQL", regexp.MustCompile(`(?i)SQL syntax.*?MySQL`)},
|
||||
{"MySQL", regexp.MustCompile(`(?i)check the manual that corresponds to your (MySQL|MariaDB) server version`)},
|
||||
{"MySQL", regexp.MustCompile(`(?i)valid MySQL result`)},
|
||||
{"MySQL", regexp.MustCompile(`(?i)com\.mysql\.jdbc`)},
|
||||
{"MySQL", regexp.MustCompile(`(?i)MySqlException`)},
|
||||
{"MariaDB", regexp.MustCompile(`(?i)MariaDB server version for the right syntax`)},
|
||||
{"PostgreSQL", regexp.MustCompile(`(?i)PostgreSQL.*?ERROR`)},
|
||||
{"PostgreSQL", regexp.MustCompile(`(?i)pg_query\(\):`)},
|
||||
{"PostgreSQL", regexp.MustCompile(`(?i)unterminated quoted string at or near`)},
|
||||
{"PostgreSQL", regexp.MustCompile(`(?i)org\.postgresql\.util\.PSQLException`)},
|
||||
{"Microsoft SQL Server", regexp.MustCompile(`(?i)Unclosed quotation mark after the character string`)},
|
||||
{"Microsoft SQL Server", regexp.MustCompile(`(?i)Microsoft SQL (Server|Native Client)`)},
|
||||
{"Microsoft SQL Server", regexp.MustCompile(`(?i)System\.Data\.SqlClient\.SqlException`)},
|
||||
{"Microsoft SQL Server", regexp.MustCompile(`(?i)Incorrect syntax near`)},
|
||||
{"Oracle", regexp.MustCompile(`(?i)ORA-[0-9]{4,5}`)},
|
||||
{"Oracle", regexp.MustCompile(`(?i)quoted string not properly terminated`)},
|
||||
{"Oracle", regexp.MustCompile(`(?i)oracle\.jdbc`)},
|
||||
{"SQLite", regexp.MustCompile(`(?i)SQLite/JDBCDriver`)},
|
||||
{"SQLite", regexp.MustCompile(`(?i)SQLite3::query`)},
|
||||
{"SQLite", regexp.MustCompile(`(?i)unrecognized token:`)},
|
||||
{"SQLite", regexp.MustCompile(`(?i)sqlite3\.OperationalError`)},
|
||||
{"IBM DB2", regexp.MustCompile(`(?i)CLI Driver.*?DB2`)},
|
||||
{"IBM DB2", regexp.MustCompile(`(?i)DB2 SQL error`)},
|
||||
{"Sybase", regexp.MustCompile(`(?i)Sybase message:`)},
|
||||
}
|
||||
|
||||
// MatchDBMSError returns the DBMS whose error signature appears in body, if any.
|
||||
// It is exported and pure so it can be unit-tested without network access.
|
||||
func MatchDBMSError(body string) (string, bool) {
|
||||
if body == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, sig := range dbmsErrorSignatures {
|
||||
if sig.Regex.MatchString(body) {
|
||||
return sig.DBMS, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Analyze injects breaking payloads and reports an error-based SQLi when a DBMS
|
||||
// error signature appears that was not present in the baseline response.
|
||||
func (a *Analyzer) Analyze(options *analyzers.Options) (bool, string, error) {
|
||||
if options == nil || options.FuzzGenerated.Component == nil || options.HttpClient == nil {
|
||||
return false, "", nil
|
||||
}
|
||||
gr := options.FuzzGenerated
|
||||
|
||||
defer func() {
|
||||
_ = gr.Component.SetValue(gr.Key, gr.OriginalValue)
|
||||
}()
|
||||
|
||||
// Baseline: if the page already shows a DBMS error with the original value,
|
||||
// any later match is meaningless, so we bail to avoid false positives.
|
||||
baselineBody, err := analyzers.SendValueAndReadBody(options, gr.OriginalValue)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if _, matched := MatchDBMSError(baselineBody); matched {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
for _, payload := range breakingPayloads {
|
||||
body, err := analyzers.SendValueAndReadBody(options, gr.OriginalValue+payload)
|
||||
if err != nil {
|
||||
// A single failed probe (timeout, reset) must not abort the whole
|
||||
// analysis; the remaining payloads may still surface the bug.
|
||||
continue
|
||||
}
|
||||
if dbms, matched := MatchDBMSError(body); matched {
|
||||
return true, "sqli: " + dbms + " error triggered by payload " + strconv.Quote(payload), nil
|
||||
}
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package sqli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAnalyzerRegistered(t *testing.T) {
|
||||
require.NotNil(t, analyzers.GetAnalyzer("sqli_error"))
|
||||
require.Equal(t, "sqli_error", (&Analyzer{}).Name())
|
||||
}
|
||||
|
||||
func TestMatchDBMSError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantDBMS string
|
||||
wantHit bool
|
||||
}{
|
||||
{
|
||||
name: "mysql syntax error",
|
||||
body: `You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '''`,
|
||||
wantDBMS: "MySQL",
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "postgres error",
|
||||
body: `Query failed: ERROR: unterminated quoted string at or near "'"`,
|
||||
wantDBMS: "PostgreSQL",
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "mssql unclosed quotation",
|
||||
body: `Unclosed quotation mark after the character string ''.`,
|
||||
wantDBMS: "Microsoft SQL Server",
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "oracle ORA code",
|
||||
body: `ORA-01756: quoted string not properly terminated`,
|
||||
wantDBMS: "Oracle",
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "sqlite operational error",
|
||||
body: `sqlite3.OperationalError: unrecognized token: "'"`,
|
||||
wantDBMS: "SQLite",
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "benign html no error",
|
||||
body: `<html><body><h1>Welcome</h1><p>no errors here</p></body></html>`,
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "the word mysql alone is not a match",
|
||||
body: `We use MySQL as our database backend. Learn more in the docs.`,
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: ``,
|
||||
wantHit: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dbms, hit := MatchDBMSError(tc.body)
|
||||
require.Equal(t, tc.wantHit, hit)
|
||||
if tc.wantHit {
|
||||
require.Equal(t, tc.wantDBMS, dbms)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakingPayloadsNonEmpty(t *testing.T) {
|
||||
require.NotEmpty(t, breakingPayloads)
|
||||
for _, p := range breakingPayloads {
|
||||
require.NotEmpty(t, p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package ssrf implements an in-band server-side request forgery analyzer for
|
||||
// the fuzzer.
|
||||
//
|
||||
// It injects URLs pointing at cloud instance-metadata endpoints (AWS IMDS, GCP
|
||||
// metadata) using several IP-encoding bypasses, and confirms a hit only when
|
||||
// the response body contains a signature unique to those metadata services.
|
||||
// This catches the high-impact, in-band SSRF case where the application fetches
|
||||
// the attacker-supplied URL and reflects the metadata back (frequently leaking
|
||||
// cloud credentials). Blind SSRF requires out-of-band correlation and is out of
|
||||
// scope for this self-contained analyzer.
|
||||
package ssrf
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
)
|
||||
|
||||
const analyzerName = "ssrf"
|
||||
|
||||
// Analyzer implements the analyzers.Analyzer interface for in-band SSRF.
|
||||
type Analyzer struct{}
|
||||
|
||||
var _ analyzers.Analyzer = &Analyzer{}
|
||||
|
||||
func init() {
|
||||
analyzers.RegisterAnalyzer(analyzerName, &Analyzer{})
|
||||
}
|
||||
|
||||
func (a *Analyzer) Name() string {
|
||||
return analyzerName
|
||||
}
|
||||
|
||||
func (a *Analyzer) ApplyInitialTransformation(data string, params map[string]interface{}) string {
|
||||
return analyzers.ApplyPayloadTransformations(data)
|
||||
}
|
||||
|
||||
// metadataPayloads target cloud metadata services. 169.254.169.254 is encoded
|
||||
// several ways (IPv6-mapped, decimal, hex) to bypass naive blocklists.
|
||||
var metadataPayloads = []string{
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://169.254.169.254/latest/dynamic/instance-identity/document",
|
||||
"http://[::ffff:a9fe:a9fe]/latest/meta-data/",
|
||||
"http://2852039166/latest/meta-data/",
|
||||
"http://0xa9fea9fe/latest/meta-data/",
|
||||
"http://metadata.google.internal/computeMetadata/v1/instance/?recursive=true",
|
||||
}
|
||||
|
||||
var (
|
||||
// AWS instance-identity document JSON
|
||||
reAWSInstanceID = regexp.MustCompile(`"instanceId"\s*:\s*"i-[0-9a-f]{8,17}"`)
|
||||
reAWSImageID = regexp.MustCompile(`"imageId"\s*:\s*"ami-[0-9a-f]{8,17}"`)
|
||||
// AWS STS credentials surfaced from IMDS (keys begin with ASIA)
|
||||
reAWSCreds = regexp.MustCompile(`"AccessKeyId"\s*:\s*"ASIA[0-9A-Z]{16,}"`)
|
||||
)
|
||||
|
||||
// MatchSSRFSignature returns the metadata service whose signature appears in
|
||||
// body, if any. It is exported and pure for unit-testing without a network.
|
||||
func MatchSSRFSignature(body string) (string, bool) {
|
||||
if body == "" {
|
||||
return "", false
|
||||
}
|
||||
if reAWSCreds.MatchString(body) {
|
||||
return "AWS instance credentials (IMDS)", true
|
||||
}
|
||||
if reAWSInstanceID.MatchString(body) && reAWSImageID.MatchString(body) {
|
||||
return "AWS instance identity document (IMDS)", true
|
||||
}
|
||||
// AWS IMDS metadata listing: several well-known keys appear together.
|
||||
if strings.Contains(body, "ami-id") &&
|
||||
strings.Contains(body, "instance-id") &&
|
||||
strings.Contains(body, "instance-action") {
|
||||
return "AWS IMDS metadata", true
|
||||
}
|
||||
// GCP metadata recursive responses carry these structural keys together.
|
||||
// NOTE: we deliberately do NOT match a bare "computeMetadata" substring: it
|
||||
// appears in the injected request URL itself (…/computeMetadata/v1/…), so an
|
||||
// application that merely reflects the payload would otherwise be a false
|
||||
// positive. Requiring response-only JSON keys keeps this in-band signal high
|
||||
// confidence.
|
||||
if strings.Contains(body, "\"machineType\"") && strings.Contains(body, "\"serviceAccounts\"") {
|
||||
return "GCP instance metadata", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Analyze injects metadata SSRF payloads and reports a hit when a metadata
|
||||
// signature appears that was not present in the baseline response.
|
||||
func (a *Analyzer) Analyze(options *analyzers.Options) (bool, string, error) {
|
||||
if options == nil || options.FuzzGenerated.Component == nil || options.HttpClient == nil {
|
||||
return false, "", nil
|
||||
}
|
||||
gr := options.FuzzGenerated
|
||||
|
||||
defer func() {
|
||||
_ = gr.Component.SetValue(gr.Key, gr.OriginalValue)
|
||||
}()
|
||||
|
||||
baselineBody, err := analyzers.SendValueAndReadBody(options, gr.OriginalValue)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if _, matched := MatchSSRFSignature(baselineBody); matched {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
for _, payload := range metadataPayloads {
|
||||
body, err := analyzers.SendValueAndReadBody(options, payload)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if svc, matched := MatchSSRFSignature(body); matched {
|
||||
return true, "ssrf: " + svc + " disclosed via payload " + strconv.Quote(payload), nil
|
||||
}
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package ssrf
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAnalyzerRegistered(t *testing.T) {
|
||||
require.NotNil(t, analyzers.GetAnalyzer("ssrf"))
|
||||
require.Equal(t, "ssrf", (&Analyzer{}).Name())
|
||||
}
|
||||
|
||||
func TestMatchSSRFSignature(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantSvc string
|
||||
wantHit bool
|
||||
}{
|
||||
{
|
||||
name: "aws instance identity document",
|
||||
body: `{"accountId":"123456789012","imageId":"ami-0abcd1234ef567890","instanceId":"i-0abcd1234ef567890","region":"us-east-1"}`,
|
||||
wantSvc: "AWS instance identity document (IMDS)",
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "aws imds metadata listing",
|
||||
body: "ami-id\nami-launch-index\nhostname\niam/\ninstance-action\ninstance-id\nlocal-hostname\n",
|
||||
wantSvc: "AWS IMDS metadata",
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "aws sts credentials",
|
||||
body: `{"Code":"Success","AccessKeyId":"ASIAEXAMPLE1234567890","SecretAccessKey":"x","Token":"y"}`,
|
||||
wantSvc: "AWS instance credentials (IMDS)",
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "gcp compute metadata recursive response",
|
||||
body: `{"hostname":"vm.c.proj.internal","machineType":"projects/1/machineTypes/e2-medium","serviceAccounts":{"default":{"email":"[email protected]"}}}`,
|
||||
wantSvc: "GCP instance metadata",
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "benign body no metadata",
|
||||
body: `<html><body>welcome to the dashboard, instance-id of order is 5</body></html>`,
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
// Regression: an app that merely reflects the injected GCP payload URL
|
||||
// (which contains "computeMetadata") must NOT be flagged. Only a real
|
||||
// metadata *response* (structural JSON keys) counts.
|
||||
name: "reflected gcp payload url is not a hit",
|
||||
body: `<html><body>you searched for: http://metadata.google.internal/computeMetadata/v1/instance/?recursive=true</body></html>`,
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
// Regression: reflected AWS metadata URL alone is not disclosure.
|
||||
name: "reflected aws payload url is not a hit",
|
||||
body: `<html><body>fetched http://169.254.169.254/latest/meta-data/ failed</body></html>`,
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "partial aws keys do not match identity doc",
|
||||
body: `{"instanceId":"i-0abcd1234ef567890"}`,
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: ``,
|
||||
wantHit: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc, hit := MatchSSRFSignature(tc.body)
|
||||
require.Equal(t, tc.wantHit, hit)
|
||||
if tc.wantHit {
|
||||
require.Equal(t, tc.wantSvc, svc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataPayloadsNonEmpty(t *testing.T) {
|
||||
require.NotEmpty(t, metadataPayloads)
|
||||
for _, p := range metadataPayloads {
|
||||
require.NotEmpty(t, p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Package ssti implements a server-side template injection (SSTI) analyzer for
|
||||
// the fuzzer. It uses a randomized arithmetic oracle: it injects a multiplication
|
||||
// expression in a number of common template-engine syntaxes, wrapped in unique
|
||||
// random sentinels, and confirms injection only when the template engine
|
||||
// evaluates the expression (i.e. the product appears between the sentinels).
|
||||
//
|
||||
// This arithmetic-evaluation approach yields very low false positives: a plain
|
||||
// reflection of the payload leaves the literal expression (e.g. "${7*7}")
|
||||
// between the sentinels, which never matches the product ("49"). A match
|
||||
// therefore means the server actually evaluated the expression — a strong SSTI
|
||||
// signal that frequently escalates to RCE.
|
||||
package ssti
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
)
|
||||
|
||||
const analyzerName = "ssti"
|
||||
|
||||
// Analyzer implements the analyzers.Analyzer interface for SSTI detection.
|
||||
type Analyzer struct{}
|
||||
|
||||
var _ analyzers.Analyzer = &Analyzer{}
|
||||
|
||||
func init() {
|
||||
analyzers.RegisterAnalyzer(analyzerName, &Analyzer{})
|
||||
}
|
||||
|
||||
func (a *Analyzer) Name() string {
|
||||
return analyzerName
|
||||
}
|
||||
|
||||
func (a *Analyzer) ApplyInitialTransformation(data string, params map[string]interface{}) string {
|
||||
return analyzers.ApplyPayloadTransformations(data)
|
||||
}
|
||||
|
||||
// Probe is a single SSTI payload for a family of template engines.
|
||||
type Probe struct {
|
||||
// Engine names the template engine families this syntax targets.
|
||||
Engine string
|
||||
// Payload is the value to inject (sentinels + expression).
|
||||
Payload string
|
||||
}
|
||||
|
||||
// Analyze injects arithmetic SSTI probes and reports whether any template engine
|
||||
// evaluated the expression.
|
||||
func (a *Analyzer) Analyze(options *analyzers.Options) (bool, string, error) {
|
||||
if options == nil || options.FuzzGenerated.Component == nil || options.HttpClient == nil {
|
||||
return false, "", nil
|
||||
}
|
||||
gr := options.FuzzGenerated
|
||||
|
||||
first := analyzers.GetRandomInteger()
|
||||
second := analyzers.GetRandomInteger()
|
||||
product := first * second
|
||||
startToken := randToken()
|
||||
endToken := randToken()
|
||||
|
||||
probes := GenerateProbes(first, second, startToken, endToken)
|
||||
|
||||
// Always restore the original value once we are done probing.
|
||||
defer func() {
|
||||
_ = gr.Component.SetValue(gr.Key, gr.OriginalValue)
|
||||
}()
|
||||
|
||||
for _, probe := range probes {
|
||||
body, err := analyzers.SendValueAndReadBody(options, probe.Payload)
|
||||
if err != nil {
|
||||
// A single failed probe (timeout, reset) must not abort the whole
|
||||
// analysis; the remaining engine syntaxes may still evaluate.
|
||||
continue
|
||||
}
|
||||
|
||||
if DetectEvaluation(body, startToken, endToken, product) {
|
||||
return true, "ssti: expression evaluated by " + probe.Engine + " (got " + strconv.Itoa(product) + ")", nil
|
||||
}
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
// GenerateProbes builds the SSTI payloads for a*b across common template-engine
|
||||
// syntaxes, each wrapped in the given sentinels. It is exported and pure so it
|
||||
// can be unit-tested without any network access.
|
||||
func GenerateProbes(a, b int, startToken, endToken string) []Probe {
|
||||
as := strconv.Itoa(a)
|
||||
bs := strconv.Itoa(b)
|
||||
expr := as + "*" + bs
|
||||
|
||||
wrap := func(payload string) string {
|
||||
return startToken + payload + endToken
|
||||
}
|
||||
|
||||
return []Probe{
|
||||
{Engine: "EL/SpEL/JSP-EL/Mako/Freemarker", Payload: wrap("${" + expr + "}")},
|
||||
{Engine: "Jinja2/Twig/Nunjucks/Angular", Payload: wrap("{{" + expr + "}}")},
|
||||
{Engine: "Ruby ERB/Thymeleaf/JSF", Payload: wrap("#{" + expr + "}")},
|
||||
{Engine: "Thymeleaf", Payload: wrap("*{" + expr + "}")},
|
||||
{Engine: "Razor", Payload: wrap("@(" + expr + ")")},
|
||||
{Engine: "ERB/ASP", Payload: wrap("<%= " + expr + " %>")},
|
||||
{Engine: "Velocity", Payload: wrap("#set($p=" + expr + ")${p}")},
|
||||
{Engine: "Smarty/generic", Payload: wrap("{" + expr + "}")},
|
||||
}
|
||||
}
|
||||
|
||||
// DetectEvaluation reports whether the response body contains the evaluated
|
||||
// product between the two sentinels, which indicates the template engine
|
||||
// executed the injected expression. It is exported and pure for testability.
|
||||
//
|
||||
// Optional whitespace is allowed on either side of the product: some engines
|
||||
// emit incidental whitespace between the sentinels and the result (e.g. a
|
||||
// newline left by a directive such as Velocity's #set), which a strict
|
||||
// contiguous match would miss. A plain reflection still fails to match because
|
||||
// the literal expression ("7*7"), not the product ("49"), survives between the
|
||||
// sentinels.
|
||||
func DetectEvaluation(body, startToken, endToken string, product int) bool {
|
||||
if body == "" || startToken == "" || endToken == "" {
|
||||
return false
|
||||
}
|
||||
re := regexp.MustCompile(regexp.QuoteMeta(startToken) + `\s*` + strconv.Itoa(product) + `\s*` + regexp.QuoteMeta(endToken))
|
||||
return re.MatchString(body)
|
||||
}
|
||||
|
||||
// randToken returns a short random lowercase alphabetic sentinel. Alphabetic
|
||||
// (never numeric) so it cannot be confused with the arithmetic product.
|
||||
func randToken() string {
|
||||
return "s" + analyzers.RandAlphaToken(6)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package ssti
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAnalyzerRegistered(t *testing.T) {
|
||||
require.NotNil(t, analyzers.GetAnalyzer("ssti"), "ssti analyzer must be registered")
|
||||
require.Equal(t, "ssti", (&Analyzer{}).Name())
|
||||
}
|
||||
|
||||
func TestGenerateProbes(t *testing.T) {
|
||||
const (
|
||||
a = 31
|
||||
b = 1337
|
||||
start = "sAAAA"
|
||||
end = "sBBBB"
|
||||
)
|
||||
probes := GenerateProbes(a, b, start, end)
|
||||
require.NotEmpty(t, probes)
|
||||
|
||||
expr := "31*1337"
|
||||
for _, p := range probes {
|
||||
require.NotEmpty(t, p.Engine)
|
||||
// every payload must carry the raw arithmetic expression and both sentinels
|
||||
require.Contains(t, p.Payload, expr, "probe %q must contain the expression", p.Engine)
|
||||
require.True(t, strings.HasPrefix(p.Payload, start), "probe %q must start with sentinel", p.Engine)
|
||||
require.True(t, strings.HasSuffix(p.Payload, end), "probe %q must end with sentinel", p.Engine)
|
||||
// the unevaluated payload must NOT contain the product (avoids self-match)
|
||||
require.NotContains(t, p.Payload, strconv.Itoa(a*b))
|
||||
}
|
||||
|
||||
// sanity: well-known syntaxes are present
|
||||
var syntaxes []string
|
||||
for _, p := range probes {
|
||||
syntaxes = append(syntaxes, p.Payload)
|
||||
}
|
||||
joined := strings.Join(syntaxes, "\n")
|
||||
require.Contains(t, joined, "${31*1337}")
|
||||
require.Contains(t, joined, "{{31*1337}}")
|
||||
require.Contains(t, joined, "#{31*1337}")
|
||||
}
|
||||
|
||||
func TestDetectEvaluation(t *testing.T) {
|
||||
const (
|
||||
start = "sXYZ12"
|
||||
end = "sQRS98"
|
||||
product = 1849 // 43*43
|
||||
)
|
||||
|
||||
t.Run("evaluated product between sentinels is detected", func(t *testing.T) {
|
||||
body := "<html>result: " + start + strconv.Itoa(product) + end + " done</html>"
|
||||
require.True(t, DetectEvaluation(body, start, end, product))
|
||||
})
|
||||
|
||||
t.Run("reflected unevaluated expression is NOT a hit", func(t *testing.T) {
|
||||
// the engine did not evaluate, so the literal expression survives
|
||||
body := "<html>result: " + start + "43*43" + end + " done</html>"
|
||||
require.False(t, DetectEvaluation(body, start, end, product))
|
||||
})
|
||||
|
||||
t.Run("product without sentinels is NOT a hit", func(t *testing.T) {
|
||||
// product appears naturally elsewhere (e.g. a price) but not between sentinels
|
||||
body := "<html>total is 1849 dollars</html>"
|
||||
require.False(t, DetectEvaluation(body, start, end, product))
|
||||
})
|
||||
|
||||
t.Run("product with only one sentinel is NOT a hit", func(t *testing.T) {
|
||||
body := start + strconv.Itoa(product) + "DIFFERENT"
|
||||
require.False(t, DetectEvaluation(body, start, end, product))
|
||||
})
|
||||
|
||||
t.Run("empty inputs are safe", func(t *testing.T) {
|
||||
require.False(t, DetectEvaluation("", start, end, product))
|
||||
require.False(t, DetectEvaluation("body", "", end, product))
|
||||
require.False(t, DetectEvaluation("body", start, "", product))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRandTokenIsAlphabetic(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
tok := randToken()
|
||||
require.NotEmpty(t, tok)
|
||||
for _, r := range tok {
|
||||
require.True(t, r >= 'a' && r <= 'z', "token must be lowercase alphabetic, got %q", tok)
|
||||
}
|
||||
}
|
||||
// two tokens should differ (extremely high probability)
|
||||
require.NotEqual(t, randToken(), randToken())
|
||||
}
|
||||
@@ -115,26 +115,14 @@ func (a *Analyzer) Analyze(options *analyzers.Options) (bool, string, error) {
|
||||
replaced := strings.ReplaceAll(gr.OriginalPayload, "[SLEEPTIME]", strconv.Itoa(delay))
|
||||
replaced = a.ApplyInitialTransformation(replaced, options.AnalyzerParameters)
|
||||
|
||||
if err := gr.Component.SetValue(gr.Key, replaced); err != nil {
|
||||
return 0, errors.Wrap(err, "could not set value in component")
|
||||
}
|
||||
|
||||
rebuilt, err := gr.Component.Rebuild()
|
||||
rebuilt, err := analyzers.SetValueAndRebuild(gr, replaced)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "could not rebuild request")
|
||||
}
|
||||
|
||||
// copy headers from the original request since Rebuild() only
|
||||
// carries headers present at parse time, not post-parse injections
|
||||
for k, vs := range gr.Request.Header {
|
||||
if gr.Component.Name() == "header" && k == gr.Key {
|
||||
continue
|
||||
}
|
||||
rebuilt.Header[k] = vs
|
||||
return 0, errors.Wrap(err, "could not rebuild request with value")
|
||||
}
|
||||
|
||||
gologger.Verbose().Msgf("[%s] Sending request with %d delay for: %s", a.Name(), delay, rebuilt.String())
|
||||
|
||||
options.RateLimit()
|
||||
timeTaken, err := doHTTPRequestWithTimeTracing(rebuilt, options.HttpClient)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "could not do request with time tracing")
|
||||
@@ -202,8 +190,11 @@ func doHTTPRequestWithTimeTracing(req *retryablehttp.Request, httpclient *retrya
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "could not do request")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
_, err = io.ReadAll(resp.Body)
|
||||
// Drain (bounded) so the connection can be reused; we only care about the
|
||||
// time-to-first-byte captured by the trace, not the body contents.
|
||||
_, err = io.Copy(io.Discard, io.LimitReader(resp.Body, analyzers.MaxResponseBodyBytes))
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "could not read response body")
|
||||
}
|
||||
|
||||
@@ -8,10 +8,7 @@ import (
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const (
|
||||
analyzerName = "xss_context"
|
||||
maxResponseBodyBytes = 10 * 1024 * 1024 // 10 MiB
|
||||
)
|
||||
const analyzerName = "xss_context"
|
||||
|
||||
// XSSAnalyzer implements the analyzers.Analyzer interface for XSS context detection.
|
||||
type XSSAnalyzer struct{}
|
||||
@@ -41,32 +38,16 @@ func (a *XSSAnalyzer) Analyze(options *analyzers.Options) (bool, string, error)
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
if err := gr.Component.SetValue(gr.Key, payload); err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
defer func() {
|
||||
_ = gr.Component.SetValue(gr.Key, gr.OriginalValue)
|
||||
}()
|
||||
|
||||
rebuilt, err := gr.Component.Rebuild()
|
||||
body, err := analyzers.SendValueAndReadBody(options, payload)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
resp, err := options.HttpClient.Do(rebuilt)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
ctx, err := AnalyzeReflectionContext(string(body), payload)
|
||||
ctx, err := AnalyzeReflectionContext(body, payload)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
@@ -191,28 +172,28 @@ var eventHandlers = map[string]struct{}{
|
||||
"ontouchmove": {},
|
||||
"ontouchcancel": {},
|
||||
// added after review, these are newer DOM events that were missing
|
||||
"onauxclick": {},
|
||||
"onbeforeinput": {},
|
||||
"onformdata": {},
|
||||
"onslotchange": {},
|
||||
"onauxclick": {},
|
||||
"onbeforeinput": {},
|
||||
"onformdata": {},
|
||||
"onslotchange": {},
|
||||
"onsecuritypolicyviolation": {},
|
||||
}
|
||||
|
||||
// executableScriptTypes lists MIME types that browsers actually execute.
|
||||
// Empty string covers <script> with no type attribute.
|
||||
var executableScriptTypes = map[string]struct{}{
|
||||
"": {},
|
||||
"text/javascript": {},
|
||||
"application/javascript": {},
|
||||
"text/ecmascript": {},
|
||||
"application/ecmascript": {},
|
||||
"module": {},
|
||||
"text/jscript": {},
|
||||
"text/livescript": {},
|
||||
"": {},
|
||||
"text/javascript": {},
|
||||
"application/javascript": {},
|
||||
"text/ecmascript": {},
|
||||
"application/ecmascript": {},
|
||||
"module": {},
|
||||
"text/jscript": {},
|
||||
"text/livescript": {},
|
||||
"text/x-ecmascript": {},
|
||||
"text/x-javascript": {},
|
||||
"application/x-javascript": {},
|
||||
"application/x-ecmascript": {},
|
||||
"application/x-javascript": {},
|
||||
"application/x-ecmascript": {},
|
||||
}
|
||||
|
||||
// executableURLSinks maps URL attribute names to the set of tags where
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuiltInAnalyzersRegistered(t *testing.T) {
|
||||
names := []string{
|
||||
"cmdi",
|
||||
"cors",
|
||||
"crlf",
|
||||
"host_header_injection",
|
||||
"lfi",
|
||||
"open_redirect",
|
||||
"sqli_error",
|
||||
"ssrf",
|
||||
"ssti",
|
||||
"time_delay",
|
||||
"xss_context",
|
||||
}
|
||||
for _, name := range names {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
require.NotNil(t, analyzers.GetAnalyzer(name))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,15 @@ import (
|
||||
"github.com/projectdiscovery/fastdialer/fastdialer"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/cmdi"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/cors"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/crlf"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/hostheader"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/lfi"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/redirect"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/sqli"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/ssrf"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/ssti"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/time"
|
||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/xss"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/operators"
|
||||
|
||||
@@ -1581,6 +1581,10 @@ func (request *Request) executeRequest(input *contextargs.Context, generatedRequ
|
||||
HttpClient: analyzerClient,
|
||||
ResponseTimeDelay: duration,
|
||||
AnalyzerParameters: request.Analyzer.Parameters,
|
||||
// Analyzer probes share the scan's rate limit and host backoff.
|
||||
RateLimitTake: func() {
|
||||
_ = request.rateLimitTake(input.Context(), hostBackoffKey(generatedRequest, input))
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
gologger.Warning().Msgf("Could not analyze response: %v\n", err)
|
||||
|
||||
Reference in new issue
Block a user