nginx-http-shield-module: Block Ancient Exploits Without a WAF

Somewhere in your access log, right now, a bot is asking for /cgi-bin/php?-d+allow_url_include. That’s CVE-2012-1823, a PHP-CGI hole patched in May 2012. Fourteen years dead, and it still turns up in every fresh server’s logs within hours of the machine getting a public IP, because somewhere out there it still works on somebody.

nginx-http-shield-module is a small nginx dynamic module built for exactly that traffic. It blocks exploitation of web vulnerabilities that were patched years ago: SQL injection probes, ancient PHP and Java RCE chains, Log4Shell, Shellshock, path traversal, cloud-metadata SSRF. Roughly 400 signatures in 28 categories, compiled straight into the module. No rules language, no regex engine, no external ruleset to keep fed and watered.

Not a WAF, and not a replacement for one. A real WAF (Coraza or ModSecurity with the OWASP CRS) is still the recommended front line for any application that matters. This module exists for the problem big hosters know too well: hundreds of customer vhosts running old crap you don’t control and can’t patch. For those, it’s the last line of defense, the floor under everything, not the wall in front of one thing.

The design rule is blunt: every signature is chosen so that no legitimate client ever sends it. That keeps false positives near zero, and near-zero false positives are what make it safe to switch on in front of a large, mixed, not-fully-patched customer base, where a full WAF would be too heavy or too noisy to run everywhere.

Why you still get scanned for 2012

Because scanning is free. An exploit attempt costs its operator nothing, so the rational move is to try every CVE against every IP, forever. Your logs are the proof: Shellshock probes (patched 2014), Drupalgeddon2 (patched 2018), Citrix path traversal (patched 2019), all still arriving daily like junk mail addressed to the previous tenant.

CISA’s routinely-exploited-vulnerabilities lists kept featuring Log4Shell for years after the December 2021 patch, and Log4Shell is the young one in this crowd. The half-life of a web exploit is measured in decades, because the half-life of unmaintained software is measured in decades.

If you run one server with one patched app, this is noise. If you host a few hundred customer applications, it stops being noise. Some fraction of those apps is a WordPress from 2019 that “works fine, don’t touch it”. Another fraction runs a Java stack nobody can rebuild because the one person who understood the build left in 2022. You can’t patch what you don’t own, and you can’t make a customer patch by being disappointed at them. I’ve tried.

The textbook answer is a WAF in front of everything. ModSecurity with the full OWASP CRS is a fine answer for one app you care about. Multiplied across hundreds of vhosts it means real CPU per request and a false-positive tuning negotiation with every single customer, and most hosters quietly give up and run nothing at all.

The shield module is the piece you can afford to run everywhere: a legacy-exploit floor. Cheap, always on, catches the background radiation. Put a real WAF on top of the handful of apps that justify one. And if your scanner traffic has been getting weirder lately, that’s the vibe-coded AI scanner wave; the floor catches most of what those bots throw too, since they mostly replay the same ancient payloads with better grammar.

What the nginx shield module blocks

