DDoS Attacks and DDoS Defense
On this page
When the Flood Is the Attack#
A Distributed Denial-of-Service (DDoS) attack floods a service with more traffic than it can handle, or with traffic that is cheap to send but expensive to process, until legitimate users cannot get through. The "distributed" part is what makes it hard: the flood comes from thousands of compromised devices (a botnet), from rented "booter" services, or from innocent open servers tricked into reflecting traffic at you. There is no single source address to block.
Defending well means knowing which layer is being attacked, because the fixes are completely different at each one.
The Three Families of Flood#
| Family | Target | Examples | Measured in | Primary defense |
|---|---|---|---|---|
| Volumetric | Your bandwidth | UDP floods, DNS/NTP/memcached/CLDAP amplification | Bits per second | Upstream scrubbing, anycast CDN |
| Protocol | Connection state in kernels, firewalls, load balancers | SYN floods, fragmented packets | Packets per second, open connections | SYN cookies, timeouts, stateless edges |
| Application (L7) | Your code, web server and database | HTTP floods on search, login, export, cache-busting query strings; "low and slow" attacks like Slowloris | Requests per second (or concurrent connections, for low-and-slow) | Caching, rate limits, challenges, request timeouts, cheap code paths |
Amplification deserves a closer look because it explains why volumetric attacks can be so large. Protocols like DNS, NTP and memcached run over UDP, which has no handshake, so the sender's source address is never verified. The attacker sends a small query with the victim's IP forged as the source to thousands of open servers, and each server sends a much larger response to the victim. The amplification factor varies by protocol from a few times to thousands of times. The long-term fix is network-level: ISPs filtering spoofed source addresses at their edge (known as BCP 38 ingress filtering) and operators closing open resolvers and exposed memcached instances. If you run any UDP service, make sure it is not an open amplifier for someone else.
Protocol attacks exhaust state rather than bandwidth. A SYN flood sends the first packet of the TCP handshake and never completes it, filling the kernel's queue of half-open connections (the handshake itself is covered in TCP handshake and flags). Slowloris also exhausts connection state, but it is usually classed as an application-layer (L7) attack because it works through HTTP itself: it opens many real connections and sends partial request headers very slowly, so each connection holds a web-server worker for as long as the server's timeout allows, using almost no bandwidth.
Application-layer attacks look like normal HTTPS requests from real browsers or headless clients. They target whatever is expensive: a search endpoint that runs a full-text query, a login endpoint that runs a slow password hash, or any URL with a random query string that misses the cache and hits the origin.
Why the Target Cannot Win Alone#
A 1 Gbps uplink cannot filter a 100 Gbps flood: the traffic saturates the link before your firewall ever sees it. Filtering has to happen upstream, on a network with more capacity than the attacker can generate. That is why volumetric defense is bought, not built:
- CDNs and DDoS-protection providers announce your IP space or front your hostnames from many locations with anycast routing. Each attacking device's traffic lands at the nearest edge location, so the flood is split across a global network and absorbed there.
- Scrubbing centers receive diverted traffic (often via a BGP announcement for your prefixes during an attack), drop the attack traffic and forward clean traffic to your origin over a tunnel or private link.
- Cloud providers include baseline network-layer protection on their load balancers, with paid tiers that add application-layer rules and response teams.
The job left to you is prevention and design: make sure all traffic actually goes through that protection, and make your application cheap to serve.
Protect the Origin Address#
A CDN does nothing if attackers can reach your origin server directly. Origin IPs leak through old DNS records, a mail server on the same host, a subdomain that was never proxied, TLS certificates served on the raw IP, and error pages. Mitigations:
- Firewall the origin so it only accepts HTTP/HTTPS from your provider's published IP ranges (or use an authenticated tunnel from the provider so the origin has no public listener at all).
- Move the origin to a new IP after putting it behind the CDN, since the old one is in historical DNS datasets.
- Keep non-web services (mail, SSH, VPN) on separate addresses.
The CDN and firewalls lessons cover the building blocks.
Hardening the Server Against Protocol and L7 Floods#
On Linux, SYN cookies let the kernel answer SYNs without storing state until the handshake completes. Most distributions enable them by default; check rather than assume:
sysctl net.ipv4.tcp_syncookies # expect: net.ipv4.tcp_syncookies = 1
ss -n state syn-recv | wc -l # count half-open connections right now
ss -s # summary of socket states
A SYN-RECV count that jumps from a handful to thousands, while legitimate traffic is flat, is the signature of a SYN flood.
At the web server, short timeouts and per-client limits defeat Slowloris and blunt HTTP floods. An nginx example:
# Shared memory zones keyed by client address
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_conn_zone $binary_remote_addr zone=conns:10m;
server {
client_header_timeout 10s; # slow header dribbling gets cut off
client_body_timeout 10s;
keepalive_timeout 15s;
send_timeout 10s;
limit_conn conns 20; # max simultaneous connections per IP
limit_req_status 429;
location / {
limit_req zone=perip burst=40 nodelay;
}
location = /login {
limit_req zone=login burst=5;
}
}
burst allows short spikes above the rate (a page loading many assets) before rejections start. Behind a CDN, $binary_remote_addr is the CDN's address, not the client's, so configure the real-IP module to trust the provider's forwarded header from its ranges only; otherwise you rate limit the CDN itself. Per-IP limits alone are weak against large botnets where each device sends only a few requests, which is why edge providers add reputation, fingerprinting and JavaScript challenges on top. The same ideas appear from the other side in rate limiting and politeness.
Make the Legitimate Path Cheap#
Application design decides how much a request costs you, and therefore how many requests it takes to knock you over:
- Cache aggressively at the edge. A cached page costs the origin nothing, however many times it is requested. Normalise or strip unknown query parameters from the cache key so
/?x=randomcannot force a miss on every request. - Gate expensive endpoints: search, login, password reset, report export and file conversion get stricter rate limits, a challenge, or a requirement to be logged in.
- Move heavy work to queues. An endpoint that enqueues a job and returns 202 is far cheaper to flood than one that generates a PDF inline.
- Bound everything: page sizes, query complexity (especially GraphQL), upload sizes, database statement timeouts.
- Autoscale with a ceiling. Scaling helps absorb spikes, but unlimited scaling turns a DDoS into a large cloud bill. Set a maximum and an alert.
Detecting an Attack Early#
The first minutes decide the outcome, because that is when someone has to decide to engage the provider or switch on stricter rules. Alert on requests per second per endpoint, error rate and latency percentiles, bandwidth and packets per second, and connection-table usage. When an alert fires, you need to answer "who and what" quickly. A short script over an access log does that:
import re
from collections import Counter
LINE = re.compile(r'^(\S+) \S+ \S+ \[([^:]+:\d+:\d+):\d+ [^\]]+\] "(\S+) (\S+)')
ips, paths, minutes = Counter(), Counter(), Counter()
with open("access.log", encoding="utf-8", errors="replace") as f:
for line in f:
m = LINE.match(line)
if not m:
continue
ip, minute, method, path = m.groups()
ips[ip] += 1
paths[path.split("?")[0]] += 1 # group by path, ignore cache-busting params
minutes[minute] += 1
print("busiest minutes:", minutes.most_common(3))
print("top paths:", paths.most_common(5))
print("top clients:", ips.most_common(5))
During an L7 flood you typically see one or two paths dominating (often /search or /), a minute-by-minute count that jumps by an order of magnitude, and either a few very loud IPs (easy: block them) or thousands of IPs each with similar small counts (hard: needs edge challenges and fingerprinting, not IP blocks).
The Runbook#
Write the runbook before you need it, and rehearse it:
- Confirm it is an attack, not a launch, a marketing email or a broken client retry loop (check referrers and user agents).
- Identify the layer: bandwidth saturated, connection tables full, or specific endpoints slow.
- Engage the provider: switch on "under attack" or challenge mode, apply rate-limit rules to the targeted paths, or request scrubbing. Know the contact route and account details in advance.
- Shed load deliberately: disable expensive non-essential features, serve a static maintenance page for non-critical paths.
- Communicate status to users and stakeholders.
- Afterwards, review what was targeted and make that path cheaper or better protected.
Practice#
On infrastructure you own, run a load-testing tool such as Locust or k6 against a staging copy of your app. Find the endpoint whose latency collapses first, then add caching or a rate limit and measure again. Never "stress test" systems you do not own or have written permission to test; unauthorised load testing is a DDoS in law.
Check your understanding
4 quick questions on this lesson. Get them all right and the lesson is marked complete.