mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2026-11-07 17:43:04 -10:00
Compare commits
149
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40d08ee493 | ||
|
|
27ff6b19b7 | ||
|
|
361f3b1383 | ||
|
|
8cda906559 | ||
|
|
a2e403a1d8 | ||
|
|
bbebeba8b2 | ||
|
|
578d00829a | ||
|
|
3be47b098f | ||
|
|
b29ecc6907 | ||
|
|
29559cbd2a | ||
|
|
94f7148b7e | ||
|
|
1829fca65c | ||
|
|
accfe3551b | ||
|
|
d6ec8407fb | ||
|
|
bb2da9bbea | ||
|
|
6e2b59827c | ||
|
|
875b229313 | ||
|
|
91f8bd1e4f | ||
|
|
fb0eed5122 | ||
|
|
29833da25a | ||
|
|
9b519d2264 | ||
|
|
4fed40f75a | ||
|
|
2f4ac3d9e5 | ||
|
|
27ac1e8726 | ||
|
|
5b499e0650 | ||
|
|
7748411200 | ||
|
|
0642f05241 | ||
|
|
df707d924f | ||
|
|
5f5a40c2d4 | ||
|
|
82d5a8acc2 | ||
|
|
9d3be8b22e | ||
|
|
76dd059821 | ||
|
|
46fbc92a76 | ||
|
|
36d0adba18 | ||
|
|
049e6b629e | ||
|
|
2c6b223bb4 | ||
|
|
0827b2779b | ||
|
|
0971fb83e2 | ||
|
|
62dae92b3d | ||
|
|
e44df6e947 | ||
|
|
25f50f1036 | ||
|
|
f9446e9290 | ||
|
|
faaeaf30b1 | ||
|
|
b7fe335285 | ||
|
|
7481b20d78 | ||
|
|
48600a24de | ||
|
|
ec4036147e | ||
|
|
9b04153a3b | ||
|
|
23f3407af7 | ||
|
|
be40690a29 | ||
|
|
64f94f3e66 | ||
|
|
b2b54a7ac4 | ||
|
|
22aedebe5d | ||
|
|
5896509c84 | ||
|
|
825dcfb10a | ||
|
|
d465cc82a4 | ||
|
|
48f36b4edc | ||
|
|
0e7a56b0d7 | ||
|
|
f8cf960022 | ||
|
|
cbd2cb3881 | ||
|
|
1fc87e6b02 | ||
|
|
7a08c714f9 | ||
|
|
6903f2d559 | ||
|
|
27edbd6a64 | ||
|
|
be678d9d7c | ||
|
|
515b22a35a | ||
|
|
cd0122aa85 | ||
|
|
b7d22eb52b | ||
|
|
c2cecb92a8 | ||
|
|
9206560d1d | ||
|
|
e94840ad56 | ||
|
|
aec0b6eebf | ||
|
|
b6dd16ffbf | ||
|
|
d510a9c2fb | ||
|
|
de382b5de3 | ||
|
|
7ed324680f | ||
|
|
684d55eeed | ||
|
|
4dcb37ad23 | ||
|
|
ab506a2bb6 | ||
|
|
6863ab16e5 | ||
|
|
7b44856ce8 | ||
|
|
288f657fca | ||
|
|
87a8c61932 | ||
|
|
9c26dc1d2a | ||
|
|
613e581542 | ||
|
|
5c9f02a4fc | ||
|
|
df57ae6d29 | ||
|
|
2a5392eaad | ||
|
|
bdf7e983c5 | ||
|
|
6a3300b13d | ||
|
|
266593d7a0 | ||
|
|
7b0ca00fdf | ||
|
|
f5c033162f | ||
|
|
2e258fc3f5 | ||
|
|
b3596ac64e | ||
|
|
ac52212d17 | ||
|
|
1f79f786da | ||
|
|
3ab77ec3e7 | ||
|
|
58354693e0 | ||
|
|
dc817c8dbd | ||
|
|
71c268d59e | ||
|
|
e55509d881 | ||
|
|
998641728c | ||
|
|
4e3f78c2c0 | ||
|
|
7a9ec9c8da | ||
|
|
5d876b88e5 | ||
|
|
1d2a913241 | ||
|
|
7d143c989a | ||
|
|
128ec95adb | ||
|
|
aa45ef92f6 | ||
|
|
7f57e92074 | ||
|
|
3ae6ca8e3d | ||
|
|
6c615b76df | ||
|
|
681854444d | ||
|
|
449fdd2c37 | ||
|
|
9d7aa53226 | ||
|
|
f766893685 | ||
|
|
a4422d8962 | ||
|
|
efed3f2d8a | ||
|
|
3ee4f0f408 | ||
|
|
091191f518 | ||
|
|
827f4a9f88 | ||
|
|
bd1c4dd226 | ||
|
|
f990fe4d5d | ||
|
|
00ed0552da | ||
|
|
099a037928 | ||
|
|
12d6f03826 | ||
|
|
1644ba7e81 | ||
|
|
a113cf0db8 | ||
|
|
97337e2b4a | ||
|
|
8d29c12eef | ||
|
|
4fc17dc284 | ||
|
|
035fc270f9 | ||
|
|
3f341f0222 | ||
|
|
26ce6d2ef2 | ||
|
|
0f34b1b6b8 | ||
|
|
77ba7792f3 | ||
|
|
8caa65a839 | ||
|
|
a4afaa41b2 | ||
|
|
abcdcf19a4 | ||
|
|
9dc004926d | ||
|
|
3d2f411e1d | ||
|
|
763e490f42 | ||
|
|
421ec70686 | ||
|
|
1eeb2f3979 | ||
|
|
ea108ee04f | ||
|
|
aac1427cdc | ||
|
|
c4552c6011 | ||
|
|
4331cd0f7a |
No files matched your search
@@ -36,7 +36,7 @@ It also specifies a `WorkflowLoader` interface that is used during workflow load
|
||||
|
||||
```go
|
||||
type WorkflowLoader interface {
|
||||
GetTemplatePathsByTags(tags []string) []string
|
||||
GetTemplatePathsByTags(tags []string, noValidate bool) []string
|
||||
GetTemplatePaths(templatesList []string, noValidate bool) []string
|
||||
}
|
||||
```
|
||||
|
||||
@@ -145,12 +145,12 @@ TARGET:
|
||||
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
|
||||
-resume string resume scan from and save to specified file (clustering will be disabled)
|
||||
-sa, -scan-all-ips scan all the IP's associated with dns record
|
||||
-iv, -ip-version string[] IP version to scan of hostname (4,6) - (default 4)
|
||||
-iv, -ip-version string[] IP version to scan of hostname (4,6); default uses dialer fallback, -sa scans all addresses
|
||||
-dun, -disable-url-normalization keep targets exactly as supplied instead of collapsing urls that address the same resource
|
||||
-mup, -max-urls-per-pattern int scan at most this many urls sharing one structural shape, e.g. /user/1 and /user/2 (0 to scan all)
|
||||
|
||||
TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-ro, -required-only use only required fields in input format when generating requests
|
||||
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
|
||||
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
|
||||
@@ -177,7 +177,7 @@ TEMPLATES:
|
||||
-egm, -enable-global-matchers enable loading global matchers templates
|
||||
-file enable loading file templates
|
||||
-llm enable llm matchers and extractors (semantic matching)
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
|
||||
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
|
||||
-llm-model string model used for semantic matching
|
||||
-llm-timeout int time in seconds to wait for a single llm call (default 30)
|
||||
@@ -221,6 +221,7 @@ OUTPUT:
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-pe, -pdf-export string file to export results in PDF format
|
||||
-rd, -redact string[] redact given list of keys from query parameter, request header and body
|
||||
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
|
||||
|
||||
CONFIGURATIONS:
|
||||
-config string path to the nuclei configuration file
|
||||
@@ -255,6 +256,7 @@ CONFIGURATIONS:
|
||||
-rsr, -response-size-read int max response size to read in bytes
|
||||
-rss, -response-size-save int max response size to read in bytes (default 1048576)
|
||||
-rqss, -request-size-save int max request body size to save in bytes (0 to save the whole body) (default 32768)
|
||||
-mrbi, -max-request-body-inflight int max request body bytes held by in-flight http requests; requests with bodies of 1MB or more queue past it (0 disables)
|
||||
-reset reset removes all nuclei configuration and data files (including nuclei-templates)
|
||||
-tlsi, -tls-impersonate enable experimental client hello (ja3) tls randomization
|
||||
-hae, -http-api-endpoint string experimental http api endpoint
|
||||
@@ -326,6 +328,7 @@ OPTIMIZATIONS:
|
||||
-nh, -no-httpx disable httpx probing for non-url input
|
||||
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
|
||||
-no-stdin disable stdin processing
|
||||
-ac, -auto-calibrate drop HTTP matches on a host's catch-all page (answers missing paths with 2xx/3xx)
|
||||
|
||||
HEADLESS:
|
||||
-headless enable templates that require headless browser support (root user on Linux will disable sandbox)
|
||||
|
||||
+3
-2
@@ -148,7 +148,7 @@ TARGET:
|
||||
-iv, -ip-version string[] IP version to scan of hostname (4,6) - (default 4)
|
||||
|
||||
TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-ro, -required-only use only required fields in input format when generating requests
|
||||
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
|
||||
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
|
||||
@@ -175,7 +175,7 @@ TEMPLATES:
|
||||
-egm, -enable-global-matchers enable loading global matchers templates
|
||||
-file enable loading file templates
|
||||
-llm enable llm matchers and extractors (semantic matching)
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
|
||||
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
|
||||
-llm-model string model used for semantic matching
|
||||
-llm-timeout int time in seconds to wait for a single llm call (default 30)
|
||||
@@ -219,6 +219,7 @@ OUTPUT:
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-pe, -pdf-export string file to export results in PDF format
|
||||
-rd, -redact string[] redact given list of keys from query parameter, request header and body
|
||||
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
|
||||
|
||||
CONFIGURATIONS:
|
||||
-config string path to the nuclei configuration file
|
||||
|
||||
+3
-2
@@ -148,7 +148,7 @@ TARGET:
|
||||
-iv, -ip-version string[] IP version to scan of hostname (4,6) - (default 4)
|
||||
|
||||
TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-ro, -required-only use only required fields in input format when generating requests
|
||||
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
|
||||
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
|
||||
@@ -175,7 +175,7 @@ TEMPLATES:
|
||||
-egm, -enable-global-matchers enable loading global matchers templates
|
||||
-file enable loading file templates
|
||||
-llm enable llm matchers and extractors (semantic matching)
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
|
||||
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
|
||||
-llm-model string model used for semantic matching
|
||||
-llm-timeout int time in seconds to wait for a single llm call (default 30)
|
||||
@@ -219,6 +219,7 @@ OUTPUT:
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-pe, -pdf-export string file to export results in PDF format
|
||||
-rd, -redact string[] redact given list of keys from query parameter, request header and body
|
||||
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
|
||||
|
||||
CONFIGURATIONS:
|
||||
-config string path to the nuclei configuration file
|
||||
|
||||
+3
-2
@@ -165,7 +165,7 @@ TARGET:
|
||||
-mup, -max-urls-per-pattern int scan at most this many urls sharing one structural shape, e.g. /user/1 and /user/2 (0 to scan all)
|
||||
|
||||
TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-ro, -required-only use only required fields in input format when generating requests
|
||||
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
|
||||
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
|
||||
@@ -192,7 +192,7 @@ TEMPLATES:
|
||||
-egm, -enable-global-matchers enable loading global matchers templates
|
||||
-file enable loading file templates
|
||||
-llm enable llm matchers and extractors (semantic matching)
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
|
||||
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
|
||||
-llm-model string model used for semantic matching
|
||||
-llm-timeout int time in seconds to wait for a single llm call (default 30)
|
||||
@@ -236,6 +236,7 @@ OUTPUT:
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-pe, -pdf-export string file to export results in PDF format
|
||||
-rd, -redact string[] redact given list of keys from query parameter, request header and body
|
||||
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
|
||||
|
||||
CONFIGURATIONS:
|
||||
-config string path to the nuclei configuration file
|
||||
|
||||
+3
-2
@@ -148,7 +148,7 @@ TARGET:
|
||||
-iv, -ip-version string[] IP version to scan of hostname (4,6) - (default 4)
|
||||
|
||||
TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-ro, -required-only use only required fields in input format when generating requests
|
||||
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
|
||||
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
|
||||
@@ -175,7 +175,7 @@ TEMPLATES:
|
||||
-egm, -enable-global-matchers enable loading global matchers templates
|
||||
-file enable loading file templates
|
||||
-llm enable llm matchers and extractors (semantic matching)
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
|
||||
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
|
||||
-llm-model string model used for semantic matching
|
||||
-llm-timeout int time in seconds to wait for a single llm call (default 30)
|
||||
@@ -219,6 +219,7 @@ OUTPUT:
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-pe, -pdf-export string file to export results in PDF format
|
||||
-rd, -redact string[] redact given list of keys from query parameter, request header and body
|
||||
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
|
||||
|
||||
CONFIGURATIONS:
|
||||
-config string path to the nuclei configuration file
|
||||
|
||||
+3
-2
@@ -148,7 +148,7 @@ TARGET:
|
||||
-iv, -ip-version string[] IP version to scan of hostname (4,6) - (default 4)
|
||||
|
||||
TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-ro, -required-only use only required fields in input format when generating requests
|
||||
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
|
||||
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
|
||||
@@ -175,7 +175,7 @@ TEMPLATES:
|
||||
-egm, -enable-global-matchers enable loading global matchers templates
|
||||
-file enable loading file templates
|
||||
-llm enable llm matchers and extractors (semantic matching)
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
|
||||
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
|
||||
-llm-model string model used for semantic matching
|
||||
-llm-timeout int time in seconds to wait for a single llm call (default 30)
|
||||
@@ -219,6 +219,7 @@ OUTPUT:
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-pe, -pdf-export string file to export results in PDF format
|
||||
-rd, -redact string[] redact given list of keys from query parameter, request header and body
|
||||
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
|
||||
|
||||
CONFIGURATIONS:
|
||||
-config string path to the nuclei configuration file
|
||||
|
||||
+3
-2
@@ -148,7 +148,7 @@ TARGET:
|
||||
-iv, -ip-version string[] IP version to scan of hostname (4,6) - (default 4)
|
||||
|
||||
TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-ro, -required-only use only required fields in input format when generating requests
|
||||
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
|
||||
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
|
||||
@@ -175,7 +175,7 @@ TEMPLATES:
|
||||
-egm, -enable-global-matchers enable loading global matchers templates
|
||||
-file enable loading file templates
|
||||
-llm enable llm matchers and extractors (semantic matching)
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
|
||||
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
|
||||
-llm-model string model used for semantic matching
|
||||
-llm-timeout int time in seconds to wait for a single llm call (default 30)
|
||||
@@ -219,6 +219,7 @@ OUTPUT:
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-pe, -pdf-export string file to export results in PDF format
|
||||
-rd, -redact string[] redact given list of keys from query parameter, request header and body
|
||||
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
|
||||
|
||||
CONFIGURATIONS:
|
||||
-config string path to the nuclei configuration file
|
||||
|
||||
+3
-2
@@ -148,7 +148,7 @@ TARGET:
|
||||
-iv, -ip-version string[] IP version to scan of hostname (4,6) - (default 4)
|
||||
|
||||
TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-ro, -required-only use only required fields in input format when generating requests
|
||||
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
|
||||
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
|
||||
@@ -175,7 +175,7 @@ TEMPLATES:
|
||||
-egm, -enable-global-matchers enable loading global matchers templates
|
||||
-file enable loading file templates
|
||||
-llm enable llm matchers and extractors (semantic matching)
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
|
||||
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
|
||||
-llm-model string model used for semantic matching
|
||||
-llm-timeout int time in seconds to wait for a single llm call (default 30)
|
||||
@@ -219,6 +219,7 @@ OUTPUT:
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-pe, -pdf-export string file to export results in PDF format
|
||||
-rd, -redact string[] redact given list of keys from query parameter, request header and body
|
||||
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
|
||||
|
||||
CONFIGURATIONS:
|
||||
-config string path to the nuclei configuration file
|
||||
|
||||
+3
-2
@@ -148,7 +148,7 @@ TARGET:
|
||||
-iv, -ip-version string[] IP version to scan of hostname (4,6) - (default 4)
|
||||
|
||||
TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
-ro, -required-only use only required fields in input format when generating requests
|
||||
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
|
||||
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
|
||||
@@ -175,7 +175,7 @@ TEMPLATES:
|
||||
-egm, -enable-global-matchers enable loading global matchers templates
|
||||
-file enable loading file templates
|
||||
-llm enable llm matchers and extractors (semantic matching)
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
|
||||
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
|
||||
-llm-model string model used for semantic matching
|
||||
-llm-timeout int time in seconds to wait for a single llm call (default 30)
|
||||
@@ -219,6 +219,7 @@ OUTPUT:
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-pe, -pdf-export string file to export results in PDF format
|
||||
-rd, -redact string[] redact given list of keys from query parameter, request header and body
|
||||
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
|
||||
|
||||
CONFIGURATIONS:
|
||||
-config string path to the nuclei configuration file
|
||||
|
||||
+103
-238
@@ -1,255 +1,120 @@
|
||||
<!-- Generated by securitycontext.dev. Source HEAD: 3e1e0e81f091c7817bbf7af64bb8eec6e8c2bac1. -->
|
||||
<!-- Generated by securitycontext.dev. Source HEAD: df57ae6d2955864fd92a1bd2565fe9e4e9a7592b. -->
|
||||
# Security Context: projectdiscovery/nuclei @ dev
|
||||
|
||||
> Reference context for editing projectdiscovery/nuclei. Before changing any component below, **preserve the listed guards and do not reintroduce these fixed issues**. Each fingerprint is a regression check, and each shared attack surface is a guard that must keep holding on every path.
|
||||
|
||||
_Generated 2026-09-01 16:22 UTC by secc_
|
||||
|
||||
_Analyzed commit: 3e1e0e81f091c7817bbf7af64bb8eec6e8c2bac1_
|
||||
|
||||
**Remediation coverage: 56.7%**
|
||||
_17 of 30 historical fixes have no remaining unguarded variant; 368 open remediation leads to verify. Measures fix consistency, not a vulnerability verdict._
|
||||
|
||||
- Commits analyzed: **4814**
|
||||
- Commits flagged for review: **235**
|
||||
- Confirmed historical security fixes: **32**
|
||||
- Known published CVEs: **0**
|
||||
|
||||
## Summary
|
||||
|
||||
Nuclei's historical security posture is dominated by sandbox escapes and code execution via unsigned or unverified templates, with recurring SSRF, path traversal, and auth bypass issues across JS libraries, loaders, and workflow paths. The most critical theme is inconsistent enforcement of signature verification and local-file-access (-lfa) guards across direct, workflow, DAST, and JS-sandbox entry points, enabling variant attacks. A second theme is network policy (host allowlist) gaps in LDAP dialing and proxy handling. The security context also includes dependency upgrades (Alpine, go-git) and output file permission hardening, but the highest-value focus is on template trust boundaries and sandbox enforcement parity.
|
||||
_Analyzed [df57ae6d29][c-df57ae6d29] on `dev` · 5174 commits, 548 security-relevant examined · generated 2026-11-02_
|
||||
|
||||
## Recurring Security Patterns
|
||||
|
||||
**15.6% of classified fixes repeat a previously fixed pattern** (5 recurring, 27 first-seen). A recurring pattern means this repository fixed a similar issue before — not that the code is currently vulnerable.
|
||||
|
||||
- **Code Execution:** [1c440e755a](https://github.com/projectdiscovery/nuclei/commit/1c440e755a97471c56fb0276ee8a8c4132974645) → [9de96e4dda](https://github.com/projectdiscovery/nuclei/commit/9de96e4dda5a03da963b9ae6582f03ea55791a76) — matched on `class:code execution`, `component:pkg/catalog/loader/loader.go`
|
||||
- **Code Execution via Unsigned Template:** [fa56800fcc](https://github.com/projectdiscovery/nuclei/commit/fa56800fcca3c8c8fe8e8dec28584210ca1527de) → [fac0b7196d](https://github.com/projectdiscovery/nuclei/commit/fac0b7196d2fbca56c8af8ff8e1899c111041a37) — matched on `class:code execution via unsigned template`, `CWE-347`, `sink:loadtemplates`
|
||||
- **SSRF:** [a167e6c57b](https://github.com/projectdiscovery/nuclei/commit/a167e6c57b80f5dc7be231231b62d7c594ee2770) → [9def9af7e3](https://github.com/projectdiscovery/nuclei/commit/9def9af7e3f7ebcf5cd561c37b623b9999ee7572) — matched on `class:ssrf`, `CWE-918`, `component:pkg/js/libs/ldap/ldap.go`
|
||||
- **Dependency Vulnerability:** [d93b4a01df](https://github.com/projectdiscovery/nuclei/commit/d93b4a01df9c218a36dd2fccfb1c2c1f96b181fe) → [fcd5c6b111](https://github.com/projectdiscovery/nuclei/commit/fcd5c6b111ddcb746525f88b7d0bf4b4abbc2529) — matched on `class:dependency vulnerability`, `CWE-1104`, `component:dockerfile`
|
||||
- **Sandbox Escape:** [96049a32fa](https://github.com/projectdiscovery/nuclei/commit/96049a32fa70aef0483d4180d386a57b3c2d973a) → [cf2a98b03d](https://github.com/projectdiscovery/nuclei/commit/cf2a98b03d76327c20fb0183b7f1c849eb692c91) — matched on `class:sandbox escape`, `CWE-284`, `component:v2/internal/runner/processor.go`, `sink:stdlib allmodulenames`, `sink:stdlib getmodulemap`
|
||||
**7.1% of classified fixes repeat a previously fixed pattern** (1 recurring, 13 first-seen). A recurring pattern means this repository fixed a similar issue before — not that the code is currently vulnerable.
|
||||
|
||||
## Top Risks
|
||||
|
||||
### 1. Sandbox Escape: `pkg/js/compiler/pool.go, v2/pkg/protocols/javascript/js.go, v2/pkg/protocols/headless/engine/engine.go, v2/internal/runner/processor.go` (high)
|
||||
Components with the strongest concentration of past fixes for one class.
|
||||
|
||||
Multiple sandbox escapes across JS require(), eval/ReadFile, headless browser flags, and tengo imports, indicating weak default sandbox boundaries that require consistent -lfa and safe-module enforcement.
|
||||
|
||||
Evidence: 6f2ade6a9b427c284c15a43445f9c7f055e60e5d, c35162c8ef061a96dd74b54c8f022c1a41cf5833, df55f7a2eb591a7949b5c66a9c54db54aa28f450, cf2a98b03d76327c20fb0183b7f1c849eb692c91, 96049a32fa70aef0483d4180d386a57b3c2d973a
|
||||
|
||||
### 2. Code Execution via Unsigned Template: `pkg/catalog/loader/loader.go, pkg/templates/workflows.go, pkg/templates/templates.go` (critical)
|
||||
|
||||
Unsigned templates can execute code through direct loading, workflows, and DAST branches; signature verification was bypassed via cache poisoning and missing guards on specific paths.
|
||||
|
||||
Evidence: 9de96e4dda5a03da963b9ae6582f03ea55791a76, 1c440e755a97471c56fb0276ee8a8c4132974645, fac0b7196d2fbca56c8af8ff8e1899c111041a37, e86f38299765b82ad724fdb701557e0eaff3884d, fa56800fcca3c8c8fe8e8dec28584210ca1527de
|
||||
|
||||
### 3. Auth Bypass: `pkg/templates/compile.go, pkg/templates/signer/tmpl_signer.go, pkg/catalog/loader/loader.go, pkg/protocols/http/request.go` (high)
|
||||
|
||||
Signature verification can be bypassed via cached metadata, improper signature extraction, or missing checks on specific load paths, and cookie jar bypass via disabled cookie handling.
|
||||
|
||||
Evidence: 4534e9cb3034ebe5cc7b0cecabe399b6fb9ce0e4, 0da993afe6d41b4b1b814e8fad23a2acba13c60a, 9bd4db3f74aedd067af568df4f9198a8a090f68b, a976ca8d3f6a18c9ca746c8ce73f28fdcd518642
|
||||
|
||||
### 4. SSRF: `pkg/js/libs/ldap/ldap.go, v2/internal/runner/proxy.go` (high)
|
||||
|
||||
SSRF via LDAP dialing to internal hosts and via proxy configuration; host allowlist checks were missing or applied to the wrong target (domain vs controller).
|
||||
|
||||
Evidence: 9def9af7e3f7ebcf5cd561c37b623b9999ee7572, a167e6c57b80f5dc7be231231b62d7c594ee2770, 0e46d3e0410f89e4af41099f5cd38fa7f54ae54f
|
||||
|
||||
### 5. Path Traversal: `pkg/catalog/config/nucleiconfig.go, v2/internal/runner/update.go, v2/pkg/protocols/common/protocolstate/headless.go` (high)
|
||||
|
||||
Path traversal via lexical prefix checks, zip-slip in template extraction, and file:// scheme in headless mode; canonicalization and containment checks are required.
|
||||
|
||||
Evidence: 20cbf0bd73893c1270582c9778b6b26a5cb11206, f9c214a66f37a00fc29fe64232b690e6285ec59c, d3928e080d819f25306bfbaecc399f03516db5aa
|
||||
|
||||
### 6. Code Execution: `pkg/catalog/loader/loader.go, pkg/templates/workflows.go` (high)
|
||||
|
||||
Code execution via cache poisoning and unverified code templates in workflows; requires -code flag and verified templates.
|
||||
|
||||
Evidence: 9de96e4dda5a03da963b9ae6582f03ea55791a76, e86f38299765b82ad724fdb701557e0eaff3884d
|
||||
|
||||
## Shared Attack Surfaces
|
||||
|
||||
Capabilities guarded by more than one past fix on different code paths. A guard enforced on one entry point is often missing on a sibling; verify each alternate path before assuming it's covered.
|
||||
|
||||
### 1. Template signature verification and unsigned-template opt-in enforcement across all load paths
|
||||
|
||||
- Guard that should hold everywhere: signature verification (template.Verified, Verify), DisableUnsignedTemplates flag, -code flag for code templates, cache invalidation on verification status/fingerprint/digest
|
||||
- Entry points: `pkg/catalog/loader/loader.go (LoadTemplatesWithTags, DAST branch)`, `pkg/templates/workflows.go (loadTemplates, workflowExecutor)`, `pkg/templates/templates.go (loadTemplates)`, `pkg/templates/compile.go (template.Verified)`, `pkg/templates/signer/tmpl_signer.go (Verify)`
|
||||
- Check: Inspect the DAST and workflow sub-loaders: they must enforce the same DisableUnsignedTemplates and -code checks as the direct loader, and must not rely on cached verification results without re-checking signatures.
|
||||
- Evidence: 9de96e4dda5a03da963b9ae6582f03ea55791a76, 1c440e755a97471c56fb0276ee8a8c4132974645, fac0b7196d2fbca56c8af8ff8e1899c111041a37, e86f38299765b82ad724fdb701557e0eaff3884d, fa56800fcca3c8c8fe8e8dec28584210ca1527de, 4534e9cb3034ebe5cc7b0cecabe399b6fb9ce0e4, 0da993afe6d41b4b1b814e8fad23a2acba13c60a, 9bd4db3f74aedd067af568df4f9198a8a090f68b
|
||||
|
||||
### 2. Local file access (read/write) outside the sandbox, gated by -lfa flag
|
||||
|
||||
- Guard that should hold everywhere: -lfa flag enforcement, path canonicalization and containment (sandbox directory), file:// scheme blocking, allowAllFiles stripping, require() registry rebuild
|
||||
- Entry points: `pkg/js/libs/krbforge/krbforge.go (CreateTicket)`, `pkg/js/libs/mysql/mysql_private.go (sql.Open)`, `pkg/js/compiler/pool.go (require)`, `v2/pkg/protocols/javascript/js.go (ReadFile, eval)`, `v2/pkg/protocols/common/protocolstate/headless.go (file:)`, `v2/pkg/protocols/common/generators/load.go (loadPayloadsFromFile)`
|
||||
- Check: Verify that every JS library and protocol helper (krbforge, mysql, require, headless file://, payload loaders) enforces the -lfa flag and path containment; a sibling path that skips the -lfa check is a sandbox escape.
|
||||
- Evidence: 3a8b65cb45f121fcdf418f60e5177ed9f250c122, 9611926a9a1ca99e8ed554d1b26fbeff60e06368, 6f2ade6a9b427c284c15a43445f9c7f055e60e5d, c35162c8ef061a96dd74b54c8f022c1a41cf5833, d3928e080d819f25306bfbaecc399f03516db5aa, e7fb40a41308f6c41267e858d1781eb67f7e0572
|
||||
|
||||
### 3. Network policy (host allowlist) enforcement before outbound connections
|
||||
|
||||
- Guard that should hold everywhere: IsHostAllowed check on the actual target host (controller, not domain), proxy validation and connectivity check, ProxyInternal gate for HTTP_PROXY env
|
||||
- Entry points: `pkg/js/libs/ldap/ldap.go (Dial, newLdapSession)`, `v2/internal/runner/proxy.go (loadProxyServers, os.Setenv HTTP_PROXY)`
|
||||
- Check: Inspect other dialing paths (e.g., HTTP, headless) to ensure they apply the same host allowlist check on the resolved IP/host, not just a user-supplied domain, and that proxy settings cannot redirect traffic to internal hosts.
|
||||
- Evidence: 9def9af7e3f7ebcf5cd561c37b623b9999ee7572, a167e6c57b80f5dc7be231231b62d7c594ee2770, 0e46d3e0410f89e4af41099f5cd38fa7f54ae54f, 3f5c055aca1499642caf4f40e8cf55ad32bd4da9
|
||||
|
||||
### 4. Path traversal in file extraction and template path trust boundaries
|
||||
|
||||
- Guard that should hold everywhere: canonical path containment check (filepath.Rel, prefix), zip-slip validation against template directory, sandbox path prefix validation
|
||||
- Entry points: `pkg/catalog/config/nucleiconfig.go (IsCustomTemplate)`, `v2/internal/runner/update.go (os.OpenFile)`, `v2/pkg/protocols/common/generators/load.go (loadPayloadsFromFile)`
|
||||
- Check: Audit all file-write and file-read paths that accept template-controlled paths (e.g., template extraction, payload loading) to ensure they use canonical containment, not lexical prefix checks, and apply the same sandbox directory restriction.
|
||||
- Evidence: 20cbf0bd73893c1270582c9778b6b26a5cb11206, f9c214a66f37a00fc29fe64232b690e6285ec59c, e7fb40a41308f6c41267e858d1781eb67f7e0572
|
||||
|
||||
## Historical Vulnerability Classes
|
||||
|
||||
| Class | Count |
|
||||
|---|---|
|
||||
| Sandbox Escape | 7 |
|
||||
| Auth Bypass | 4 |
|
||||
| Code Execution | 3 |
|
||||
| Dependency Vulnerability | 3 |
|
||||
| Path Traversal | 3 |
|
||||
| SSRF | 3 |
|
||||
| Code Execution via Unsigned Template | 2 |
|
||||
| Arbitrary File Read | 1 |
|
||||
| Cryptographic Weakness | 1 |
|
||||
| Deserialization of Untrusted Data | 1 |
|
||||
| Information Disclosure | 1 |
|
||||
| Insecure File Permissions | 1 |
|
||||
| Race Condition | 1 |
|
||||
| Security Misconfiguration | 1 |
|
||||
| # | Class | Component | Severity | Fixes | Evidence |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | SSRF | `pkg/protocols/common/protocolstate/headless.go` | high | 2 | [d43c1f547f][c-d43c1f547f], [d9dabbb4a1][c-d9dabbb4a1] |
|
||||
| 2 | Arbitrary File Access | `v2/pkg/protocols/common/generators/load.go` | high | 0 (1 component gone, not counted) | [e5154d362a][c-e5154d362a] |
|
||||
| 3 | Arbitrary File Access | `pkg/js/libs/mysql/mysql.go` | high | 1 | [634cecbb28][c-634cecbb28] |
|
||||
| 4 | Code Execution | `pkg/fuzz/execute.go` | high | 1 | [ccbfb12bd0][c-ccbfb12bd0] |
|
||||
| 5 | Code Execution | `pkg/protocols/common/expressions/expressions.go` | high | 1 | [d221732016][c-d221732016] |
|
||||
| 6 | Information Disclosure | `internal/httpapi/apiendpoint.go` | high | 1 | [a20aa26024][c-a20aa26024] |
|
||||
|
||||
## Fixed-Issue Fingerprints
|
||||
|
||||
These are real security fixes mined from git history. Use them as regression checks.
|
||||
Real security fixes mined from git history; use them as regression checks. `verified` fixes are linked to a published advisory, `inferred` fixes were confirmed from the diff evidence alone. Each is identified by its short commit SHA, which the variant-leads report uses to reference it back to this history.
|
||||
|
||||
- **Auth Bypass** (high) [CWE-287]: Honors disable-cookie by preventing the shared cookie jar from being applied when cookie handling is disabled, closing a path where cookies could be reused across requests.
|
||||
- sink: `SetCookieJar(input.CookieJar)`
|
||||
- fix: Adds !request.DisableCookie check before applying shared cookie jar
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/a976ca8d3f6a18c9ca746c8ce73f28fdcd518642 (2026-08-25)
|
||||
- **Code Execution** (high) [CWE-347]: This commit fixes a cache poisoning vulnerability where unsigned templates could bypass signature verification and execute malicious code during compilation or request execution.
|
||||
- sink: `template compilation and execution of JavaScript`
|
||||
- fix: cache invalidation based on verification status, verifier fingerprint, and content digest
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/9de96e4dda5a03da963b9ae6582f03ea55791a76 (2026-08-04)
|
||||
- **Code Execution via Unsigned Template** (high) [CWE-347]: Enforces signature verification on JavaScript templates, preventing execution of unsigned templates that could lead to arbitrary code execution.
|
||||
- sink: `loading unsigned javascript templates into execution paths`
|
||||
- fix: Require signature verification for javascript templates in direct loading and workflows when signing is enforced
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/fac0b7196d2fbca56c8af8ff8e1899c111041a37 (2026-07-03)
|
||||
- **Sandbox Escape** (high) [CWE-22]: Enforces nuclei's local file access policy on krbforge output paths, preventing arbitrary file writes outside the sandbox.
|
||||
- sink: `gpkrb.CreateTicket(cfg) with cfg.OutputFile`
|
||||
- fix: Path normalization into sandbox and denial of outside paths unless -lfa
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/3a8b65cb45f121fcdf418f60e5177ed9f250c122 (2026-06-24)
|
||||
- **SSRF** (high) [CWE-918]: Adds network policy host allowlist checks before LDAP dialing, preventing SSRF to restricted internal hosts.
|
||||
- sink: `dialers.Fastdialer.Dial`
|
||||
- fix: host allowlist check via IsHostAllowed before dialing
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/9def9af7e3f7ebcf5cd561c37b623b9999ee7572 (2026-06-24)
|
||||
- **Arbitrary File Read** (high) [CWE-22]: Gates MySQL's allowAllFiles option behind the -lfa flag, preventing arbitrary local file reads via LOAD DATA LOCAL INFILE.
|
||||
- sink: `sql.Open("mysql", dsn) with AllowAllFiles=true`
|
||||
- fix: DSN sandbox that strips allowAllFiles unless -lfa is set
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/9611926a9a1ca99e8ed554d1b26fbeff60e06368 (2026-06-19)
|
||||
- **Code Execution** (critical) [CWE-94]: Moves the unsigned code-template guard ahead of the DAST branch, preventing unsigned code templates from being loaded and executed under DAST, which previously bypassed the signature check.
|
||||
- sink: `Template loading and execution`
|
||||
- fix: Hoisting unsigned code-template guard before DAST branch to ensure it applies on all load paths
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/1c440e755a97471c56fb0276ee8a8c4132974645 (2026-06-19)
|
||||
- **Path Traversal** (high) [CWE-22]: This commit replaces lexical prefix checks with canonical path containment checks across multiple filesystem trust boundaries, preventing path traversal where sibling paths were incorrectly treated as trusted.
|
||||
- sink: `IsCustomTemplate`
|
||||
- fix: path canonicalization and containment check
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/20cbf0bd73893c1270582c9778b6b26a5cb11206 (2026-06-04)
|
||||
- **Information Disclosure** (medium) [CWE-200]: This commit fixes a data leakage issue in workflows where sibling state could leak across concurrent executions, and race requests ignored dynamic values, by syncing context and cloning inputs.
|
||||
- sink: `syncWorkflowInputToTemplateCtx`
|
||||
- fix: context synchronization and input cloning
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/805bcfe76f3629dbe71021795d4ce865406c1555 (2026-05-08)
|
||||
- **Sandbox Escape** (high) [CWE-284]: Fixes sandbox escape where require() bypassed local file access restrictions and reused privileged module cache across executions.
|
||||
- sink: `require() using default host filesystem loader`
|
||||
- fix: Rebuild require registry per execution and enforce allow-local-file-access sandbox rules
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/6f2ade6a9b427c284c15a43445f9c7f055e60e5d (2026-04-10)
|
||||
- **Cryptographic Weakness** (medium) [CWE-338]: Replaces predictable math/rand PRNG with cryptographically secure crypto/rand in JS helper functions.
|
||||
- sink: `rand.Intn(255) / rand.Int63()`
|
||||
- fix: Replace predictable math/rand with crypto/rand
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/44ef9ce34ffcfdfca3a96626abc73d99dd0e83ab (2026-03-15)
|
||||
- **Auth Bypass** (high) [CWE-347]: Caching signature verification results without cryptographic re-check allows a metadata index compromise to bypass template signature verification.
|
||||
- sink: `template.Verified, template.TemplateVerifier`
|
||||
- fix: cached verification data reuse
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/4534e9cb3034ebe5cc7b0cecabe399b6fb9ce0e4 (2026-01-21)
|
||||
- **Race Condition** (medium) [CWE-362]: Fixes a race condition in workflow execution caused by shared context callbacks, which could lead to data corruption or crashes; the primary impact is reliability, not a direct security breach.
|
||||
- sink: `shared context callbacks in workflow execution`
|
||||
- fix: fix race condition by making workflow execution sequential
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/24d1f58682027e23cb21a299a170abd2bdf20dc2 (2025-12-05)
|
||||
- **Auth Bypass** (high) [CWE-347]: Fixes template signature verification by centralizing signature extraction and requiring the signature to be at the end of the template, preventing bypass.
|
||||
- sink: `Verify`
|
||||
- fix: signature extraction and verification
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/0da993afe6d41b4b1b814e8fad23a2acba13c60a (2024-08-19)
|
||||
- **Dependency Vulnerability** (medium) [CWE-1104]: Upgrades Alpine base image from 3.18.5 to 3.18.6 to fix known security vulnerabilities in bundled packages.
|
||||
- sink: `FROM alpine:3.18.5`
|
||||
- fix: Upgrade base image to patched version
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/fcd5c6b111ddcb746525f88b7d0bf4b4abbc2529 (2024-03-15)
|
||||
- **Dependency Vulnerability** (medium) [CWE-1104]: Upgrades Alpine base image from 3.18.2 to 3.18.5 to fix known security vulnerabilities in bundled packages.
|
||||
- sink: `FROM alpine:3.18.2`
|
||||
- fix: Upgrade base image to patched version
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/d93b4a01df9c218a36dd2fccfb1c2c1f96b181fe (2024-03-15)
|
||||
- **Dependency Vulnerability** (high) [CWE-1035]: Updates go-git to fix known vulnerabilities (CVE-2023-49569, CVE-2023-49568), which could be exploited when processing untrusted git data.
|
||||
- sink: `go-git library usage`
|
||||
- fix: dependency upgrade to patched version
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/1700cdf4e23128a358066364062246b58c1c013f (2024-03-14)
|
||||
- **Code Execution via Unsigned Template** (high) [CWE-347]: Introduces a flag to disable unsigned templates, preventing execution of unverified templates that could lead to arbitrary code execution.
|
||||
- sink: `loading unsigned templates when -dut flag is set`
|
||||
- fix: Adds -disable-unsigned-templates flag to skip unsigned templates and report them separately
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/fa56800fcca3c8c8fe8e8dec28584210ca1527de (2024-03-14)
|
||||
- **Auth Bypass** (medium) [CWE-347]: This commit introduces a flag to skip unsigned templates, closing a path where an attacker could execute untrusted templates by bypassing signature verification.
|
||||
- sink: `parsed.Verified`
|
||||
- fix: skip unverified templates when DisableUnsignedTemplates is set
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/9bd4db3f74aedd067af568df4f9198a8a090f68b (2024-03-10)
|
||||
- **Code Execution** (high) [CWE-94]: Prevents execution of unverified code templates in workflows, mitigating code execution risk.
|
||||
- sink: `template.RequestsCode`
|
||||
- fix: require -code flag and verified templates for code templates in workflows
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/e86f38299765b82ad724fdb701557e0eaff3884d (2024-03-01)
|
||||
- **SSRF** (high) [CWE-918]: Fixes SSRF by validating the actual LDAP controller host against the network policy instead of the domain name.
|
||||
- sink: `newLdapSession`
|
||||
- fix: host allowlist check on controller instead of domain
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/a167e6c57b80f5dc7be231231b62d7c594ee2770 (2024-01-20)
|
||||
- **Sandbox Escape** (high) [CWE-94]: Disables eval and tightens file read validation in the JavaScript sandbox, preventing template-driven code execution and file access outside the sandbox.
|
||||
- sink: `eval function and file read in sandbox mode`
|
||||
- fix: Disable eval function and rewrite file validation in sandbox mode
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/c35162c8ef061a96dd74b54c8f022c1a41cf5833 (2023-10-13)
|
||||
- **Path Traversal** (high) [CWE-22]: Adds network policy and blocks file:// protocol in headless mode unless -lfa is set, preventing local file disclosure.
|
||||
- sink: `file:// protocol access in headless browser`
|
||||
- fix: Network policy validation and file:// scheme blocking when local file access is disabled
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/d3928e080d819f25306bfbaecc399f03516db5aa (2023-08-25)
|
||||
- **Deserialization of Untrusted Data** (medium) [CWE-502]: Fixes JSON deserialization by adding explicit UnmarshalJSON methods and correcting tags, preventing type confusion and potential logic bypasses.
|
||||
- sink: `UnmarshalJSON on user-controlled fields like Type and Name`
|
||||
- fix: Adds proper UnmarshalJSON implementations and corrects json tags to prevent type confusion
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/85090b75317977bb9301444c9dfd8e4c2dba667a (2023-02-07)
|
||||
- **Sandbox Escape** (medium) [CWE-22]: The commit adds sandbox mode that restricts payload file paths to the template directory, preventing arbitrary file reads when sandbox is enabled.
|
||||
- sink: `loadPayloadsFromFile`
|
||||
- fix: path prefix validation against template directory and template path directory
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/e7fb40a41308f6c41267e858d1781eb67f7e0572 (2022-11-16)
|
||||
- **Insecure File Permissions** (low) [CWE-732]: Fixes overly permissive file permissions on debug output files by changing from os.ModePerm (0777) to 0644.
|
||||
- sink: `os.OpenFile`
|
||||
- fix: restrict file mode to 0644
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/af4854f90de728bb08689d5cae3dec4acfdb6b9a (2022-06-20)
|
||||
- **Security Misconfiguration** (medium) [CWE-16]: Prevents setting HTTP_PROXY environment variable unless explicitly enabled, avoiding potential proxy hijacking.
|
||||
- sink: `os.Setenv(types.HTTP_PROXY_ENV, proxyURL.String())`
|
||||
- fix: Added check for options.ProxyInternal before setting HTTP_PROXY environment variable
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/3f5c055aca1499642caf4f40e8cf55ad32bd4da9 (2022-03-09)
|
||||
- **Sandbox Escape** (high) [CWE-250]: The commit restricts the use of the --no-sandbox flag to Linux root users, preventing sandbox escape vulnerabilities in other environments.
|
||||
- sink: `NoSandbox(true)`
|
||||
- fix: Conditionally disable sandbox only on Linux as root
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/df55f7a2eb591a7949b5c66a9c54db54aa28f450 (2021-12-05)
|
||||
- **Path Traversal** (high) [CWE-22]: This commit fixes a zip-slip path traversal vulnerability by validating the extracted file path against the configured template directory.
|
||||
- sink: `os.OpenFile`
|
||||
- fix: path canonicalization and prefix check
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/f9c214a66f37a00fc29fe64232b690e6285ec59c (2021-11-25)
|
||||
- **SSRF** (high) [CWE-918]: Adds validation and connectivity checks for SOCKS5 proxies before launching a scan, preventing SSRF through proxy configuration.
|
||||
- sink: `Proxy connection validation and loading`
|
||||
- fix: Proxy server validation and connectivity check before scan launch
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/0e46d3e0410f89e4af41099f5cd38fa7f54ae54f (2021-11-10)
|
||||
- **Sandbox Escape** (high) [CWE-284]: This commit adds a sandbox flag that, when enabled, restricts the tengo scripting engine to safe modules, preventing sandbox escape and arbitrary command execution.
|
||||
- sink: `stdlib.GetModuleMap(stdlib.AllModuleNames()...)`
|
||||
- fix: Added sandbox flag to restrict imports to safe modules when sandbox is enabled
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/cf2a98b03d76327c20fb0183b7f1c849eb692c91 (2020-12-13)
|
||||
- **Sandbox Escape** (high) [CWE-284]: This commit restricts the tengo scripting engine to a safe module list, preventing sandbox escape and arbitrary command execution via workflow templates.
|
||||
- sink: `stdlib.GetModuleMap(stdlib.AllModuleNames()...)`
|
||||
- fix: Restricted tengo script imports to a safe module list
|
||||
- commit: https://github.com/projectdiscovery/nuclei/commit/96049a32fa70aef0483d4180d386a57b3c2d973a (2020-12-13)
|
||||
**By component** (directories with the most fixes):
|
||||
|
||||
- `pkg/protocols/common/protocolstate` — 2 fixes: SSRF (2)
|
||||
|
||||
- [1c0f77d28e][c-1c0f77d28e] **fix(js/net): enforce network policy on proxied target in JS net dial** — SSRF, high [CWE-918] · inferred · 2026-10-18
|
||||
`pkg/js/libs/net/net.go` · sink `dialHTTPProxy` · fix: input validation
|
||||
- [d43c1f547f][c-d43c1f547f] **fix(headless): canonicalize IPv4-mapped IPv6 literals and re-validate dial targets** — SSRF, high [CWE-918] · inferred · 2026-10-13
|
||||
`pkg/protocols/common/protocolstate/headless.go` · sink `net.DialTimeout` · fix: canonicalization before validation
|
||||
- [d9dabbb4a1][c-d9dabbb4a1] **fix(headless): enforce network policy on ws and wss schemes (#7867)** — SSRF, high [CWE-918] · inferred · 2026-10-12
|
||||
`pkg/protocols/common/protocolstate/headless.go` · sink `isValidHost` · fix: canonicalization before validation
|
||||
- [a20aa26024][c-a20aa26024] **Fix pprof/auth exposure on http-api-endpoint and harden file permissions** — Information Disclosure, high [CWE-200] · inferred · 2026-08-03
|
||||
`internal/httpapi/apiendpoint.go` · sink `http.ListenAndServe` · fix: authentication verification
|
||||
- [fac0b7196d][c-fac0b7196d] **feat(templates): require signatures for javascript templates (#7514)** — Signature Verification Bypass, high [CWE-347] · verified (CVE-2026-76819, GHSA-vxg7-f2jj-jmqm) · 2026-07-03
|
||||
`pkg/catalog/loader/loader.go` · sink `t.HasJavascriptRequest` · fix: cryptographic verification
|
||||
- [ccbfb12bd0][c-ccbfb12bd0] **refactor(protocols): template rendering boundary (#7499)** — Code Execution, high [CWE-94] · verified (CVE-2026-76805, GHSA-jpvm-9frm-hjcq) · 2026-06-30
|
||||
`pkg/fuzz/execute.go` · sink `expressions.Evaluate` · fix: sandbox isolation
|
||||
- [3a8b65cb45][c-3a8b65cb45] **fix(js): reject unsandboxed ccache writes in krbforge (#7482)** — Sandbox Escape, high [CWE-693] · inferred · 2026-06-24
|
||||
`pkg/js/libs/krbforge/krbforge.go` · sink `OutputFile` · fix: sandbox isolation
|
||||
- [68daef44a7][c-68daef44a7] **fix(js): escape dbname in lib/pq URL (#7479)** — SQL Injection, high [CWE-89] · inferred · 2026-06-23
|
||||
`pkg/js/libs/postgres/postgres.go` · sink `fmt.Sprintf` (library call — the operation around it is the sink) · fix: context-aware output escaping
|
||||
- [634cecbb28][c-634cecbb28] **gate mysql allowAllFiles behind -lfa** — Arbitrary File Access, high [CWE-22] · verified (CVE-2026-76803, GHSA-xhmx-w2j4-rw3q) · 2026-06-19
|
||||
`pkg/js/libs/mysql/mysql.go` · sink `sql.Open` · fix: sandbox isolation
|
||||
- [20cbf0bd73][c-20cbf0bd73] **refactor: use path-aware filesystem containment checks (#7420)** — Path Traversal, high [CWE-22] · inferred · 2026-06-04
|
||||
`pkg/installer/template.go` · sink `filepath.Join` · fix: canonicalization before validation
|
||||
- [6f2ade6a9b][c-6f2ade6a9b] **fix(js): respect \`allow-local-file-access\` in \`require\` (#7332)** — Sandbox Escape, high [CWE-693] · inferred · 2026-04-10
|
||||
`pkg/js/compiler/pool.go` · sink `require.DefaultSourceLoader` · fix: sandbox isolation
|
||||
- [d221732016][c-d221732016] **fix(expressions): only eval template-authored expressions (#7221)** — Code Execution, high [CWE-94] · verified (CVE-2026-76805, GHSA-jpvm-9frm-hjcq) · 2026-03-16
|
||||
`pkg/protocols/common/expressions/expressions.go` · sink `FindExpressions` · fix: allowlist enforcement
|
||||
- [0da993afe6][c-0da993afe6] **Merge commit from fork** — Signature Verification Bypass, medium [CWE-347] · verified (CVE-2024-43405, GHSA-7h5p-mmpp-hgmm) · 2024-08-19
|
||||
`pkg/templates/signer/tmpl_signer.go` · sink `os.ReadFile` · fix: cryptographic verification
|
||||
- [e5154d362a][c-e5154d362a] **fixing payload load (#3927)** — Arbitrary File Access, high [CWE-22] · verified (CVE-2023-37896, GHSA-2xx4-jj5v-6mff) · 2023-07-14
|
||||
`v2/pkg/protocols/common/generators/load.go` · sink `loadPayloads` · fix: sandbox isolation
|
||||
|
||||
## Historical Vulnerability Classes
|
||||
|
||||
| Class | Fixes | Verified | Most recent fix |
|
||||
|---|---|---|---|
|
||||
| SSRF | 3 | 0 | [2026-10-18][c-1c0f77d28e] 1c0f77d28e |
|
||||
| Arbitrary File Access | 1 (1 component gone, not counted) | 2 | [2026-06-19][c-634cecbb28] 634cecbb28 |
|
||||
| Code Execution | 2 | 2 | [2026-06-30][c-ccbfb12bd0] ccbfb12bd0 |
|
||||
| Sandbox Escape | 2 | 0 | [2026-06-24][c-3a8b65cb45] 3a8b65cb45 |
|
||||
| Signature Verification Bypass | 2 | 2 | [2026-07-03][c-fac0b7196d] fac0b7196d |
|
||||
| Information Disclosure | 1 | 0 | [2026-08-03][c-a20aa26024] a20aa26024 |
|
||||
| Path Traversal | 1 | 0 | [2026-06-04][c-20cbf0bd73] 20cbf0bd73 |
|
||||
| SQL Injection | 1 | 0 | [2026-06-23][c-68daef44a7] 68daef44a7 |
|
||||
|
||||
## Known Advisories
|
||||
|
||||
### Linked to a fix commit in this history (5)
|
||||
|
||||
- **CVE-2026-76805** / GHSA-jpvm-9frm-hjcq (medium 5.3) CWE-200 — fixed in [ccbfb12bd0][c-ccbfb12bd0], [d221732016][c-d221732016]
|
||||
- Environment Variable Disclosure via Response-Derived Data in DAST/Fuzz Mode. A vulnerability in Nuclei's DAST/fuzz expression evaluation path allows a malicious target server to trigger disclosure of ...
|
||||
- **CVE-2026-76803** / GHSA-xhmx-w2j4-rw3q (medium 5.3) CWE-284 — fixed in [634cecbb28][c-634cecbb28]
|
||||
- Local File Read via MySQL Client Sandbox Bypass. A vulnerability in Nuclei's JavaScript MySQL client library allows arbitrary local file reads that bypass the `-allow-local-file-access` (`-lfa`) sandb...
|
||||
- **CVE-2026-76819** / GHSA-vxg7-f2jj-jmqm (high 8.6) CWE-94,CWE-787 — fixed in [fac0b7196d][c-fac0b7196d]
|
||||
- Arbitrary Code Execution via Goja JavaScript Engine Vulnerability. A vulnerability in the Goja JavaScript engine used by Nuclei's `javascript:` protocol allows arbitrary native code execution on the s...
|
||||
- **CVE-2024-43405** / GHSA-7h5p-mmpp-hgmm (high 7.4) CWE-78 — fixed in [0da993afe6][c-0da993afe6]
|
||||
- Nuclei Template Signature Verification Bypass. ## Summary A vulnerability has been identified in Nuclei's template signature verification system that could allow an attacker to bypass the signature ch...
|
||||
- **CVE-2023-37896** / GHSA-2xx4-jj5v-6mff (medium 5.9) CWE-22 — fixed in [e5154d362a][c-e5154d362a]
|
||||
- Nuclei < 2.9.9 - Sandbox restriction bypass. ## Overview Affected: < 2.9.9 (patched 2.9.9).
|
||||
|
||||
### Without a linked fix commit (6)
|
||||
|
||||
Published elsewhere, fixed before the history window, or not yet linked; full descriptions are in the JSON context.
|
||||
|
||||
| Advisory | Severity | Published | CWE | Summary |
|
||||
|---|---|---|---|---|
|
||||
| CVE-2026-76804 / GHSA-qgw5-7j4f-fg97 | medium 5.5 | 2026-07-16 | CWE-284 | Local File Read via Workflow File-Protocol Gate Bypass. A vulnerability in Nuclei's workfl... |
|
||||
| CVE-2026-76802 / GHSA-jpf4-98qj-qr67 | medium 4.7 | 2026-07-16 | CWE-78 | Arbitrary Command Execution via DAST Code Signature Bypass. A vulnerability in Nuclei's DA... |
|
||||
| CVE-2026-41645 / GHSA-jm34-66cf-qpvr | medium 5.3 | 2026-04-18 | CWE-94 | Environment variable disclosure via Response-Derived DSL Expressions. A vulnerability in N... |
|
||||
| CVE-2026-41646 / GHSA-29rg-wmcw-hpf4 | medium 5.5 | 2026-04-18 | CWE-284 | Local File Read via require() Module Loader Bypass. A vulnerability in Nuclei's JavaScript... |
|
||||
| CVE-2024-40641 / GHSA-c3q9-c27p-cw9h | high 7.4 | 2024-07-17 | CWE-78 | Unsigned code template execution through workflows in projectdiscovery/nuclei. ### Summary... |
|
||||
| CVE-2024-27920 / GHSA-w5wx-6g2r-r78q | high 7.4 | 2024-03-15 | CWE-78 | Unsigned code template execution through workflows in projectdiscovery/nuclei. ### Overvie... |
|
||||
|
||||
[c-df57ae6d29]: https://github.com/projectdiscovery/nuclei/commit/df57ae6d2955864fd92a1bd2565fe9e4e9a7592b
|
||||
[c-d43c1f547f]: https://github.com/projectdiscovery/nuclei/commit/d43c1f547f98ac56654f4335ad20af0cca4c229c
|
||||
[c-d9dabbb4a1]: https://github.com/projectdiscovery/nuclei/commit/d9dabbb4a19e897a6bc12334a030a43035b27b73
|
||||
[c-e5154d362a]: https://github.com/projectdiscovery/nuclei/commit/e5154d362af900e83b30fb79028bfab1a7f0c648
|
||||
[c-634cecbb28]: https://github.com/projectdiscovery/nuclei/commit/634cecbb289db494247c04a9c4ec6a113aef3cc0
|
||||
[c-ccbfb12bd0]: https://github.com/projectdiscovery/nuclei/commit/ccbfb12bd01447ba25f6e755dfb2bee677736da6
|
||||
[c-d221732016]: https://github.com/projectdiscovery/nuclei/commit/d2217320162d5782ca7cb95bef9dda17063818f3
|
||||
[c-a20aa26024]: https://github.com/projectdiscovery/nuclei/commit/a20aa260245e5db889c5a421d4a65aeb2a64bf46
|
||||
[c-1c0f77d28e]: https://github.com/projectdiscovery/nuclei/commit/1c0f77d28ed639a34c2036fa23c7503ffac0bf27
|
||||
[c-fac0b7196d]: https://github.com/projectdiscovery/nuclei/commit/fac0b7196d2fbca56c8af8ff8e1899c111041a37
|
||||
[c-3a8b65cb45]: https://github.com/projectdiscovery/nuclei/commit/3a8b65cb45f121fcdf418f60e5177ed9f250c122
|
||||
[c-68daef44a7]: https://github.com/projectdiscovery/nuclei/commit/68daef44a7eafd41c39690924620e418762909ef
|
||||
[c-20cbf0bd73]: https://github.com/projectdiscovery/nuclei/commit/20cbf0bd73893c1270582c9778b6b26a5cb11206
|
||||
[c-6f2ade6a9b]: https://github.com/projectdiscovery/nuclei/commit/6f2ade6a9b427c284c15a43445f9c7f055e60e5d
|
||||
[c-0da993afe6]: https://github.com/projectdiscovery/nuclei/commit/0da993afe6d41b4b1b814e8fad23a2acba13c60a
|
||||
|
||||
---
|
||||
_Fixed-issue fingerprints and CVE matches are derived from real data; LLM-written prose is advisory and should be verified against the cited commits._
|
||||
_Fixed-issue fingerprints and advisory matches are derived from git history and published advisories; the risk tables and pattern descriptions above are derived from them and should be verified against the cited commits._
|
||||
@@ -530,6 +530,16 @@ Description of the template.
|
||||
|
||||
You can go in-depth here on what the template actually does.
|
||||
|
||||
Description, impact and remediation may hold {{placeholders}} filled for
|
||||
each finding: named extractors, payloads, variables and DSL helpers
|
||||
(network helpers excluded), and the result fields host, port, ip, url,
|
||||
scheme, path, matched-at, matcher-name and extractor-name, which win over
|
||||
values of the same name. Use join(", ", name) for an extractor with
|
||||
several values, and brackets for a hyphenated name inside a helper:
|
||||
to_upper([matcher-name]). Unknown placeholders stay as written, and so
|
||||
does a field whose expression fails. Global matcher templates match on
|
||||
other templates' responses, so only the result fields fill their text.
|
||||
|
||||
|
||||
|
||||
Examples:
|
||||
@@ -543,6 +553,10 @@ description: Bower is a package manager which stores package information in the
|
||||
description: Subversion ALM for the enterprise before 8.8.2 allows reflected XSS at multiple locations
|
||||
```
|
||||
|
||||
```yaml
|
||||
description: Redis {{version}} on {{host}}:{{port}} accepts the default password
|
||||
```
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@@ -559,6 +573,8 @@ Impact of the template.
|
||||
|
||||
You can go in-depth here on impact of the template.
|
||||
|
||||
Supports the same placeholders as description.
|
||||
|
||||
|
||||
|
||||
Examples:
|
||||
@@ -665,6 +681,8 @@ Remediation steps for the template.
|
||||
|
||||
You can go in-depth here on how to mitigate the problem found by this template.
|
||||
|
||||
Supports the same placeholders as description.
|
||||
|
||||
|
||||
|
||||
Examples:
|
||||
@@ -3757,6 +3775,8 @@ Part Definitions:
|
||||
- <code>req</code> - Headless request made from the client
|
||||
- <code>duration</code> - Latest measured operation duration in seconds
|
||||
- <code>resp,body,data</code> - Headless response received from client (default)
|
||||
- <code>header,all_headers</code> - Headless response headers
|
||||
- <code>status_code</code> - Status code received from the server
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ extend-exclude = [
|
||||
"vendor/",
|
||||
# Go checksum file
|
||||
"go.sum",
|
||||
# Generated history. Commit SHAs are not words.
|
||||
"SECURITY_CONTEXT.md",
|
||||
]
|
||||
|
||||
[default.extend-identifiers]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const headerChildEnv = "NUCLEI_HEADER_CHILD_ARGS"
|
||||
|
||||
// -H values can contain "#", on the command line, in a header file and in the
|
||||
// config file, while lines of a header file that start with "#" are skipped as
|
||||
// comments.
|
||||
func TestHeaderValuesKeepHash(t *testing.T) {
|
||||
if args := os.Getenv(headerChildEnv); args != "" {
|
||||
options = &types.Options{Logger: gologger.DefaultLogger}
|
||||
os.Args = append([]string{os.Args[0]}, strings.Split(args, "\n")...)
|
||||
readConfig()
|
||||
fmt.Printf("headers=%s\n", strings.Join(options.CustomHeaders, "|"))
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
headerFile := filepath.Join(root, "headers.txt")
|
||||
require.NoError(t, os.WriteFile(headerFile, []byte("# auth headers\nX-Token: abc#def\nReferer: https://app.example.com/#/dashboard\n"), 0o600))
|
||||
configFile := filepath.Join(root, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(configFile, []byte("header:\n - \"X-Config: cfg#value\"\n"), 0o600))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"cli and header file", []string{"-H", "Cookie: sid=1; color=#ff0000", "-H", headerFile}, "Cookie: sid=1; color=#ff0000|X-Token: abc#def|Referer: https://app.example.com/#/dashboard"},
|
||||
{"config file", []string{"-config", configFile}, "X-Config: cfg#value"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tempRoot := filepath.Join(t.TempDir(), "tmp")
|
||||
require.NoError(t, os.Mkdir(tempRoot, 0o700))
|
||||
|
||||
args := append(tt.args, "-disable-update-check")
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestHeaderValuesKeepHash$", "-test.count=1")
|
||||
cmd.Env = isolatedNucleiStorageEnv(root, tempRoot, headerChildEnv+"="+strings.Join(args, "\n"))
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, string(output))
|
||||
require.Contains(t, string(output), "headers="+tt.want+"\n")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,8 @@
|
||||
# severity: low
|
||||
# # duplicate-issue-check (optional) flag to enable duplicate tracking issue check
|
||||
# duplicate-issue-check: false
|
||||
# # duplicate-issue-page-size (optional) controls how many issues to fetch per page when searching for duplicates
|
||||
# # duplicate-issue-page-size (optional) sets the number of issues to request per page when searching for duplicates
|
||||
# # (GitLab returns at most 100 issues per page)
|
||||
# duplicate-issue-page-size: 100
|
||||
# # duplicate-issue-max-pages (optional) limits how many pages to fetch when searching for duplicates (0 = no limit)
|
||||
# duplicate-issue-max-pages: 0
|
||||
|
||||
+23
-4
@@ -55,6 +55,17 @@ var (
|
||||
inlineSecretsTempMu sync.Mutex
|
||||
)
|
||||
|
||||
// normalizeHeaderValue trims a -H value and drops it when the whole line is a
|
||||
// "#" comment. The goflags default strips inline comments, which cut values
|
||||
// such as "Cookie: color=#fff" at the "#".
|
||||
func normalizeHeaderValue(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "#") {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func main() {
|
||||
options.Logger = gologger.DefaultLogger
|
||||
|
||||
@@ -255,6 +266,11 @@ func readConfig() *goflags.FlagSet {
|
||||
var pdcpAuth string
|
||||
var fuzzFlag bool
|
||||
|
||||
// FileStringSliceOptions as set up by runner.ConfigureOptions, but keeping
|
||||
// "#" inside -H values
|
||||
headerSliceOptions := goflags.FileStringSliceOptions
|
||||
headerSliceOptions.Normalize = normalizeHeaderValue
|
||||
|
||||
flagSet := goflags.NewFlagSet()
|
||||
flagSet.CaseSensitive = true
|
||||
flagSet.SetDescription(`Nuclei is a fast, template based vulnerability scanner focusing
|
||||
@@ -271,10 +287,10 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.StringSliceVarP(&options.Targets, "target", "u", nil, "target URLs/hosts to scan", goflags.CommaSeparatedStringSliceOptions),
|
||||
flagSet.StringVarP(&options.TargetsFilePath, "list", "l", "", "path to file containing a list of target URLs/hosts to scan (one per line)"),
|
||||
flagSet.StringVarP(&options.InlineTargetsList, "targets-inline", "", "", "inline multiline target list (for use in template profiles)"),
|
||||
flagSet.StringSliceVarP(&options.ExcludeTargets, "exclude-hosts", "eh", nil, "hosts to exclude to scan from the input list (ip, cidr, hostname)", goflags.FileCommaSeparatedStringSliceOptions),
|
||||
flagSet.StringSliceVarP(&options.ExcludeTargets, "exclude-hosts", "eh", nil, "hosts to exclude to scan from the input list (ip, cidr, hostname)", goflags.FileNormalizedOriginalStringSliceOptions),
|
||||
flagSet.StringVar(&options.Resume, "resume", "", "resume scan from and save to specified file (clustering will be disabled)"),
|
||||
flagSet.BoolVarP(&options.ScanAllIPs, "scan-all-ips", "sa", false, "scan all the IP's associated with dns record"),
|
||||
flagSet.StringSliceVarP(&options.IPVersion, "ip-version", "iv", nil, "IP version to scan of hostname (4,6) - (default 4)", goflags.CommaSeparatedStringSliceOptions),
|
||||
flagSet.StringSliceVarP(&options.IPVersion, "ip-version", "iv", nil, "IP version to scan of hostname (4,6); default uses dialer fallback, -sa scans all addresses", goflags.CommaSeparatedStringSliceOptions),
|
||||
flagSet.BoolVarP(&options.DisableURLNormalization, "disable-url-normalization", "dun", false, "keep targets exactly as supplied instead of collapsing urls that address the same resource"),
|
||||
flagSet.IntVarP(&options.MaxURLsPerPattern, "max-urls-per-pattern", "mup", 0, "scan at most this many urls sharing one structural shape, e.g. /user/1 and /user/2 (0 to scan all)"),
|
||||
)
|
||||
@@ -309,7 +325,7 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.BoolVarP(&options.EnableGlobalMatchersTemplates, "enable-global-matchers", "egm", false, "enable loading global matchers templates"),
|
||||
flagSet.BoolVar(&options.EnableFileTemplates, "file", false, "enable loading file templates"),
|
||||
flagSet.BoolVar(&options.EnableLLM, "llm", false, "enable llm matchers and extractors (semantic matching)"),
|
||||
flagSet.StringVar(&options.LLMProvider, "llm-provider", "openai", "llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter)"),
|
||||
flagSet.StringVar(&options.LLMProvider, "llm-provider", "openai", "llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev)"),
|
||||
flagSet.StringVar(&options.LLMBaseURL, "llm-base-url", "", "openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)"),
|
||||
flagSet.StringVar(&options.LLMModel, "llm-model", "", "model used for semantic matching"),
|
||||
flagSet.IntVar(&options.LLMTimeout, "llm-timeout", 30, "time in seconds to wait for a single llm call"),
|
||||
@@ -355,6 +371,7 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.StringVarP(&options.JSONLExport, "jsonl-export", "jle", "", "file to export results in JSONL(ine) format"),
|
||||
flagSet.StringVarP(&options.PDFExport, "pdf-export", "pe", "", "file to export results in PDF format"),
|
||||
flagSet.StringSliceVarP(&options.Redact, "redact", "rd", nil, "redact given list of keys from query parameter, request header and body", goflags.CommaSeparatedStringSliceOptions),
|
||||
flagSet.BoolVarP(&options.NoRedact, "no-redact", "nrd", false, "disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted"),
|
||||
)
|
||||
|
||||
flagSet.CreateGroup("configs", "Configurations",
|
||||
@@ -366,7 +383,7 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.IntVarP(&options.MaxRedirects, "max-redirects", "mr", 10, "max number of redirects to follow for http templates"),
|
||||
flagSet.BoolVarP(&options.DisableRedirects, "disable-redirects", "dr", false, "disable redirects for http templates"),
|
||||
flagSet.StringVarP(&options.ReportingConfig, "report-config", "rc", "", "nuclei reporting module configuration file"), // TODO merge into the config file or rename to issue-tracking
|
||||
flagSet.StringSliceVarP(&options.CustomHeaders, "header", "H", nil, "custom header/cookie to include in all http request in header:value format (cli, file)", goflags.FileStringSliceOptions),
|
||||
flagSet.StringSliceVarP(&options.CustomHeaders, "header", "H", nil, "custom header/cookie to include in all http request in header:value format (cli, file)", headerSliceOptions),
|
||||
flagSet.RuntimeMapVarP(&options.Vars, "var", "V", nil, "custom vars in key=value format"),
|
||||
flagSet.StringVarP(&options.ResolversFile, "resolvers", "r", "", "file containing resolver list for nuclei"),
|
||||
flagSet.BoolVarP(&options.SystemResolvers, "system-resolvers", "sr", false, "use system DNS resolving as error fallback"),
|
||||
@@ -390,6 +407,7 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.IntVarP(&options.ResponseReadSize, "response-size-read", "rsr", 0, "max response size to read in bytes"),
|
||||
flagSet.IntVarP(&options.ResponseSaveSize, "response-size-save", "rss", unitutils.Mega, "max response size to read in bytes"),
|
||||
flagSet.IntVarP(&options.RequestSaveSize, "request-size-save", "rqss", 32*unitutils.Kilo, "max request body size to save in bytes (0 to save the whole body)"),
|
||||
flagSet.IntVarP(&options.MaxRequestBodyInFlight, "max-request-body-inflight", "mrbi", 0, "max request body bytes held by in-flight http requests; requests with bodies of 1MB or more queue past it (0 disables)"),
|
||||
flagSet.CallbackVar(func() { resetRequested = true }, "reset", "reset removes all nuclei configuration and data files (including nuclei-templates)"),
|
||||
flagSet.BoolVarP(&options.TlsImpersonate, "tls-impersonate", "tlsi", false, "enable experimental client hello (ja3) tls randomization"),
|
||||
flagSet.StringVarP(&options.HttpApiEndpoint, "http-api-endpoint", "hae", "", "experimental http api endpoint"),
|
||||
@@ -470,6 +488,7 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.BoolVar(&options.PreflightPortScan, "preflight-portscan", false, "run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)"),
|
||||
flagSet.BoolVar(&options.DisableStdin, "no-stdin", false, "disable stdin processing"),
|
||||
flagSet.BoolVar(&options.EnableHTTPCache, "http-cache", false, "enable HTTP cache (RFC 9111) for HTTP requests"),
|
||||
flagSet.BoolVarP(&options.AutoCalibrate, "auto-calibrate", "ac", false, "drop HTTP matches on a host's catch-all page (answers missing paths with 2xx/3xx)"),
|
||||
)
|
||||
|
||||
flagSet.CreateGroup("headless", "Headless",
|
||||
|
||||
@@ -15,13 +15,14 @@ require (
|
||||
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d
|
||||
github.com/RedTeamPentesting/adauth v0.5.4-0.20260511073005-3d18e8a5a687
|
||||
github.com/adrg/xdg v0.5.3
|
||||
github.com/alecthomas/chroma v0.10.0
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/alexsnet/go-vnc v0.1.0
|
||||
github.com/alitto/pond v1.9.2
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/andygrunwald/go-jira v1.16.1
|
||||
github.com/antchfx/htmlquery v1.3.5
|
||||
github.com/antchfx/htmlquery v1.3.7
|
||||
github.com/antchfx/xmlquery v1.4.4
|
||||
github.com/antchfx/xpath v1.3.6
|
||||
github.com/antchfx/xpath v1.3.9
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.16
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.20
|
||||
@@ -33,10 +34,11 @@ require (
|
||||
github.com/clbanning/mxj/v2 v2.7.0
|
||||
github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c
|
||||
github.com/docker/go-units v0.5.0
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707
|
||||
github.com/elazarl/goproxy v1.7.2
|
||||
github.com/fatih/structs v1.1.0
|
||||
github.com/fullstorydev/grpcurl v1.9.3
|
||||
github.com/getkin/kin-openapi v0.144.0
|
||||
github.com/getkin/kin-openapi v0.149.0
|
||||
github.com/getsops/sops/v3 v3.9.2
|
||||
github.com/go-echarts/go-echarts/v2 v2.6.0
|
||||
github.com/go-git/go-git/v5 v5.19.2
|
||||
@@ -61,6 +63,7 @@ require (
|
||||
github.com/jhump/protoreflect v1.17.0
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/kitabisa/go-ci v1.0.3
|
||||
github.com/klauspost/compress v1.18.7
|
||||
github.com/leslie-qiwa/flat v0.0.0-20230424180412-f9d1cf014baa
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/logrusorgru/aurora/v4 v4.0.0
|
||||
@@ -71,11 +74,12 @@ require (
|
||||
github.com/miekg/dns v1.1.73
|
||||
github.com/oiweiwei/go-msrpc v1.2.12
|
||||
github.com/olekukonko/tablewriter v1.0.8
|
||||
github.com/openrdap/rdap v0.9.1
|
||||
github.com/ory/dockertest/v3 v3.12.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/praetorian-inc/fingerprintx v1.1.15
|
||||
github.com/projectdiscovery/clistats v0.1.7
|
||||
github.com/projectdiscovery/dsl v0.8.24
|
||||
github.com/projectdiscovery/dsl v0.9.0
|
||||
github.com/projectdiscovery/fastdialer v0.6.0
|
||||
github.com/projectdiscovery/fasttemplate v0.0.2
|
||||
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c
|
||||
@@ -85,7 +89,7 @@ require (
|
||||
github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d
|
||||
github.com/projectdiscovery/gologger v1.1.73
|
||||
github.com/projectdiscovery/gostruct v0.0.2
|
||||
github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e
|
||||
github.com/projectdiscovery/govaluate v0.0.0-20261102102118-9294133b15a2
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76
|
||||
github.com/projectdiscovery/hmap v0.0.102
|
||||
github.com/projectdiscovery/httpx v1.12.0
|
||||
@@ -102,8 +106,8 @@ require (
|
||||
github.com/projectdiscovery/tlsx v1.4.3-0.20261020052910-acb80c2c7c75
|
||||
github.com/projectdiscovery/uncover v1.2.1
|
||||
github.com/projectdiscovery/useragent v0.0.109
|
||||
github.com/projectdiscovery/utils v0.11.12-0.20261025172519-64b3c221cfe5
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.5
|
||||
github.com/projectdiscovery/utils v0.11.13-0.20261103094816-5c0b20e35350
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.6
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.7
|
||||
github.com/redis/go-redis/v9 v9.19.0
|
||||
github.com/remeh/sizedwaitgroup v1.0.0
|
||||
@@ -113,6 +117,7 @@ require (
|
||||
github.com/segmentio/ksuid v1.0.4
|
||||
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466
|
||||
github.com/sijms/go-ora/v2 v2.9.0
|
||||
github.com/spaolacci/murmur3 v1.1.0
|
||||
github.com/spf13/cast v1.10.0
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/syndtr/goleveldb v1.0.0
|
||||
@@ -171,11 +176,9 @@ require (
|
||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||
github.com/VividCortex/ewma v1.2.0 // indirect
|
||||
github.com/akrylysov/pogreb v0.10.2 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
|
||||
github.com/alecthomas/kingpin/v2 v2.4.0 // indirect
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.4 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
@@ -234,12 +237,10 @@ require (
|
||||
github.com/dimchansky/utfbom v1.1.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/djherbis/times v1.6.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/docker/cli v29.2.0+incompatible // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
@@ -262,7 +263,7 @@ require (
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.9.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.6 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
@@ -323,7 +324,6 @@ require (
|
||||
github.com/kaiakz/ubuffer v0.0.0-20200803053910-dd1083087166 // indirect
|
||||
github.com/kataras/jwt v0.1.10 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/klauspost/compress v1.18.7 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
@@ -366,7 +366,6 @@ require (
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/opencontainers/runc v1.3.6 // indirect
|
||||
github.com/openrdap/rdap v0.9.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.23 // indirect
|
||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||
@@ -385,14 +384,13 @@ require (
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
|
||||
github.com/sirupsen/logrus v1.10.2 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect
|
||||
|
||||
@@ -147,15 +147,13 @@ github.com/akrylysov/pogreb v0.10.2 h1:e6PxmeyEhWyi2AKOBIJzAEi4HkiC+lKyCocRGlnDi
|
||||
github.com/akrylysov/pogreb v0.10.2/go.mod h1:pNs6QmpQ1UlTJKDezuRWmaqkgUE2TuU0YTWyqJZ7+lI=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
|
||||
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/kingpin/v2 v2.3.1/go.mod h1:oYL5vtsvEHZGHxU7DMp32Dvx+qL+ptGn6lWaot2vCNE=
|
||||
github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY=
|
||||
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
|
||||
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
|
||||
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
@@ -180,14 +178,13 @@ github.com/andygrunwald/go-jira v1.16.1 h1:WoQEar5XoDRAibOgKzTFELlPNlKAtnfWr296R
|
||||
github.com/andygrunwald/go-jira v1.16.1/go.mod h1:UQH4IBVxIYWbgagc0LF/k9FRs9xjIiQ8hIcC6HfLwFU=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0=
|
||||
github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA=
|
||||
github.com/antchfx/htmlquery v1.3.7 h1:KCUklE1EO/xm6iS3CWx6iatFlcnENBk4I4UYa2pBmLU=
|
||||
github.com/antchfx/htmlquery v1.3.7/go.mod h1:/uBTMnFEl6LaBlIgw3KZ+c061tAQdlfbCXObZoxwQf4=
|
||||
github.com/antchfx/xmlquery v1.4.4 h1:mxMEkdYP3pjKSftxss4nUHfjBhnMk4imGoR96FRY2dg=
|
||||
github.com/antchfx/xmlquery v1.4.4/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fusrx9b12fc=
|
||||
github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
|
||||
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antchfx/xpath v1.3.9 h1:muXJSod6jRzhsiUjvduNSxsRSptFZ2GOJ1oMD1QkPSA=
|
||||
github.com/antchfx/xpath v1.3.9/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
@@ -359,7 +356,6 @@ github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c h1:+Zo5Ca9
|
||||
github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c/go.mod h1:HJGU9ULdREjOcVGZVPB5s6zYmHi1RxzT71l2wQyLmnE=
|
||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
@@ -427,8 +423,8 @@ github.com/gaissmai/bart v0.30.0 h1:K1oTSQ6Nf76bhrgxeTqzZa4hE0M9x4xDfL6n6UG67Sc=
|
||||
github.com/gaissmai/bart v0.30.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64=
|
||||
github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
|
||||
github.com/getkin/kin-openapi v0.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y=
|
||||
github.com/getkin/kin-openapi v0.144.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
|
||||
github.com/getkin/kin-openapi v0.149.0 h1:ZbhmVJ4yq5RZDUsyP8lcBcGMsjsaTqXEFt6isdtMDfA=
|
||||
github.com/getkin/kin-openapi v0.149.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY=
|
||||
github.com/getsops/gopgagent v0.0.0-20240527072608-0c14999532fe h1:QKe/kmAYbndxwu91TcjHERsnMh5SgOB1x/qicvOdUJ8=
|
||||
github.com/getsops/gopgagent v0.0.0-20240527072608-0c14999532fe/go.mod h1:awFzISqLJoZLm+i9QQ4SgMNHDqljH6jWV0B36V5MrUM=
|
||||
github.com/getsops/sops/v3 v3.9.2 h1:Cuuahc/UGu7W3+NXd+mrl+2oXGxxj/jIPsw6Ah2CbxA=
|
||||
@@ -456,8 +452,8 @@ github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuw
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4=
|
||||
github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
@@ -935,8 +931,8 @@ github.com/projectdiscovery/cdncheck v1.3.2 h1:I453ZWNBeG0WWIy3wi9412l4PTzorLgeb
|
||||
github.com/projectdiscovery/cdncheck v1.3.2/go.mod h1:5IkUMMlcGaeg1DWW5oJVd3r9X3EpJ88j5HxBeF4Me9M=
|
||||
github.com/projectdiscovery/clistats v0.1.7 h1:NXf4lUzx1QcbJd6QycNwwYHTv2FsW9MchQ2LsgSL6C0=
|
||||
github.com/projectdiscovery/clistats v0.1.7/go.mod h1:RxRGxhMzkT344VTTvZWIrojurUXdQQRN1yfwgg/88I8=
|
||||
github.com/projectdiscovery/dsl v0.8.24 h1:rN2w6LSIjA16qKGRjyj70oxdwXGaspe6sAjEkP7F2fk=
|
||||
github.com/projectdiscovery/dsl v0.8.24/go.mod h1:V+EludvS5PS19UNMlM3CuBwZ+sFnMaQhCFGxysDUanQ=
|
||||
github.com/projectdiscovery/dsl v0.9.0 h1:i1KyCbKJyKfHkgdbfh0JkWdd14APUjpEYf+GaYANlCg=
|
||||
github.com/projectdiscovery/dsl v0.9.0/go.mod h1:ntNFkkiYCTEosGFTbApA61RMrvJTCjnakPxnWj56MyY=
|
||||
github.com/projectdiscovery/fastdialer v0.6.0 h1:4ZeFQ6LyRH4D8e3qO/yzqIP4Cf9Fr0DduaVpJD6ENnE=
|
||||
github.com/projectdiscovery/fastdialer v0.6.0/go.mod h1:FpHwa9S5PUgfPbh+irOm+cdU6AO631XidxkhPwPv7yY=
|
||||
github.com/projectdiscovery/fasttemplate v0.0.2 h1:h2cISk5xDhlJEinlBQS6RRx0vOlOirB2y3Yu4PJzpiA=
|
||||
@@ -957,8 +953,8 @@ github.com/projectdiscovery/gologger v1.1.73 h1:WX2nzq29cL7HdvOHqIttacoid/5lMwap
|
||||
github.com/projectdiscovery/gologger v1.1.73/go.mod h1:mJwODZcFDg70ihINpOvZevmBtgvpP8H9/l8Y+OPhZPY=
|
||||
github.com/projectdiscovery/gostruct v0.0.2 h1:s8gP8ApugGM4go1pA+sVlPDXaWqNP5BBDDSv7VEdG1M=
|
||||
github.com/projectdiscovery/gostruct v0.0.2/go.mod h1:H86peL4HKwMXcQQtEa6lmC8FuD9XFt6gkNR0B/Mu5PE=
|
||||
github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e h1:vxzgQlz2Cy/YvizYDQx9OhucBcmBotfDhbQ4yCY2vfA=
|
||||
github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e/go.mod h1:xH7bPwHxUlz1yx9UlVeTF+UVCUaKhTnZgaxHb5z362E=
|
||||
github.com/projectdiscovery/govaluate v0.0.0-20261102102118-9294133b15a2 h1:aSLbDvrJQPlp4HvojU+bTU5XV8xdAVCjoc5gX1Wfbw4=
|
||||
github.com/projectdiscovery/govaluate v0.0.0-20261102102118-9294133b15a2/go.mod h1:Z3VjpUJV1pnXDwvAddA8aZ4e839SoeftB7CSdZntAVI=
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76 h1:AN70bbi6BBs7KpIM9w0LxygUN7uzT/oH+owDIQ+Fz/k=
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76/go.mod h1:cWHYnRXoYWHtTpOYyAp5laGYX8GH8ITUhgQaP8G/8FA=
|
||||
github.com/projectdiscovery/hmap v0.0.102 h1:ybPuf7UQvuOLiHLOUbVMvd+PSlItQU5iQpP1Erl8kaU=
|
||||
@@ -999,10 +995,10 @@ github.com/projectdiscovery/uncover v1.2.1 h1:8U46T/96CLT7BPoXBgkTvWqB06lOyeTSLv
|
||||
github.com/projectdiscovery/uncover v1.2.1/go.mod h1:0p8onrWxfpXQEYs90ZDzTSpu1107gWmodX1NWqu/+z4=
|
||||
github.com/projectdiscovery/useragent v0.0.109 h1:b86BPdZvmgbJousa04jAugDRY4Y+rAvEamE0TFCPmeE=
|
||||
github.com/projectdiscovery/useragent v0.0.109/go.mod h1:sRyBqDAcD31RKYrO8fyXGowLd36fNwY1g0MNW9mp7zk=
|
||||
github.com/projectdiscovery/utils v0.11.12-0.20261025172519-64b3c221cfe5 h1:AOspf7ScbwZr8A2YnjCFd4ZJAMSFqzfEaIJIbJPuu10=
|
||||
github.com/projectdiscovery/utils v0.11.12-0.20261025172519-64b3c221cfe5/go.mod h1:TJgsyVjB02CavwZVFzUzPg6YOSJXQxLhL808JCiQKlU=
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.5 h1:UZradYF1GIcruSIV5wwo/Coph9/VnM17oajLD9eXR2U=
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.5/go.mod h1:4E6+fh/ckaY3wo5tlM7lNYSkhuTYRHzu2YmGIRraXP0=
|
||||
github.com/projectdiscovery/utils v0.11.13-0.20261103094816-5c0b20e35350 h1:uXqpYa6XKV9dCvoti9PbSgIWIsBKPu5YKMywB9G4q0Q=
|
||||
github.com/projectdiscovery/utils v0.11.13-0.20261103094816-5c0b20e35350/go.mod h1:TJgsyVjB02CavwZVFzUzPg6YOSJXQxLhL808JCiQKlU=
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.6 h1:1FXCMeCa1xVu2zaW+eeBcbVxtlMStqGOlKcMLyh2pzI=
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.6/go.mod h1:4E6+fh/ckaY3wo5tlM7lNYSkhuTYRHzu2YmGIRraXP0=
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.7 h1:Odmjd1o4EkMff2F6QHi1dIYPIQPZUFy24YqmQCprmTk=
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.7/go.mod h1:nqD1iPMcofpRA1DbXavPByJhWURrshT+l8WN9XjyQag=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
@@ -1055,8 +1051,8 @@ github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
|
||||
github.com/sandrolain/httpcache v1.4.0 h1:Jf4Vx62X2ybvNPSpPvI1kT3xvMdDG1AsApQjOQKO9E0=
|
||||
github.com/sandrolain/httpcache v1.4.0/go.mod h1:kHBuXveitSn39SNPBhdf/ybG272X706HJ2RJqOQ+Em0=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c=
|
||||
github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -40,8 +39,6 @@ func GetPlaygroundServer() *PlaygroundServer {
|
||||
mux.HandleFunc("POST /user", patchUnsanitizedUserHandler)
|
||||
mux.HandleFunc("GET /blog/posts", getPostsHandler)
|
||||
|
||||
registerAnalyzerRoutes(mux)
|
||||
|
||||
handler := recoverPlaygroundRequest(logPlaygroundRequest(mux))
|
||||
return &PlaygroundServer{handler: handler}
|
||||
}
|
||||
@@ -68,239 +65,6 @@ 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>
|
||||
@@ -324,16 +88,6 @@ 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>
|
||||
`))
|
||||
@@ -530,12 +284,6 @@ 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() {
|
||||
|
||||
+29
-5
@@ -4,6 +4,8 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -87,11 +89,7 @@ func NewUploadWriter(ctx context.Context, logger *gologger.Logger, creds *pdcpau
|
||||
tmp.Update()
|
||||
u.uploadURL = tmp.URL
|
||||
|
||||
// create http client
|
||||
opts := retryablehttp.DefaultOptionsSingle
|
||||
opts.NoAdjustTimeout = true
|
||||
opts.Timeout = time.Duration(3) * time.Minute
|
||||
u.client = retryablehttp.NewClient(opts)
|
||||
u.client = newUploadClient()
|
||||
|
||||
// create context
|
||||
ctx, u.cancel = context.WithCancel(ctx)
|
||||
@@ -101,6 +99,32 @@ func NewUploadWriter(ctx context.Context, logger *gologger.Logger, creds *pdcpau
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// newUploadClient returns the client for cloud uploads. The upload sends the
|
||||
// API key and the scan results, so the client verifies the server certificate.
|
||||
// The retryablehttp default clients skip verification.
|
||||
func newUploadClient() *retryablehttp.Client {
|
||||
transport := retryablehttp.DefaultReusePooledTransport()
|
||||
// fastdialer DialTLS does not verify certificates, so net/http does the
|
||||
// TLS handshake over the fastdialer connection.
|
||||
transport.DialTLSContext = nil
|
||||
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
|
||||
opts := retryablehttp.DefaultOptionsSingle
|
||||
opts.NoAdjustTimeout = true
|
||||
opts.Timeout = time.Duration(3) * time.Minute
|
||||
opts.HttpClient = &http.Client{Transport: transport}
|
||||
// a certificate error does not go away on retry
|
||||
defaultRetry := retryablehttp.DefaultRetryPolicy()
|
||||
opts.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) {
|
||||
var certErr *tls.CertificateVerificationError
|
||||
if errors.As(err, &certErr) {
|
||||
return false, nil
|
||||
}
|
||||
return defaultRetry(ctx, resp, err)
|
||||
}
|
||||
return retryablehttp.NewClient(opts)
|
||||
}
|
||||
|
||||
// SetScanID sets the scan id for the upload writer
|
||||
func (u *UploadWriter) SetScanID(id string) error {
|
||||
if !xidRegex.MatchString(id) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package pdcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
pdcpauth "github.com/projectdiscovery/utils/auth/pdcp"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestUploadWriter(t *testing.T, gotKey *atomic.Value) (*UploadWriter, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotKey.Store(r.Header.Get(pdcpauth.ApiKeyHeaderName))
|
||||
_, _ = w.Write([]byte(`{"id":"abcdefghij0123456789"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
creds := &pdcpauth.PDCPCredentials{APIKey: "test-key", Server: srv.URL}
|
||||
u, err := NewUploadWriter(context.Background(), gologger.DefaultLogger, creds)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(u.Close)
|
||||
return u, srv
|
||||
}
|
||||
|
||||
func TestUploadRejectsUntrustedCertificate(t *testing.T) {
|
||||
var gotKey atomic.Value
|
||||
u, _ := newTestUploadWriter(t, &gotKey)
|
||||
|
||||
err := u.upload([]byte("{}\n"))
|
||||
require.Error(t, err)
|
||||
var unknownAuthority x509.UnknownAuthorityError
|
||||
require.ErrorAs(t, err, &unknownAuthority)
|
||||
require.Nil(t, gotKey.Load(), "API key must not reach a server with an untrusted certificate")
|
||||
}
|
||||
|
||||
func TestUploadTrustedCertificate(t *testing.T) {
|
||||
var gotKey atomic.Value
|
||||
u, srv := newTestUploadWriter(t, &gotKey)
|
||||
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(srv.Certificate())
|
||||
transport, ok := u.client.HTTPClient.Transport.(*http.Transport)
|
||||
require.True(t, ok)
|
||||
transport.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
|
||||
|
||||
require.NoError(t, u.upload([]byte("{}\n")))
|
||||
require.Equal(t, "test-key", gotKey.Load())
|
||||
require.Equal(t, "abcdefghij0123456789", u.scanID)
|
||||
}
|
||||
@@ -39,6 +39,9 @@ func (r *Runner) initializeTemplatesHTTPInput() (*hybrid.HybridMap, error) {
|
||||
}
|
||||
httpxOptions.RetryMax = r.options.Retries
|
||||
httpxOptions.Timeout = time.Duration(r.options.Timeout) * time.Second
|
||||
if len(r.options.InternalResolversList) > 0 {
|
||||
httpxOptions.Resolvers = r.options.InternalResolversList
|
||||
}
|
||||
|
||||
dialers := protocolstate.GetDialersWithId(r.options.ExecutionId)
|
||||
if dialers == nil {
|
||||
|
||||
+35
-1
@@ -2,8 +2,12 @@ package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
|
||||
llmclient "github.com/projectdiscovery/nuclei/v3/pkg/operators/common/llm"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
utilsllm "github.com/projectdiscovery/utils/llm"
|
||||
@@ -28,6 +32,32 @@ func (a *llmMatcherClient) Complete(ctx context.Context, prompt string, asJSON b
|
||||
})
|
||||
}
|
||||
|
||||
// llmJudgeClient is an llmMatcherClient whose provider classifies natively, so
|
||||
// llm matchers ask it through Judge instead of Complete.
|
||||
type llmJudgeClient struct {
|
||||
*llmMatcherClient
|
||||
warnComplete sync.Once
|
||||
}
|
||||
|
||||
func (a *llmJudgeClient) Judge(ctx context.Context, question string, labels, inputs []string) (llmclient.Verdict, error) {
|
||||
verdict, err := a.client.Judge(ctx, utilsllm.JudgeRequest{Question: question, Labels: labels, Inputs: inputs})
|
||||
if err != nil {
|
||||
return llmclient.Verdict{}, err
|
||||
}
|
||||
return llmclient.Verdict{Label: verdict.Label, Confidence: verdict.Confidence, Model: verdict.Model}, nil
|
||||
}
|
||||
|
||||
// Complete is only reached by llm extractors, which need free text a
|
||||
// classifier cannot produce. Returning here spends no call budget and
|
||||
// makes no provider request. Extractors turn the error into an empty
|
||||
// result, and the warning says why.
|
||||
func (a *llmJudgeClient) Complete(context.Context, string, bool) (string, error) {
|
||||
a.warnComplete.Do(func() {
|
||||
gologger.Warning().Msgf("llm provider %q cannot run llm extractors, they return nothing", utilsllm.ProviderJev)
|
||||
})
|
||||
return "", errors.New("llm provider cannot run extractors")
|
||||
}
|
||||
|
||||
// configureLLM builds the scan's llm client from options. It returns nil unless
|
||||
// -llm is set, so nothing reaches a model until the user opts in. The API key is
|
||||
// read from the environment by the provider layer, never from options.
|
||||
@@ -52,5 +82,9 @@ func configureLLM(options *types.Options) (llmclient.Client, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &llmMatcherClient{client: client, model: options.LLMModel}, nil
|
||||
matcherClient := &llmMatcherClient{client: client, model: options.LLMModel}
|
||||
if client.CanJudge() {
|
||||
return &llmJudgeClient{llmMatcherClient: matcherClient}, nil
|
||||
}
|
||||
return matcherClient, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
llmclient "github.com/projectdiscovery/nuclei/v3/pkg/operators/common/llm"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConfigureLLMExposesJudgeOnlyForClassifiers(t *testing.T) {
|
||||
t.Setenv("LLM_API_KEY", "key")
|
||||
|
||||
client, err := configureLLM(&types.Options{EnableLLM: true, LLMProvider: "jev", LLMModel: "jev-latest"})
|
||||
require.NoError(t, err)
|
||||
require.Implements(t, (*llmclient.Judge)(nil), client)
|
||||
|
||||
client, err = configureLLM(&types.Options{EnableLLM: true, LLMProvider: "ollama", LLMModel: "llama3.2"})
|
||||
require.NoError(t, err)
|
||||
_, isJudge := client.(llmclient.Judge)
|
||||
require.False(t, isJudge, "chat providers keep the Complete path")
|
||||
}
|
||||
|
||||
func TestJevExtractorDoesNotSpendCallBudget(t *testing.T) {
|
||||
t.Setenv("LLM_API_KEY", "key")
|
||||
// ProxyFromEnvironment caches the first request in the process. Match the
|
||||
// proxy the interactsh client test expects, and keep the local server direct.
|
||||
for _, key := range []string{"HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", "https_proxy", "no_proxy"} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:9")
|
||||
t.Setenv("NO_PROXY", "127.0.0.1,localhost")
|
||||
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
_, _ = w.Write([]byte(`{"model":"jev-1.14.0","answers":{"q":{"type":"noul","noul":0.9}}}`))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client, err := configureLLM(&types.Options{
|
||||
EnableLLM: true,
|
||||
LLMProvider: "jev",
|
||||
LLMBaseURL: server.URL + "/v1",
|
||||
LLMModel: "jev-latest",
|
||||
LLMMaxCalls: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.Complete(context.Background(), "extract the title", true)
|
||||
require.ErrorContains(t, err, "cannot run extractors")
|
||||
_, err = client.Complete(context.Background(), "extract another", true)
|
||||
require.Error(t, err)
|
||||
require.Zero(t, calls.Load())
|
||||
|
||||
judge := client.(llmclient.Judge)
|
||||
verdict, err := judge.Judge(context.Background(), "login page?", nil, []string{"<form>"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "yes", verdict.Label)
|
||||
require.EqualValues(t, 1, calls.Load())
|
||||
}
|
||||
@@ -228,25 +228,14 @@ func ValidateOptions(options *types.Options) error {
|
||||
}
|
||||
}
|
||||
|
||||
// verify that a valid ip version type was selected (4, 6)
|
||||
if len(options.IPVersion) == 0 {
|
||||
// add ipv4 as default
|
||||
options.IPVersion = append(options.IPVersion, "4")
|
||||
}
|
||||
var useIPV4, useIPV6 bool
|
||||
// An empty -iv leaves one unpinned target so the dialer can fall back
|
||||
// between address families. -sa resolves and scans every selected address.
|
||||
// Explicit -iv 4, -iv 6 and -iv 4,6 retain their usual selection semantics.
|
||||
for _, ipv := range options.IPVersion {
|
||||
switch ipv {
|
||||
case "4":
|
||||
useIPV4 = true
|
||||
case "6":
|
||||
useIPV6 = true
|
||||
default:
|
||||
if ipv != "4" && ipv != "6" {
|
||||
return fmt.Errorf("unsupported ip version: %s", ipv)
|
||||
}
|
||||
}
|
||||
if !useIPV4 && !useIPV6 {
|
||||
return errors.New("ipv4 and/or ipv6 must be selected")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -90,3 +90,15 @@ func TestReadsTargetsFromStdin(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The default must remain empty so the provider can distinguish automatic
|
||||
// IPv6 preference from explicit user-supplied -iv selections.
|
||||
func TestValidateOptionsIPVersionPreference(t *testing.T) {
|
||||
automatic := &types.Options{}
|
||||
require.NoError(t, ValidateOptions(automatic))
|
||||
require.Empty(t, automatic.IPVersion)
|
||||
for _, versions := range []goflags.StringSlice{{"4"}, {"6"}, {"4", "6"}} {
|
||||
require.NoError(t, ValidateOptions(&types.Options{IPVersion: versions}))
|
||||
}
|
||||
require.ErrorContains(t, ValidateOptions(&types.Options{IPVersion: goflags.StringSlice{"7"}}), "unsupported ip version")
|
||||
}
|
||||
@@ -67,11 +67,13 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/utils/excludematchers"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/headless/engine"
|
||||
httpProtocol "github.com/projectdiscovery/nuclei/v3/pkg/protocols/http"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/catchall"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpclientpool"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils/httpbody"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils/stats"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
@@ -675,6 +677,7 @@ func (r *Runner) RunEnumeration() error {
|
||||
Catalog: r.catalog,
|
||||
IssuesClient: r.issuesClient,
|
||||
RateLimiter: r.rateLimiter,
|
||||
RequestBodyBudget: httpbody.NewBudget(int64(r.options.MaxRequestBodyInFlight)),
|
||||
Interactsh: r.interactsh,
|
||||
ProjectFile: r.projectFile,
|
||||
Browser: r.browser,
|
||||
@@ -734,6 +737,9 @@ func (r *Runner) RunEnumeration() error {
|
||||
if r.options.HostBackoff {
|
||||
executorOpts.HostBackoff = hostbackoff.New(hostbackoff.Config{Max: r.options.HostBackoffMax})
|
||||
}
|
||||
if r.options.AutoCalibrate {
|
||||
executorOpts.CatchAll = catchall.New(0)
|
||||
}
|
||||
|
||||
executorEngine := core.New(r.options)
|
||||
executorEngine.SetExecuterOptions(executorOpts)
|
||||
@@ -1072,6 +1078,7 @@ func (r *Runner) displayExecutionInfo(store *loader.Store) {
|
||||
stats.DisplayAsWarning(httpProtocol.SetThreadToCountZero)
|
||||
stats.ForceDisplayWarning(templates.SkippedUnverifiedTemplateStats)
|
||||
stats.ForceDisplayWarning(templates.SkippedRequestSignatureTemplateStats)
|
||||
stats.ForceDisplayWarning(templates.SkippedWorkflowStepStats)
|
||||
|
||||
cfg := config.DefaultConfig
|
||||
|
||||
|
||||
@@ -6,11 +6,17 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
@@ -197,3 +203,63 @@ func TestNewInteractshHTTPClient(t *testing.T) {
|
||||
require.NotNil(t, proxyURL)
|
||||
require.Equal(t, "http://127.0.0.1:9", proxyURL.String())
|
||||
}
|
||||
|
||||
func TestInitializeTemplatesHTTPInputUsesResolvers(t *testing.T) {
|
||||
for _, key := range []string{"HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
|
||||
listener, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
queries := new(atomic.Int32)
|
||||
server := &dns.Server{PacketConn: listener, Handler: dns.HandlerFunc(func(writer dns.ResponseWriter, request *dns.Msg) {
|
||||
queries.Add(1)
|
||||
response := new(dns.Msg).SetReply(request)
|
||||
for _, question := range request.Question {
|
||||
if question.Qtype == dns.TypeA {
|
||||
response.Answer = append(response.Answer, &dns.A{Hdr: dns.RR_Header{Name: question.Name, Rrtype: dns.TypeA, Class: dns.ClassINET}, A: net.ParseIP("127.0.0.1")})
|
||||
}
|
||||
}
|
||||
_ = writer.WriteMsg(response)
|
||||
})}
|
||||
started := make(chan struct{})
|
||||
server.NotifyStartedFunc = func() { close(started) }
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- server.ActivateAndServe() }()
|
||||
<-started
|
||||
t.Cleanup(func() { require.NoError(t, server.Shutdown()); require.NoError(t, <-done) })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
resolverFile := filepath.Join(t.TempDir(), "resolv.conf")
|
||||
require.NoError(t, os.WriteFile(resolverFile, nil, 0o600))
|
||||
t.Setenv("RESOLVERS_PATH", resolverFile)
|
||||
|
||||
options := &types.Options{
|
||||
ExecutionId: t.Name(),
|
||||
Timeout: 5,
|
||||
BulkSize: 1,
|
||||
InternalResolversList: []string{listener.LocalAddr().String()},
|
||||
}
|
||||
require.NoError(t, protocolstate.Init(options))
|
||||
t.Cleanup(func() { protocolstate.Close(options.ExecutionId) })
|
||||
|
||||
target := "probe.nuclei.test:" + strconv.Itoa(srv.Listener.Addr().(*net.TCPAddr).Port)
|
||||
r := &Runner{
|
||||
options: options,
|
||||
inputProvider: provider.NewSimpleInputProviderWithUrls(options.ExecutionId, target),
|
||||
Logger: &gologger.Logger{},
|
||||
}
|
||||
|
||||
hm, err := r.initializeTemplatesHTTPInput()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = hm.Close() })
|
||||
|
||||
probed, ok := hm.Get(input.ProbeKey(target, ""))
|
||||
require.True(t, ok, "the probe should resolve the target through the -r resolver")
|
||||
require.Equal(t, "http://"+target, string(probed))
|
||||
require.Positive(t, queries.Load())
|
||||
}
|
||||
@@ -6,13 +6,15 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/alecthomas/chroma/quick"
|
||||
"github.com/alecthomas/chroma/v2/quick"
|
||||
"github.com/logrusorgru/aurora/v4"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
|
||||
)
|
||||
|
||||
@@ -38,12 +40,17 @@ func (r *Runner) verboseTemplate(tpl *templates.Template) {
|
||||
tpl.Info.SeverityHolder.Severity))
|
||||
}
|
||||
|
||||
// listedTemplate names its fields like a result event, so a -jsonl listing
|
||||
// joins with scan results on the same keys.
|
||||
type listedTemplate struct {
|
||||
Template string `json:"template,omitempty"`
|
||||
TemplateID string `json:"template-id"`
|
||||
TemplatePath string `json:"template-path"`
|
||||
Info model.Info `json:"info"`
|
||||
}
|
||||
|
||||
func (r *Runner) listAvailableStoreTemplates(store *loader.Store) {
|
||||
r.Logger.Print().Msgf(
|
||||
"\nListing available %v nuclei templates for %v",
|
||||
config.DefaultConfig.TemplateVersion,
|
||||
config.DefaultConfig.TemplatesDirectory,
|
||||
)
|
||||
r.listingHeader("templates")
|
||||
// order templates alphabetically by path
|
||||
templates := store.Templates()
|
||||
sort.Slice(templates, func(i, j int) bool {
|
||||
@@ -51,6 +58,10 @@ func (r *Runner) listAvailableStoreTemplates(store *loader.Store) {
|
||||
})
|
||||
|
||||
for _, tpl := range templates {
|
||||
if r.options.JSONL && !r.options.TemplateDisplay {
|
||||
r.listTemplateJSON(tpl)
|
||||
continue
|
||||
}
|
||||
if hasExtraFlags(r.options) {
|
||||
if r.options.TemplateDisplay {
|
||||
colorize := !r.options.NoColor
|
||||
@@ -78,12 +89,36 @@ func (r *Runner) listAvailableStoreTemplates(store *loader.Store) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) listAvailableTags(tagsMap map[string]int) {
|
||||
func (r *Runner) listTemplateJSON(tpl *templates.Template) {
|
||||
path, _ := utils.TemplatePathURL(tpl.Path, tpl.ID, "")
|
||||
marshalled, err := json.Marshal(listedTemplate{
|
||||
Template: path,
|
||||
TemplateID: tpl.ID,
|
||||
TemplatePath: tpl.Path,
|
||||
Info: tpl.Info,
|
||||
})
|
||||
if err != nil {
|
||||
r.Logger.Error().Msgf("Could not marshal the template %s: %s", tpl.Path, err)
|
||||
return
|
||||
}
|
||||
r.Logger.Print().Msgf("%s", marshalled)
|
||||
}
|
||||
|
||||
// listingHeader is skipped for -jsonl, where every line must parse as JSON.
|
||||
func (r *Runner) listingHeader(what string) {
|
||||
if r.options.JSONL {
|
||||
return
|
||||
}
|
||||
r.Logger.Print().Msgf(
|
||||
"\nListing available %v nuclei tags for %v",
|
||||
"\nListing available %v nuclei %s for %v",
|
||||
config.DefaultConfig.TemplateVersion,
|
||||
what,
|
||||
config.DefaultConfig.TemplatesDirectory,
|
||||
)
|
||||
}
|
||||
|
||||
func (r *Runner) listAvailableTags(tagsMap map[string]int) {
|
||||
r.listingHeader("tags")
|
||||
|
||||
type kv struct {
|
||||
Key string `json:"tag"`
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/gologger/formatter"
|
||||
"github.com/projectdiscovery/gologger/levels"
|
||||
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const listedTemplateYAML = `id: listed-template
|
||||
info:
|
||||
name: Listed Template
|
||||
author: pdteam
|
||||
severity: info
|
||||
tags: listing
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
`
|
||||
|
||||
func TestTemplateListWritesJSONLines(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := config.DefaultConfig
|
||||
oldRoot := cfg.TemplatesDirectory
|
||||
t.Cleanup(func() { cfg.SetTemplatesDir(oldRoot) })
|
||||
cfg.SetTemplatesDir(root)
|
||||
|
||||
path := filepath.Join(root, "http", "listed-template.yaml")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, os.WriteFile(path, []byte(listedTemplateYAML), 0o600))
|
||||
|
||||
writer := &ignoreFileLogWriter{}
|
||||
logger := &gologger.Logger{}
|
||||
logger.SetFormatter(formatter.NewCLI(true))
|
||||
logger.SetWriter(writer)
|
||||
logger.SetMaxLevel(levels.LevelInfo)
|
||||
|
||||
options := testutils.DefaultOptions.Copy()
|
||||
options.ExecutionId = t.Name()
|
||||
options.Logger = logger
|
||||
options.Templates = []string{path}
|
||||
options.TemplateList = true
|
||||
options.JSONL = true
|
||||
testutils.Init(options)
|
||||
t.Cleanup(func() { testutils.Cleanup(options) })
|
||||
|
||||
executerOpts := testutils.NewMockExecuterOptions(options, nil)
|
||||
executerOpts.Parser = templates.NewParser()
|
||||
store, err := loader.New(loader.NewConfig(options, disk.NewCatalog(root), executerOpts))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, store.LoadTemplatesOnlyMetadata())
|
||||
|
||||
require.Len(t, store.Templates(), 1)
|
||||
|
||||
r := &Runner{options: options, Logger: logger}
|
||||
r.listAvailableStoreTemplates(store)
|
||||
|
||||
require.Len(t, writer.records, 1, "JSONL output carries no banner line")
|
||||
var listed map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(writer.records[0].message)), &listed))
|
||||
require.Equal(t, "listed-template", listed["template-id"])
|
||||
require.Equal(t, filepath.Join("http", "listed-template.yaml"), listed["template"])
|
||||
require.Equal(t, "Listed Template", listed["info"].(map[string]any)["name"])
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/utils/excludematchers"
|
||||
browserEngine "github.com/projectdiscovery/nuclei/v3/pkg/protocols/headless/engine"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/catchall"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
@@ -110,6 +111,9 @@ func newNucleiExecutor(opts *NucleiExecutorOptions) (*nucleiExecutor, error) {
|
||||
if opts.Options.HostBackoff {
|
||||
executorOpts.HostBackoff = hostbackoff.New(hostbackoff.Config{Max: opts.Options.HostBackoffMax})
|
||||
}
|
||||
if opts.Options.AutoCalibrate {
|
||||
executorOpts.CatchAll = catchall.New(0)
|
||||
}
|
||||
|
||||
executorEngine := core.New(opts.Options)
|
||||
executorEngine.SetExecuterOptions(executorOpts)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
@@ -20,28 +19,6 @@ 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"
|
||||
)
|
||||
@@ -64,73 +41,6 @@ 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
|
||||
|
||||
@@ -102,6 +102,7 @@ var httpTestcases = []integrationCase{
|
||||
{Path: "protocols/http/raw-unsafe-path-single-slash.yaml", TestCase: &httpRawUnsafePathSingleSlash{}},
|
||||
{Path: "protocols/http/disable-http-cache.yaml", TestCase: &httpDisableCache{}},
|
||||
{Path: "protocols/http/http-cache.yaml", TestCase: &httpCache{}},
|
||||
{Path: "protocols/http/get.yaml", TestCase: &httpExcludeHostsFileComments{}},
|
||||
}
|
||||
|
||||
type httpMultiVarSharing struct{}
|
||||
@@ -1983,3 +1984,59 @@ func (h *httpDisableCache) Execute(filePath string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensure that comments in an -exclude-hosts file are ignored
|
||||
type httpExcludeHostsFileComments struct{}
|
||||
|
||||
// Execute executes a test case and returns an error if occurred
|
||||
func (h *httpExcludeHostsFileComments) Execute(filePath string) error {
|
||||
router := httprouter.New()
|
||||
router.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
_, _ = fmt.Fprintf(w, "This is test matcher text")
|
||||
})
|
||||
ts := httptest.NewServer(router)
|
||||
defer ts.Close()
|
||||
|
||||
excludeHosts := func(content string) ([]string, error) {
|
||||
file, err := os.CreateTemp("", "exclude-hosts-*.txt")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = os.Remove(file.Name()) }()
|
||||
_, err = file.WriteString(content)
|
||||
_ = file.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return testutils.RunNucleiTemplateAndGetResults(filePath, ts.URL, debug, "-eh", file.Name())
|
||||
}
|
||||
|
||||
// a comment line is not a host, even when it reads as an invalid regex
|
||||
results, err := excludeHosts("# hosts out of scope (see ticket\n203.0.113.10\n")
|
||||
if err != nil {
|
||||
return fmt.Errorf("comment line: %w", err)
|
||||
}
|
||||
if err := expectResultsCount(results, 1); err != nil {
|
||||
return fmt.Errorf("comment line: %w", err)
|
||||
}
|
||||
|
||||
// a trailing comment does not stop the host from being excluded
|
||||
results, err = excludeHosts("127.0.0.1 # test server\n")
|
||||
if err != nil {
|
||||
return fmt.Errorf("trailing comment: %w", err)
|
||||
}
|
||||
if err := expectResultsCount(results, 0); err != nil {
|
||||
return fmt.Errorf("trailing comment: %w", err)
|
||||
}
|
||||
|
||||
// comma separated hosts are still split when passed on the command line
|
||||
results, err = testutils.RunNucleiTemplateAndGetResults(filePath, ts.URL, debug, "-eh", "203.0.113.10, 127.0.0.1")
|
||||
if err != nil {
|
||||
return fmt.Errorf("comma separated: %w", err)
|
||||
}
|
||||
if err := expectResultsCount(results, 0); err != nil {
|
||||
return fmt.Errorf("comma separated: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/ory/dockertest/v3"
|
||||
)
|
||||
|
||||
// ipmiSimSetup runs OpenIPMI's BMC simulator with an anonymous user,
|
||||
// ADMIN/ADMIN and root/calvin, and cipher suite 0 enabled.
|
||||
const ipmiSimSetup = `apt-get update -qq && apt-get install -y -qq --no-install-recommends openipmi >/dev/null && mkdir -p /etc/ipmi && cat > /etc/ipmi/lan.conf <<'EOF'
|
||||
name "bmc"
|
||||
set_working_mc 0x20
|
||||
startlan 1
|
||||
addr 0.0.0.0 623
|
||||
priv_limit admin
|
||||
allowed_auths_callback none md2 md5 straight
|
||||
allowed_auths_user none md2 md5 straight
|
||||
allowed_auths_operator none md2 md5 straight
|
||||
allowed_auths_admin none md2 md5 straight
|
||||
guid a123456789abcdefa123456789abcdef
|
||||
endlan
|
||||
user 1 true "" "" user 10 none md2 md5 straight
|
||||
user 2 true "ADMIN" "ADMIN" admin 10 none md2 md5 straight
|
||||
user 3 true "root" "calvin" admin 10 none md2 md5 straight
|
||||
EOF
|
||||
printf 'mc_setbmc 0x20\nmc_add 0x20 0 no-device-sdrs 0x23 9 8 0x9f 0x1291 0xf02 persist_sdr\nmc_enable 0x20\n' > /etc/ipmi/emu.cmd
|
||||
exec ipmi_sim -c /etc/ipmi/lan.conf -f /etc/ipmi/emu.cmd -n`
|
||||
|
||||
type javascriptIPMI struct{}
|
||||
|
||||
func (j *javascriptIPMI) Execute(filePath string) error {
|
||||
pool, err := dockertest.NewPool("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create docker pool: %w", err)
|
||||
}
|
||||
if err := pool.Client.Ping(); err != nil {
|
||||
return fmt.Errorf("could not connect to Docker: %w", err)
|
||||
}
|
||||
resource, err := pool.RunWithOptions(&dockertest.RunOptions{
|
||||
Repository: "debian",
|
||||
Tag: "bookworm-slim",
|
||||
Platform: "linux/amd64",
|
||||
Cmd: []string{"sh", "-c", ipmiSimSetup},
|
||||
ExposedPorts: []string{"623/udp"},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not start ipmi simulator: %w", err)
|
||||
}
|
||||
defer purge(pool, resource)
|
||||
|
||||
address := net.JoinHostPort("127.0.0.1", resource.GetPort("623/udp"))
|
||||
if err := waitForServiceCheck(address, 3*time.Minute, rmcpPingCheck); err != nil {
|
||||
return err
|
||||
}
|
||||
results, err := runSignedNucleiTemplateAndGetResults(filePath, address, debug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, 1)
|
||||
}
|
||||
|
||||
// rmcpPingCheck waits for an answer to an RMCP presence ping, since the
|
||||
// port is udp and cannot be probed with a connect.
|
||||
func rmcpPingCheck(address string) error {
|
||||
conn, err := net.Dial("udp", address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
if _, err := conn.Write([]byte{0x06, 0x00, 0xff, 0x06, 0x00, 0x00, 0x11, 0xbe, 0x80, 0x00, 0x00, 0x00}); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
reply := make([]byte, 64)
|
||||
n, err := conn.Read(reply)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n < 9 || reply[8] != 0x40 {
|
||||
return fmt.Errorf("unexpected rmcp reply")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -46,6 +46,7 @@ var jsTestcases = []integrationCase{
|
||||
{Path: "protocols/javascript/http-get.yaml", TestCase: &javascriptHTTPGet{}},
|
||||
{Path: "protocols/javascript/http-client-flow.yaml", TestCase: &javascriptHTTPClientFlow{}},
|
||||
{Path: "protocols/javascript/http-denied.yaml", TestCase: &javascriptHTTPDenied{}},
|
||||
{Path: "protocols/javascript/ipmi.yaml", TestCase: &javascriptIPMI{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
||||
{Path: "protocols/javascript/rsync-test.yaml", TestCase: &javascriptRsyncTest{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
||||
{Path: "protocols/javascript/vnc-pass-brute.yaml", TestCase: &javascriptVncPassBrute{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
||||
{Path: "protocols/javascript/postgres-pass-brute.yaml", TestCase: &javascriptPostgresPassBrute{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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"
|
||||
@@ -1,29 +0,0 @@
|
||||
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"
|
||||
@@ -1,37 +0,0 @@
|
||||
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"
|
||||
@@ -1,29 +0,0 @@
|
||||
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"
|
||||
@@ -1,29 +0,0 @@
|
||||
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"
|
||||
@@ -1,35 +0,0 @@
|
||||
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"
|
||||
@@ -1,29 +0,0 @@
|
||||
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"
|
||||
@@ -1,29 +0,0 @@
|
||||
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"
|
||||
@@ -1,29 +0,0 @@
|
||||
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"
|
||||
@@ -1,33 +0,0 @@
|
||||
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"
|
||||
@@ -1,29 +0,0 @@
|
||||
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"
|
||||
@@ -1,29 +0,0 @@
|
||||
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"
|
||||
@@ -1,29 +0,0 @@
|
||||
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
@@ -1,72 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,46 @@
|
||||
id: ipmi-checks
|
||||
|
||||
info:
|
||||
name: IPMI Checks
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
javascript:
|
||||
- code: |
|
||||
const ipmi = require('nuclei/ipmi');
|
||||
const caps = ipmi.GetAuthCapabilities(Host, Port);
|
||||
const root = ipmi.GetRAKPHash(Host, Port, 'root');
|
||||
|
||||
// cipher suite 0 lets a wrong password in
|
||||
const anyPassword = new ipmi.Client();
|
||||
const cipherZero = anyPassword.Login(Host, Port, 'ADMIN', 'wrong', 0);
|
||||
anyPassword.Close();
|
||||
|
||||
// an encrypted, signed session, then Get Device ID and admin privilege
|
||||
const client = new ipmi.Client();
|
||||
client.Login(Host, Port, 'ADMIN', 'ADMIN', 3);
|
||||
const device = client.Command(0x06, 0x01, []);
|
||||
const admin = client.Command(0x06, 0x3b, [0x04]);
|
||||
client.Close();
|
||||
const manufacturer = device.Data[6] | device.Data[7] << 8 | device.Data[8] << 16;
|
||||
|
||||
let wrongRejected = false;
|
||||
try { new ipmi.Client().Login(Host, Port, 'ADMIN', 'wrong', 3); } catch (e) { wrongRejected = true; }
|
||||
|
||||
ipmi.Ping(Host, Port) && caps.IPMI20 && caps.AnonymousLogin &&
|
||||
ipmi.GetCipherSuites(Host, Port).includes(0) &&
|
||||
ipmi.GetSystemGUID(Host, Port).length == 32 &&
|
||||
ipmi.FindRAKPPassword(root, ['admin', 'calvin']) == 'calvin' &&
|
||||
!ipmi.CheckRAKPPassword(root, 'admin') &&
|
||||
cipherZero && wrongRejected &&
|
||||
device.CompletionCode == 0 && manufacturer == 4753 &&
|
||||
admin.CompletionCode == 0;
|
||||
|
||||
args:
|
||||
Host: "{{Host}}"
|
||||
Port: "623"
|
||||
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "success == true"
|
||||
+8
-6
@@ -39,12 +39,14 @@ func createEphemeralObjects(ctx context.Context, base *NucleiEngine, opts *types
|
||||
Catalog: base.catalog,
|
||||
IssuesClient: base.rc,
|
||||
RateLimiter: base.rateLimiter,
|
||||
Interactsh: base.interactshClient,
|
||||
Colorizer: aurora.New(aurora.WithColors(true)),
|
||||
ResumeCfg: types.NewResumeCfg(),
|
||||
Parser: base.parser,
|
||||
Browser: base.browserInstance,
|
||||
DoNotCache: opts.DoNotCacheTemplates,
|
||||
|
||||
RequestBodyBudget: base.executerOpts.RequestBodyBudget,
|
||||
Interactsh: base.interactshClient,
|
||||
Colorizer: aurora.New(aurora.WithColors(true)),
|
||||
ResumeCfg: types.NewResumeCfg(),
|
||||
Parser: base.parser,
|
||||
Browser: base.browserInstance,
|
||||
DoNotCache: opts.DoNotCacheTemplates,
|
||||
}
|
||||
if base.interactshClient != nil {
|
||||
u.executerOpts.InteractshScope = base.interactshClient.NewRequestScope()
|
||||
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
nucleiUtils "github.com/projectdiscovery/nuclei/v3/pkg/utils"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils/httpbody"
|
||||
"github.com/projectdiscovery/ratelimit"
|
||||
pdcpauth "github.com/projectdiscovery/utils/auth/pdcp"
|
||||
)
|
||||
@@ -279,6 +280,7 @@ func (e *NucleiEngine) init(ctx context.Context) error {
|
||||
Catalog: e.catalog,
|
||||
IssuesClient: e.rc,
|
||||
RateLimiter: e.rateLimiter,
|
||||
RequestBodyBudget: httpbody.NewBudget(int64(e.opts.MaxRequestBodyInFlight)),
|
||||
Interactsh: e.interactshClient,
|
||||
Colorizer: aurora.New(aurora.WithColors(true)),
|
||||
ResumeCfg: types.NewResumeCfg(),
|
||||
@@ -358,6 +360,9 @@ func (e *NucleiEngine) init(ctx context.Context) error {
|
||||
|
||||
httpxOptions := httpx.DefaultOptions
|
||||
httpxOptions.Timeout = 5 * time.Second
|
||||
if len(e.opts.InternalResolversList) > 0 {
|
||||
httpxOptions.Resolvers = e.opts.InternalResolversList
|
||||
}
|
||||
if client, err := httpx.New(&httpxOptions); err != nil {
|
||||
return err
|
||||
} else {
|
||||
|
||||
@@ -7,18 +7,22 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/alecthomas/chroma/quick"
|
||||
"github.com/alecthomas/chroma/v2/quick"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
pdcpauth "github.com/projectdiscovery/utils/auth/pdcp"
|
||||
"github.com/projectdiscovery/utils/errkit"
|
||||
)
|
||||
|
||||
const (
|
||||
var (
|
||||
aiTemplateGeneratorAPIEndpoint = "https://api.projectdiscovery.io/v1/template/ai"
|
||||
// aiTemplateHTTPClient verifies TLS, since the request carries the PDCP API key.
|
||||
aiTemplateHTTPClient = &http.Client{}
|
||||
// aiTemplateIDRegex keeps the template file name inside the pdcp directory.
|
||||
aiTemplateIDRegex = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
)
|
||||
|
||||
type AITemplateResponse struct {
|
||||
@@ -112,7 +116,7 @@ func generateAITemplate(prompt string) (string, string, error) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(pdcpauth.ApiKeyHeaderName, creds.APIKey)
|
||||
|
||||
resp, err := retryablehttp.DefaultClient().Do(req)
|
||||
resp, err := aiTemplateHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", errkit.Newf("Failed to send HTTP request: %v", err)
|
||||
}
|
||||
@@ -138,5 +142,9 @@ func generateAITemplate(prompt string) (string, string, error) {
|
||||
return "", "", errkit.Newf("Failed to generate template")
|
||||
}
|
||||
|
||||
if !aiTemplateIDRegex.MatchString(result.TemplateID) {
|
||||
return "", "", errkit.Newf("Invalid template ID in API response: %q", result.TemplateID)
|
||||
}
|
||||
|
||||
return result.Completion, result.TemplateID, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package loader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newAITemplateServer(t *testing.T, templateID string, gotKey *atomic.Value) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotKey.Store(r.Header.Get("X-Api-Key"))
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"completion": "id: test\n",
|
||||
"template_id": templateID,
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
oldEndpoint, oldClient := aiTemplateGeneratorAPIEndpoint, aiTemplateHTTPClient
|
||||
t.Cleanup(func() { aiTemplateGeneratorAPIEndpoint, aiTemplateHTTPClient = oldEndpoint, oldClient })
|
||||
aiTemplateGeneratorAPIEndpoint = srv.URL
|
||||
t.Setenv("PDCP_API_KEY", "test-key")
|
||||
if templateID != "" {
|
||||
// Trust the test certificate, to check the template_id only.
|
||||
aiTemplateHTTPClient = srv.Client()
|
||||
}
|
||||
}
|
||||
|
||||
func TestAITemplateVerifiesTLS(t *testing.T) {
|
||||
var gotKey atomic.Value
|
||||
newAITemplateServer(t, "", &gotKey)
|
||||
|
||||
_, _, err := generateAITemplate("check for exposed admin panels")
|
||||
require.ErrorContains(t, err, "certificate")
|
||||
require.Nil(t, gotKey.Load(), "the API key went to an untrusted server")
|
||||
}
|
||||
|
||||
func TestAITemplateRejectsUnsafeID(t *testing.T) {
|
||||
for _, id := range []string{"../../.config/nuclei/config", "a/b", `a\b`, "a.b", "a b"} {
|
||||
var gotKey atomic.Value
|
||||
newAITemplateServer(t, id, &gotKey)
|
||||
_, _, err := generateAITemplate("check for exposed admin panels")
|
||||
require.Error(t, err, id)
|
||||
}
|
||||
|
||||
var gotKey atomic.Value
|
||||
newAITemplateServer(t, "Abc-123_x", &gotKey)
|
||||
_, templateID, err := generateAITemplate("check for exposed admin panels")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Abc-123_x", templateID)
|
||||
}
|
||||
@@ -631,10 +631,10 @@ func (store *Store) noteMissingCapabilities(templatePath string, missingCaps []t
|
||||
// erroring out on discovering any faulty templates.
|
||||
func (store *Store) ValidateTemplates() error {
|
||||
templatePaths, errs := store.config.Catalog.GetTemplatesPath(store.finalTemplates)
|
||||
store.logErroredTemplates(errs)
|
||||
notFound := store.logErroredTemplates(errs)
|
||||
|
||||
workflowPaths, errs := store.config.Catalog.GetTemplatesPath(store.finalWorkflows)
|
||||
store.logErroredTemplates(errs)
|
||||
notFound += store.logErroredTemplates(errs)
|
||||
|
||||
templatePathsMap := make(map[string]struct{}, len(templatePaths))
|
||||
for _, path := range templatePaths {
|
||||
@@ -646,7 +646,8 @@ func (store *Store) ValidateTemplates() error {
|
||||
workflowPathsMap[path] = struct{}{}
|
||||
}
|
||||
|
||||
if store.areTemplatesValid(templatePathsMap) && store.areWorkflowsValid(workflowPathsMap) {
|
||||
// a path that can't be resolved is not validated, so it can't count as valid
|
||||
if store.areTemplatesValid(templatePathsMap) && store.areWorkflowsValid(workflowPathsMap) && notFound == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1056,10 +1057,15 @@ func workflowContainsProtocol(workflow []*workflows.WorkflowTemplate) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Store) logErroredTemplates(erred map[string]error) {
|
||||
// logErroredTemplates logs the templates that could not be found and returns
|
||||
// how many of them were not handled by the NotFoundCallback.
|
||||
func (s *Store) logErroredTemplates(erred map[string]error) int {
|
||||
notFound := 0
|
||||
for template, err := range erred {
|
||||
if s.NotFoundCallback == nil || !s.NotFoundCallback(template) {
|
||||
s.logger.Error().Msgf("Could not find template '%s': %s", template, err)
|
||||
notFound++
|
||||
}
|
||||
}
|
||||
return notFound
|
||||
}
|
||||
@@ -176,25 +176,8 @@ func TestLoadTemplatesOnlyMetadataLogsCachedTemplateParseErrors(t *testing.T) {
|
||||
require.Contains(t, output.String(), "Could not load template")
|
||||
}
|
||||
|
||||
func TestValidateTemplatesRejectsRuntimeCompilationError(t *testing.T) {
|
||||
templatePath := filepath.Join(t.TempDir(), "invalid-regex-group.yaml")
|
||||
require.NoError(t, os.WriteFile(templatePath, []byte(`id: invalid-regex-group
|
||||
|
||||
info:
|
||||
name: Invalid Regex Group
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
extractors:
|
||||
- type: regex
|
||||
group: -1
|
||||
regex:
|
||||
- "(a)(b)"
|
||||
`), 0o600))
|
||||
func newValidateStore(t *testing.T, executionID, templatePath string) (*Store, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
|
||||
options := testutils.DefaultOptions.Copy()
|
||||
var output bytes.Buffer
|
||||
@@ -203,7 +186,7 @@ http:
|
||||
logger.SetWriter(&utils.CaptureWriter{Buffer: &output})
|
||||
logger.SetMaxLevel(levels.LevelDebug)
|
||||
options.Logger = logger
|
||||
options.ExecutionId = "loader-invalid-regex-group"
|
||||
options.ExecutionId = executionID
|
||||
options.Templates = []string{templatePath}
|
||||
options.Validate = true
|
||||
options.TemplateLoadingConcurrency = 1
|
||||
@@ -226,10 +209,50 @@ http:
|
||||
|
||||
store, err := New(NewConfig(options, catalog, executerOpts))
|
||||
require.NoError(t, err)
|
||||
return store, &output
|
||||
}
|
||||
|
||||
func TestValidateTemplatesRejectsRuntimeCompilationError(t *testing.T) {
|
||||
templatePath := filepath.Join(t.TempDir(), "invalid-regex-group.yaml")
|
||||
require.NoError(t, os.WriteFile(templatePath, []byte(`id: invalid-regex-group
|
||||
|
||||
info:
|
||||
name: Invalid Regex Group
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
extractors:
|
||||
- type: regex
|
||||
group: -1
|
||||
regex:
|
||||
- "(a)(b)"
|
||||
`), 0o600))
|
||||
|
||||
store, output := newValidateStore(t, "loader-invalid-regex-group", templatePath)
|
||||
require.Error(t, store.ValidateTemplates())
|
||||
require.Contains(t, output.String(), "regex extractor group must be >= 0")
|
||||
}
|
||||
|
||||
func TestValidateTemplatesRejectsMissingTemplatePath(t *testing.T) {
|
||||
templatePath := filepath.Join(t.TempDir(), "no-such-templates")
|
||||
|
||||
store, output := newValidateStore(t, "loader-missing-template-path", templatePath)
|
||||
require.Error(t, store.ValidateTemplates())
|
||||
require.Contains(t, output.String(), "Could not find template")
|
||||
}
|
||||
|
||||
func TestValidateTemplatesAcceptsMissingPathHandledByCallback(t *testing.T) {
|
||||
templatePath := filepath.Join(t.TempDir(), "no-such-templates")
|
||||
|
||||
store, _ := newValidateStore(t, "loader-missing-path-callback", templatePath)
|
||||
store.NotFoundCallback = func(string) bool { return true }
|
||||
require.NoError(t, store.ValidateTemplates())
|
||||
}
|
||||
|
||||
func TestRemoteTemplates(t *testing.T) {
|
||||
catalog := disk.NewCatalog("")
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package core
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/cookiejar"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
@@ -32,22 +33,45 @@ func (e *Engine) executeWorkflow(ctx *scan.ScanContext, w *workflows.Workflow) b
|
||||
templateThreads++
|
||||
}
|
||||
swg, _ := syncutil.New(syncutil.WithSize(templateThreads))
|
||||
// pending tracks every started step until it returns. swg only limits
|
||||
// concurrency and its slots can be released early, so completion is
|
||||
// tracked here instead.
|
||||
var pending sync.WaitGroup
|
||||
|
||||
for _, template := range w.Workflows {
|
||||
newCtx := scan.NewScanContext(ctx.Context(), ctx.Input.Clone())
|
||||
if err := e.runWorkflowStep(template, newCtx, results, swg, w); err != nil {
|
||||
// Root steps do not own a waitgroup slot.
|
||||
if err := e.runWorkflowStep(template, newCtx, results, swg, &pending, w, nil); err != nil {
|
||||
gologger.Warning().Msgf(workflowStepExecutionError, template.Template, err)
|
||||
}
|
||||
}
|
||||
|
||||
swg.Wait()
|
||||
pending.Wait()
|
||||
|
||||
return results.Load()
|
||||
}
|
||||
|
||||
// slotRelease returns a func that gives back one swg slot. It is safe to call
|
||||
// from several goroutines and several times; only the first call releases.
|
||||
func slotRelease(swg *syncutil.AdaptiveWaitGroup) func() {
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(swg.Done)
|
||||
}
|
||||
}
|
||||
|
||||
// runWorkflowStep runs a workflow step for the workflow. It executes the workflow
|
||||
// in a recursive manner running all subtemplates and matchers.
|
||||
func (e *Engine) runWorkflowStep(template *workflows.WorkflowTemplate, ctx *scan.ScanContext, results *atomic.Bool, swg *syncutil.AdaptiveWaitGroup, w *workflows.Workflow) error {
|
||||
//
|
||||
// releaseSlot, when non-nil, releases the AdaptiveWaitGroup slot held by the
|
||||
// goroutine that is running this step. It must be called before this step
|
||||
// blocks on swg.AddWithContext for child steps; otherwise every in-flight
|
||||
// parent can hold a slot while waiting for another, which deadlocks when the
|
||||
// number of matching nested steps reaches TemplateThreads (see #8074).
|
||||
//
|
||||
// pending is incremented for every child step before it starts, and each child
|
||||
// calls Done when it returns, so the root can wait for the whole tree.
|
||||
func (e *Engine) runWorkflowStep(template *workflows.WorkflowTemplate, ctx *scan.ScanContext, results *atomic.Bool, swg *syncutil.AdaptiveWaitGroup, pending *sync.WaitGroup, w *workflows.Workflow, releaseSlot func()) error {
|
||||
var firstMatched bool
|
||||
var err error
|
||||
var mainErr error
|
||||
@@ -131,18 +155,25 @@ func (e *Engine) runWorkflowStep(template *workflows.WorkflowTemplate, ctx *scan
|
||||
continue
|
||||
}
|
||||
|
||||
// Free this step's slot before blocking on child acquires.
|
||||
if releaseSlot != nil {
|
||||
releaseSlot()
|
||||
}
|
||||
for _, subtemplate := range matcher.Subtemplates {
|
||||
if err := swg.AddWithContext(ctx.Context()); err != nil {
|
||||
return
|
||||
}
|
||||
pending.Add(1)
|
||||
|
||||
go func(subtemplate *workflows.WorkflowTemplate) {
|
||||
defer swg.Done()
|
||||
defer pending.Done()
|
||||
release := slotRelease(swg)
|
||||
defer release()
|
||||
|
||||
// create a new context with the same input but with unset callbacks
|
||||
// clone the Input so that other parallel executions won't overwrite the shared variables when subsequent templates are running
|
||||
subCtx := scan.NewScanContext(ctx.Context(), ctx.Input.Clone())
|
||||
if err := e.runWorkflowStep(subtemplate, subCtx, results, swg, w); err != nil {
|
||||
if err := e.runWorkflowStep(subtemplate, subCtx, results, swg, pending, w, release); err != nil {
|
||||
gologger.Warning().Msgf(workflowStepExecutionError, subtemplate.Template, err)
|
||||
}
|
||||
}(subtemplate)
|
||||
@@ -162,18 +193,27 @@ func (e *Engine) runWorkflowStep(template *workflows.WorkflowTemplate, ctx *scan
|
||||
return mainErr
|
||||
}
|
||||
if len(template.Subtemplates) > 0 && firstMatched {
|
||||
// Free this step's slot before blocking on child acquires so nested
|
||||
// matching steps cannot exhaust TemplateThreads and deadlock (#8074).
|
||||
if releaseSlot != nil {
|
||||
releaseSlot()
|
||||
}
|
||||
for _, subtemplate := range template.Subtemplates {
|
||||
if err := swg.AddWithContext(ctx.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
pending.Add(1)
|
||||
|
||||
go func(template *workflows.WorkflowTemplate) {
|
||||
defer pending.Done()
|
||||
release := slotRelease(swg)
|
||||
defer release()
|
||||
|
||||
// create a new context with the same input but with unset callbacks
|
||||
subCtx := scan.NewScanContext(ctx.Context(), ctx.Input.Clone())
|
||||
if err := e.runWorkflowStep(template, subCtx, results, swg, w); err != nil {
|
||||
if err := e.runWorkflowStep(template, subCtx, results, swg, pending, w, release); err != nil {
|
||||
gologger.Warning().Msgf(workflowStepExecutionError, template.Template, err)
|
||||
}
|
||||
swg.Done()
|
||||
}(subtemplate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,6 +303,89 @@ func TestWorkflowsSameStepExecutersRefreshTemplateContext(t *testing.T) {
|
||||
require.Equal(t, "foo", secondWhoami, "could not refresh workflow context into later executers in the same step")
|
||||
}
|
||||
|
||||
func TestWorkflowNestedSubtemplatesDoNotDeadlockAtConcurrencyLimit(t *testing.T) {
|
||||
// Repro for #8074: with TemplateThreads equal to the number of matching
|
||||
// nested parents, each parent used to hold a waitgroup slot while calling
|
||||
// AddWithContext for its children, so every slot was occupied by a parent
|
||||
// waiting for another slot and the workflow never finished.
|
||||
progressBar, _ := progress.NewStatsTicker(0, false, false, false, 0)
|
||||
matchOutputs := []*output.InternalWrappedEvent{{
|
||||
OperatorsResult: &operators.Result{},
|
||||
Results: []*output.ResultEvent{{}},
|
||||
}}
|
||||
|
||||
const parents = 3
|
||||
var entered atomic.Int32
|
||||
allHolding := make(chan struct{})
|
||||
releaseParents := make(chan struct{})
|
||||
leafRuns := &atomic.Int32{}
|
||||
|
||||
leaf := &mockExecuter{result: true, executeScanHook: func(ctx *scan.ScanContext) {
|
||||
leafRuns.Add(1)
|
||||
}}
|
||||
parentExec := &mockExecuter{
|
||||
result: true,
|
||||
outputs: matchOutputs,
|
||||
executeScanHook: func(ctx *scan.ScanContext) {
|
||||
if entered.Add(1) == parents {
|
||||
close(allHolding)
|
||||
}
|
||||
// Stay inside ExecuteWithResults so the parent still owns its
|
||||
// waitgroup slot while every peer also holds one.
|
||||
<-releaseParents
|
||||
},
|
||||
}
|
||||
mkParent := func() *workflows.WorkflowTemplate {
|
||||
return &workflows.WorkflowTemplate{
|
||||
Executers: []*workflows.ProtocolExecuterPair{{
|
||||
Executer: parentExec,
|
||||
Options: &protocols.ExecutorOptions{Progress: progressBar},
|
||||
}},
|
||||
Subtemplates: []*workflows.WorkflowTemplate{{
|
||||
Executers: []*workflows.ProtocolExecuterPair{{
|
||||
Executer: leaf,
|
||||
Options: &protocols.ExecutorOptions{Progress: progressBar},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
workflow := &workflows.Workflow{
|
||||
Options: &protocols.ExecutorOptions{Options: &types.Options{TemplateThreads: parents}},
|
||||
Workflows: []*workflows.WorkflowTemplate{{
|
||||
Executers: []*workflows.ProtocolExecuterPair{{
|
||||
Executer: &mockExecuter{result: true, outputs: matchOutputs},
|
||||
Options: &protocols.ExecutorOptions{Progress: progressBar},
|
||||
}},
|
||||
Subtemplates: []*workflows.WorkflowTemplate{mkParent(), mkParent(), mkParent()},
|
||||
}},
|
||||
}
|
||||
|
||||
engine := &Engine{}
|
||||
input := contextargs.NewWithInput(context.Background(), "https://test.com")
|
||||
ctx := scan.NewScanContext(context.Background(), input)
|
||||
|
||||
done := make(chan bool, 1)
|
||||
go func() {
|
||||
done <- engine.executeWorkflow(ctx, workflow)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-allHolding:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("nested parents did not all acquire TemplateThreads slots")
|
||||
}
|
||||
close(releaseParents)
|
||||
|
||||
select {
|
||||
case matched := <-done:
|
||||
require.True(t, matched, "nested workflow should report a match")
|
||||
require.EqualValues(t, parents, leafRuns.Load(), "each nested parent should run its leaf")
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("nested workflow deadlocked at TemplateThreads == matching nested parents")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowSubtemplatesUnblockCancelledAcquire(t *testing.T) {
|
||||
progressBar, _ := progress.NewStatsTicker(0, false, false, false, 0)
|
||||
var started atomic.Int32
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -32,15 +28,6 @@ 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
|
||||
@@ -75,164 +62,9 @@ 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()))
|
||||
)
|
||||
|
||||
@@ -249,46 +81,17 @@ func ApplyPayloadTransformations(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
func randStringBytesMask(n int) string {
|
||||
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)
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letterBytes[random.Intn(len(letterBytes))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// GetRandomInteger returns a random integer between 1000 and 9999
|
||||
func GetRandomInteger() int {
|
||||
randMu.Lock()
|
||||
defer randMu.Unlock()
|
||||
return random.Intn(9000) + 1000
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -1,546 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
// 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"
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
// 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"
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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,14 +115,26 @@ 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)
|
||||
|
||||
rebuilt, err := analyzers.SetValueAndRebuild(gr, replaced)
|
||||
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()
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "could not rebuild request with value")
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -190,11 +202,8 @@ func doHTTPRequestWithTimeTracing(req *retryablehttp.Request, httpclient *retrya
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "could not do request")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 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))
|
||||
_, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "could not read response body")
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import (
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const analyzerName = "xss_context"
|
||||
const (
|
||||
analyzerName = "xss_context"
|
||||
maxResponseBodyBytes = 10 * 1024 * 1024 // 10 MiB
|
||||
)
|
||||
|
||||
// XSSAnalyzer implements the analyzers.Analyzer interface for XSS context detection.
|
||||
type XSSAnalyzer struct{}
|
||||
@@ -38,16 +41,32 @@ 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)
|
||||
}()
|
||||
|
||||
body, err := analyzers.SendValueAndReadBody(options, payload)
|
||||
rebuilt, err := gr.Component.Rebuild()
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
ctx, err := AnalyzeReflectionContext(body, payload)
|
||||
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)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
@@ -172,28 +191,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
|
||||
|
||||
+14
-18
@@ -155,23 +155,14 @@ func (rule *Rule) Execute(input *ExecuteRuleInput) (err error) {
|
||||
baseInteractURLs := append([]string(nil), input.InteractURLs...)
|
||||
if rule.generator == nil {
|
||||
for _, component := range finalComponentList {
|
||||
var dataKeys map[string]struct{}
|
||||
|
||||
optionVars := rule.options.Options.Vars.AsMap()
|
||||
constants := rule.options.Constants
|
||||
dataValues := rule.options.NewVariablesScope(baseValues, optionVars, constants)
|
||||
// get vars from variables while replacing interactsh urls
|
||||
// Render variables once, allocating their interactsh URLs. The
|
||||
// output may contain runtime data, so it is never rendered again.
|
||||
evaluation := rule.options.Variables.EvaluateWithInteractshScope(dataValues, rule.options.Interactsh)
|
||||
evaluatedValues, interactURLs := evaluation.Values, evaluation.InteractURLs
|
||||
input.Values, dataKeys = mergeFuzzValueLayers(
|
||||
fuzzValueLayer{values: evaluatedValues, kind: fuzzValueTemplate},
|
||||
fuzzValueLayer{values: baseValues, kind: fuzzValueData},
|
||||
fuzzValueLayer{values: optionVars, kind: fuzzValueData},
|
||||
fuzzValueLayer{values: constants, kind: fuzzValueData},
|
||||
)
|
||||
// Replace template-text interactsh markers without evaluating runtime values.
|
||||
input.Values, interactURLs = rule.evaluateVarsWithInteractsh(input.Values, interactURLs, dataKeys)
|
||||
input.InteractURLs = mergeInteractURLs(baseInteractURLs, interactURLs)
|
||||
input.Values = generators.MergeMaps(evaluation.Values, baseValues, optionVars, constants)
|
||||
input.InteractURLs = mergeInteractURLs(baseInteractURLs, evaluation.InteractURLs)
|
||||
|
||||
err := rule.executeRuleValues(input, component)
|
||||
if err != nil {
|
||||
@@ -240,15 +231,17 @@ func (rule *Rule) prepareGeneratorValues(values, baseValues map[string]interface
|
||||
evaluation := rule.options.Variables.EvaluateWithInteractshScope(evaluationValues, rule.options.Interactsh)
|
||||
evaluatedValues, interactURLs := evaluation.Values, evaluation.InteractURLs
|
||||
inputValues, dataKeys := mergeFuzzValueLayers(
|
||||
fuzzValueLayer{values: evaluatedValues, kind: fuzzValueData},
|
||||
fuzzValueLayer{values: values, kind: fuzzValueTemplate},
|
||||
fuzzValueLayer{values: evaluatedValues, kind: fuzzValueTemplate},
|
||||
fuzzValueLayer{values: baseValues, kind: fuzzValueData},
|
||||
fuzzValueLayer{values: optionVars, kind: fuzzValueData},
|
||||
fuzzValueLayer{values: constants, kind: fuzzValueData},
|
||||
)
|
||||
|
||||
// Generator values are template-authored payload definitions until this
|
||||
// render. Runtime/base, CLI option, and constant values remain data.
|
||||
// render. Rendered variables, runtime/base, CLI option, and constant
|
||||
// values remain data. Payloads layer over variables so a payload that
|
||||
// shares a variable's name is still rendered.
|
||||
return rule.evaluateVarsWithInteractsh(inputValues, interactURLs, dataKeys)
|
||||
}
|
||||
|
||||
@@ -326,7 +319,7 @@ func (rule *Rule) evaluateVars(input string) (string, error) {
|
||||
func (rule *Rule) evaluateVarsWithInteractsh(data map[string]interface{}, interactshUrls []string, dataKeys map[string]struct{}) (map[string]interface{}, []string) {
|
||||
// Check if Interactsh options are configured
|
||||
if rule.options.Interactsh != nil {
|
||||
data = maps.Clone(data)
|
||||
rendered := maps.Clone(data)
|
||||
|
||||
interactshUrlsMap := make(map[string]struct{})
|
||||
for _, url := range interactshUrls {
|
||||
@@ -335,7 +328,8 @@ func (rule *Rule) evaluateVarsWithInteractsh(data map[string]interface{}, intera
|
||||
|
||||
interactshUrls = mapsutil.GetKeys(interactshUrlsMap)
|
||||
|
||||
// Iterate through template-text data to replace Interactsh URL markers.
|
||||
// Render each template-text value against the unrendered input, so
|
||||
// the result does not depend on map iteration order.
|
||||
for k, v := range data {
|
||||
if _, ok := dataKeys[k]; ok {
|
||||
continue
|
||||
@@ -349,7 +343,7 @@ func (rule *Rule) evaluateVarsWithInteractsh(data map[string]interface{}, intera
|
||||
InteractURLs: interactshUrls,
|
||||
})
|
||||
if err == nil {
|
||||
data[k] = got.Text
|
||||
rendered[k] = got.Text
|
||||
for _, url := range got.InteractURLs {
|
||||
if _, ok := interactshUrlsMap[url]; ok {
|
||||
continue
|
||||
@@ -360,6 +354,8 @@ func (rule *Rule) evaluateVarsWithInteractsh(data map[string]interface{}, intera
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data = rendered
|
||||
}
|
||||
|
||||
return data, interactshUrls
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package fuzz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/component"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/generators"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/variables"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
// requestBodyWithDSL is request data that looks like a DSL expression.
|
||||
requestBodyWithDSL = `PLAIN-{{to_upper("abc")}}-END`
|
||||
queryRuleTarget = "http://127.0.0.1/echo?p=SEED"
|
||||
)
|
||||
|
||||
// fakeInteractshClient hands out numbered URLs without contacting a server.
|
||||
// Fuzz rules only allocate URLs through NewURLWithData, so the embedded nil
|
||||
// IClient is never called.
|
||||
type fakeInteractshClient struct {
|
||||
interactsh.IClient
|
||||
|
||||
issued int
|
||||
}
|
||||
|
||||
func (c *fakeInteractshClient) NewURLWithData(string) (string, error) {
|
||||
c.issued++
|
||||
|
||||
return fmt.Sprintf("oast%d.example", c.issued), nil
|
||||
}
|
||||
|
||||
// newQueryRule compiles a single-mode rule that replaces the p query value
|
||||
// with fuzzValue.
|
||||
func newQueryRule(
|
||||
t *testing.T,
|
||||
fuzzValue string,
|
||||
generator *generators.PayloadGenerator,
|
||||
templateVars variables.Variable,
|
||||
client interactsh.IClient,
|
||||
) *Rule {
|
||||
t.Helper()
|
||||
|
||||
rule := &Rule{
|
||||
Part: component.RequestQueryComponent,
|
||||
Type: "replace",
|
||||
Mode: "single",
|
||||
Fuzz: SliceOrMapSlice{Value: []string{fuzzValue}},
|
||||
}
|
||||
err := rule.Compile(generator, &protocols.ExecutorOptions{
|
||||
Variables: templateVars,
|
||||
Options: &types.Options{},
|
||||
Interactsh: client,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return rule
|
||||
}
|
||||
|
||||
// executeQueryRule runs rule against queryRuleTarget and returns the generated
|
||||
// requests.
|
||||
func executeQueryRule(
|
||||
t *testing.T,
|
||||
rule *Rule,
|
||||
method string,
|
||||
body io.Reader,
|
||||
values map[string]any,
|
||||
) []GeneratedRequest {
|
||||
t.Helper()
|
||||
|
||||
req, err := retryablehttp.NewRequest(method, queryRuleTarget, body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var got []GeneratedRequest
|
||||
err = rule.Execute(&ExecuteRuleInput{
|
||||
Input: contextargs.NewWithInput(context.Background(), queryRuleTarget),
|
||||
BaseRequest: req,
|
||||
Values: values,
|
||||
Callback: func(gr GeneratedRequest) bool {
|
||||
got = append(got, gr)
|
||||
|
||||
return true
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return got
|
||||
}
|
||||
|
||||
// newRequestDataVariableRule returns a query rule whose template declares
|
||||
// b: "{{body}}", so b captures request data.
|
||||
func newRequestDataVariableRule(t *testing.T, fuzzValue string, payloads map[string]any) *Rule {
|
||||
t.Helper()
|
||||
|
||||
templateVars := newFuzzTestVariables()
|
||||
templateVars.Set("b", "{{body}}")
|
||||
|
||||
var generator *generators.PayloadGenerator
|
||||
|
||||
if payloads != nil {
|
||||
var err error
|
||||
|
||||
generator, err = generators.New(
|
||||
payloads, generators.BatteringRamAttack, "", disk.NewCatalog(""), "", types.DefaultOptions(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// The CLI creates an Interactsh client even with -no-interactsh.
|
||||
return newQueryRule(t, fuzzValue, generator, templateVars, &interactsh.Client{})
|
||||
}
|
||||
|
||||
// fuzzedQueryValues runs rule against a POST request whose body is
|
||||
// requestBodyWithDSL and returns the fuzzed p query value of each generated
|
||||
// request.
|
||||
func fuzzedQueryValues(t *testing.T, rule *Rule) []string {
|
||||
t.Helper()
|
||||
|
||||
generated := executeQueryRule(t, rule, http.MethodPost, strings.NewReader(requestBodyWithDSL), map[string]any{
|
||||
"body": requestBodyWithDSL,
|
||||
})
|
||||
|
||||
got := make([]string, 0, len(generated))
|
||||
for _, gr := range generated {
|
||||
got = append(got, gr.Request.URL.Query().Get("p"))
|
||||
}
|
||||
|
||||
return got
|
||||
}
|
||||
|
||||
func TestExecuteKeepsRequestDataInTemplateVariableLiteral(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("fuzz value", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rule := newRequestDataVariableRule(t, "{{b}}", nil)
|
||||
|
||||
require.Equal(t, []string{requestBodyWithDSL}, fuzzedQueryValues(t, rule))
|
||||
})
|
||||
|
||||
t.Run("payload value", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rule := newRequestDataVariableRule(t, "{{inj}}", map[string]any{
|
||||
"inj": []any{"{{b}}"},
|
||||
})
|
||||
|
||||
// Each run iterates a fresh value map. Repeat so the result
|
||||
// cannot depend on map iteration order.
|
||||
for range 256 {
|
||||
require.Equal(t, []string{requestBodyWithDSL}, fuzzedQueryValues(t, rule))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteRendersTemplateVariableInteractshMarker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
templateVars := newFuzzTestVariables()
|
||||
templateVars.Set("marker", "{{interactsh-url}}")
|
||||
rule := newQueryRule(t, "{{marker}}", nil, templateVars, &fakeInteractshClient{})
|
||||
|
||||
got := executeQueryRule(t, rule, http.MethodGet, nil, nil)
|
||||
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, []string{"oast1.example"}, got[0].InteractURLs)
|
||||
require.Equal(t, "oast1.example", got[0].Request.URL.Query().Get("p"))
|
||||
}
|
||||
|
||||
func TestPrepareGeneratorValuesRendersPayloadSharingVariableName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
templateVars := newFuzzTestVariables()
|
||||
templateVars.Set("inj", "variable")
|
||||
|
||||
rule := &Rule{
|
||||
options: &protocols.ExecutorOptions{
|
||||
Variables: templateVars,
|
||||
Options: &types.Options{},
|
||||
Interactsh: &fakeInteractshClient{},
|
||||
},
|
||||
}
|
||||
|
||||
values, _ := rule.prepareGeneratorValues(map[string]any{
|
||||
"inj": `{{to_upper("payload")}}`,
|
||||
}, nil)
|
||||
|
||||
require.Equal(t, "PAYLOAD", values["inj"])
|
||||
}
|
||||
|
||||
func TestPrepareGeneratorValuesRendersPayloadsAgainstUnrenderedInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rule := &Rule{
|
||||
options: &protocols.ExecutorOptions{
|
||||
Variables: newFuzzTestVariables(),
|
||||
Options: &types.Options{},
|
||||
Interactsh: &interactsh.Client{},
|
||||
},
|
||||
}
|
||||
|
||||
for range 256 {
|
||||
values, _ := rule.prepareGeneratorValues(map[string]any{
|
||||
"upper": `{{to_upper("x")}}`,
|
||||
"ref": "{{upper}}",
|
||||
}, nil)
|
||||
|
||||
require.Equal(t, "X", values["upper"])
|
||||
// ref sees the unrendered definition of upper as data.
|
||||
require.Equal(t, `{{to_upper("x")}}`, values["ref"])
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ Formats implements support for passing a number of request source as input provi
|
||||
Currently the following formats are implemented -
|
||||
|
||||
- Burp Suite XML Request/Response file
|
||||
- HAR (HTTP Archive) file exported by browsers and proxies
|
||||
- Proxify JSONL output file
|
||||
- OpenAPI Specification file
|
||||
- Postman Collection file
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package har
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/types"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
|
||||
)
|
||||
|
||||
// HARFormat is an HTTP Archive (HAR 1.2) parser, the format browsers and
|
||||
// proxies export captured traffic in.
|
||||
type HARFormat struct {
|
||||
opts formats.InputFormatOptions
|
||||
}
|
||||
|
||||
// New creates a new HAR parser
|
||||
func New() *HARFormat {
|
||||
return &HARFormat{}
|
||||
}
|
||||
|
||||
var _ formats.Format = &HARFormat{}
|
||||
|
||||
type archive struct {
|
||||
Log struct {
|
||||
Entries []entry `json:"entries"`
|
||||
} `json:"log"`
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
Request struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Headers []header `json:"headers"`
|
||||
PostData *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"postData"`
|
||||
} `json:"request"`
|
||||
Response struct {
|
||||
Status int `json:"status"`
|
||||
StatusText string `json:"statusText"`
|
||||
Headers []header `json:"headers"`
|
||||
Content struct {
|
||||
Text string `json:"text"`
|
||||
Encoding string `json:"encoding"`
|
||||
} `json:"content"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
type header struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Name returns the name of the format
|
||||
func (h *HARFormat) Name() string {
|
||||
return "har"
|
||||
}
|
||||
|
||||
func (h *HARFormat) SetOptions(options formats.InputFormatOptions) {
|
||||
h.opts = options
|
||||
}
|
||||
|
||||
// Parse parses the input and calls the provided callback
|
||||
// function for each RawRequest it discovers.
|
||||
func (h *HARFormat) Parse(input io.Reader, resultsCb formats.ParseReqRespCallback, filePath string) error {
|
||||
var har archive
|
||||
if err := json.NewDecoder(input).Decode(&har); err != nil {
|
||||
return errors.Wrap(err, "could not decode har file")
|
||||
}
|
||||
|
||||
for _, e := range har.Log.Entries {
|
||||
// Browsers also log data:, blob: and extension URLs, which have no
|
||||
// host to send a request to.
|
||||
if !strings.HasPrefix(e.Request.URL, "http://") && !strings.HasPrefix(e.Request.URL, "https://") {
|
||||
continue
|
||||
}
|
||||
rawRequest, err := types.ParseRawRequestWithURL(rawRequestOf(e), e.Request.URL)
|
||||
if err != nil {
|
||||
gologger.Warning().Msgf("har: Could not parse request %s: %s\n", e.Request.URL, err)
|
||||
continue
|
||||
}
|
||||
// Status 0 is a request that was blocked or never answered.
|
||||
if e.Response.Status != 0 {
|
||||
rawRequest.Response = &types.HttpResponse{Raw: rawResponseOf(e)}
|
||||
}
|
||||
resultsCb(rawRequest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rawRequestOf rebuilds the request as HTTP/1.1 with an absolute target, which
|
||||
// carries the authority that HTTP/2 captures only keep in pseudo-headers.
|
||||
func rawRequestOf(e entry) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(e.Request.Method + " " + e.Request.URL + " HTTP/1.1\r\n")
|
||||
writeHeaders(&b, e.Request.Headers)
|
||||
if e.Request.PostData != nil {
|
||||
b.WriteString(e.Request.PostData.Text)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func rawResponseOf(e entry) string {
|
||||
statusText := e.Response.StatusText
|
||||
if statusText == "" {
|
||||
statusText = http.StatusText(e.Response.Status)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("HTTP/1.1 " + strconv.Itoa(e.Response.Status) + " " + statusText + "\r\n")
|
||||
writeHeaders(&b, e.Response.Headers)
|
||||
body := e.Response.Content.Text
|
||||
if e.Response.Content.Encoding == "base64" {
|
||||
if decoded, err := base64.StdEncoding.DecodeString(body); err == nil {
|
||||
body = string(decoded)
|
||||
}
|
||||
}
|
||||
b.WriteString(body)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeHeaders(b *strings.Builder, headers []header) {
|
||||
for _, h := range headers {
|
||||
// HTTP/2 pseudo-headers (:authority, :path, ...) are not headers.
|
||||
if strings.HasPrefix(h.Name, ":") {
|
||||
continue
|
||||
}
|
||||
b.WriteString(h.Name + ": " + h.Value + "\r\n")
|
||||
}
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package har
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const chromeHAR = "../testdata/chrome.har"
|
||||
|
||||
func parse(t *testing.T, input string) []*types.RequestResponse {
|
||||
t.Helper()
|
||||
file, err := os.Open(input)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
var got []*types.RequestResponse
|
||||
require.NoError(t, New().Parse(file, func(rr *types.RequestResponse) bool {
|
||||
got = append(got, rr)
|
||||
return false
|
||||
}, input))
|
||||
return got
|
||||
}
|
||||
|
||||
func TestHARParse(t *testing.T) {
|
||||
got := parse(t, chromeHAR)
|
||||
|
||||
urls := make([]string, 0, len(got))
|
||||
for _, rr := range got {
|
||||
urls = append(urls, rr.URL.String())
|
||||
}
|
||||
require.Equal(t, []string{
|
||||
"https://ginandjuice.shop/catalog?category=Juice",
|
||||
"https://ginandjuice.shop/login",
|
||||
"https://ginandjuice.shop/blocked.js",
|
||||
}, urls, "non-http entries such as data: URLs are skipped")
|
||||
}
|
||||
|
||||
func TestHARParseHTTP2Request(t *testing.T) {
|
||||
rr := parse(t, chromeHAR)[0]
|
||||
|
||||
require.Equal(t, "GET", rr.Request.Method)
|
||||
cookie, _ := rr.Request.Headers.Get("cookie")
|
||||
require.Equal(t, "session=abc123", cookie)
|
||||
for _, name := range rr.Request.Headers.GetKeys() {
|
||||
require.False(t, strings.HasPrefix(name, ":"), "pseudo-header %s is not a real header", name)
|
||||
}
|
||||
|
||||
req, err := rr.BuildRequest()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "ginandjuice.shop", req.Host)
|
||||
}
|
||||
|
||||
func TestHARParseBodyAndResponse(t *testing.T) {
|
||||
got := parse(t, chromeHAR)
|
||||
|
||||
login := got[1]
|
||||
require.Equal(t, "POST", login.Request.Method)
|
||||
require.Equal(t, "username=carlos&password=hunter2", login.Request.Body)
|
||||
require.NotNil(t, login.Response)
|
||||
require.Contains(t, login.Response.Raw, "302 Found")
|
||||
require.Contains(t, login.Response.Raw, "Location: /my-account")
|
||||
|
||||
require.NotNil(t, got[0].Response)
|
||||
require.Contains(t, got[0].Response.Raw, "<h1>Juice</h1>", "base64 content is decoded")
|
||||
|
||||
require.Nil(t, got[2].Response, "a request that never got an answer has no response")
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build gofuzz
|
||||
// +build gofuzz
|
||||
|
||||
package openapi
|
||||
|
||||
// Fuzz exercises OpenAPI 3.0 schema parsing and request generation. The input
|
||||
// bytes are treated as a raw schema document (YAML or JSON) and fed through the
|
||||
// format's Parse, which loads the schema and generates requests from it.
|
||||
func Fuzz(data []byte) int {
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
if len(data) > fuzzMaxInputSize {
|
||||
return -1
|
||||
}
|
||||
if !fuzzOpenAPIParsing(data) {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/types"
|
||||
)
|
||||
|
||||
const fuzzMaxInputSize = 64 << 10
|
||||
|
||||
// fuzzOpenAPIParsing feeds data to the OpenAPI format parser as a raw schema
|
||||
// document and drives request generation. It must never panic for any input;
|
||||
// it returns true when the schema parsed and at least one request was produced.
|
||||
func fuzzOpenAPIParsing(data []byte) bool {
|
||||
if len(data) == 0 || len(data) > fuzzMaxInputSize {
|
||||
return false
|
||||
}
|
||||
|
||||
format := New()
|
||||
format.SetOptions(formats.InputFormatOptions{})
|
||||
|
||||
produced := false
|
||||
cb := func(rr *types.RequestResponse) bool {
|
||||
produced = true
|
||||
return true
|
||||
}
|
||||
if err := format.Parse(bytes.NewReader(data), cb, "fuzz.openapi"); err != nil {
|
||||
return false
|
||||
}
|
||||
return produced
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFuzzOpenAPIParsingSeedCorpus(t *testing.T) {
|
||||
entries, err := os.ReadDir("testdata/gofuzz-corpus")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, entries)
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join("testdata/gofuzz-corpus", entry.Name()))
|
||||
require.NoError(t, err)
|
||||
require.Truef(t, fuzzOpenAPIParsing(data), "seed %s should produce at least one request", entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzOpenAPIParsingRejectsOversizeInput(t *testing.T) {
|
||||
require.False(t, fuzzOpenAPIParsing(make([]byte, fuzzMaxInputSize+1)))
|
||||
}
|
||||
|
||||
// Null entries decode to nil pointers and used to panic during request
|
||||
// generation. The remaining valid entries must still produce requests.
|
||||
func TestFuzzOpenAPIParsingNullEntries(t *testing.T) {
|
||||
specs := map[string]string{
|
||||
"null server": `
|
||||
openapi: 3.0.0
|
||||
info: {title: t, version: "1"}
|
||||
servers:
|
||||
-
|
||||
- {url: "http://example.com"}
|
||||
paths:
|
||||
/users:
|
||||
get:
|
||||
responses: {"200": {description: ok}}
|
||||
`,
|
||||
"null media type": `
|
||||
openapi: 3.0.0
|
||||
info: {title: t, version: "1"}
|
||||
servers: [{url: "http://example.com"}]
|
||||
paths:
|
||||
/users:
|
||||
post:
|
||||
requestBody:
|
||||
content:
|
||||
application/xml:
|
||||
application/json:
|
||||
schema: {type: object, properties: {name: {type: string}}}
|
||||
responses: {"200": {description: ok}}
|
||||
`,
|
||||
}
|
||||
|
||||
for name, spec := range specs {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
require.True(t, fuzzOpenAPIParsing([]byte(spec)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRequestsOnlyNullServers(t *testing.T) {
|
||||
spec := `
|
||||
openapi: 3.0.0
|
||||
info: {title: t, version: "1"}
|
||||
servers:
|
||||
-
|
||||
paths:
|
||||
/users:
|
||||
get:
|
||||
responses: {"200": {description: ok}}
|
||||
`
|
||||
err := New().Parse(strings.NewReader(spec), func(*types.RequestResponse) bool { return false }, "spec.yaml")
|
||||
require.ErrorContains(t, err, "no servers found in openapi schema")
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/clbanning/mxj/v2"
|
||||
@@ -33,7 +34,9 @@ const (
|
||||
|
||||
// GenerateRequestsFromSchema generates http requests from an OpenAPI 3.0 document object
|
||||
func GenerateRequestsFromSchema(schema *openapi3.T, opts formats.InputFormatOptions, callback formats.ParseReqRespCallback) error {
|
||||
if len(schema.Servers) == 0 {
|
||||
// Null entries in `servers:` are skipped below, so a list of only null
|
||||
// entries has no usable server, the same as an empty list.
|
||||
if !slices.ContainsFunc(schema.Servers, func(s *openapi3.Server) bool { return s != nil }) {
|
||||
return errors.New("no servers found in openapi schema")
|
||||
}
|
||||
globalParams, globalSecurityErr := selectSecurityParameters(schema, &schema.Security, opts.Variables)
|
||||
@@ -49,6 +52,11 @@ func GenerateRequestsFromSchema(schema *openapi3.T, opts formats.InputFormatOpti
|
||||
}
|
||||
|
||||
for _, serverURL := range schema.Servers {
|
||||
// A null entry in the `servers:` list decodes to a nil *Server, so skip
|
||||
// it rather than dereferencing it.
|
||||
if serverURL == nil {
|
||||
continue
|
||||
}
|
||||
pathURL := serverURL.URL
|
||||
// Split the server URL into baseURL and serverPath
|
||||
u, err := url.Parse(pathURL)
|
||||
@@ -268,6 +276,11 @@ func generateRequestsFromOp(opts *generateReqOptions) error {
|
||||
|
||||
if opts.op.RequestBody != nil {
|
||||
for content, value := range opts.op.RequestBody.Value.Content {
|
||||
// A null media-type entry (e.g. `content: {application/json: }`)
|
||||
// decodes to a nil *MediaType, so skip it before dereferencing.
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
cloned := req.Clone(req.Context())
|
||||
|
||||
var val interface{}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"openapi":"3.0.0","info":{"title":"t","version":"1"},"servers":[{"url":"http://example.com"}],"paths":{"/a":{"post":{"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"k":{"type":"string"}}}}}},"responses":{"200":{"description":"ok"}}}}}}
|
||||
@@ -0,0 +1,7 @@
|
||||
openapi: 3.0.0
|
||||
info: {title: t, version: "1"}
|
||||
servers: [{url: "http://example.com"}]
|
||||
paths:
|
||||
/users:
|
||||
get:
|
||||
responses: {"200": {description: ok}}
|
||||
@@ -0,0 +1,12 @@
|
||||
openapi: 3.0.0
|
||||
info: {title: t, version: "1"}
|
||||
servers: [{url: "http://example.com"}]
|
||||
paths:
|
||||
/users:
|
||||
post:
|
||||
requestBody:
|
||||
content:
|
||||
application/xml:
|
||||
application/json:
|
||||
schema: {type: object, properties: {name: {type: string}}}
|
||||
responses: {"200": {description: ok}}
|
||||
@@ -0,0 +1,9 @@
|
||||
openapi: 3.0.0
|
||||
info: {title: t, version: "1"}
|
||||
servers:
|
||||
-
|
||||
- {url: "http://example.com"}
|
||||
paths:
|
||||
/users:
|
||||
get:
|
||||
responses: {"200": {description: ok}}
|
||||
@@ -3,7 +3,9 @@ package swagger
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"path"
|
||||
"slices"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi2"
|
||||
"github.com/getkin/kin-openapi/openapi2conv"
|
||||
@@ -55,6 +57,7 @@ func (j *SwaggerFormat) Parse(input io.Reader, resultsCb formats.ParseReqRespCal
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not decode openapi 2.0 schema")
|
||||
}
|
||||
dropNullEntries(schemav2)
|
||||
schema, err := openapi2conv.ToV3(schemav2)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not convert openapi 2.0 schema to 3.0")
|
||||
@@ -67,6 +70,32 @@ func (j *SwaggerFormat) Parse(input io.Reader, resultsCb formats.ParseReqRespCal
|
||||
return openapi.GenerateRequestsFromSchema(schema, j.opts, resultsCb)
|
||||
}
|
||||
|
||||
// dropNullEntries removes null entries from the maps and lists of a decoded
|
||||
// OpenAPI 2.0 document. A null value such as `paths: {/x: }` or an empty list
|
||||
// item under `parameters:` decodes to a nil pointer, which openapi2conv.ToV3
|
||||
// dereferences without checking.
|
||||
func dropNullEntries(doc *openapi2.T) {
|
||||
maps.DeleteFunc(doc.Paths, func(_ string, v *openapi2.PathItem) bool { return v == nil })
|
||||
maps.DeleteFunc(doc.Parameters, func(_ string, v *openapi2.Parameter) bool { return v == nil })
|
||||
dropNullResponses(doc.Responses)
|
||||
for _, pathItem := range doc.Paths {
|
||||
pathItem.Parameters = slices.DeleteFunc(pathItem.Parameters, func(v *openapi2.Parameter) bool { return v == nil })
|
||||
for _, op := range pathItem.Operations() {
|
||||
op.Parameters = slices.DeleteFunc(op.Parameters, func(v *openapi2.Parameter) bool { return v == nil })
|
||||
dropNullResponses(op.Responses)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dropNullResponses removes null responses, and null headers of the remaining
|
||||
// responses, which openapi2conv.ToV3Response also dereferences.
|
||||
func dropNullResponses(responses map[string]*openapi2.Response) {
|
||||
maps.DeleteFunc(responses, func(_ string, v *openapi2.Response) bool { return v == nil })
|
||||
for _, response := range responses {
|
||||
maps.DeleteFunc(response.Headers, func(_ string, v *openapi2.Header) bool { return v == nil })
|
||||
}
|
||||
}
|
||||
|
||||
func decodeYAML(data []byte, target interface{}) error {
|
||||
var value interface{}
|
||||
if err := yaml.Unmarshal(data, &value); err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package swagger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/types"
|
||||
@@ -39,3 +40,69 @@ func TestSwaggerAPIParser(t *testing.T) {
|
||||
}
|
||||
require.ElementsMatch(t, gotMethodsToURLs, expectedURLs, "could not get swagger urls")
|
||||
}
|
||||
|
||||
func TestSwaggerParseNullEntries(t *testing.T) {
|
||||
// Each spec has a null map value or list item that decodes to a nil
|
||||
// pointer and used to panic inside openapi2conv.ToV3.
|
||||
specs := map[string]string{
|
||||
"null-path-item": `
|
||||
swagger: "2.0"
|
||||
info: {title: t, version: "1"}
|
||||
host: localhost
|
||||
paths:
|
||||
/x:
|
||||
`,
|
||||
"null-parameter": `
|
||||
swagger: "2.0"
|
||||
info: {title: t, version: "1"}
|
||||
host: localhost
|
||||
paths:
|
||||
/x:
|
||||
get:
|
||||
parameters:
|
||||
-
|
||||
responses: {"200": {description: ok}}
|
||||
`,
|
||||
"null-response": `
|
||||
swagger: "2.0"
|
||||
info: {title: t, version: "1"}
|
||||
host: localhost
|
||||
paths:
|
||||
/x:
|
||||
get:
|
||||
responses: {"200": }
|
||||
`,
|
||||
"null-response-header": `
|
||||
swagger: "2.0"
|
||||
info: {title: t, version: "1"}
|
||||
host: localhost
|
||||
responses:
|
||||
Shared:
|
||||
description: ok
|
||||
headers:
|
||||
X-Shared:
|
||||
paths:
|
||||
/x:
|
||||
get:
|
||||
responses:
|
||||
"200":
|
||||
description: ok
|
||||
headers:
|
||||
X-Rate-Limit:
|
||||
`,
|
||||
}
|
||||
|
||||
for name, spec := range specs {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var count int
|
||||
err := New().Parse(strings.NewReader(spec), func(request *types.RequestResponse) bool {
|
||||
count++
|
||||
return false
|
||||
}, "spec.yaml")
|
||||
require.NoError(t, err)
|
||||
if name != "null-path-item" {
|
||||
require.Equal(t, 1, count, "the operation should still produce a request")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"log": {
|
||||
"version": "1.2",
|
||||
"creator": { "name": "WebInspector", "version": "537.36" },
|
||||
"entries": [
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://ginandjuice.shop/catalog?category=Juice",
|
||||
"httpVersion": "http/2.0",
|
||||
"headers": [
|
||||
{ "name": ":authority", "value": "ginandjuice.shop" },
|
||||
{ "name": ":method", "value": "GET" },
|
||||
{ "name": ":path", "value": "/catalog?category=Juice" },
|
||||
{ "name": ":scheme", "value": "https" },
|
||||
{ "name": "accept", "value": "text/html" },
|
||||
{ "name": "cookie", "value": "session=abc123" }
|
||||
],
|
||||
"queryString": [{ "name": "category", "value": "Juice" }],
|
||||
"cookies": [],
|
||||
"headersSize": -1,
|
||||
"bodySize": 0
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "http/2.0",
|
||||
"headers": [{ "name": "content-type", "value": "text/html; charset=utf-8" }],
|
||||
"content": { "size": 19, "mimeType": "text/html", "text": "PGgxPkp1aWNlPC9oMT4=", "encoding": "base64" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://ginandjuice.shop/login",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"headers": [
|
||||
{ "name": "Host", "value": "ginandjuice.shop" },
|
||||
{ "name": "Content-Type", "value": "application/x-www-form-urlencoded" }
|
||||
],
|
||||
"postData": { "mimeType": "application/x-www-form-urlencoded", "text": "username=carlos&password=hunter2" }
|
||||
},
|
||||
"response": {
|
||||
"status": 302,
|
||||
"statusText": "Found",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"headers": [{ "name": "Location", "value": "/my-account" }],
|
||||
"content": { "size": 0, "mimeType": "" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "data:image/png;base64,iVBORw0KGgo=",
|
||||
"httpVersion": "",
|
||||
"headers": []
|
||||
},
|
||||
"response": { "status": 200, "statusText": "", "httpVersion": "", "headers": [], "content": { "size": 0 } }
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://ginandjuice.shop/blocked.js",
|
||||
"httpVersion": "",
|
||||
"headers": []
|
||||
},
|
||||
"response": { "status": 0, "statusText": "", "httpVersion": "", "headers": [], "content": { "size": 0 } }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats/burp"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats/har"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats/json"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats/openapi"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats/raw"
|
||||
@@ -142,6 +143,7 @@ func (i *HttpInputProvider) Close() {}
|
||||
// Supported Providers
|
||||
var providersList = []formats.Format{
|
||||
burp.New(),
|
||||
har.New(),
|
||||
json.New(),
|
||||
yaml.New(),
|
||||
openapi.New(),
|
||||
|
||||
Loaded 100 of 251 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user