The 28 categories are the greatest hits of the last fifteen years. SQL injection tokens like union select and into outfile. Path traversal, including the .%2e/ normalization bypass from Apache’s CVE-2021-41773 and the overlong-UTF-8 slashes that date back to the Nimda era. Log4Shell’s ${jndi: family with its nested ${${ obfuscations. Shellshock’s () {. Java deserialization gadget classes, Struts OGNL, Spring4Shell, Drupalgeddon2, Rails YAML, the vBulletin widget RCE. Cloud-metadata SSRF against 169.254.169.254 and its Alibaba, Oracle and Google equivalents, including decimal and hex IP encodings. Webshell filenames: c99.php, wso.php, friends. Plus a set of fixed n-day exploit paths (F5’s /tmui/login.jsp/..;/, Citrix’s /vpn/../vpns/, vCenter’s uploadova, Fortinet’s credential-leak path) that no legitimate request has ever contained.

It inspects the request line and query string, the User-Agent, Referer and Content-Type headers, and, when enabled, the request body. Two checks are structural rather than substring: a request-borne Proxy: header (httpoxy, CVE-2016-5385) and a Range: header with more than ten ranges (the Apache Killer DoS, CVE-2011-3192).

One thing it deliberately refuses to do: block scanners by their User-Agent name. A sqlmap UA string costs the attacker one flag to change, so matching it buys you a warm feeling and nothing else. Payloads carried inside those headers are another matter. Shellshock arrives via User-Agent, and it gets caught, because the payload is the crime, not the name tag.

Every signature is stored as a multi-token combination that only appears in an attack, never a bare keyword. There’s a test file, t/05-fp-negative.t, whose entire job is to feed the module legitimate-but-suspicious-looking traffic and fail the build if any signature got greedy. A signature that blocks a paying customer’s search form costs more than the exploit it was meant to stop. That test exists because this lesson is normally learned at 3 a.m., by phone.

One pass, no regex

The module runs in nginx’s PRECONTENT phase. For each request it builds two normalized copies of every inspected input, a lowercased raw copy and a percent-decoded, plus-to-space, lowercased copy, and runs all enabled signatures over both in a single pass through an Aho-Corasick automaton.

Why two copies? Because attackers percent-encode. union select arrives as %75nion%20%73elect and matches nothing in the raw bytes; decoded, it lights up. But some payloads only exist before decoding, like the %c0%af overlong-UTF-8 slash from the IIS/Nimda era or the raw %0d%0a of a CRLF response split, so the raw copy gets scanned too. Both views, one automaton pass each, and the decoder is nginx’s own ngx_unescape_uri(), not a reimplementation that drifts from what the server actually serves.

That single pass is the whole performance story. Scan time is O(bytes) and does not grow with the number of signatures, so extending the table is free at runtime. A typical URI plus User-Agent (358 bytes) scans in 1.0 µs. The full 8 KB default body cap costs 23 µs.

nginx-http-shield-module scan cost numbers: 28 categories, 400 signatures, one microsecond per request
Scan-core numbers. Cost depends on bytes, never on signature count or content.

The first engine was dumber, and it’s worth telling on myself here. It ran one linear sweep per signature, roughly 500 sweeps per buffer, with a first-byte prefilter to skip obvious non-starters. Looked fine on benchmarks full of English text. Then you feed it 512 bytes of nothing but /, the single most common byte in a URI, the prefilter fires on every position, and the sweep count explodes: 120 µs for a buffer that benign traffic scans in about one. An attacker-steerable 100x slowdown, sitting in the code that was supposed to stop denial of service. The Aho-Corasick rewrite scans that same hostile buffer in 1.4 µs, and cost now depends on length alone.

The automaton weighs about 3.4 MB, built once at configuration load and shared read-only by every worker. There’s no per-request allocation beyond two scratch buffers. Body inspection reads the buffered request body up to the cap and then resumes phase processing, the same mechanism the stock mirror module uses.

Config: detect first, block later

Build it as a dynamic module against your nginx or Angie source, then load it:

./configure --add-dynamic-module=/path/to/nginx-http-shield-module
make modules
load_module modules/ngx_http_shield_module.so;

http {
    # Start in detect mode. Watch the logs. Then block.
    shield detect;

    server {
        location / {
            shield block;          # off | detect | block
            shield_body on;        # inspect request body (default on)
            shield_max_body 8k;    # bytes of body scanned (default 8k)
            shield_status 403;     # 403 | 404 | 419 | 429 | 444
        }

        location /legacy-app/ {
            shield block;
            shield_skip sqli xss;  # disable specific categories here
        }
    }
}

Rollout is four steps. Set shield detect; at the http level. Watch error.log for shield: detected attack lines; each names the category and the source but never echoes attacker bytes, so nobody gets to inject terminal escapes into your log pipeline. If a legacy app legitimately trips a category (an old CMS that ships serialized objects in cookies will, eventually), shield_skip that category for that location only. Then flip to shield block; and get on with your day.

shield_status picks what blocked clients receive: 403 by default, 404 if you’d rather not confirm anything exists, or nginx’s special 444, which closes the connection without sending a response at all. Scanners don’t read your error pages anyway.

Body scanning has one operational rule: shield_max_body (default 8k) is a DoS budget, not a dial to crank. Scan cost is linear in that value, and both the body length and the Content-Type that opts a request into body scanning are attacker-controlled. Bodies bigger than the cap pass through unscanned; an upload is never rejected for being big. On endpoints that take large uploads, set shield_body off and stop paying the toll entirely.

When inspection fails, it fails closed

Here’s the corner most homegrown security modules get wrong. What happens when the module can’t inspect a buffer at all? A pool allocation fails under memory pressure, or the request-body temp file can’t be read. The lazy answer is to shrug and let the request through. That’s also an attacker’s favorite answer: pressure the server’s memory, then walk in unscanned. An unscanned request is not a clean request.

So in block mode the module fails closed: the request is rejected with a 500 and error.log records shield: could not inspect <what>, failing closed. In detect mode, which by definition never changes the response, it logs that the request went unscanned and serves it. This is the only case where block mode returns anything other than your configured shield_status. A body over the cap is not an inspection failure; that’s the documented budget, and those requests pass.

From block to blocklist: logging and AbuseIPDB

Blocking a request protects you. Reporting the source protects everyone else. The module can emit one JSON object per hit with the shield_log directive — either to a file or straight to syslog:

shield_log /var/log/nginx/shield.json;      # one JSON object per line
shield_log syslog:server=10.0.0.1,tag=shield;  # or ship it off-box

Each line carries the timestamp, client IP, matched category, source (uri, body, header…), mode and the request line — and nothing else. The request line is the only attacker-controlled field, so it is JSON-escaped at the emit point: bytes below 0x20 or at/above 0x80 become \uXXXX, so a hostile path can neither break the JSON nor inject a forged record. There is deliberately no |command log target; a pipe in the config is a fork-bomb waiting for a crafted request, so it is rejected at load time.

Once you have that file, the shipped reporter (reporter/abuseipdb-reporter.py) turns it into AbuseIPDB reports. It runs out of band from nginx on purpose: the request hot path should never hold an API key, a rate limiter or network state. The daemon tails the log (surviving logrotate through an inode/offset state file), skips private and reserved IPs, de-duplicates each address for fifteen minutes, enforces a daily cap so a flood can’t burn your free-tier quota, and backs off — honouring Retry-After — when the API pushes back, retrying the unsent line rather than dropping it. The public report comment carries only METHOD /path; the query string is stripped, because URLs routinely smuggle tokens and e-mail addresses you must never forward to a third party. The API key lives in a 0600 environment file, never on the command line, and the provided systemd unit runs it under DynamicUser with the kernel, filesystem and syscall surface locked down.

Suppression and offset state are persisted, so a crash-loop can’t reset the dedup window or the daily cap and overrun your quota — a small pytest suite pins exactly that, along with the PII strip and the backoff. Start with --dry-run to watch what would be sent before you send anything.

What it is not

It is not a WAF, and it doesn’t want to be one. No anomaly scoring, no paranoia levels, no rule updates to babysit, and no coverage for attacks against your actual application logic. If an app matters enough to deserve negative-security depth, run Coraza or ModSecurity with the OWASP CRS on that app, on top of the floor, not instead of it. The two-layer setup is the point: CRS where you can afford the tuning, shield everywhere else.

It also pairs nicely with the error-abuse module: shield turns exploit probes into 403s, error-abuse notices a client collecting 403s and bans it. Floor plus bouncer.

The source is MIT-licensed on GitHub, with the test suite, the fuzz harness and the CI gauntlet (ASan, UBSan, Valgrind, libFuzzer, CodeQL) in plain view, because hostile-input parser code that hasn’t been fuzzed is a liability with a README. A prebuilt libnginx-mod-http-shield package for the deb.myguard.nl repository is on the roadmap.

Before you decide whether you need this, grep last week’s access log for jndi. I’ll wait.

Is nginx-http-shield-module a replacement for ModSecurity or Coraza?

No. It’s a fixed-signature floor for long-patched exploits, with false positives near zero and a cost low enough to run on every vhost. A real WAF adds anomaly scoring, virtual patching and application-level coverage; run one on the apps that need it, layered on top of the shield module.

How much latency does the shield module add?

About 1 µs for a typical request line plus headers, and 23 µs worst case for a full 8 KB body scan, measured over the scan core. All signatures are matched in a single Aho-Corasick pass, so cost depends only on input length, never on content or signature count.

Can nginx-http-shield-module block legitimate traffic?

Signatures are multi-token combinations chosen so no legitimate client ever sends them, and a false-positive test suite (t/05-fp-negative.t) gates every change. Roll out in detect mode first and watch the logs; if a legacy app trips a category, disable just that category for that location with shield_skip.

Does it work with Angie?

Yes. It builds as a standard dynamic module against nginx or Angie sources with ./configure –add-dynamic-module, and loads with load_module modules/ngx_http_shield_module.so.

What happens to large file uploads?

Only the first shield_max_body bytes (default 8k) of a request body are scanned; anything beyond the cap passes through unscanned, and size alone never blocks an upload. On dedicated upload endpoints, switch body inspection off entirely with shield_body off.

How do I add my own signature or category?

Signatures live in src/ngx_http_shield_patterns.h: lowercase, always multi-token, never a bare keyword. A new category is an enum value, a pattern table and one row in the category array; the Aho-Corasick engine picks it up without any engine change, and scan cost stays O(bytes).

Related reading