DDoS Low-and-Slow Detection: Finding Attacks That Fly Under Rate Limit Radar

Back in 2019 I got paged at 2:47 AM because an origin pool was falling over, but our volumetric dashboards were completely green. No spike in requests per second, no bandwidth flood, nothing our standard alarms cared about. Turns out a botnet of about 800 hosts was opening connections and trickling incomplete POST bodies at one byte every 10 seconds — just enough to hold worker threads hostage for five minutes at a time. That's the ugly reality of low-and-slow DDoS: it doesn't try to knock the door down, it just quietly holds it shut.

Why Your Rate Limiter Can't See the Slow Attack

Most rate limiting I've seen deployed at the edge is fundamentally count-based. If IP X makes more than N requests in T seconds, you tarpit or 403 it. That's great for the noisy stuff we cover in DDoS Volumetric Attack Patterns: Identifying and Absorbing Bandwidth Floods, but low-and-slow DDoS lives deliberately below that line. A typical Slowloris or RUDY variant will send a perfectly valid request rate — maybe 1 request every 30-60 seconds — and then drag out the request lifecycle itself.

I've debugged origins where the classic NGINX limit_req showed zero blocks while Apache's MaxRequestWorkers was exhausted. The attacker wasn't violating rate; they were violating time. They'd send headers one fragment at a time, or declare a Content-Length: 8192 and then dribble the body at 5 bytes per second. From the HTTP Working Group Specifications perspective, it's still a valid HTTP/1.1 exchange until the server decides it's not. If your detection only looks at completed requests per minute, you've already missed the subtle DDoS holding 10,000 connections open.

What's tricky is that legitimate clients do this too, especially on mobile or satellite links. The difference isn't the act, it's the distribution and intent. One client sending a slow POST because it's on a train in a tunnel is noise; 2,000 clients all sending headers with 15-second gaps, never completing, with identical header order and window sizes? That's rate limit evasion by design.

# This standard rate limit misses slow attacks entirely
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

server {
    # Attacker sending 1 req/min with 300s header stall will never hit this
    limit_req zone=api burst=20 nodelay;

    # What you actually need to watch: incomplete request timers
    client_header_timeout 10s;
    client_body_timeout 10s;
    # Log $request_time vs $upstream_response_time vs incomplete states
    log_format slow_trace '$remote_addr - $request - $request_time '
                         'header_time:$http_x_header_duration body_bytes:$request_length';
}

Behavioral Fingerprinting Instead of Counting Requests

After that 2019 incident we stopped trying to count our way out of the problem. We shifted to fingerprinting behavior over the life of the connection. For subtle DDoS detection, you don't want to know "how many requests," you want to know "how does this client behave while making a request." That means tracking things like time-to-first-byte for headers, inter-arrival time of header fragments, TCP window updates without data, and body transfer rate vs. advertised Content-Length.

One technique that's worked well for us at the CDN layer is scoring connection fluidity. A normal browser, even on a slow link, tends to burst. As noted in web.dev Articles on network performance, real browsers try to get data out as fast as the network allows. Attack toolkits do the opposite — they pace deliberately, often with eerily consistent timing. We had a case last year where the entire attack had a 5.05 second pause between header lines, every single connection, for 14 hours. No human or real network jitter is that precise.

If you're running an edge that terminates TLS, you've got even more signals before you even parse HTTP. Take a look at this simplified scoring we run in our eBPF/xDP layer to flag possible DDoS Application Layer Attack Techniques: Slowloris, RUDY, and HTTP Floods without waiting for the request to finish:

// Pseudocode for connection-fluidity scorer (runs per-tcp-flow)
struct flow_state {
    u64 header_start_ns;
    u64 last_byte_ns;
    int header_bytes;
    int fragments;
    float score;
};

void on_tcp_payload(flow_state *f, int bytes, u64 now_ns) {
    f->fragments++;
    // Penalize tiny, evenly-spaced fragments
    u64 delta = now_ns - f->last_byte_ns;
    if (bytes < 64 && delta > 4e9 && delta < 6e9) {
        f->score += 2.5; // Consistent 5s pacing is a classic tell
    }
    // Penalize long header completion time vs bytes sent
    u64 header_duration = now_ns - f->header_start_ns;
    if (header_duration > 15e9 && f->header_bytes < 512) {
        f->score += 3.0;
    }
    // Penalize advertised vs actual body rate mismatch
    if (f->expected_body_len > 4096) {
        float bps = f->body_bytes / (header_duration / 1e9);
        if (bps > 0 && bps < 20) f->score += 1.5;
    }
    f->last_byte_ns = now_ns;

    if (f->score > 8.0) {
        mark_for_challenge(f); // Send JS challenge / CAPTCHA / 429 with Retry-After
        // Don't RST immediately - you want to confirm with L7 signals
    }
}

Telemetry That Actually Exposes Low-and-Slow Patterns

