0%
毅种循环

返回

Email Phishing Eng. · 3: Bayes & NLP AdversarialBlur image

~6,200 Chinese characters in the original; about 32 minutes to read

This is Part 3 of the Email Phishing Engineering series. The first two parts covered the sending channel and anti-sandbox. Delivery alone is not enough: if the body is filtered by the gateway, everything upstream is wasted.

Whether a message can enter the gateway and whether, once inside, it stays out of the junk folder are two completely different adversarial dimensions. This post dissects the core content-detection logic of Secure Email Gateways (SEGs)—Bayesian classifiers and NLP semantic engines—and the engineering approaches used to evade them.


image 1785951205 005
image 1785951205 005
Author: 可惜夜 (Kexi Ye)

First published on the WeChat official account: Yofune Security Research. Follow for more.

0x01 Adversary Model: The Dual-Layer Detection Architecture of the SEG Content Plane#

Differences between domestic Chinese and overseas environments directly shape content-adversarial strategy. The primary SEGs in overseas enterprise networks are Microsoft Defender for Office 365 and Proofpoint. The domestic landscape is more fragmented: large state-owned enterprises and financial institutions commonly deploy Chinese mail gateways, and security vendors’ product lines are numerous—too many to list. SMBs heavily use Tencent Exmail and Alibaba Cloud enterprise mail, whose anti-spam backends plug into cloud capabilities.

Across these products, content detection is architecturally similar—two layers stacked underneath:

  1. Bayesian probability layer — bag-of-words based. SpamAssassin and Rspamd are the canonical open-source implementations; domestic vendors typically retrain on Chinese corpora on top of this foundation.
  2. Deep semantic layer — many domestic deployments use lightweight BERT-base–style detectors (not expanded here; search the usual keywords if interested).

The two layers are not serial hard blocks; they are score-weighted and merged. A message can score very low on Bayes and still be blocked if the deep semantic layer catches phishing features. The objective is a final score below threshold—you have to fight both layers at once.

image 1785951205 001
image 1785951205 001

SpamAssassin’s Bayes Plugin (lib/Mail/SpamAssassin/Plugin/Bayes.pm)#

Tokenization pipeline (source tokenize at lines 1134–1217; _tokenize_line at lines 1219–1380):

raw text regex extraction min-3-char filter SHA1 hash, take 40 bits stop-word filter
bash

Token extraction regex (inside _tokenize_line, lines 1235–1240):

image 1785951205 004
image 1785951205 004

