Skip to main content
Xsec

Modern WAF internals: architecture, evasion, and NyxR production data

Published on 39 min read

Updated on

A Web Application Firewall (WAF) analyzes Hypertext Transfer Protocol (HTTP) traffic before it reaches the origin server, meaning the final application service. A modern WAF is stateful: it retains state across events to correlate a request with a client, policy, and history. It must also canonicalize each message, reducing equivalent representations to one common form exactly as downstream components do. If the proxy, an intermediary that receives and relays the request, the WAF, and the application assign different meanings to the same bytes, the detection engine no longer protects the message consumed by the origin.

On June 23, 2026, from 19:35 to 19:40 Paris time (UTC+2), the NyxR security gateway recorded 3,119 requests from one scanner on my application nyxr.app. In five minutes, it traversed 2,100 distinct Uniform Resource Identifiers (URIs), the resource identifiers targeted by requests. The data plane, the components that synchronously interpret and decide each request before a response can be sent, blocked 3,117 of them, or 99.94%, with 0 ms latency at the 50th percentile or median (p50) and the 95th percentile (p95). In parallel, ModSecurity, an open-source WAF engine, produced 3,121 audit transactions associated with rule 913100 from the Open Worldwide Application Security Project Core Rule Set (OWASP CRS), a generic ruleset that identifies the User-Agent, the string through which a client declares its software, of a known scanner here.

Recognizing a known scanner is only the visible part of the result. For such a block to be reliable, a modern WAF must also reconstruct the correct client address behind proxies, reject ambiguous HTTP messages, execute controls in the right order, apply policy locally, and record the decision without counting the same transaction twice. A mistake in any layer can create a bypass, a request that escapes the control, or a false positive, legitimate traffic incorrectly classified as malicious.

I designed NyxR around this chain. This article presents the resulting architecture choices from message framing to the final decision, then tests their behavior against one month of metrics produced by my application nyxr.app.

Architecture and the trust chain

The historical WAF model is a filter: a request enters, expressions compare it with signatures, and the request is accepted or rejected. That model explains a rule engine, but not a modern system.

A real request crosses several interpreters. The client serializes a message. A Content Delivery Network (CDN), a distributed network of caches and proxies, may receive it first; a load balancer distributes traffic across servers, while a reverse proxy relays requests on behalf of the origin. Each can decode and normalize the request, translate HTTP/2 to HTTP/1.1, add fields, and reuse a connection to the backend, the downstream service.

The WAF then builds its own variable collections. Finally, the application framework decodes the Uniform Resource Locator (URL), the address of the requested resource, along with cookies, forms, and structured bodies. JavaScript Object Notation (JSON) is a textual structured-data format; Extensible Markup Language (XML) is a markup language; a multipart body separates several parts, notably fields and files, with boundaries. Security depends on all these interpreters agreeing.

A Request for Comments (RFC) is a technical specification published within the Internet Engineering Task Force (IETF) ecosystem, the organization that standardizes Internet protocols. RFC 9110 separates HTTP semantics from its representations. RFC 9112 defines HTTP/1.1 framing and warns that divergent interpretations enable request smuggling, an attack that causes two successive components to delimit the same request stream differently. HTTP/2 and HTTP/3 move framing into binary frames, but conversion to an HTTP/1.1 backend creates a new interpretation boundary.

A modern WAF must therefore answer five distinct questions:

  1. Where does the request begin and end? This is the framing and parsing problem, meaning structural analysis of the message.
  2. Who or what probably sent it? This is the identity and proxy-trust problem.
  3. What does its content mean after transformation? This is the canonicalization and detection problem.
  4. What does its relation to earlier requests reveal? This is the temporal and behavioral problem.
  5. Which action remains safe under failure? This is the architecture and degraded-mode problem.

Detection is only one step. Parsing, normalization, enrichment, decision, enforcement, the effective application of an action, logging, and learning need separate semantics.

These responsibilities have neither the same deadline nor the same failure mode. Organizing them into distinct planes keeps the current decision local while policy and analysis evolve on longer cycles.

Three planes, not a monolith. In a modern WAF architecture, a plane is a logical group of components organized around one responsibility and execution timing; it does not necessarily map to a separate machine. The data plane makes the current decision: it terminates the connection, establishes client context, applies local controls, inspects content, then blocks or forwards the request. The control plane manages policy publication. It receives the desired state, a declarative description of the policy expected by the operator, validates and versions it, then turns it into executable configuration without participating in the current request. The observation plane receives a copy of decisions after enforcement, retains history, correlates events, and proposes changes. A proposal can influence a future request only after passing back through the control plane; it never rewrites a decision already made. I designed NyxR around this separation as a self-hosted Unified Web Security Gateway that combines a reverse proxy, WAF, and behavioral controls in front of multiple services.

The critical path, or hot path, is the part of the data plane containing every operation a request must await before it receives a response. A database or remote service placed on that path therefore becomes a direct latency and availability dependency. In the NyxR implementation, the hot path combines NGINX, a web server and reverse proxy, OpenResty, an NGINX distribution that embeds the Lua scripting language through LuaJIT, a just-in-time compiler for Lua, then libModSecurity v3 and OWASP CRS. Transport Layer Security (TLS) encrypts a connection between two peers; its Server Name Indication (SNI) extension announces the target hostname during negotiation so the correct certificate and service can be selected.

Off that path, the control plane writes desired state to PostgreSQL, a relational database, while TypeScript, a typed variant of JavaScript, builds a bundle, a complete configuration package ready for activation. The observation plane transports events through Redis Streams, an ordered in-memory log provided by Redis, then a scorer turns weighted signals into a score. A rule in shadow mode, or observation mode, computes and logs the decision it would have made without changing the response sent to the client. Cyber Threat Intelligence (CTI) indicators are known threat data, such as addresses associated with malicious infrastructure.

