Skip to content

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:

  1. A message arrives over 500 KB.
  2. spamc returns EX_TOOBIG with empty output — no score, no error text, nothing to parse.
  3. spamass-milter can't extract a score from empty output, so it adds no X-Spam-* headers at all. The milter log shows this as a terse, easy-to-miss line:
    spamass-milter[765]: Could not extract score from <>
    
  4. 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.
  5. With no X-Spam-Flag: YES header, any Sieve/procmail rule that files spam into Junk based on that header has nothing to match against. The message goes straight to INBOX, 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:

EXTRA_FLAGS="-a -r 999999 -- -s 5000000"

(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:

Learned tokens from 0 message(s) (700 message(s) examined)

— 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:

sa-learn --spam --max-size 5000000 /path/to/maildir/cur

🚪 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