Static vulnerability scanner for nginx configurations. Parses configs with nginx's own tokenizer and matches against declarative JSON rules to detect known CVEs in rewrite/script engine directive patterns.
cd ngxray
git submodule update --init
make
python3 scan.py /etc/nginx/nginx.confRules are defined as JSON files in rules/. The scanner ships with:
| Rule | CVE | Pattern |
|---|---|---|
rewrite-is-args |
CVE-2026-42945 | rewrite … /path?args + set $var $1 |
rewrite-is-args-if |
CVE-2026-42945 | rewrite … /path?args + if (… $1 …) |
nested-capture-redirect |
CVE-2026-9256 | rewrite ^/((…))$ … redirect with overlapping $N |
Affected: NGINX 0.6.27 – 1.30.0, NGINX Plus R32 – R36. Fixed: 1.31.0 / 1.30.1.
A rewrite replacement containing ? sets e->is_args=1 in the script engine. This flag persists into subsequent set or if directives, causing the copy pass to URI-escape capture data (3x expansion) into a buffer sized for raw bytes.
location ~ ^/api/(.*)$ {
rewrite ^/api/(.*)$ /internal?migrated=true;
set $original_endpoint $1; # $1 evaluated with stale is_args=1
}Affected: through NGINX 1.31.0. Fixed: 1.31.1 / 1.30.2.
Nested capture groups cause the fast-path escape budget to undercount — the same URI bytes are escaped once per overlapping $N reference.
rewrite ^/((.*))$ http://backend/$1$2 redirect;# Single file
python3 scan.py /etc/nginx/nginx.conf
# Directory (recursive, all *.conf files)
python3 scan.py /etc/nginx/
# Filenames from stdin
find /etc/nginx -name '*.conf' | python3 scan.py -
# Pre-parsed JSONL (faster for large batches)
find /etc/nginx -name '*.conf' | ./build/nginx_conf_parse - | python3 scan.py --jsonl -
# JSON output
python3 scan.py --json /etc/nginx/nginx.conf
# Custom rules directory
python3 scan.py --rules ./my-rules/ /etc/nginx/nginx.confThe corpus_tools/ package can collect public nginx configuration material
from GitHub Code Search and extract nginx snippets from wrapper formats.
python3 corpus_tools/collect_github_nginx_corpus.py \
--output-dir corpus_out/nginx-rift \
--query-file corpus_tools/queries/nginx-rift.txt
python3 corpus_tools/extract_nginx_configs.py \
--input-dir corpus_out/nginx-rift/raw \
--output-dir corpus_out/nginx-rift/extracted
python3 scan.py corpus_out/nginx-rift/extractedSee corpus_tools/README.md and corpus_tools/docs/ for the full workflow and
the documented Rift-focused collection method.
Drop a JSON file in rules/. The engine loads all *.json files from the rules directory at startup.
{
"id": "rule-name",
"cve": "CVE-YYYY-NNNNN",
"severity": "CRITICAL",
"message": "short description shown in output",
"affected": "nginx version range",
"fixed": "fixed versions",
"ref": "https://link-to-advisory",
"match": [ ... ],
"tests": {
"vulnerable": ["server { ... }"],
"safe": ["server { ... }"]
}
}Each rule can include tests with vulnerable (must trigger) and safe (must not trigger) config snippets. Run python3 scan.py --test to validate all rules.
match is a list of directive conditions. If there's one condition, the engine searches the entire config tree for any matching directive. If there are multiple, all conditions must match sibling directives in the same block.
Each condition:
{
"directive": "rewrite",
"args": {
"0": { "contains": "?" },
"1": { "regex": "\\$[1-9]" }
},
"any_arg": { "regex": "\\$[1-9]" },
"or": [
{ "args": { "1": { "contains": "?" } } },
{ "any_arg_from": 2, "any_of": ["redirect", "permanent"] }
]
}args— keyed by position (as string). Each value is a condition object.max_args— reject if the directive has more than N args.overlapping_refs— verify that$Nreferences inargs[1]correspond to capture groups inargs[0]that physically contain each other.any_arg— condition must match at least one arg at any position.or— at least one branch must match. Branches can checkargsorany_arg_from+any_of.
| Key | Type | Matches when |
|---|---|---|
contains |
string | Arg contains the substring |
regex |
string | Arg matches the regex |
not_regex |
string | Arg does NOT match the regex |
any_of |
[string] | Arg equals one of the values |
extract |
string | Regex with a capture group — extracts all matches for counting |
min_unique |
int | (used with extract) At least N distinct matches found |
extract + min_unique is the general mechanism for counting patterns in an arg. For example, to require at least 2 distinct capture references ($1–$9):
{ "extract": "\\$([1-9])", "min_unique": 2 }{
"id": "proxy-buffer-overflow",
"cve": "CVE-2099-99999",
"severity": "CRITICAL",
"message": "proxy_pass with oversized buffer — heap overflow",
"ref": "https://example.com/advisory",
"match": [
{
"directive": "proxy_pass",
"args": { "0": { "regex": "https?://" } }
},
{
"directive": "proxy_buffer_size",
"args": { "0": { "regex": "^(6[5-9]|[7-9]\\d|\\d{3,})k$" } }
}
],
"tests": {
"vulnerable": [
"server { location / { proxy_pass http://backend; proxy_buffer_size 128k; } }"
],
"safe": [
"server { location / { proxy_pass http://backend; proxy_buffer_size 8k; } }"
]
}
}The parser/ directory contains a standalone C program that uses nginx's own tokenizer (ngx_conf_read_token and ngx_conf_parse from src/core/ngx_conf_file.c) to parse config files into a JSON AST. This is the same lexer nginx uses at startup — no reimplementation.
The key modification: ngx_conf_handler() (which dispatches directives to compiled-in modules) is replaced with conf_handler(), which records every directive into a tree regardless of name. This lets the parser handle any valid config without the module system. The modification is applied as a patch at build time — no nginx source files are copied into this repo.
The remaining nginx source files (pool allocator, string functions, logging, etc.) are compiled directly from the submodule.
scan.py loads JSON rules from rules/, parses configs via nginx_conf_parse, and evaluates each rule against the AST. All detection specifics live in the JSON rule files.
Requires a C compiler and Python 3. nginx source is included as a git submodule.
git submodule update --init
make
make testThis repo is mirrored read-only to github.com/califio/ngxray via Copybara. See COPYBARA.md.