Mail Over ~500 KB Bypasses SpamAssassin Entirely: spamc's Silent Size Ceiling¶
🛑 Problem¶
An obviously spammy or phishing message lands straight in INBOX with no X-Spam-* headers at all — not a low score, not a missed Bayes hit, just nothing. SpamAssassin looks broken, but every other message that day scanned fine. Checking spamd's own log confirms it: there's no processing message line for this one anywhere. spamd never saw it.
This isn't a tuning problem, a Bayes problem, or a DNSBL problem. It's a size limit nobody configured, because nobody knew it existed.
🔬 Root cause¶
spamc — the client spamass-milter shells out to for every message — refuses to scan anything over its --max-size, which defaults to 500,000 bytes (≈500 KB). This default lives inside spamc itself, not in spamass-milter's own config, spamd's config, or anywhere spamassassin.conf would suggest looking. Nothing about the usual tuning flags (-a for authenticated skip, -r for the reject threshold) touches it — it's a separate flag entirely, and most spamass-milter deployments never set it.
The failure chain, once triggered:
- A message arrives over 500 KB.
spamcreturnsEX_TOOBIGwith empty output — no score, no error text, nothing to parse.spamass-miltercan't extract a score from empty output, so it adds noX-Spam-*headers at all. The milter log shows this as a terse, easy-to-miss line:- If the milter's failure-mode setting (Postfix's
milter_default_action, or the equivalent on other MTAs) is set to accept-on-failure — a common default, reasoned as "if the milter hiccups, mail should still flow" — the message delivers anyway. - With no
X-Spam-Flag: YESheader, any Sieve/procmail rule that files spam into Junk based on that header has nothing to match against. The message goes straight toINBOX, fully unscanned, with every appearance of having passed a clean scan.
The tell that separates this from "the filter is broken": check spamd's log around the delivery timestamp for a message of comparable size that scanned normally. If a smaller message minutes away shows a clean processing message / score line and the oversized one shows only Could not extract score, the filter is healthy — this one specific message just never reached it.
🎯 Why this is an active evasion, not just an edge case¶
Padding a message past a known scan ceiling — with an oversized attachment, an embedded image, or scraped filler text — is a textbook evasion technique, precisely because operators don't expect a size cap like this to exist. It doesn't require anything sophisticated: a spammer just needs a message a little over 500 KB, and the padding itself doesn't have to be plausible. A production box that had this hole open for two months showed:
- 100% bypass rate for every message over the ceiling — every single oversized message during that window skipped scanning entirely, no exceptions.
- Padding as crude as a stock image plus a chunk of scraped Wikipedia text (a classic "Bayes hashbuster" — bulk text meant to dilute a Bayesian score, repurposed here to simply avoid Bayes altogether).
- A steady trickle rather than a flood — roughly one bypassing message every 4–5 days — which is exactly what makes it easy to miss: too infrequent to look like an outage, frequent enough to be a reliable delivery channel for an attacker.
- The bulk of what got through was recognizable spam/fraud (recurring conference-spam campaigns, a BEC/invoice-fraud cluster) that a human was quietly hand-deleting from Junk-adjacent folders the whole time, with no idea the filter had never actually seen any of it.
🧯 The fix¶
Raise spamc's ceiling explicitly. Flags after a bare -- in spamass-milter's startup options pass through to the underlying spamc call:
(Adjust -a/-r to whatever's already in use — the relevant part is the -- -s <bytes> passthrough.) Pick a ceiling at or under the MTA's own message-size limit (Postfix's message_size_limit, for example) — there's no point letting spamc accept something the MTA would reject anyway. 5 MB is a reasonable default: comfortably past realistic padding, without spending scanner CPU on large legitimate attachments that are rarely spam.
Verify the fix actually closed the hole, don't just trust that it applied. Build a test message just over the old ceiling containing the GTUBE test string, and compare:
# With the new ceiling: should return a non-zero count of X-Spam headers
{ printf "Subject: size-limit verification\n\n";
printf "XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\n";
head -c 1500000 /dev/zero | tr "\0" "x"; } | spamc -s 5000000 | grep -c "^X-Spam"
# Without -s (the old default): should return 0 — that contrast IS the proof
Then confirm end to end by watching the milter log after a real oversized message arrives — processing message lines should now appear for messages that previously would have only logged Could not extract score.
⚠️ The same default exists independently in sa-learn — fixing one does not fix the other¶
sa-learn (used for Bayes training) has its own, separate 500 KB --max-size default. It isn't shared configuration with spamc — raising spamc's ceiling has zero effect on sa-learn. A training run against a Maildir containing oversized messages will silently report something like:
— no error, just zero messages actually learned from, hiding inside an otherwise-normal-looking summary line. If Bayes training covers a mailbox that could plausibly contain large messages, sa-learn needs the same treatment:
🚪 The design choice that turns a scan skip into a silent delivery¶
None of the above would matter much if a milter failure caused mail to defer instead of deliver. The reasoning "accept on milter failure so mail keeps flowing if something hiccups" sounds safe, but it means every milter failure mode — a crashed process, a missing socket, a protocol timeout, or this size-ceiling case — is indistinguishable from "scanned clean." The message delivers with no spam headers and no error visible anywhere except an easily-missed log line.
Setting the milter failure action to defer/tempfail instead means a genuinely wedged scanner stalls inbound mail rather than silently waving it through unscanned — a real trade-off (a stuck milter becomes a mail outage instead of an invisible bypass), but one that surfaces failures instead of hiding them. Worth deciding deliberately rather than inheriting whatever a setup guide defaulted to.
🔎 Quick diagnostic checklist¶
# Does an override already exist anywhere?
grep -r "max-size\|max_size" /etc/sysconfig/spamass-milter* /etc/mail/spamassassin/ 2>/dev/null
# Find oversized messages and check whether they were actually scanned
find /path/to/maildir -size +500000c -newermt 'YYYY-MM-DD'
# For each candidate: was it scanned, or did the milter fail to extract a score?
grep "processing message" /var/log/maillog | grep '<message-id>'
grep "Could not extract score" /var/log/maillog
# sa-learn's independent default, checked the same way
sa-learn --spam --max-size 5000000 /path/to/maildir/cur # vs. omitting --max-size
Related¶
- Postfix + SpamAssassin: Bayes Spam Filtering Setup — the full setup guide this bypass was found against; §2 covers the
-sflag in the context of initial configuration - Fail2ban Misses Low-and-Slow Brute Force — same shape of bug: a security control with a default that quietly excludes a whole class of the thing it's supposed to catch
- Castopod: Stale Federated Avatar URLs — same investigative pattern: a misleading error code hiding the real, much simpler cause