Technical diagram
THREE PLANES, ONE ENFORCEMENT POINT: The request path stays local while configuration and analysis evolve independently.NYXR · SEPARATION OF RESPONSIBILITIESTHREE PLANES, ONE ENFORCEMENT POINTThe request path stays local while configuration and analysis evolve independently.DATA PLANE · SYNCHRONOUSCONTROL PLANEOBSERVATION / LEARNING PLANEoriginbundleeventsCLIENTHTTP / TLSrequestEDGEOpenRestySNI · real IPLOCAL GATESLua policyCTI · rate · botfingerprintWAFModSecurity+ CRSparse · scoreDESIRED STATEPostgreSQLservices · rulesPUBLISHConfigbuildertest · swapEVIDENCEStructured logsaction · reason · scoreTRANSPORTRedis Streamsbounded live pathHISTORYSecurity eventsfield snapshotCORRELATEScorerdecay · evidenceLEARNProfilestest before useHOT PATH · LOCAL STATE · VERSIONED POLICY · ASYNC EVIDENCE · EXPLICIT FAILURE MODES
NyxR architecture, from the request path to configuration and observation.How to read the diagramRead the data plane from left to right. It makes the synchronous request decision from local state. The control plane publishes validated versioned bundles. The observation plane receives copies of decisions and can enrich future policy without becoming a dependency of the current request.

This separation addresses a fundamental constraint: a synchronous request decision must not call PostgreSQL, an artificial-intelligence model, or an analytical datastore, storage optimized for aggregating and querying events, on every request. An Access Control List (ACL) is a set of rules that allows or denies traffic according to explicit criteria. An Internet Protocol (IP) address identifies an interface on an IP network; Classless Inter-Domain Routing (CIDR) notation combines an address with a prefix length to identify an entire network, for example 192.0.2.0/24. In a modern WAF, ACLs needed for the current request must be evaluated locally in the hot path so traffic can be blocked or passed without a remote-database dependency. A snapshot is a coherent copy frozen at a point in time. NyxR applies this principle to ACLs, reputation snapshots, counters, and other decision policies. A control-plane failure therefore leaves the last valid bundle serving traffic.

This local autonomy is real only if policy can be published without exposing a partial configuration. The activation mechanism must therefore preserve a last-known-good state and make each change verifiable before it reaches the data plane.

The config builder, the component that compiles desired state into executable files, does not edit the active file line by line. It renders a complete bundle, performs structural checks and nginx -t, and computes a checksum that detects any byte-level difference. It writes a new version and atomically swaps the active link, exposing no intermediate state, then reloads the gateway and performs a health check. An unhealthy activation restores the previous version.

For a penetration tester, this architecture provides three useful guarantees to verify:

  • the policy version that served a request can be tied to the decision;
  • control-plane compromise does not imply immediate data-plane compromise;
  • an unavailable analytical dependency should not silently change the accepted HTTP grammar.
WarningFail-open or fail-closed

It also creates a test question: is the last-known-good bundle actually autonomous, or can a hidden remote dependency turn an outage into fail-open behavior, where traffic is allowed when the control fails? A fail-closed system denies traffic during that failure.

From socket to decision

Plane separation establishes who publishes policy, who decides, and who observes. It does not yet show the order in which the data plane acquires the information required for a decision, so the next path follows one request from the raw connection to the final action.

A network socket is the software endpoint through which the server receives a connection. From that socket onward, a request should not be modeled as a string passed to an is_malicious() function: it evolves as new information becomes available. A client fingerprint summarizes technical traits without claiming to identify the client; rate limiting bounds requests within a time window; a challenge requires additional proof, often browser execution, before processing continues.

Building the decision context

Technical diagram
A REQUEST IS NOT A STRING: Framing, identity, history, content and cost are evaluated at different stages.REQUEST LIFECYCLE · ORDERED TRUSTA REQUEST IS NOT A STRINGFraming, identity, history, content and cost are evaluated at different stages.SYNCHRONOUS REQUEST PATHEVIDENCE EMITTED AFTER DECISIONlog1 · FRAMINGHTTP parserdesync guard2 · IDENTITYReal IPtrusted hops3 · CONTEXTACL + CTIgeo · bans4 · BEHAVIORFingerprint+ counterslog or block5 · CONTENTModSecurity+ CRSphases · score6 · FRICTIONLimit orchallenge7AppDECISIONallowed · blockedchallenged · bannedATTRIBUTIONaction ≠ reasontest ≠ blockPROVENANCErule · sourceconfig versionPERFORMANCEedge + upstreamlatency separatedREJECT AMBIGUITY · ESTABLISH IDENTITY · CHEAP GATES FIRST · PARSE BODY · RECORD FINAL ACTION
Conceptual hot path. Exact handlers and enforcement modes remain service-policy dependent.How to read the diagramThe request first needs an unambiguous boundary and a trustworthy source identity. Cheap local gates can then reject known risk before body inspection. CRS parses and scores the transaction, while challenge or rate controls handle automation and resource abuse that a payload signature cannot express.

1. TLS termination and HTTP framing.

TLS termination decrypts the connection at the gateway and exposes the HTTP message. The ClientHello is the first message sent by the client during TLS negotiation; its versions, cipher suites, and extensions expose traits of the network stack. Termination also sets a trust boundary: an attacker who can reach the origin directly bypasses every control at the edge, the perimeter layer exposed to the Internet. Mutual TLS (mTLS) authenticates both server and client with certificates; together with network restriction or an origin secret, it prevents an unauthorized client from bypassing the gateway.

Before inspecting a payload, the useful content carried by the request, the gateway must reject messages whose length or structure could be interpreted differently downstream. HTTP Desync Attacks, The HTTP Garden, and WAFFLED show that parser disagreement is not an academic edge case. WAFFLED reports 1,207 confirmed bypasses across five widely deployed WAFs by mutating non-malicious aspects of JSON, XML, and multipart messages.

A modern WAF must reject framing ambiguity before trust decisions. In NyxR, I placed a local desynchronization guard at that exact point. Its job is not to detect Structured Query Language (SQL) injection, where input changes the structure of a database query, but to reject a request before a proxy and origin can assign two meanings to the same byte stream.

Framing establishes an unambiguous request representation, but not the network identity to which the decision should be attached. Once the message is delimited, the trust chain must determine which address may feed reputation, quotas, and bans.

2. Real client IP and the trust chain.

The X-Forwarded-For (XFF) header carries the address chain declared by traversed proxies; it is not an identity, but a claim. NGINX ngx_http_realip_module turns that claim into a client address only after trusted sources are configured through set_real_ip_from. If the whole Internet is trusted, the attacker selects their reputation, country, rate-limit key, and sometimes their allowlist status, meaning membership in an explicit list of permitted sources.