You can't detect what you don't log. I learned this the hard way when our standard CDN log pipeline was only emitting entries for completed requests. Slow connections that were killed by client_header_timeout simply never showed up, so our SIEM thought everything was fine while origins were dying. Now we emit a log line for every connection close, completed or not, with timeout reason attached.

This is where most teams confuse low-and-slow with other reflected or amplified attacks. They'll go hunt for UDP spikes because that's what DDoS DNS Amplification Defense: Stopping Reflected Attacks at CDN Scale trains them to look for, while the real problem is 1,200 concurrent connections that have been open for 4 minutes and haven't finished sending headers. You need histograms, not just counters: distribution of Time-To-Last-Header-Byte, Body-Transfer-Rate, and Concurrent-Incomplete-Conns-Per-/24. When we started graphing the p95 header completion time per PoP, those attacks lit up like a Christmas tree — flat traffic rate, but p95 header time jumped from 0.3s to 47s.

The table below is pulled from a recent incident where we compared 24 hours of legitimate traffic against a confirmed slow attack that evaded a 100 req/min limiter. If you aren't tracking these specific metrics at the edge, you won't see the attack until your origin thread pool does:

Signal (Per-Connection Metric) Legitimate Baseline (p50 / p95) Low-and-Slow Attack Cluster
Header Completion Time 0.12s / 0.41s 18.7s / 52.3s (intentionally stalled)
Body Transfer Rate (advertised 8KB POST) 4.2 KB/s / 1.1 KB/s (mobile) 0.016 KB/s / 0.008 KB/s (1 byte / 8-12s)
Concurrent Incomplete Connections per /24 0.2 / 1.1 38.5 / 61.0 (highly correlated)
Header Fragment Size ~680 bytes / burst 12-45 bytes, 1 header line per packet
Inter-Arrival Jitter (header lines) Variable (2ms - 800ms) Low jitter: 5.02s ± 0.08s (automated pacing)

From Theory to Edge: Implementing Behavioral Scoring Without Melting Your Origin

Knowing what to look for is the easy part — running it at 2.3M requests per second without adding 40ms of latency is where most teams fall apart. You can't do this inspection at your origin. By the time that slow POST hits your app server, you've already burned a worker thread for 110+ seconds. We've got to push the logic to the edge, right where TLS terminates, and make decisions before the request ever hits your infrastructure.

The trick isn't to block aggressively, it's to score and shed. Every connection gets a live behavioral score based on three signals we talked about earlier: header inter-arrival time, request completeness ratio, and connection duration versus bytes transferred. A legitimate mobile client on a 3G connection might trickle at 2-3 KB/s for 15 seconds — that's fine. A R.U.D.Y. bot sending one byte of a POST body every 11 seconds for 4 minutes to hold 800 connections open with just 12 IPs? That's a score of 85+ out of 100 and it gets tar-pitted or challenged. At the edge we keep this state in a lightweight in-memory ring buffer per PoP — you don't need to ship every header timestamp to your SIEM. We're sampling 1:1000 for long-term baselining and keeping full-fidelity for the last 60 seconds in shared memory. That keeps memory overhead under 180MB per node even during a 500k concurrent slow connection event.

Here's a simplified version of the scoring logic we run as a Lua filter in NGINX/OpenResty at the edge. It's not a full WAF rule — it's a stateful connection tracker that runs before your rate limiter even fires:

-- low_and_slow_score.lua - runs on header receive + body read events
local score = 0
local now = ngx.now()
local conn_duration = now - ctx.conn_start_time
local header_interval = now - (ctx.last_header_time or ctx.conn_start_time)
local bytes_per_sec = ctx.bytes_received / (conn_duration + 0.1)

-- Signal 1: Abnormally slow header cadence (legit is <0.8s, attack is 5-15s)
if header_interval > 4.5 and ctx.headers_received < ctx.headers_expected then
  score = score + 35
  if header_interval > 10 then score = score + 15 end
end

-- Signal 2: High duration, tiny payload - classic slow POST / Slowloris
if conn_duration > 30 and bytes_per_sec < 250 and ctx.request_complete == false then
  score = score + 40
end

-- Signal 3: Connection holds without progress
if conn_duration > 45 and ctx.last_byte_delta > 12 then
  score = score + 25
end

-- Action thresholds tuned from 30 days of baseline (p95 mobile = 22)
if score >= 75 then
  ngx.var.slow_score_action = "tarpit" -- delay + CAPTCHA, don't RST
elseif score >= 50 then
  ngx.var.slow_score_action = "priority_dequeue"
end

ctx.last_header_time = now

Advanced Evasion Tactics and Troubleshooting Noisy Detection

