ONXzero

Blog · Engineering ·

One hit is not enough: threshold blocking, replayed on 30 days of attacks

Blocking on the second suspicious request instead of the first would have stopped 171 of 173 attackers and 97.8% of their requests over the last 30 days, without blocking anyone who showed signs of being a person. We know because we replayed every attack our clients received against the new policy before shipping it.

Most WAFs block on the first rule match. For a hotel with thousands of bookings a month, that is a hard sell. A regex that misreads one guest note costs a reservation, and the guest never tells you why they left. So many of our larger sites run ONXZero in shadow mode: it detects and reports everything, but blocks nothing. That is safe, but it is not protection.

The policy: certainty blocks now, ambiguity waits for a second hit

Not every match is equally ambiguous, so the policy splits rules into two groups.

Exploit syntax blocks on the first hit. These are payloads no person types into a booking form: React2Shell (CVE-2025-55182) flight references, Spring4Shell (CVE-2022-22965) and Next.js Server Action parameter names, Log4Shell lookups, PHP object injection, XML external entities, and PHP stream wrappers such as php://filter.

Generic rules block from the second hit. SQL injection, XSS, command injection, path traversal and template injection rules can, rarely, match ordinary text. Their first hit from an IP is logged and passed. A second hit from the same IP on the same site within 5 minutes is blocked, whatever the category.

The reasoning is about behaviour, not payloads. A guest who writes an odd note does it once. A scanner tries dozens of payloads a minute. The second hit is where the two stop looking alike.

Not blocking on one weak signal is not a new idea. OWASP Core Rule Set adds rule matches into an anomaly score per request and blocks above a threshold. Fastly's Next-Gen WAF flags an IP once its attack signals pass a threshold within a time window. Our variant differs in two ways: exploit syntax skips the threshold entirely, and we checked the numbers by replaying our own attack history before shipping.

It is a per-site switch that applies only outside shadow mode, so a site moves from “report only” straight to “threshold” without ever passing through block-on-first-hit. If the client cannot read or write its counter, it blocks: a failure never lets through more than the strict mode would.

The replay: 1,747 real attacks

We exported every regex incident from 15 hotel and spa websites over 30 days: 1,747 incidents from 131 IP addresses. IPs were replaced by a keyed hash, and the export held no payloads, user agents or personal data.

Each row carried the site, timestamp, rule category, path, network type, and whether that IP had ever produced human signals: browser biometrics or a solved proof-of-work challenge from our client SDK.

We counted an attacker as one IP on one site, which gave 173 actors. One automated scanner alone produced 870 of the incidents. We then replayed each actor's timeline under several policies and counted what would have been stopped and what would have passed.

Results

Policy Attackers stopped Attack requests stopped Requests let through People blocked
Block every hit (classic WAF) 173 of 173 100% 0 0
Exploit syntax at once, others from the 2nd hit in 5 min 171 of 173 97.8% 38 0
2nd hit in 5 min only, no exploit group 155 of 173 90.1% 173 0

The threshold policy gives up 2.2% of attack requests: the 38 first attempts from IPs that used only generic payloads. In exchange, no single ambiguous match can ever block anyone.

The two actors it never stopped each made exactly one attempt. One was a staff member's reservation note that an old rule misread as XSS, a false positive the threshold policy would have saved. The other was a single path-traversal probe from a hosting network that never came back.

What surprised us

The window length did not matter. Five minutes, one hour and 24 hours gave identical results, because scanners fire in bursts.

Of the 155 actors with more than one hit, 130 sent the second within a second of the first, and none took longer than 32 seconds. We kept 5 minutes, the setting kindest to people.

Bar chart of time from the first to the second regex hit, for 155 site-and-IP actors with two or more hits over 30 days. 130 came back within 1 second, 23 within 1 to 5 seconds, 1 within 5 to 15 seconds, 1 within 15 to 60 seconds, and none between 1 and 5 minutes. The slowest returned after 32 seconds.

135 of the 173 attackers were stopped on their very first request. The exploit group did most of the work: React2Shell probes alone hit 15 sites, and they are unambiguous. Without that group, 16 more attackers would have slipped through with scattered single attempts.

The attackers barely target forms. Most hits landed on the home page and on /booking/, sprayed with generic payloads. The guest booking order form itself had no regex hits at all in those 30 days.

Caveats, and how we are rolling it out

  • “No people blocked” rests on a partial signal. Biometrics and challenges only exist on sites running our browser SDK. On the rest, the evidence is behavioural: bursts on the home page, the same payloads across many sites, and 373 of the incidents coming from IPs that were later banned.
  • Network type was unknown for 90% of incidents. ASN enrichment only started at the end of the period, so we could not yet weigh hosting against residential traffic.
  • A replay only sees what was detected. It cannot tell us about attacks no rule matched. That is what the test corpus from our previous post is for.

The policy shipped in client 2.18.9 as a per-site setting, off by default. The first sites to switch are four of the most-attacked properties that today run in shadow mode. For them it is the step from reporting attacks to stopping them. The replay script ships with the code, so every future export can be checked against the policy before anyone touches a switch.

Back to the blog