The correct invariant for a modern WAF is: only an explicitly trusted network hop may replace the socket peer address. In NyxR, I start with an empty trust set, publish approved proxy CIDRs in the bundle, and apply an additional guard. IPv4 and IPv6 are the two deployed versions of IP, with different address spaces and sometimes different routes. The PROXY protocol carries the original connection address outside HTTP headers between two mutually trusted proxies. A red-team assessment should test all these paths, plus direct access, the CDN, load balancer, and administration network.

A sufficiently trustworthy network identity makes address-keyed decisions usable. The data plane can then apply the cheapest local controls before reserving deep inspection for the requests that remain.

3. Cheap local gates.

ACLs, geolocation, CTI indicators, active bans, and selected reputation checks can run before deep body inspection. This saves processor time, memory, and origin bandwidth. The priority is safe only when bypass semantics are explicit: an allowlist must state which controls it skips and which remain mandatory.

These gates remove already-known sources or contexts, but an isolated request can still look ordinary. Behavioral correlation adds time, errors, and route sequence so automation spread across several transactions can be recognized.

4. Behavior and client fingerprint.

IP, the Autonomous System Number (ASN) of the network announcing that address on the Internet, the User-Agent family, header order, HTTP version, challenge outcomes, rate, HTTP 404 responses, authentication failures, and route sequences all have different lifetimes. Correlation can expose a distributed bot whose individual payloads look harmless.

Behavior describes how a client acts over time, not what the current transaction contains. ModSecurity and CRS provide that message-level inspection by searching the normalized request’s variables, headers, and body.

5. ModSecurity and CRS inspection.

The WAF parses available variables, applies deterministic transformations, executes rules, and accumulates an anomaly score. This score is the sum of points added by rules matching the transaction, not an attack probability. Body size, JSON depth, and argument-count limits are part of the control: an ignored parse error creates a region that the application understands but the WAF does not.

Inspection produces matches and a score, but it does not select the client-visible effect by itself. The final stage evaluates those signals under policy and chooses forwarding, rate limiting, challenge, temporary ban, or denial.

6. Friction and final decision.

Blocking is not always the best response. A challenge can distinguish an interactive browser from automation. A rate limit can protect an expensive endpoint, a combination of HTTP method and application route, without pretending that every request is malicious. A temporary ban can dampen a sequence. The final log must keep the applied action separate from the candidate reason.

This path places the rule engine among several independent controls. Interpreting its output correctly still requires understanding when a rule runs, which variables it can see, and how its points may become a disruptive action.

Interpreting ModSecurity and OWASP CRS

ModSecurity v3 executes rules in five documented phases: request headers, request body, response headers, response body, and logging. Data is cumulative. A phase 1 rule does not yet see every body argument; a phase 5 rule can no longer block the transaction. The ModSecurity v3 reference describes these contracts and v2 compatibility limitations.

The families shown below include Local File Inclusion (LFI), inclusion of a local file, Remote Code Execution (RCE), execution of remote code, Cross-Site Scripting (XSS), injection of code executed in a browser, and SQL injection (SQLi). The Paranoia Level (PL) selects the sensitivity of enabled rules. DetectionOnly mode retains detections without applying disruptive actions, while On permits actions such as rejecting the request.

Technical diagram
DETECTION AND BLOCKING ARE SEPARATE: Rules add evidence; a later policy compares the cumulative score to a threshold.MODSECURITY · CRS COLLABORATIVE DETECTIONDETECTION AND BLOCKING ARE SEPARATERules add evidence; a later policy compares the cumulative score to a threshold.REQUEST INSPECTIONPOLICY / OUTCOMEmodePLexcludePHASE 1Headersmethod · URI · framePROCESSORBody parserJSON · form · XMLPHASE 2Argumentsnormalized targetsCRS RULESProtocol · LFIRCE · XSS · SQLimatches add pointsEVALUATIONRule 949score ≥ thresholdTUNINGParanoia levelcoverage vs noiseEXCEPTIONURI / parameternarrow target removalDETECTAudit evidenceno disruptionBLOCKLocal responserequest never reaches appPHASE ORDER · TRANSFORMATION PIPELINE · PARSER ERRORS · ANOMALY SCORE · EXCLUSION SCOPE
Simplified ModSecurity v3 and CRS inbound transaction flow.How to read the diagramPhase 1 can inspect headers and select a body processor. Phase 2 receives parsed arguments when body processing succeeds. Individual CRS rules contribute severity-weighted anomaly points. The blocking evaluation then compares the cumulative inbound score with the configured threshold. DetectionOnly preserves the evidence without applying the disruptive action.

Phases determine which data each rule can access, but not how several matches are aggregated. Anomaly scoring provides that composition layer before the blocking threshold is evaluated.

Anomaly score is not probability.

CRS uses collaborative detection. A critical rule typically adds 5 points, an error 4, a warning 3, and a notice 2. Detection rules are separate from the blocking evaluation. The Anomaly Scoring documentation explains how REQUEST-949-BLOCKING-EVALUATION.conf compares the total inbound score with the configured threshold.

A score of 10 means neither 10% risk nor twice the attack likelihood of score 5. It means rules added ten policy points. Defensible evidence retains at least:

  • every matching rule identifier;
  • examined variables and transformations;
  • executed paranoia level;
  • anomaly threshold;
  • DetectionOnly or On mode;
  • final gateway action.

The score explains how rules contribute to a transaction, but its sensitivity still depends on which rules are active and in what context. Paranoia level broadens coverage, while exclusions precisely remove legitimate targets that produce false positives.

Paranoia level and exclusions.

Paranoia levels add increasingly aggressive rules from PL1 to PL4. Raising the threshold to compensate for a higher PL can hide a single critical rule. A stronger strategy selects PL by risk, keeps a low threshold, observes false positives, and writes narrow exclusions.

A safe exclusion targets a rule, a URI, and ideally one parameter. Disabling the whole SQLi family because a Markdown field contains the word select fixes the false positive by removing protection. The following generic example does not reproduce NyxR configuration: REQUEST_URI is the received route, ARGS is the parameter collection, and REQBODY_ERROR indicates a body-parser failure.

crs-tuning.conf
# Remove only the "content" parameter from this rule on this endpoint.
SecRule REQUEST_URI "@beginsWith /api/articles" \
"id:100100,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942100;ARGS:content"
# Keep a parsing error as a blocking event.
SecRule REQBODY_ERROR "!@eq 0" \
"id:100110,phase:2,t:none,log,deny,status:400,msg:'Request body parsing failed'"
# Publish the transaction scoring policy explicitly.
SecAction "id:100120,phase:1,pass,nolog,t:none,\
setvar:tx.blocking_paranoia_level=2,\
setvar:tx.inbound_anomaly_score_threshold=5"

Identifiers 100100 through 100120 are reserved for the example. Production identifiers need a documented local range that cannot collide with CRS.

Exclusions tune the scope of variables the engine can analyze; they provide no visibility into a truncated, oversized, or unparsable body. Resource bounds and the policy applied when they are exceeded therefore form a separate security boundary.

WarningLimits are security decisions

The ModSecurity directives SecRequestBodyLimit, SecRequestBodyNoFilesLimit, SecArgumentsLimit, and SecRequestBodyJsonDepthLimit respectively bound body size, body size excluding files, argument count, and JSON depth. If policy processes only the beginning of an oversized body and forwards the rest, the application can receive bytes the WAF never inspected. If policy always rejects, it can break legitimate uploads. Limits therefore need route-aware design and tests against actual content types.

A modern WAF must version these limits and adapt them to the formats actually accepted by each service. NyxR applies that model per service; a REQBODY_ERROR failure produces an HTTP 400 Bad Request response, explicitly rejecting a body the engine could not parse.

In the production extract I built, 12 transactions from three addresses triggered the local parsing-error control. The volume is small, but its semantics matter: NyxR distinguished “unrecognized content” from “recognized and benign content.”

Correlating identity without slowing the data plane

The preceding chain can decide one transaction, but its IP address cannot reliably connect several requests to the same software or behavior. Correlation must therefore enrich identity with protocol and temporal traits without turning the hot path into an analytical dependency.

Fingerprint and behavior

A Network Address Translation (NAT) gateway makes several clients share one public IP address; proxies and cloud egress create a similar effect. An IP address is therefore an observed network point, not a human identity.

Before sending the first HTTP byte, a TLS client emits a ClientHello. This message proposes TLS versions, cipher suites, signature algorithms, key groups, and extensions such as SNI or Application-Layer Protocol Negotiation (ALPN), which selects protocols such as HTTP/1.1 or HTTP/2; the server then selects compatible parameters in its response. The gateway terminating TLS therefore sees a structured description of the client library’s capabilities and choices before it sees the application request.

JA4 turns that description into an a_b_c fingerprint documented by the FoxIO project. The human-readable a section encodes transport type, TLS version, presence of an SNI hostname, cipher and extension counts, and an ALPN summary. Section b contains the first 12 hexadecimal characters of a hash over the sorted cipher-suite list. Section c does the same for sorted extensions, excluding SNI and ALPN, completed with signature algorithms. Secure Hash Algorithm 256-bit (SHA-256) is a hash function that reduces a variable-length list to a deterministic digest; JA4 retains only 12 characters for compactness.

Sorting removes the effect of randomized cipher and extension order. Generate Random Extensions And Sustain Extensibility (GREASE) values, synthetic values injected to keep TLS implementations from ossifying around a known list, are ignored. JA4 is therefore more stable than raw concatenation, but it is neither authentication nor a unique identifier: a shared library produces the same fingerprint across many hosts, a configurable stack can imitate a browser, an update can change the fingerprint, and upstream TLS termination can hide the original ClientHello.

A modern WAF can complement JA4 with HTTP traits instead of depending on one identifier. In NyxR, I chose a composite built from header order, User-Agent family, HTTP version, missing traits, and ALPN. A coherence measure compares client claims with protocol shape. The objective is not to prove maliciousness, but to make rotation of one attribute less effective.

Technical diagram
AN IP ADDRESS IS NOT A CLIENT: Useful confidence emerges from partially independent signals and their stability over time.ADVERSARIAL IDENTITY · MULTI-SIGNAL MODELAN IP ADDRESS IS NOT A CLIENTUseful confidence emerges from partially independent signals and their stability over time.NETWORK / PROTOCOLAPPLICATION BEHAVIORPOLICY STATEtraitshistoryNETWORKIP · ASNcountry · feedTLSJA4ClientHello traitsHTTP COMPOSITEHeader order · UA familyprotocol · missing headersSTATESessioncookie · challengeSEQUENCERoutesrate · 404 · authCOHERENCEClaims vs behaviorbrowser shape · timingEXPLAINABLE SCOREweighted evidence + decaynot a probabilityTEST MODELog onlyno blockingENFORCEFrictionlimit · challengeOVERRIDEallow · ban · expire · rollbackSOURCE IP · TRUSTED HOPS · TLS TRAITS · HEADER COHERENCE · SESSION · ROUTE SEQUENCE · TIME
Identity graph used for reasoning, not a claim that any single signal proves malicious intent.How to read the diagramIP reputation is fast but collapses users behind NAT, proxies and cloud egress. TLS and HTTP fingerprints describe implementation traits, not a person. Session state, route behavior and challenge outcomes add temporal evidence. A modern policy keeps each contribution visible and can compute and log a fingerprint without letting it affect the response before enforcement is explicitly enabled.

A fingerprint stabilizes the client’s technical context, but it proves no malicious intent. Behavioral scoring adds time by accumulating only explainable events, then reducing their weight when they stop recurring.

The server-side behavioral score accumulates evidence and decays by half-life, the time after which a signal’s weight is divided by two. Only events with a positive signal touch Redis. A benign request does not artificially subtract points; silence decays the score. ModSecurity and access-log signals are disjoint so the same transaction is not scored twice.

ImportantA behavioral score is not truth

The score is an accumulator of weighted evidence. It can become very large during sustained automation. It must never be displayed as a percentage of maliciousness.

The score applies deterministic rules that have already been published; it cannot discover new profiles or validate their safety by itself. Learning can analyze history and propose an evolution, provided it remains asynchronous and passes back through the control plane before enforcement.