If your attacker’s still using a single Python Slowloris script from 2012, you’ve already won. The modern low-and-slow operators don't do that. They're distributing 15,000 IPs from residential proxy pools, each opening just 2-3 connections and completing 90% of the request perfectly before stalling on the final byte. They randomize User-Agents, rotate JA3 hashes with headless Chrome, and even mimic legitimate TCP window scaling. We saw one campaign last quarter that interleaved real volumetric bursts with slow holds — they'd carpet bomb your /24 with 800 Gbps for 30 seconds to trigger your volumetric scrubbing, then go dead silent and hold 40,000 slow connections while your auto-mitigation scaled back down. That's why you can't treat this in isolation. Understanding how volumetric vectors like DDoS NTP Reflection Mitigation: Blocking Monlist-Based Amplification Attacks and DDoS Memcached Amplification Defense: Neutralizing 51,000x Amplification Factor interact with slow holds is critical — attackers love to hide the slow bleed behind the loud noise.

So when you're troubleshooting, start by assuming your detection is too sensitive, not too loose. The number one source of false positives isn't bots — it's legitimate clients on terrible networks and corporate proxies that coalesce connections. We had a client flag 8% of their Southeast Asia traffic as "slow" until we realized their p95 header interval there is 3.2 seconds, not 0.6s like in US-East. Fix this by baselining per PoP and per ASN, not globally. Set your initial tarpit threshold at the 99.2nd percentile for that region, monitor for a week, then tighten by 5% increments. And don't RST or drop — tarpit with a 5-second delayed 429 plus a JavaScript challenge. Real browsers will retry and pass; attack scripts holding sockets won't. Also, correlate your slow-connection map with subnet-level anomalies. If you suddenly see 12,000 IPs each holding 2 connections across your entire /22, that's not a bunch of slow phones — that's DDoS Carpet Bombing Defense: When Attackers Spread Traffic Across Your Entire Subnet adapted for the low-and-slow world.

If detection feels noisy with no clear attack, check three things: first, your origin's keepalive and timeout settings. If your origin holds connections for 120s but your edge timeout is 75s, you'll create artificial "stalled" signals. Align them — we run edge at 20s and origin at 15s for non-websocket traffic. Second, look for clock skew in header timestamps if you're aggregating logs from multiple PoPs. And third, validate you're not scoring health checks and synthetic monitors. We've seen customers accidentally tarpitting their own Datadog agents because they sent partial headers every 10 seconds by design.

Frequently Asked Questions

How do you tell the difference between an attack and a real user on a slow 3G connection?

It's all about consistency and progression. A real slow client is still progressing — bytes per second might be low (800-1,500 B/s), but it's steady and the request eventually completes in 12-25 seconds. An attack holds deliberately — you'll see 0-50 B/s, header intervals locked at 10-11 seconds to stay just under your timeout, and connections that live for 90-300 seconds without ever completing. We also weight it with behavioral context: real users have valid cookies, fetch assets, and have varied TLS fingerprints. Low-and-slow bots typically have no prior session, request the same heavy endpoint like /search or /login, and never request static assets. Score both, don't rely on speed alone.

Can't attackers just bypass this by using headless browsers that look completely legitimate?

They can and they do, but it raises their cost by 40-60x. A headless Chrome instance can only hold ~80-120 concurrent slow connections per 2GB RAM, versus 10,000+ for a raw socket script. To hold 50,000 connections, they'd need a serious botnet, not a cheap VPS. Our defense then shifts from TCP behavior to economic pressure: we issue a lightweight proof-of-work or Turnstile challenge on connections that score 50-74. Headless browsers can solve it, but solving 50,000 challenges concurrently burns their CPU and forces them to complete the request. Once they complete it, they're no longer "low-and-slow" and your standard rate limiter can handle them.

How long do we need to retain telemetry to baseline and detect these attacks?

You don't need months. For detection, you need 60-90 seconds of high-fidelity per-connection state at the edge — anything older is useless for real-time scoring. For baselining, retain sampled aggregates for 14-30 days. We keep 1:1000 sampled header intervals, TTFB, and bytes-per-second percentiles per PoP, per ASN, per endpoint. That's about 4.2GB per day for a 1Tbps property and gives you a solid p50/p95/p99 to tune thresholds against. Keep full logs for the 5 minutes before and after a scored event for forensics — that's where you'll find the 12 IPs that were actually responsible.

Does TLS 1.3 and HTTP/2 make low-and-slow detection harder?

Yes, and no. TLS 1.3 with 0-RTT and encrypted ClientHello hides less than you'd think — we still see the TCP and TLS handshake timing before HTTP even starts. HTTP/2 actually helps us because its multiplexing and SETTINGS frames give us more signals. An attacker holding an HTTP/2 connection open with a single stalled stream while sending PING frames every 15 seconds looks wildly different from a browser multiplexing 6-12 streams with proper flow control. HTTP/3 over QUIC is trickier since it's UDP, but the same principles apply: track stream completion ratio and flow control stalling. If anything, modern protocols give you *more* behavioral data to score, as long as you're inspecting at the edge where you terminate them.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker