Fail2ban Misses Low-and-Slow Brute Force: Per-Connection Thresholds and Short findtime¶
🛑 Problem¶
A mail server is being brute-forced continuously — hundreds of IMAP auth failures a day against guessed role accounts (admin@, info@, postmaster@, contact@, support@, billing@, hr@, sales@, test@, no-reply@) from a dozen or more IPs — and Fail2ban bans none of them.
Jail status looks healthy. The jails are enabled, the config parses, other jails are actively banning. One jail may even report zero bans since it was created, which is easy to read as "nothing to catch" rather than "never matched anything".
🔍 Diagnosis¶
Step 1 — Confirm the attack is real and quantify the pace¶
# failures per source IP over the retained logs
zgrep -h "auth failed" /var/log/maillog* \
| grep -oE 'rip=[0-9.]+' | cut -d= -f2 | sort | uniq -c | sort -rn | head
# attempts per hour for one attacker — this is the tell
journalctl --since "7 days ago" --no-pager \
| grep "<attacker_ip>" | grep -oE '^[A-Z][a-z]{2} [0-9]{2} [0-9]{2}' \
| sort | uniq -c | tail -20
A steady 1–3 attempts per hour, around the clock is the signature. It is deliberately paced under typical thresholds.
Step 2 — Check whether anything actually succeeded¶
Always establish this before anything else — it decides whether you are cleaning up or responding to a breach.
Step 3 — Read the failregex, not the jail status¶
This is the step people skip. A jail can be enabled, parsing, and monitoring the right file while its regex matches nothing.
Two failure modes to look for:
a) A per-connection attempt threshold.
failregex = : (imap|pop3)-login: Login aborted: Connection closed \(auth failed, (?:[3-9]|\d{2,}) attempts.*rip=<ADDR>
That requires 3 or more failures inside a single connection. Now look at the attacker's actual log line:
imap-login: Login aborted: Connection closed (auth failed, 1 attempts in 2 secs) (auth_failed): user=<billing@example.com>, ..., rip=203.0.113.10
1 attempts, then disconnect. One try per connection, every time. The regex can never match it.
b) A hardcoded value that has drifted into irrelevance.
A filter written for one specific defunct domain matches only that domain. If the jail has 0 total bans since creation, suspect this.
Step 4 — Prove the regex against real logs¶
0 matched on a log full of the attack confirms the filter — not the jail wiring — is the problem.
Step 5 — Check findtime against the observed pace¶
Even a correct regex fails to ban if the window is too short:
| Setting | Attacker pace | Result |
|---|---|---|
maxretry = 5, findtime = 600 (10 min) |
~2/hour | never reaches 5 in a 10-minute window — never bans |
maxretry = 5, findtime = 86400 (24 h) |
~2/hour | ~48/day — bans comfortably |
✅ Fix¶
Key the filter on which account is being tried, not on per-connection counts¶
If the server hosts a known, small set of accounts, any auth attempt for anything else is illegitimate by definition. That is a far stronger discriminator than counting attempts, and it is immune to the 1-attempt-per-connection evasion.
Filter 1 — any domain you do not host:
# /etc/fail2ban/filter.d/dovecot-defunct-domain.conf
[Definition]
failregex = : (imap|pop3)-login: .*auth failed.*user=<[^>]*@(?!example\.com>)[^>]*>.*rip=<HOST>
ignoreregex =
journalmatch = _SYSTEMD_UNIT=dovecot.service
Filter 2 — nonexistent accounts at a domain you do host:
# /etc/fail2ban/filter.d/dovecot-unknown-user.conf
[Definition]
failregex = : (imap|pop3)-login: .*auth failed.*user=<(?!(?:realuser|otheruser)(?:@example\.com)?>)[^>@]*(?:@example\.com)?>.*rip=<HOST>
ignoreregex =
journalmatch = _SYSTEMD_UNIT=dovecot.service
The [^>@]* plus optional @domain construction keeps filter 2 from overlapping filter 1, so an IP is not banned twice for the same event.
Jail settings — long window, modest threshold¶
[dovecot-unknown-user]
enabled = true
filter = dovecot-unknown-user
port = pop3,pop3s,imap,imaps
action = %(action_)s
logpath = /var/log/maillog
maxretry = 5
findtime = 86400
bantime = 604800
backend = auto
findtime = 86400 is the point of the whole exercise. Catching low-and-slow means measuring over a day, not ten minutes.
⚠️ Validate before enabling — this filter can lock you out¶
A "ban everything that isn't the real account" filter will ban you if your own client authenticates in a form you did not think of.
# 1. Does it match the attack?
fail2ban-regex /var/log/maillog /etc/fail2ban/filter.d/dovecot-unknown-user.conf
# 2. Does it match YOU? Both counts must be 0.
out=$(fail2ban-regex /var/log/maillog /etc/fail2ban/filter.d/dovecot-unknown-user.conf --print-all-matched)
echo "$out" | sed -n '/Matched line/,$p' | grep -c 'user=<realuser@example\.com>'
echo "$out" | sed -n '/Matched line/,$p' | grep -c 'user=<realuser>'
[!warning] Check the unqualified username Mail clients frequently send a bare username (
marcus) rather than the full address (marcus@example.com) — often on the SMTP side only, while IMAP uses the full form. On the majormail deployment this appeared 210 times in retained logs, including from the owner's own home IP. A filter excluding onlymarcus@example.comwould have banned the owner's own client.Before writing exclusions, check the source IPs of every username you see:
Attacker dictionary entries show as ~2 attempts from many scattered IPs. Your own client shows as many attempts from one or two known IPs. The distinction is obvious once you look, and invisible if you don't.
🔁 Why This Happens¶
| Cause | Detail |
|---|---|
| Filters are tuned against noisy attackers | Thresholds like "3+ failures per connection" exist to spare legitimate clients (notably mobile CGNAT, where many users share one IP). That tradeoff is reasonable — and it creates a documented, exploitable blind spot. |
| Attackers pace under the window | ~2 attempts/hour never reaches maxretry inside a 10-minute findtime, regardless of how correct the regex is. |
| Hardcoded filters rot silently | A regex naming one specific domain keeps parsing cleanly forever while matching nothing. Nothing errors. |
| Zero bans reads as "all quiet" | An empty Banned IP list looks like success. For a jail that has never banned anything, it usually means the filter never matched. |
[!note] A narrowing "fix" can reopen an old hole On majormail the
dovecot-invalidfilter was widened on 2026-04-08 specifically to catchConnection closed (auth failed)against this same role-account dictionary — and it worked (1,348 matching lines at the time). It was later narrowed to require[3-9]+ attemptsto spare CGNAT clients, which reopened the gap for anyone attempting once per connection. Same attack, same account list, four months apart. When you narrow a security filter, re-runfail2ban-regexagainst the original attack sample.
🧪 Verifying a jail is actually working¶
New jails and pre-existing jails behave differently, and this trips people up:
- A newly added jail has no stored file position, so on first start it scans the log from the beginning and can ban retroactively.
- A pre-existing jail resumes from its stored position and only sees new lines. It will read
0 failedimmediately after a reload even when its regex is perfect. - logrotate restarts fail2ban (
/etc/logrotate.d/fail2ban), which resetsTotal failedcounters. A jail that had counted failures can show0again with no fault.
So do not conclude a filter is broken because the counter reads zero. Prove it directly against recent lines:
awk '/^Aug 8 01:/' /var/log/maillog > /tmp/slice.log
fail2ban-regex /tmp/slice.log /etc/fail2ban/filter.d/<filter>.conf | grep '^Lines:'
🔎 Quick Diagnostic Commands¶
# Which usernames are being tried, ranked
zgrep -h "auth failed" /var/log/maillog* | grep -oE 'user=<[^>]*>' | sort | uniq -c | sort -rn | head -15
# Bare (unqualified) usernames — the false-positive trap
zgrep -h "auth failed" /var/log/maillog* | grep -oE 'user=<[^>@]*>' | sort | uniq -c | sort -rn
# Has any attempt ever succeeded from this IP?
zgrep -h "<ip>" /var/log/maillog* | grep -c "Logged in"
# Which accounts actually authenticate (ground truth for exclusions)
zgrep -h "Logged in" /var/log/maillog* | grep -oE 'user=<[^>]*>' | sort | uniq -c | sort -rn
# Test a filter without enabling it
fail2ban-regex /var/log/maillog /etc/fail2ban/filter.d/<filter>.conf
# Confirm config parses before reloading
fail2ban-client --test && fail2ban-client reload
Related¶
- Mail Client Stops Receiving: Fail2ban IMAP Self-Ban — the opposite failure: a filter that bans too much
- Apache Directory-Scan Fail2ban Jail — building a custom jail from scratch
- Fail2ban Rule Bloat Cleanup