# Extract tokens via regex: match ASCII words/English characters, plus multi-byte UTF-8
# sequences (to support Chinese and other non-ASCII text)
s{ ( [A-Za-z0-9,@*!_'"\$. -]+  |     # match ordinary ASCII tokens
     [\xC0-\xDF][\x80-\xBF]       |     # match UTF-8 two-byte characters
     [\xE0-\xEF][\x80-\xBF]{2}    |     # match UTF-8 three-byte characters (incl. common CJK)
     [\xF0-\xF4][\x80-\xBF]{3}    |     # match UTF-8 four-byte characters
     [\xA1-\xFF] ) | . }                # match ISO-8859-style single-byte / edge multi-byte;
                                        # replace unmatched single chars with space
 { defined $1 ? $1 : ' ' }xsge;
bash

SHA1 hash compressed to a 40-bit key (inside tokenize, line 1212):

# SHA1 the extracted token; keep the last 5 bytes (40 bits) of the digest as the unique
# key stored in the statistical database
$tokens{substr(sha1($token), -5)} = $token  if $token ne '';
bash

SpamAssassin’s implementation has three hard design boundaries that are worth exploiting.

Token count cap (line 250):

image 1785951205 003
image 1785951205 003

# Max number of effective tokens that participate in the log-likelihood ranking / score
# merge: 150. Weaker tokens beyond this limit are discarded.
use constant N_SIGNIFICANT_TOKENS => 150;
bash

When computing the Bayesian score, SpamAssassin ranks all tokenized tokens by probability deviation (how strongly a word leans spam vs. ham), descending, and takes at most the top 150 most discriminative tokens for the final calculation. Anything beyond 150 is dropped. The 150 limit is hardcoded.

If the body is padded with enough (more than 150) strong ham words—high-frequency terms from normal business mail—the truly sensitive malicious tokens can be pushed out of that 150-token scoring window so they never participate in the final score.

image 1785951205 002
image 1785951205 002

Robinson smoothing (lines 1644–1653; constants defined in CombineChi.pm lines 47–50: FW_S_CONSTANT = 0.030, FW_S_DOT_X = 0.538 * 0.030 = 0.01614):

# 1. Compute the raw Bayesian classification probability
my $prob = ($s * $nn) / ($n * $ns + $s * $nn);

# 2. If Robinson smoothing is enabled (for low-frequency tokens)
if (USE_ROBINSON_FX_EQUATION_FOR_LOW_FREQS) {
  my $robn = $s + $n; # total document count for this token across all samples
  # Gary Robinson's f(w) formula for the smoothed probability:
  # P_smooth = (s * x + n * P_raw) / (s + n)
  $prob = ($Mail::SpamAssassin::Bayes::Combine::FW_S_DOT_X + ($robn * $prob))
          / ($Mail::SpamAssassin::Bayes::Combine::FW_S_CONSTANT + $robn);
}
bash

robn is the total frequency of that word in the corpus. Inject rare or obscure misspelled terms and the smoothing formula pulls their probability back toward 0.5 (no discriminative signal). Tokens at 0.5 rank at the bottom and never enter the 150-token scoring window. Therefore, when diluting the body you must use ordinary business vocabulary the gateway has already seen many times—not invented rare words.

Mathematical core of score combination, in CombineChi.pm (lines 60–104):

image 1785951205 006
image 1785951205 006

Fisher’s algorithm mathematically assumes word independence. Real mail has strong inter-word dependence. Injecting a coherent block of business prose warps the chi-square degrees of freedom and reduces the classifier’s confidence—often enough to tip borderline decisions toward release.

Rspamd’s Bayes Classifier (src/libstat/classifiers/bayes.c)#

Unlike SpamAssassin, Rspamd uses the OSB (Optimal Score Based) algorithm.

A core property: unigram weight is set to 0 (line 215). No matter how sensitive a single word is, it does not directly affect the final score; it must combine with neighbors into bigram or trigram windows.

Stacking isolated words is useless—you must inject coherent phrases or complete business expressions. In Chinese deployments, if the tokenizer over-segments, it can break the OSB window statistics.

Robinson smoothing in Rspamd is implemented via the PROB_COMBINE macro (line 217):

// prob: raw probability, cnt: total token occurrences, weight: Robinson smoothing weight,
// assumed: default assumed probability (usually 0.5)
#define PROB_COMBINE(prob, cnt, weight, assumed) (((weight) * (assumed) + (cnt) * (prob)) / ((weight) + (cnt)))
bash

Called from bayes_classify_token (lines 227–324):

// compute w
w = (fw * total_count) / (1.0 + fw * total_count);
// merge with smoothed probability
bayes_spam_prob = PROB_COMBINE(spam_prob, total_count, w, 0.5);
bash
Rspamd Statistical Token Flags (src/libserver/word.h and src/libstat/tokenizers/tokenizers.c)#

During tokenization, Rspamd tags tokens with flags defined as bitmasks in src/libserver/word.h (lines 34–47):

Flag nameBit offsetMask valueSecurity / adversarial meaning
RSPAMD_STAT_TOKEN_FLAG_TEXT1u << 00x01Ordinary token extracted from plain text
RSPAMD_STAT_TOKEN_FLAG_META1u << 10x02Token from metadata or directives
RSPAMD_STAT_TOKEN_FLAG_LUA_META1u << 20x04Token dynamically produced by Lua rule APIs
RSPAMD_STAT_TOKEN_FLAG_EXCEPTION1u << 30x08Exception token (e.g. features extracted from URLs)
RSPAMD_STAT_TOKEN_FLAG_HEADER1u << 40x10Token from mail header fields
RSPAMD_STAT_TOKEN_FLAG_UNIGRAM1u << 50x20Unigram (single word/character) flag
RSPAMD_STAT_TOKEN_FLAG_UTF1u << 60x40UTF-8 / Unicode multi-byte text token
RSPAMD_STAT_TOKEN_FLAG_NORMALISED1u << 70x80Successfully normalized via ICU
RSPAMD_STAT_TOKEN_FLAG_STEMMED1u << 80x100Successfully stem-extracted
RSPAMD_STAT_TOKEN_FLAG_BROKEN_UNICODE1u << 90x200Failed Unicode normalization / corrupted sequence; sets RSPAMD_TASK_FLAG_BAD_UNICODE, but does not apply a 50% Bayes weight penalty
RSPAMD_STAT_TOKEN_FLAG_STOP_WORD1u << 100x400Identified high-frequency neutral stop word
RSPAMD_STAT_TOKEN_FLAG_SKIPPED1u << 110x800Attenuated or skipped; does not participate in scoring
RSPAMD_STAT_TOKEN_FLAG_INVISIBLE_SPACES1u << 120x1000Token containing invisible characters
RSPAMD_STAT_TOKEN_FLAG_EMOJI1u << 130x2000Token containing emoji

BROKEN_UNICODE and INVISIBLE_SPACES are important technical signals for abnormal bypass behavior. Large amounts of non-canonical Unicode or hidden formatting easily trip the gateway’s anomaly interception logic.

Stemming and Custom Tokenization (tokenizers.c ≈ lines 600–650)#

Stemming collapses word forms—e.g. "approved" and "approves" merge to the stem "approv". That limits gaming counts via inflections, but it also means flooding "unanimously" globally burns strong ham reputation onto the "unanim" stem family. In Chinese or Japanese deployments, if the gateway introduces a custom CJK tokenizer, you need to learn its dictionary boundaries; OOV (out-of-vocabulary) fragmentation often weakens dilution effectiveness.

Every token is Mum-hashed for Redis-side dedup. Even equivalent Unicode substitutions change the hash and are counted as new words.

Autolearn Decision (lualib/lua_bayes_learn.lua, lines 184, 368)#

In can_learn(), Rspamd has adaptive overfit protection. Default confidence threshold is min_prob = 0.95:

-- Whether the current mail's Bayesian confidence already meets the threshold (default 0.95)
in_class = prob >= (probability_opts.min_prob or probability_opts.spam_min or 0.95)

if in_class then
  -- If the system is already confident enough, refuse further training on this sample
  -- to prevent model overfitting
  return false, reason, ctx.result
end
bash

If dilution successfully parks the score below the block line and confidence stays under 0.95, the classifier neither blocks the mail nor learns the latest feature drift—opening a stable bypass window.

image 1785951205 007
image 1785951205 007

Rspamd’s Neural Network Plugin (src/plugins/lua/neural.lua)#

Besides Bayes, Rspamd enables an ANN (neural network) classification plugin by default.

The post-filter callback ann_scores_filter (source ≈ line 121) runs after the Bayesian classifier. Features the network sees have already been preprocessed by the Bayes layer.

Feature extraction depends on five Provider interfaces (source lines 33–37):

-- Dynamically load these five feature-provider modules
pcall(require, "plugins/neural/providers/llm")           -- external LLM embedding features
pcall(require, "plugins/neural/providers/symbols")        -- rule score cache collection
pcall(require, "plugins/neural/providers/text_hash")      -- local feature hashing
pcall(require, "plugins/neural/providers/fasttext_embed") -- FastText word embeddings
pcall(require, "plugins/neural/providers/static_embed")   -- static word embeddings
bash

In production, usually only symbols and text_hash are enabled.

In hybrid feature mode, the neural net takes the Bayesian score itself as part of the feature input. Once dilution words drag the Bayes score down, that low score is fed straight into the network. The network may have learned “low Bayes score = ham,” but it cannot tell whether the mail is clean or sand-filled.

That is a blind spot in the gateway’s serial audit architecture.

When PCA feature reduction is enabled, similar benign text injected across many messages is retained as a principal component, while lower-dimensional malicious tokens are more easily filtered out during reduction—degrading the neural net.

Weight inheritance (line 1452) looks up matching old profiles via Redis zsets on providers_digest and copies weights, avoiding full retrain on config updates.

Most of the five providers still depend on token-stream segmentation. Once the Bayes layer weakens token discriminability, the neural net’s input features degrade with it.

Rspamd LLM Provider Internals (llm.lua)#

Two mechanisms in the LLM Provider design are worth attention:

Assembling input text: in collect_async, input is forced Subject-first:

"Subject: " + mail_subject + "\n" + body_content
bash

This keeps the high-value subject line at the front of the embedding position encoding so long-body truncation does not drop critical information.

Routing and cache: the plugin can route to different third-party models by detected language and uses Redis for local cache (default TTL 24 hours).

Redis cache introduces a new evasion surface. When the target gateway has this cache enabled, you can pre-send a benign message with a chosen subject phrase so the gateway caches that feature. Later messages with the same subject phrase can hit the clean cache and skip live audit.

SpamAssassin’s Standalone Neural Network Plugin (NeuralNetwork.pm)#

SpamAssassin’s NeuralNetwork plugin (v0.11.2) takes another path: it does not depend on the Bayes score; it maintains its own vocabulary and feature pipeline.

Hidden-layer structure (source lines 767–770, 1327–1330):

# Hidden layer 1 node count: ≈ √input_dim × 0.25 (rounded up)
my $num_hidden1 = int(sqrt($num_input) * 0.25 + 0.5);
$num_hidden1 = 4 if $num_hidden1 < 4; # at least 4 nodes

# Hidden layer 2 node count: half of hidden layer 1
my $num_hidden2 = int($num_hidden1 / 2);
$num_hidden2 = 2 if $num_hidden2 < 2; # at least 2 nodes
bash

The input layer maps into this dual-hidden structure with a single output neuron.

Vocabulary pruning via chi-square (_chi2_score, line 1065):

# Compute chi-square score for a term; used to drop globally weak features
sub _chi2_score {
  my ($spam, $ham, $total_spam, $total_ham) = @_;
  my $total = $total_spam + $total_ham;
  return 0 unless $total > 0;
  my $total_spam_noterm = $total_spam - $spam; # spam docs without the term
  my $total_ham_noterm = $total_ham - $ham;   # ham docs without the term

  # denominator = product of marginal frequencies
  my $denom = ($spam+$ham) * ($total-$spam-$ham) * $total_spam * $total_ham;
  return 0 unless $denom > 0;

  # chi-square contingency cross-product squared difference → discriminability score
  return ($total * ($spam*$total_ham_noterm - $ham*$total_spam_noterm)**2) / $denom;
}
bash

When the vocabulary hits the 10,000-term cleanup ceiling, the chi-square logic prefers to drop low-frequency but highly specific terms and keep high-frequency neutral ones. Diluting with high-reputation ordinary business vocabulary tends to survive vocabulary cleanup.

Class-weighted training (lines 817–825):

# Weight the minority class by the inverse of spam/ham document ratio; max weight 4.0×
if ($isspam) {
  $class_weight = ($spam_docs > 0) ? $ham_docs / $spam_docs : 1.0;
} else {
  $class_weight = ($ham_docs > 0) ? $spam_docs / $ham_docs : 1.0;
}
$class_weight = 1.0 if $class_weight < 1.0;
$class_weight = 4.0 if $class_weight > 4.0;
my $weighted_epochs = int($train_epochs * $class_weight) || 1;
bash

Mass-sending dilution mail that is labeled ham dilutes the ham_docs / spam_docs weight ratio and biases the whole model toward ham decisions.

During retrain, when class prototype vectors are recomputed, sustained dilution mail carrying benign jargon poisons those prototypes once the terms enter the vocabulary, skewing the neural judgment baseline after retrain.

The SpamAssassin neural plugin has four hard gates: if message length is under 256 characters, or fewer than 10 vocabulary hits, the system skips the neural net entirely and falls back to Bayes.

Keep visible body text around 200–250 characters (about 3–4 sentences) and hide the bulk dilution corpus via CSS (invisible text does not count toward body length). The neural net bails for insufficient effective length and falls back to Bayes—by which point the hidden dilution tokens have already bypassed it.

Other Approaches: SMTP Stacks Without Native ML#

Haraka (Node.js) content plugins have no native ML/NLP; they only forward to external Rspamd/SpamAssassin, or use plugins like karma for hard-coded reputation/SPF score adjustments. Self-built Node.js mail gateways are fragile here—once the external detectors are bypassed, local defense is nearly empty. Academic PyTorch classifiers such as yangdongchao/mailscanner are even less relevant; they never considered engineering-grade adversarial abuse.


0x02 Dimension One: HTML Polymorphic Mutation#

When gateways audit HTML, they typically parse a DOM tree and compute fingerprints (e.g. fuzzy hash). Multiple messages sent in a short window with overly similar DOM features are treated as variants of a known template.

Countermeasure: when generating HTML, compile a mutated variant per recipient to polymorphically scramble DOM features.

2.1 Structural Injection#

The compile engine dynamically injects the following mutations into the DOM:

  • Random nesting: under the <body> path, randomly introduce 1–3 layers of unstyled <div> / <span> nesting with fully dynamic random class names.

  • Empty node injection: randomly insert multiple display:none placeholders. Visually invisible, but they change DOM depth and sibling counts, altering the feature hash.

  • Attribute order shuffle: when emitting elements, randomly reorder attribute output (e.g. reverse href, style, class). Many DOM parsers are order-sensitive; this changes the gateway hash fingerprint.

2.2 Equivalent Tag Substitution (Tag Normalization Bypass)#

The compile engine randomly swaps equivalent tags via a mapping table:

Original tagMutated tagNLP parsing difference
<b><strong>Semantic tag vs. style tag
<i><em>Same as above
<strike><s> / <del>Old vs. new markup mixed
<center><div style="text-align:center">Tag vs. CSS style
<font><span style="...">Legacy style vs. CSS

Each generation swaps with ~50% probability, breaking tag-distribution feature statistics.

2.3 Randomized CSS Formatting#

Template class names are random character strings; inline CSS property formats are also chosen at random:

/* Format 1 hex */
color: #ff0000; margin: 10px;

/* Format 2 rgb() */
color: rgb(255, 0, 0); margin: 10px 10px 10px 10px;

/* Format 3 hsl shorthand */
color: hsl(0, 100%, 50%); margin: 10px;
bash

Gateway CSS extraction regex rules therefore fail to match reliably.


0x03 Dimension Two: Semantic Dilution — Pulling Down the Bayesian Score#

The math of semantic dilution: introduce many strong ham words into the body to pull down the joint Bayesian probability so an over-threshold score falls under the gateway block line. Engineering practice also has to choose injection methods that survive visibility checks.

3.1 Mathematical Principle#

The Bayesian algebraic sum works roughly as follows:

A phishing message containing 10–15 strong sensitive phrases such as “log in immediately” or “verify your account” easily breaks past +60 (gateway block lines are often around +40).

Without changing the core copy, inject large numbers of strong ham tokens (each contributing roughly −2 to −5) to drag the joint score down:

When the combined score is pulled below the intercept threshold, the system classifies the mail as ham.

From engineering practice, the dilution volume follows a ratio-based formula. TaiGong configures three dilution tiers:

LevelDilutionRatioApplicable scenario
Light2.0Consumer mail (Gmail / Outlook)
Standard5.0Enterprise mail environments
Aggressive10.0High-defense targets (finance, SOEs)

image 1785951205 008
image 1785951205 008

3.2 Seven Text Injection Techniques#

Ordered from lowest to highest detection difficulty:

#1 HTML comment region

<!-- quarterly budget review meeting scheduled for next Thursday -->
bash

Comment-region injection is the most basic—write words inside <!-- -->. Modern gateways usually strip comments during tokenization; this only works on a minority of legacy systems.

#2 Same-color micro font

<span style="font-size:1px;color:#ffffff">budget allocation approved</span>
bash

CSS sets font size to 1px and color equal to the background. Effective on simple gateways; advanced ones that audit visible-character ratios will penalize it.

#3 overflow:hidden and zero-height containers

<div style="height:0;overflow:hidden;max-height:0;opacity:0">
  team meeting scheduled for Thursday
</div>
bash

overflow:hidden plus zero-height (height:0; max-height:0; opacity:0) is more stable than a single hide style and behaves consistently across environments.

#4 ARIA semantic hiding

<span aria-hidden="true" role="presentation" style="position:absolute;left:-9999px">
  project milestone review
</span>
bash

Accessibility attributes can hide content: aria-hidden="true" role="presentation" with absolute positioning at left:-9999px. Screen readers skip it, but text-extracting gateway engines still read it.

#5 MIME multipart/alternative differential injection

Composite messages use a multipart/alternative structure: put long benign text in text/plain, real content in HTML. Gateways often first-pass only the plain part for performance—the first scoring round is already dragged down.

#6 CSS pseudo-element content injection

<style>
.d1::after { content: "budget allocation quarterly review"; }
.d2::before { content: "team meeting scheduled for Thursday afternoon"; }
</style>

<div class="d1"></div>

<div class="d2"></div>
bash

::before / ::after injection is harder to detect. Dilution lives in the stylesheet content property; ordinary extractors only read DOM textContent and never see CSSOM computed values.

#7 JavaScript delayed rendering

<div id="dilution_container" style="display:none">...</div>

<script>
setTimeout(function() {
  document.getElementById('dilution_container').style.display = 'block';
}, 5000);
</script>
bash

JS delayed rendering is the detection-difficulty ceiling. The container starts hidden; JS reveals it after several seconds. Gateways generally do not wait for JS execution, so those tokens are ignored at scan time while the user client still renders them.

3.3 Corpus Selection#

The corpus has three layers:

  • Layer 1 — General business (base ~500 entries): pure commercial copy—project approvals, meeting notices, etc. (e.g. “This quarter’s financial budget has been submitted for review”).

  • Layer 2 — Industry-specific (~200 entries): phrase sets tuned per industry, because the target industry’s gateway already routinely releases such terms as very high-ham features.

  • Layer 3 — Dynamically generated: call an LLM for the day’s or week’s business/political headlines and assemble scene-matched templates—possibly enough to slip past gateway cache lists.

Token selection criteria: SpamAssassin score negative, length over 5 characters, no URL links—all three required.


0x04 Unfinished Dimension Three: HTML5 Smuggling at the Traffic Layer#

After bypassing gateway content filters, delivering executables (e.g. Cobalt Strike payload) still requires defeating network-traffic and attachment-feature detection.

HTML5 Smuggling offers a traffic-layer evasion idea: construct the file in the victim’s local browser memory rather than transmitting it directly over the wire.

No solid implementation path has been found yet for this goal; for now it remains a concept, so it is not expanded further. Search the relevant keywords for concrete techniques.

4.1 Landing-Page Adversarial Use#

In TaiGong operations so far, practice is semantic dilution combined with the landing page.

Results are only moderate. The ideal end state would be: after the victim clicks into the landing page, the backend runs Smuggling while stuffing large amounts of benign business disclaimer–style ham text toward the gateway, effectively blocking security policy crawling and URL reputation marking.


0x05 Closing#

Judging from open-source products, strategies are largely similar; it is unclear how far closed-source appliances will evolve once LLMs participate.

The more fundamental defensive line remains on the endpoint.


References:


Series Navigation