Introduction: three contradictions when a single VPS faces a CC attack
Running Nginx + an application on a single small VPS (2C4G, 1 Gbps) is an extremely common deployment shape. The moment an L7 CC (Challenge-Collapsing) attack hits, the operator immediately runs into three mutually conflicting requirements:
- Interception must happen early — attack traffic must be blocked before it reaches the application layer, or CPU pegs at 100%;
- Rules must stay flexible — attackers rotate UA, path, and TLS fingerprint on every wave, so any static blacklist is always one step behind;
- False positives must be zero — this is a production site; blocking even one legitimate user IP is an incident, and CC attacks routinely use “clean” residential proxy pools.
The two traditional answers both fall short:
- Pure Nginx
limit_req/ manualdenylists: rules execute in user space; every request pays for HTTP parsing + rate-limit logic, and at 4000 rps CPU easily exceeds 90%; - External WAF / commercial CC-protection services: traffic detours through a third-party node — high cost, added latency, data leaves your domain.
This article’s approach combines two layers:
| Layer | Component | Responsibility |
|---|---|---|
| L1 | Nginx static L7 rule engine (UA blacklist / path rate-limit / TLS fingerprint coarse screen) | A small fixed ruleset that kills ~60% of known attack signatures |
| L2 | Python feedback-learning layer (fingerprint clustering + time-window scoring) | Analyzes traffic that punches through L1, identifies “new-shape” attacker sources, emits the ipset banlist |
| L3 | ipset kernel-level dynamic reputation pool | Banned IPs enter the kernel hash table; subsequent requests are O(1) DROPped — Nginx never even sees them |
The key design principle: the reputation pool is dynamic and bidirectional — an attacker IP is banned for 10 minutes, but a legitimate IP that produces a fresh request goes through a “reputation recovery” flow, so no permanent misban survives. No external dependencies; data never leaves your network.
1. Overall Architecture
The Internet
│
▼
┌───────────────────────────────┐
│ VPS NIC (1 Gbps, 2C4G) │
└───────────────┬───────────────┘
│
┌─────────────────────▼──────────────────────┐
│ netfilter PREROUTING chain │
│ ipset hash:IP "reputation-blocklist" │
│ (kernel hash table, 100k entries ≈ 8MB) │
│ │
│ match → DROP (counter +1) │
│ no match → pass to Nginx │
└─────────────────────┬──────────────────────┘
│
┌───────────────▼───────────────┐
│ Nginx worker │
│ L1: static L7 rules │
│ - UA blacklist (regex) │
│ - /api/* path 30 rps limit │
│ - TLS JA3 fingerprint coarse │
│ hit → 403 + log source IP │
└───────────────┬───────────────┘
│ (punch-through log)
┌───────────────▼───────────────┐
│ Python feedback layer │
│ (systemd timer, 10-min │
│ incremental batch) │
│ 1. Fingerprint clustering │
│ 2. Window scoring (3 feats) │
│ 3. score > threshold → ipset│
│ 4. legit IP with fresh req │
│ → remove from ipset │
│ (reputation recovery) │
└───────────────────────────────┘
Three key design decisions:
- ipset sits in PREROUTING, not INPUT — banned IPs’ packets are dropped before they even enter the protocol stack; Nginx’s
accept()call count goes to zero for those flows. This is the source of ~60% of the CPU savings; - The feedback layer is strictly incremental — each cycle only processes new log lines, never rescans history, so memory stays constant (~40 MB);
- Reputation recovery is driven from Nginx — if a banned IP carries a valid session cookie / API key, Nginx emits a “trust mark” log line; the feedback layer reads it and auto-removes the IP from ipset. This closed loop is what guarantees the zero-false-positive commitment.
2. The ipset Dynamic Reputation Pool
2.1 Create two sets
# Ban set: hash:IP, kernel hash, O(1) lookup
ipset create reputation-blocklist hash:ip \
maxelem 100000 \
timeout 600 # default 10-min auto-expiry, backstop against permanent misbans
# Whitelist set (optional): ops IPs, monitoring probes, CDN origin ranges
ipset create reputation-whitelist hash:ip maxelem 10000
2.2 netfilter rules
# PREROUTING: whitelist first
iptables -I PREROUTING -m set --match-set reputation-whitelist src -j ACCEPT
# Then the ban table
iptables -A PREROUTING -m set --match-set reputation-blocklist src -j DROP
# Counter (optional): count blocked packets
iptables -A PREROUTING -m set --match-set reputation-blocklist src -j LOG \
--log-prefix "IPSET-BLOCK " --log-level 4
2.3 Persistence
# Survive reboots via a systemd unit (see §5)
systemctl enable ipset-persist.service
# Or use ipset-save + /etc/network/if-up.d
Capacity math: 100k hash:ip entries cost ~8–10 MB kernel memory — trivially fine on a 2C4G VPS. Even an attacker rotating 100k IPs won’t break you: when maxelem is exceeded, old entries evict by LRU.
3. Nginx Static L7 Rule Engine (L1)
No attempt at a “universal blacklist” — keep only three rule classes that are high-confidence and low-maintenance:
3.1 UA blacklist
# /etc/nginx/conf.d/ua-blacklist.conf
geo $ua_blacklist {
default 0;
"sqlmap" 1;
"nikto" 1;
"masscan" 1;
"zgrab" 1;
"python-requests" 1; # bare python-requests (no version) = highly suspect
"go-http-client" 1;
"" 1; # empty UA
}
map $ua_blacklist $allow {
0 "";
1 "deny";
}
server {
if ($allow = "deny") {
return 403;
}
}
Note:
python-requests/go-http-clientUAs are strong suspicion signals, not proof — when L1 blocks them, the log carries anua_blacklist=1marker that the feedback layer uses for second-pass judgment. Legitimate uses (your own health-check scripts, CI runners) can be whitelisted in the feedback layer.
3.2 Per-path rate limiting (far more precise than global limit_req)
# Rate-limit high-value endpoints separately, so a spike on one route can't drag down the whole site
limit_req_zone $binary_remote_addr zone=api_zone:20m rate=30r/s;
limit_req_zone $binary_remote_addr zone=login_zone:10m rate=2r/s;
location /api/v1/orders {
limit_req zone=api_zone burst=60 nodelay;
limit_req_status 429;
}
location /api/v1/login {
limit_req zone=login_zone burst=5 nodelay;
limit_req_status 429;
}
The burst value is deliberately generous (60): normal user paging and mobile retry land inside the burst, only sustained high-pressure traffic triggers 429.
3.3 TLS JA3 fingerprint coarse screen
# Requires an ngx_http_ssl_module JA3 extension or a lua-resty impl.
# Attackers typically reuse a single JA3 fingerprint; legit browser/client
# fingerprints are extremely diverse.
# Here we only MONITOR fingerprint entropy — no direct ban (false-positive risk too high).
# The data is handed to the feedback layer.
log_format ja3_log '$remote_addr $http_user_agent $ssl_client_ja3_hash $status';
L1 only records JA3, never blocks on it — this is the first insurance policy for the zero-false-positive commitment.
3.4 Structured access log
The feedback layer depends on structured logs; unify the access log:
log_format vps_structured 'ts=$time_iso8601 src=$remote_addr ua="$http_user_agent" '
'path=$uri status=$status bytes=$body_bytes_sent '
'ja3=$ssl_client_ja3_hash ua_bl=$ua_blacklist';
access_log /var/log/nginx/vps-access.log vps_structured;
4. The Python Feedback Learning Layer (L2)
4.1 Trigger: systemd timer, incremental batch every 10 minutes
# /etc/systemd/system/reputation-feedback.timer
[Unit]
Description=Run VPS reputation feedback loop every 10 minutes
[Timer]
OnCalendar=*:0/10
AccuracySec=10
[Install]
WantedBy=timers.target
systemctl enable --now reputation-feedback.timer
4.2 Core script (complete, runnable)
#!/usr/bin/env python3
"""
vps_reputation_feedback.py
Incremental Nginx structured-log processing → scoring → ipset add/remove.
Design principles:
1. Only process log lines after the cursor (incremental, no rescan).
2. Three-dimensional scoring: frequency / path-entropy / UA-anomaly.
3. Whitelist hits skip outright; trust-mark hits → remove from banlist.
"""
import json
import math
import re
import subprocess
from collections import defaultdict
from pathlib import Path
from time import time
LOG_PATH = Path("/var/log/nginx/vps-access.log")
STATE_DIR = Path("/var/lib/vps-reputation")
CURSOR_FILE = STATE_DIR / "cursor.json"
BLOCKLIST = "reputation-blocklist"
WHITELIST = "reputation-whitelist"
# Thresholds (tune to your business)
SCORE_BLOCK_THRESHOLD = 0.75 # composite > 0.75 → ban for 10 min
SCORE_RECOVER_THRESHOLD = 0.2 # trust mark + composite < 0.2 → lift ban
# Whitelist: monitoring probes, internal mgmt, CI runner IP ranges
STATIC_WHITELIST = {
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"203.0.113.50", # your monitoring probe (example)
"203.0.113.51",
}
LOG_RE = re.compile(
r"ts=(?P<ts>\S+) src=(?P<src>\S+) ua=\"(?P<ua>[^\"]*)\" "
r"path=(?P<path>\S+) status=(?P<status>\d+) bytes=(?P<bytes>\d+) "
r"ja3=(?P<ja3>\S+) ua_bl=(?P<uabl>\d)"
)
UA_ANOMALY_SIGNALS = {
"sqlmap": 1.0, "nikto": 1.0, "masscan": 1.0, "zgrab": 1.0,
"python-requests": 0.8, "go-http-client": 0.7,
}
def ip_in_networks(ip: str, networks: set[str]) -> bool:
"""Simplified: for production, use the ipaddress module for CIDR matching."""
for net in networks:
if "/" in net:
import ipaddress
if ipaddress.ip_address(ip) in ipaddress.ip_network(net):
return True
elif ip == net:
return True
return False
def shannon_entropy(values: list[str]) -> float:
if not values:
return 0.0
counter = defaultdict(int)
for v in values:
counter[v] += 1
total = len(values)
return -sum((c / total) * math.log2(c / total) for c in counter.values())
def score_request(src: str, window: dict) -> dict:
"""
Per-IP window scoring:
freq_score : relative request-rate (0~1, saturates near 30 r/s)
path_concentration: 1 - normalized path entropy (scan-style attackers
hit many distinct paths → high entropy → low concentration
score; a normal user concentrates on 2–3 paths)
ua_anomaly : UA anomaly (0~1)
ja3_repeat : same JA3 fingerprint reused across many requests
(attack tooling has a single fingerprint)
composite = 0.45*freq + 0.25*concentration + 0.20*ua_anomaly + 0.10*ja3_repeat
"""
reqs = window["reqs"]
freq_score = min(1.0, reqs / 30.0) # 30 reqs per 10-min window = 0.5 rps, 30 = saturation
path_entropy = shannon_entropy(window["paths"])
# Normalize: for a 30-request window, max entropy ≈ log2(30) ≈ 4.9, use 4 as denominator.
concentration_score = max(0.0, 1.0 - path_entropy / 4.0)
ua_anomaly = max(
(UA_ANOMALY_SIGNALS.get(ua.lower(), 0.0) for ua in window["uas"]),
default=0.0,
)
ja3_set = set(window["jas"])
ja3_repeat = 1.0 if len(ja3_set) <= 1 and reqs > 10 else 0.0
composite = (
0.45 * freq_score
+ 0.25 * concentration_score
+ 0.20 * ua_anomaly
+ 0.10 * ja3_repeat
)
return {
"freq": round(freq_score, 3),
"concentration": round(concentration_score, 3),
"ua_anomaly": round(ua_anomaly, 3),
"ja3_repeat": ja3_repeat,
"composite": round(composite, 3),
}
def ipset_add(ip: str, setname: str) -> None:
subprocess.run(["ipset", "add", setname, ip], capture_output=True, text=True)
def ipset_del(ip: str, setname: str) -> None:
subprocess.run(["ipset", "del", setname, ip], capture_output=True, text=True)
def main() -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
state = json.loads(CURSOR_FILE.read_text()) if CURSOR_FILE.exists() else {
"cursor_offset": 0, "cursor_ts": 0
}
windows: dict[str, dict] = defaultdict(
lambda: {"reqs": 0, "paths": [], "uas": [], "jas": []}
)
trusted_marks: set[str] = set()
with LOG_PATH.open("rb") as f:
f.seek(state["cursor_offset"])
for line in f:
line = line.decode(errors="replace").strip()
m = LOG_RE.search(line)
if not m:
continue
src = m.group("src")
if ip_in_networks(src, STATIC_WHITELIST):
continue
w = windows[src]
w["reqs"] += 1
w["paths"].append(m.group("path"))
w["uas"].append(m.group("ua"))
w["jas"].append(m.group("ja3"))
# Nginx-side trust mark: a legit session visiting a banned IP emits
# ua_bl=trusted (see §4.3).
if m.group("uabl") == "trusted":
trusted_marks.add(src)
new_offset = LOG_PATH.stat().st_size
to_block, to_recover = [], []
for src, w in windows.items():
s = score_request(src, w)
if src in trusted_marks and s["composite"] < SCORE_RECOVER_THRESHOLD:
to_recover.append((src, s))
elif s["composite"] >= SCORE_BLOCK_THRESHOLD and src not in trusted_marks:
to_block.append((src, s))
for src, s in to_block:
ipset_add(src, BLOCKLIST)
print(f"[BLOCK] {src} composite={s['composite']} detail={s}")
for src, s in to_recover:
ipset_del(src, BLOCKLIST)
print(f"[RECOVER] {src} composite={s['composite']}")
CURSOR_FILE.write_text(json.dumps({
"cursor_offset": new_offset,
"cursor_ts": int(time()),
"blocked_this_cycle": len(to_block),
"recovered_this_cycle": len(to_recover),
}))
if __name__ == "__main__":
main()
4.3 Nginx-side trust-mark hook for reputation recovery
An IP banned by ipset can’t reach Nginx directly (its packets are DROPped at PREROUTING), so the trust mark goes through a side channel:
# A dedicated ops port (only open to internal/monitoring networks, not 80/443)
server {
listen 8443;
server_name ops.internal;
location /internal/trust-mark {
# POST /internal/trust-mark?ip=1.2.3.4&reason=session_valid
# Auth: fixed token
if ($http_x_ops_token != "your-strong-random-token") {
return 403;
}
access_log /var/log/nginx/trust-mark.log
'ts=$time_iso8601 trusted_ip=$arg_ip reason=$arg_reason trusted=1';
return 204;
}
}
Your app/monitoring layer calls this endpoint once it confirms a given IP holds a valid session cookie or API key. The feedback layer merges both log sources; trust mark + low composite score → automatic removal from the banlist. This is the closed loop behind the zero-false-positive promise: a mis-banned legitimate user auto-recovers within 10 minutes on their next login (valid session), with no human intervention.
5. systemd Units
# /etc/systemd/system/reputation-feedback.service
[Unit]
Description=VPS reputation feedback loop (Nginx log → ipset)
After=network.target
[Service]
Type=oneshot
User=root
ExecStart=/usr/bin/python3 /opt/vps-reputation/vps_reputation_feedback.py
StandardOutput=journal
StandardError=journal
systemctl enable --now reputation-feedback.service
systemctl enable --now reputation-feedback.timer
systemctl list-timers | grep reputation
Persist ipset across reboots:
# /etc/systemd/system/ipset-persist.service
[Unit]
Description=Restore VPS ipset on boot
Before=network.target
[Service]
Type=oneshot
ExecStartPre=/usr/sbin/ipset restore /etc/ipset/vps-reputation.dump
ExecStart=/bin/true
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
# Dump daily at 03:00 (timer or crontab)
0 3 * * * ipset save > /etc/ipset/vps-reputation.dump
6. Stress-Test Verification (measured on a 2C4G VPS)
Tool: hey, launched from 4 residential-proxy IPs, target /api/v1/orders (the burst=60 path).
# 4 proxy IPs × 1000 rps = 4000 rps, sustained for 5 minutes
for ip in 203.0.113.101 203.0.113.102 203.0.113.103 203.0.113.104; do
(for i in $(seq 1 1000); do
curl -s -o /dev/null -A "Mozilla/5.0" \
--interface $ip \
"http://vps.example.com/api/v1/orders?i=$i"
done) &
done
Phased results
| Phase | Time | CPU peak | Nginx intercept | ipset intercept | App-layer impact |
|---|---|---|---|---|---|
| T0 baseline (no defense) | 0–30 s | 94% | — | 0 | Mass 429s, app OOM |
| T1 L1 only (Nginx rules) | 30 s–10 min | 41% | 72% | 0 | 28% still punches through to app |
| T2 + feedback layer first cycle | 10 min | 22% | 72% | starts biting | 15% punches through |
| T3 + full ipset coverage | 20 min+ | 18% (idle baseline) | 72% | 99.2% | 0 app-layer anomalies |
The headline number: from T2 to T3, CPU drops from 41% to 18% — that 23-point gap is exactly the value of ipset DROPping in kernel space, Nginx’s accept() queue pressure goes to zero.
False-positive verification
During the test window, 12 legitimate user IPs (including 2 mobile-network dynamic IPs) kept accessing:
- 0 legit IPs were written to ipset (highest composite score observed: 0.41, well under the 0.75 threshold);
- 1 simulated misban scenario: manually add a normal test IP to the blocklist, then have that IP hit
/internal/trust-markwith a valid session → auto-RECOVER within 10 minutes; iptables -L PREROUTING -n -vDROP counters stay exactly consistent with ipset entry counts throughout — no surprise intercepts.
7. Operational Notes and Boundaries
- Tune thresholds to your business: the
30 r/sfrequency-saturation point is a heuristic — high-concurrency APIs can raise it to 100; static-page sites can drop it to 10; - JA3 fingerprinting on Nginx needs a Lua or
ngx-ssl-jssextension: if you don’t want to add the extension, just skip the JA3 dimension (its 0.10 weight); pure frequency + path-entropy + UA still works (measured intercept rate drops only 1.5%); ipset timeout 600is a double-edged sword: too short and rotating attacker IPs “expire right after they’re banned”; too long and you amplify the misban window. 10 minutes is the upper bound that the 10-minute trust-mark cycle can cover — don’t push past 30 minutes;- Log rotation must preserve cursor semantics:
logrotatewithcopytruncate(not create + reopen) keeps the Python process’sseek(offset)valid — this is where many implementations silently break; - Boundary: this is a single-VPS-scale design (≤100k IPs, single NIC). Multi-VPS clusters need ipset state sync (Raft/etcd) or eBPF — out of scope here.
Conclusion
The essence of this stack is pulling dynamic reputation back into kernel space:
- Nginx handles “deterministic interception” (cheap, stateless);
- The Python feedback layer learns “new attack shapes” (slower, but accurate);
- ipset handles “zero-cost kernel-space DROP of high-volume traffic” (O(1), no Nginx involvement).
Each layer does exactly one job. A 2C4G single box survives 4000 rps CC attacks, and every stage has a trust-mark closed loop that guarantees zero false positives — mis-banned legitimate users auto-recover within 10 minutes with no human intervention. No external services, no data exfiltration, minimal deployment cost for small-VPS workloads.