Learning off the critical path

An unsafe adaptive system applies a probabilistic remote output to each request. It introduces network dependency, non-deterministic latency, provider quota exposure, and a security-mode change when the model is unavailable.

A modern adaptive architecture must separate the local scenario engine, which recognizes deterministic sequences, the asynchronous scorer, which aggregates events after the request, and the verdict service, which adds a classification opinion. I implemented that separation in NyxR: a learned rule starts in observation mode, where its result is logged without influencing the response, then the new policy passes through the build and validation pipeline before it can become active.

Technical diagram
LEARNING MUST NOT OWN THE HOT PATH: Evidence can improve tomorrow's policy without delaying or corrupting today's request.CONTROL LOOP · SAFE ADAPTATIONLEARNING MUST NOT OWN THE HOT PATHEvidence can improve tomorrow's policy without delaying or corrupting today's request.CLOSED LOOP WITH EXPLICIT SAFETY GATESfailpublish1 · EDGEDecisionlocal snapshot2 · EVIDENCEEventsaction · reason3 · ANALYZEScore +campaigns4 · PROPOSETest profilenot active5 · BUILDVersionedbundle6 · VERIFYnginx -t+ health7 · ACTIVATEAtomicswapFAILURERollbackprevious bundleCONTROLOperatorexplain · overrideDECIDE LOCALLY · CORRELATE ASYNC · TEST BEFORE BLOCK · VALIDATE WHOLE BUNDLE · ATOMIC ROLLBACK
NyxR control loop: synchronous edge decisions, asynchronous correlation, versioned publication.How to read the diagramThe edge emits structured evidence after applying the current policy. The scorer correlates bounded histories and the learner proposes profiles or campaigns. New rules begin in observation when possible. The config builder validates a complete bundle, swaps it atomically and health-checks the gateway. Failure restores the previous version.

In NyxR, I designed the verdict service as asynchronous enrichment that never owns the current decision. The nyxr.app data provide a concrete example: during the final 24 hours of the window, this service exceeded its response deadline, the scorer logged fallback to the local threshold, and the gateway remained healthy. The useful fact is therefore not whether the artificial-intelligence model is available, but which deterministic decision remains active when it does not answer.

Across 482 durable bans created during the window:

  • 275 came from the behavioral scorer and 207 from edge scenarios adopted into history;
  • they covered 316 distinct targets;
  • median score at ban was 42, the 95th percentile 103, and the maximum 4,224;
  • stored enrichment concluded ban for 434, monitor for 46, and allow for 2.

Those two allow verdicts do not make every other decision wrong. They prove that a classifier, a component that assigns an observation to a category, and a deterministic threshold can disagree. Policy priority, override duration, final action, and rollback therefore need to remain visible.

One month of production traffic from nyxr.app

The preceding mechanisms define what the system should keep separate: representation, identity, signal, decision, and learning. Production data can now test whether those boundaries remain visible in the events without conflating several projections of the same request.

Reading outcomes without double counting

I extracted these aggregates from my application nyxr.app. They cover one month of activity, from June 21 to July 22, 2026.

The relational security_events table retains two projections, meaning two representations of the same traffic built for different uses:

  • 145,996 requests logged by the gateway with their final outcome;
  • 20,878 ModSecurity audit transactions carrying the primary rule match and WAF context.
NoteTwo projections of the same traffic

The physical total is 166,874 records, but it is not 166,874 unique requests. One request can emit one record into each projection.

Technical diagram
31 DAYS OF NYXR PRODUCTION TRAFFIC: Gateway outcomes and ModSecurity audit records are counted separately.PRODUCTION METRICS · JUNE 21 TO JULY 22 · PARIS TIME (UTC+2)31 DAYS OF NYXR PRODUCTION TRAFFICGateway outcomes and ModSecurity audit records are counted separately.GATEWAY ACCESS PROJECTIONWAF / BEHAVIORAL PROJECTIONTRAFFIC145,996logged request outcomesALLOW120,27882.38%BLOCK17,32011.86%FRICTION8,398challenge · ban · limitADDRESSES8,638unique sourcesMODSEC AUDIT20,878matched transactionsLFI / PATH6,667203 addressesPROTOCOL7,145288 addressesAUTO-BANS482316 targetsFINGERPRINT2,218loggednot blockedFIXED WINDOW · SANITIZED AGGREGATES · NO RAW PAYLOAD · NO USER IP · SOURCE-AWARE COUNTS
NyxR metrics recorded through July 22, 2026 at 11:47:17 Paris time (UTC+2).How to read the diagramThe upper row describes requests logged by the gateway, one record per final outcome. The lower row describes ModSecurity audit transactions and learned security state. These projections can represent the same transaction, so their counts must never be added and called unique requests.

This scope fixes the denominators and prevents the gateway log from being added to the WAF audit. Once that distinction is established, the data-plane projection can measure the actions actually returned to clients.

Gateway outcomes.

Final actionLogged requestsShare of 145,996
Allowed120,27882.38%
Blocked17,32011.86%
Challenge presented4,9093.36%
Client already banned2,8371.94%
Rate limited6520.45%

During this window, NyxR logged 8,638 distinct real addresses across 23 services. An address is not an actor and a service is not a homogeneous population. These cardinalities describe the surface; they do not attribute attacks.

Explicit reasons show several active controls: 4,212 CTI blocks across 762 addresses, 3,952 geographic blocks across 1,716 addresses, and 97 blocks from a honeypot, a decoy service designed to attract and report suspicious interactions, across 74 addresses. However, 8,994 records marked blocked lack a specific block_reason, mainly in data predating complete instrumentation. That gap makes the field unsuitable as an exhaustive truth source across the whole window.

Final actions state what was enforced, not which intelligence source contributed to a CTI block. Provenance connects each indicator to the lists carrying it so their measured coverage, freshness, and overlap can be compared.

Which CTI lists actually triggered?

CTI provenance associates the indicator that blocked a request with the public lists that contained it. This separates catalogue size from measured effectiveness. Of the 4,212 CTI-blocked requests, 3,968 remain attributable to the current provenance snapshot; 244 can no longer be attached to it, notably because they predate systematic recording of the exact matched key or concern an indicator that has since left a rotating list.

Source and construction modelAttributed blocksDistinct source addressesIndicators in the active snapshot
Data-Shield IPv4 Blocklist, Critical variant, a registry built from global probes with a stated rolling retention of 15 days3,731, or 88.6% of CTI blocks55878,699
Blocklist.de, “all attacks” list, addresses reported for attacks against participating servers during the previous 48 hours946, or 22.5%9128,795
IPsum level 3+, a daily aggregate that retains here only addresses present on at least three public lists811, or 19.3%11718,178

These counts are not exclusive: a request is attributed to every list carrying its matched indicator. The three rows therefore produce 5,488 attributions for 4,212 unique blocks. That overlap is useful evidence. Data-Shield provides the broadest coverage and dominates stopped traffic; Blocklist.de favors a very fresh window; IPsum adds a cross-list consensus condition. Operationally, provenance should be retained per indicator, each source should expire on its own cadence, and false positives should be measured per list. Adding the counters or judging a source by size alone would produce a false conclusion.

CTI explains decisions based on already-known indicators; it does not describe sequences recognized after several events. Behavioral-ban evidence shows which temporal or application signals actually contributed to that other decision path.

Which behavioral signals contributed most often?

The following table covers evidence stored in the 482 durable bans, not private deployment thresholds or every raw request. One ban can contain several signals; “bans containing signal” counts a ban once, while “evidence occurrences” preserves repetitions in its evidence record.

Signal familyRecorded mechanismBans containing signalEvidence occurrences
Sensitive-path probeThe requested route resembles a search for version-control metadata, configuration files, backups, or administration interfaces. The signal names a category; it does not reveal the deployment’s exact path set.205, or 42.5%207
LFI / RFICRS 930 and CRS 931 recognized a local- or remote-file-inclusion shape. This proves an attack pattern was present, not that a file was read.106, or 22.0%106
HTTP protocol anomalyCRS 920 and CRS 921 report inconsistent framing, methods, or headers. This is useful evidence against parser disagreement, but it must be tested with legitimate clients and proxies.78, or 16.2%86

In total, 387 of 482 bans, or 80.3%, contain at least one of these three families. Only two combine two of the families inside the same evidence record. The dominant fact is therefore not confirmed application exploitation: it is automated reconnaissance centered on paths, file inclusion, and protocol deviations. The strongest defensive gain is to stop those sequences locally, then retain WAF matches and temporal context so the ban remains explainable.

Ban signals describe accumulation over time, while the ModSecurity audit retains the rule engine’s transaction-level view. Classifying its primary identifier compares detected families, but does not turn every match into an effective block.

Recorded rule families.

The table classifies each ModSecurity transaction by its primary rule_id, the numeric identifier of the rule retained as the main cause. It does not recount every secondary rule stored in the audit array.

Primary familyAudit transactionsDistinct addressesWAF action blockedMax score
HTTP protocol, policy, and framing, rules 920 to 9227,1452883,371970
LFI, paths, and restricted files, rules 9306,6672035,54530
Scanner detection, rules 9133,39733,3975
SQL injection, rules 9421,558553820
RCE and command injection, rules 9321,454796155
Response information leakage, rules 950 to 954466155404
XSS, rules 941110142925
PHP injection, targeting PHP: Hypertext Preprocessor, a server-side language, rules 93334605
Remote File Inclusion (RFI), inclusion of a remote file, rules 9312011720
Body parsing error, local control12355

The contrast between 1,558 SQLi transactions and only 38 WAF actions recorded as blocked demonstrates why “match” does not automatically mean “request denied.” Some transactions run in detection, others may remain below threshold, and policies can differ. Correct analysis requires mode and configuration version.

These aggregates reveal dominant families but erase request order and cadence. A campaign window restores that temporal dimension, then makes it possible to compare computed identity and decision cost with the behavior actually observed.

Campaign, fingerprints, and latency

Real case: a URI-fuzzing campaign.

Technical diagram
ONE SCANNER, 2,100 DISTINCT PATHS: The edge absorbed a concentrated URI-fuzzing campaign without waiting for backend analysis.FIELD CASE · JUNE 23, 2026 · 19:35 TO 19:40 PARIS TIME (UTC+2)ONE SCANNER, 2,100 DISTINCT PATHSThe edge absorbed a concentrated URI-fuzzing campaign without waiting for backend analysis.URI-FUZZING CAMPAIGNMEASURED OUTCOMEmeasureSOURCE1 scannerscanner-shaped UAURI FUZZING3,119requests5-minute windowBREADTH2,100 URIsdistinct pathsCRS 9131003,121 auditmatchesscanner detectionDECISION3,117blocked2 allowedRATE10.4 req/s averageLOCAL LATENCYp50 0 ms · p95 0 msWORST SAMPLE477 msORIGINnot reached5 MINUTES · 10.4 REQUESTS/S AVERAGE · 99.94% BLOCKED · P95 0 MS · ORIGIN SPARED
Sanitized reconstruction from requests logged by the gateway and ModSecurity rule 913100 audit records.How to read the diagramThe gateway logged 3,119 requests in five minutes: 3,117 were blocked and two allowed. The scanner traversed 2,100 distinct URIs. ModSecurity independently emitted 3,121 matches for CRS rule 913100. Because the local denial did not reach the origin, the measured gateway latency was 0 ms at p50 and p95; the maximum was 477 ms.

This URI-fuzzing campaign, automated generation of many paths to discover unexpected routes, files, or behaviors, produced 3,119 requests logged by the gateway in 300 seconds, or 10.4 requests per second on average. It was not a volumetric Distributed Denial of Service (DDoS) attack, which distributes enough traffic to exhaust a resource, but rapid, broad application-path exploration with a scanner signature and 2,100 distinct URIs.

The 3,117 local denials have p50 and p95 of 0 ms in the NGINX log. The maximum in the window is 477 ms. This is not a claim of “zero WAF latency” because timer resolution and log-path semantics constrain the measurement. The important point is that the decision did not wait for the origin or a remote model.

The campaign also demonstrates double-counting risk: 3,121 rule 913100 audit records and 3,119 requests logged by the gateway are nearly twin projections, not 6,240 requests.

The sequence and scanner signature characterize this campaign, but they do not establish a durable client identity. Fingerprints can examine technical continuity across several addresses, provided their limits and experimental role remain explicit.

Measured fingerprints and the JA4 limit.

65,446 logged requests contain a non-empty composite fingerprint, or 44.83% of gateway access records. They cover 935 fingerprints and 6,104 logged addresses. Coherence is 0 on 9,047 records and 1 on 56,399.

224 fingerprints appear behind at least five addresses. Of these, 67 have a non-allowed action ratio at or above 50%. The most distributed fingerprint appears behind 1,235 addresses. None of this proves a botnet: a common stack, CDN, or popular browser can create high dispersion.

The learner, the component that groups recurring observations into proposed profiles, created 24 profiles classified as distributed_attack and 12 active campaigns representing 1,071 requests and 277 cumulative IP memberships. These counts are clustering hypotheses, algorithmic groups of similar observations, not attacker identities.

WarningNo non-empty JA4 value

The most important result is negative: the window contains no non-empty JA4 value, despite the field and instrumentation code. I therefore cannot attribute decisions in this window to JA4. TLS termination, the runtime version, meaning the software actually executing, and the ingestion pipeline, the path that transports the signal into storage, must be traced before evaluating that signal.

Finally, 2,218 logged requests have block_reason=fingerprint while their final action is allowed. The fingerprint was computed and logged as an experimental signal, but it did not participate in blocking those requests. Keeping the fields separate prevents a dashboard from turning an experiment into imaginary active protection.

Fingerprint coverage shows which signals were available, not the cost of evaluating them or the other controls. Percentiles by action therefore separate local responses from time spent waiting for the origin before any conclusion is drawn about WAF performance.

Cost and latency.

Percentiles calculated on requests logged by the gateway separate actions. p99 is the value below which 99% of measurements fall, exposing a latency tail hidden by the median.

Actionp50p95p99
Allowed71 ms707 ms2,641.7 ms
Locally blocked0 ms0 ms49 ms
Challenge4 ms42 ms63 ms
Client already banned0 ms0 ms2 ms
Rate limited2 ms11 ms325.5 ms

Allowed latency includes origin time; it does not measure WAF cost alone. Blocked latency mostly measures local response generation. A benchmark is a comparative measurement under controlled conditions: here it should compare the same endpoint, payload, and origin with the WAF off, in DetectionOnly, and On, while separating p50, p95, p99, processor time, memory allocations, body size, and evaluated rule count.

What the WAF can and cannot guarantee

The preceding metrics measure observable decisions and signals; they do not extend the WAF’s knowledge of application internals. Defining that boundary prevents a syntactic or behavioral detection from being presented as proof of exploitation or correct authorization.

A WAF can recognize path-traversal syntax without knowing whether the target file exists. It can identify SQLi-shaped input without seeing the eventual SQL query. It can rate-limit a purchase flow without knowing whether one operator legitimately controls one hundred accounts. It can observe an object identifier without knowing whether the authenticated principal may read that object.

An Application Programming Interface (API) exposes application operations and data to other software through a contract. The OWASP API Security Top 10 2023 places object and function authorization defects among the major risks. A generic WAF does not own the application’s authorization graph: the application must still validate the authenticated identity, object, action, and context.

Risk classUseful WAF contributionReal owning control
Syntactic injectionParser, transformations, CRS, content schemaParameterized queries, output encoding, typed validation
Broken Object Level Authorization (BOLA), also called Insecure Direct Object Reference (IDOR): access to another user’s objectRoute and volume anomaliesObject authorization in the application
Broken function authorization: an operation forbidden to the current roleProtection for known routesService-side Role-Based Access Control (RBAC), rights by role, or Attribute-Based Access Control (ABAC), rights computed from attributes
Workflow abuseRate, session, challenge, sequenceBusiness invariants and transactional controls
Server-Side Request Forgery (SSRF): making the server send a request to an attacker-chosen destinationShape detection and URL allowlistsResolver, outbound network policy, and validation after Domain Name System (DNS), the service that maps names to IP addresses
Request smugglingStrict parsing and edge normalizationFraming agreement across the full chain
Origin compromiseReject direct public trafficFirewall, mTLS, origin secret, segmentation

The WAF is a powerful compensating control, a layer that reduces a risk without fixing its cause in the application, and an observation point. It is not a substitute for secure code.

Evaluating a WAF as a red teamer

The preceding ownership boundaries determine the test method: assessment must compare the representations seen by the edge, WAF, and origin, then attribute each outcome to the component that actually owns the control.

A red team simulates an attacker within an authorized scope to evaluate controls and detection. Its assessment asks more than “which payload gets through?”: it determines which representation each component saw, which decision was applied, and which evidence survived the attempt.

Reproducible evaluation protocol

1. Map the real chain.

Record:

  • DNS resolution and any CDN;
  • client-side and origin-side protocols;
  • HTTP/2 or HTTP/3 downgrade to HTTP/1.1;
  • forwarding fields and trusted CIDRs;
  • direct origin reachability;
  • engine, CRS, and policy versions per virtual host (vhost), the configuration that maps a hostname to one service.

A different response on the origin IP and public hostname is already an architectural finding.

Mapping identifies interpreters and trust boundaries, but it does not prove that they assign the same meaning to the same bytes. Differential tests must therefore vary one representation dimension at a time and compare the output of every layer.

2. Test parser differentials.

Vary one axis at a time:

  • duplicate Content-Length, the header declaring body size, interaction with Transfer-Encoding, which describes its transfer coding, and non-canonical whitespace;
  • HTTP/2 to HTTP/1.1 conversion, HTTP/2 pseudo-headers prefixed with :, and name normalization;
  • duplicate parameters, implicit arrays, and repeated encoding;
  • application/json, application/x-www-form-urlencoded, which encodes a form as key-value pairs, multipart, and XML;
  • depth, argument count, files, and partial bodies;
  • absolute-form URLs, dot segments, encoded slashes, and framework-specific separators.

The expected result is not merely an alert. Compare what the WAF records with what an instrumented lab application receives. A differential fuzzer automatically generates input variants and looks for cases that two parsers interpret differently; it teaches more than a long static payload list.

A differential establishes which representations diverge, but not whether a rule match actually changed the request outcome. Evidence must next separate parsing, detection, scoring, enforcement, and what the origin received.

3. Separate detection, score, and action.

For each test, capture:

DimensionMinimum evidence
ParsingSelected processor, parse error, produced variables
DetectionEvery rule ID, target, transformation, and message
ScoringContributions by PL, total score, and threshold
EnforcementLocal status, closed connection, or forwarded request
OriginRequest actually received and response produced
ObservabilityAccess log, WAF audit, correlation, and config version

This evidence chain connects content to action; it remains invalid if counters and reputation use an identity controlled by the attacker. Address reconstruction, fingerprints, and collisions between legitimate clients must therefore be tested separately.

4. Test identity as a security boundary.

Send forwarding fields from an untrusted source, traverse every legitimate proxy, compare IPv4 with IPv6, and verify the actual key used for CTI, rate limiting, and bans. Rotate IPs behind a stable fingerprint, then place several legitimate clients behind one stable IP.

A valid result can be “the system declines to attribute.” Explicit uncertainty is better than false precision.

Identity tests cover the relationship between clients and decision keys, but automation can distribute activity below every instantaneous threshold. The next step extends analysis to time windows, state cardinality, and distributed sequences.

5. Test time and distribution.

Signatures see one transaction. Modern abuse spreads across:

  • low rate over many IPs;
  • User-Agent rotation with a stable route sequence;
  • expensive endpoints called below the global threshold;
  • distributed challenge failures;
  • rare sequences whose individual steps look benign.

Measure windows, half-life, key cardinality, Redis memory, NAT collisions, and recovery after expiry.

A control can remain accurate under nominal load yet silently change policy when a dependency fails. Failure tests therefore verify which mechanisms remain local, which move to observation, and which degraded mode actually becomes active.

6. Test failure modes.

In an authorized controlled environment, disable Redis, the scorer, learner, verdict service, PostgreSQL, and control plane. For every failure, document what continues, what moves to observation, what allows by default, what denies by default, and what the operator sees.

An analytical failure must not change parsing. A Redis outage must not silently disable every limit. An invalid bundle must never become active. A full event queue must not block HTTP workers, the processes that execute request handling.

Failure modes establish resilience, but not detection specificity. Each malicious case must therefore be replayed with a benign neighbor of the same shape to determine which property actually triggers the result.

7. Replay with negative controls.

Every malicious case needs a benign neighbor with the same shape: the same Content-Type, the header declaring the body format, size, parameter count, and endpoint. This neighbor is a negative control: it lacks the malicious property and verifies that this property, rather than the general shape, triggers the result.

These controlled cases become comparable only when the lab retains the configuration, inputs, and outputs of every run. A reproducible matrix then turns a one-off bypass into a verifiable and falsifiable experiment.

Building a reproducible lab.

The following harness illustrates a minimal matrix without providing an exploit payload. This shell script uses curl, a command-line client for network protocols, to submit equivalent JSON documents to a lab endpoint, compare outcomes, and preserve response headers.

waf-matrix.sh
set -euo pipefail
target="https://lab.example.test/api/parse"
artifact_dir="./artifacts/waf-$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$artifact_dir"
for case_name in baseline duplicate-key deep-json oversized-fields; do
curl --http2 --silent --show-error \
--dump-header "$artifact_dir/$case_name.headers" \
--output "$artifact_dir/$case_name.body" \
--header 'Content-Type: application/json' \
--data-binary "@$case_name.json" \
--write-out '%{http_code} %{time_total}\n' \
"$target" | tee "$artifact_dir/$case_name.result"
done
sha256sum "$artifact_dir"/* > "$artifact_dir/SHA256SUMS"

sha256sum computes a SHA-256 fingerprint for every artifact; any later modification changes that value and becomes detectable. The same run should capture the bundle version, WAF mode, PL, threshold, full audit record, gateway log, normalized request seen by the origin, and resource cost. The experiment then becomes reproducible and falsifiable.

The lab produces detailed evidence for every layer. Those observations can then be condensed into architectural invariants that remain valid when the payload, address, or tool signature changes.

Ten invariants that survive evasion.

  1. Parse before classifying. Content the WAF cannot parse needs an explicit policy.
  2. Reduce interpreters. Normalize early, avoid unnecessary downgrades, and align edge with origin.
  3. Establish identity before counters. A rate-limit key derived from a spoofable IP is not a control.
  4. Keep decisions local. The hot path must not wait for a database or remote model.
  5. Separate signal, reason, and action. A decision computed only for observation must not appear as an effective block.
  6. Version policy. An alert without a configuration version is hard to reproduce.
  7. Prefer narrow exclusions. Remove the false positive, not the protection family.
  8. Bound every state. Body, depth, arguments, cardinalities, queues, history, and Time To Live (TTL), the duration after which state expires.
  9. Measure false negatives and false positives. A false negative is an undetected attack; block rate alone rewards unusable systems.
  10. Treat the WAF as one layer. Authorization and business invariants remain in the application.

The mental model to retain

The preceding protocol turns isolated tests into durable properties. Taken together, its invariants lead to a model of the WAF as a distributed system that sees only a partial representation of the request and its sender.

The modern WAF is a partially observable distributed system. The data plane receives a representation of the request, not operator intent. It establishes an HTTP boundary, reconstructs a probabilistic identity, applies local policy, and emits evidence. Other components correlate that evidence over time, but they cannot retroactively rewrite what happened.

Four questions resolve many evasion claims:

  1. Which exact representation did the WAF parse?
  2. Which representation did the origin finally consume?
  3. Which signal contributed to which decision under which policy version?
  4. Which independent layers remain active if that signal disappears?

The production results I present from nyxr.app make this model concrete. NyxR absorbed a URI-fuzzing campaign locally, computed and logged a fingerprint without using it to block, maintained the gateway when the verdict service timed out, and distinguished a ModSecurity detection from an effective block. These facts make the system testable, which is more useful than a promise of “intelligent protection.”

SummaryThe modern view

An effective WAF is not the one that blocks the most. It is the one that interprets with the least ambiguity, decides from bounded local state, explains every action, learns without owning the hot path, and recognizes what only the application can authorize.

References

Use with an AI

Actions