Microsoft が2026年9月、不可視の文字列を利用してメールフィルターを回避する「ASCII Smuggling」を使った大規模なフィッシングキャンペーンについて報告しました。

そこで今回は Rspamd で ASCII Smuggling をフィルタリングしつつ、不可視文字列を通常の ASCII に変換して可視化(メールヘッダーに追加)できるようにしてみます。
環境
- OS:AlmaLinux release 9.8 (Olive Jaguar)
- Rspamd:4.1.5
ASCII Smuggling とは
ASCII Smuggling は、画面上では表示されない Unicode タグを使って文字列を埋め込む手法で、これまでは主に AI へのプロンプトインジェクションで知られていました。
今回使われているのは Unicode の、U+E0000 ~ U+E007F にある Tags と呼ばれる領域です。
この中には通常の ASCII 文字に対応するタグ文字が用意されています。
- U+0041:「A」⇒ U+E0041:タグ付けされた「A」
- U+0061:「a」⇒ U+E0061:タグ付けされた「a」
タグ文字は画面に表示されない性質の文字のため、Ignore all previous instructions(以前の指示をすべて無視せよ)のような ASCII 文字列をタグ文字へ変換すると、人間には見えない AI 向けのプロンプトインジェクション文字列として埋め込むこともできます。
今回確認された攻撃は AI へのプロンプトインジェクションではなく、メールフィルターによるキーワード検出を回避するために使われていたようです。
ゼロ幅スペースとの違い
不可視文字には、ゼロ幅スペース(U+200B:ZERO WIDTH SPACE)などもあり、従来よく使われてきました。
こちらも fun<U+200B>ding のように、通常の文字列(funding)の途中へ不可視文字を挟むことで NG ワード判定を崩す手法です。
Unicode タグの場合は、不可視文字自体が ASCII 文字に対応している点が異なります。
Rspamd の設定
Unicode タグが含まれているかどうかだけであれば、従来の Regexp でも検出できますが、今回は検出位置(件名・差出人・本文など)の分類や、タグ文字を ASCII へ復号してヘッダーに追加するなど込み入った事をしたかったので、Lua を使って検出処理を行います。
- Lua(検出処理)
- 検出位置を判定
- Unicode タグを検出
- ASCII へ復号してヘッダーに追加
- タグ文字を除去(オプション)
- Multimap(判定ルール)
- Map(スコア判定)
Lua に判定スコアを直接書いてしまうと、調整するたびに書き換える必要があるため、Lua では検出・診断ヘッダー生成・任意の除去を行い、Map ファイルでスコアを調整できるようにします。
Lua
Lua(ascii_smuggling.lua)では主に以下の事を行っています。
- Unicode タグの検出
- ASCII 文字への復号
- Subject / From / Reply-To / 本文などの検査
- 添付ファイル名の検査
- Subdivision Flags(イングランド、スコットランド、ウェールズ)の除外判定
- 診断用ヘッダーの生成
--
-- Unicode Tagsとゼロ幅スペースを検出 for Rspamd 4.1.x
--
-- 自動転送している場合は、除去オプションをtrueにすると転送先でのDKIM再検証に失敗する
--
--------------------------------------------------------
-- ユーザー設定
--------------------------------------------------------
-- Unicode Tags(U+E0000..U+E007F)を除去
local REMOVE_UNICODE_TAGS = false
-- ゼロ幅スペース(U+200B)を除去(検出・スコアの対象ではない)
local REMOVE_ZERO_WIDTH_SPACES = false
-- 除去を外部受信に限定(SMTP認証あり・ローカルIP・接続元不明は除外)
local CLEANUP_INBOUND_ONLY = true
-- DKIM/ARC署名保護を無視
local IGNORE_SIGNATURE_PROTECTION = false
-- MIME除去処理のサイズ上限
local MAX_SANITIZE_BYTES = 10 * 1024 * 1024
--------------------------------------------------------
local lua_mime = require "lua_mime"
local rspamd_util = require "rspamd_util"
local unpack_fn = table.unpack or unpack
local CLEANUP_ENABLED = REMOVE_UNICODE_TAGS or REMOVE_ZERO_WIDTH_SPACES
local CHECK_SYMBOL = "ASCII_SMUGGLING_CHECK"
local RESULT_SYMBOL = "ASCII_SMUGGLING_TAGS"
local HEADER_SYMBOL = "ASCII_SMUGGLING_HEADERS"
local CACHE_KEY = "ascii_smuggling_state_v2"
local HDR_STATUS = "X-ASCII-Smuggling"
local HDR_TEXT = "X-ASCII-Smuggling-Text"
local HDR_CONTEXT = "X-ASCII-Smuggling-Context"
-- 診断の表示上限と走査上限
local MAX_FINDINGS = 3
local MAX_SEQ_STORE = 512
local MAX_SEQUENCES = 1000
local MAX_TAG_CHARS_SCAN = 100000
local MAX_TEXT_PER_FINDING = 128
local MAX_CONTEXT_PER_FINDING = 160
local MAX_HEADER_VALUE = 512
local CONTEXT_CODEPOINTS = 12
local TAG_BASE = 0xE0000
local TAG_MIN = 0xE0000
local TAG_MAX = 0xE007F
local LANGUAGE_TAG = 0xE0001
local TAG_ASCII_MIN = 0xE0020
local TAG_ASCII_MAX = 0xE007E
local CANCEL_TAG = 0xE007F
local BLACK_FLAG = 0x1F3F4
local TAG_UTF8_PREFIX = "\243\160"
local VALID_SUBDIVISION_FLAGS = {
gbeng = true, -- England
gbsct = true, -- Scotland
gbwls = true, -- Wales
}
local HEADER_SOURCES = {
{ "Subject", "subject" },
{ "From", "from" },
{ "Sender", "sender" },
{ "Reply-To", "reply_to" },
{ "To", "to" },
{ "Cc", "cc" },
}
local SOURCE_ORDER = {
"subject", "from", "sender", "reply_to",
"to", "cc", "filename", "body",
}
local SOURCE_TOKEN = {
subject = "SOURCE_SUBJECT",
from = "SOURCE_FROM",
sender = "SOURCE_SENDER",
reply_to = "SOURCE_REPLY_TO",
to = "SOURCE_TO",
cc = "SOURCE_CC",
filename = "SOURCE_FILENAME",
body = "SOURCE_BODY",
}
local function is_continuation_byte(b)
return b and b >= 0x80 and b <= 0xBF
end
-- 不正UTF-8は負のバイト値として扱う
local function utf8_next(s, i)
local b1 = s:byte(i)
if not b1 then return nil, i end
if b1 < 0x80 then
return b1, i + 1
end
local b2 = s:byte(i + 1)
if b1 >= 0xC2 and b1 <= 0xDF and is_continuation_byte(b2) then
return (b1 - 0xC0) * 0x40 + (b2 - 0x80), i + 2
end
local b3 = s:byte(i + 2)
if b1 >= 0xE0 and b1 <= 0xEF and
is_continuation_byte(b2) and is_continuation_byte(b3) then
if b1 == 0xE0 and b2 < 0xA0 then return -b1, i + 1 end
if b1 == 0xED and b2 > 0x9F then return -b1, i + 1 end
return (b1 - 0xE0) * 0x1000 +
(b2 - 0x80) * 0x40 +
(b3 - 0x80), i + 3
end
local b4 = s:byte(i + 3)
if b1 >= 0xF0 and b1 <= 0xF4 and
is_continuation_byte(b2) and
is_continuation_byte(b3) and
is_continuation_byte(b4) then
if b1 == 0xF0 and b2 < 0x90 then return -b1, i + 1 end
if b1 == 0xF4 and b2 > 0x8F then return -b1, i + 1 end
return (b1 - 0xF0) * 0x40000 +
(b2 - 0x80) * 0x1000 +
(b3 - 0x80) * 0x40 +
(b4 - 0x80), i + 4
end
return -b1, i + 1
end
local function is_tag(cp)
return cp and cp >= TAG_MIN and cp <= TAG_MAX
end
local function clip(s, max_len)
if #s <= max_len then return s end
if max_len <= 3 then return s:sub(1, max_len) end
return s:sub(1, max_len - 3) .. "..."
end
local function push_ring(ring, cp)
ring[#ring + 1] = cp
if #ring > CONTEXT_CODEPOINTS then
table.remove(ring, 1)
end
end
-- 診断用の記号と入力文字を区別するためのエスケープ
local STRUCTURAL_ASCII = {
[0x5C] = true, -- \
[0x3C] = true, -- <
[0x3E] = true, -- >
[0x7C] = true, -- |
[0x3D] = true, -- =
[0x3B] = true, -- ;
}
local function render_attacker_ascii(byte)
if byte == 0x20 then
return "<SP>"
end
if STRUCTURAL_ASCII[byte] then
return string.format("\\x%02X", byte)
end
return string.char(byte)
end
local TAG_CONTEXT_ASCII_NAME = {
[0x5C] = "BACKSLASH", -- \
[0x3C] = "LESS_THAN", -- <
[0x3E] = "GREATER_THAN", -- >
[0x7C] = "PIPE", -- |
[0x3D] = "EQUALS", -- =
[0x3B] = "SEMICOLON", -- ;
}
local function render_context_tag_cp(cp)
if cp >= TAG_ASCII_MIN and cp <= TAG_ASCII_MAX then
local byte = cp - TAG_BASE
if byte == 0x20 then
return "<TAG_SPACE>"
end
local name = TAG_CONTEXT_ASCII_NAME[byte]
if name then
return "<TAG:" .. name .. ">"
end
return "<TAG:" .. string.char(byte) .. ">"
end
if cp == LANGUAGE_TAG then
return "<LANGUAGE_TAG>"
end
if cp == CANCEL_TAG then
return "<CANCEL_TAG>"
end
return string.format("<TAG:U+%X>", cp)
end
local function render_context_cp(cp)
if cp < 0 then
return string.format("\\x%02X", -cp)
end
if is_tag(cp) then
return render_context_tag_cp(cp)
end
if cp == 0x09 or cp == 0x0A or cp == 0x0D then
return " "
end
if cp >= 0x20 and cp <= 0x7E then
if STRUCTURAL_ASCII[cp] then
return string.format("\\x%02X", cp)
end
return string.char(cp)
end
if cp < 0x20 or cp == 0x7F then
return string.format("\\x%02X", cp)
end
return string.format("\\u{%X}", cp)
end
local function decode_tag_sequence_for_diag(seq, full_len)
local out = {}
for _, cp in ipairs(seq) do
if cp >= TAG_ASCII_MIN and cp <= TAG_ASCII_MAX then
out[#out + 1] = render_attacker_ascii(cp - TAG_BASE)
elseif cp == LANGUAGE_TAG then
out[#out + 1] = "<LANGUAGE_TAG>"
elseif cp == CANCEL_TAG then
out[#out + 1] = "<CANCEL_TAG>"
else
out[#out + 1] = string.format("<U+%X>", cp)
end
end
if full_len > #seq then
out[#out + 1] =
string.format("<SEQ_TRUNCATED:%d>", full_len - #seq)
end
return table.concat(out)
end
local function left_context(s, tag_pos)
local start = math.max(1, tag_pos - (CONTEXT_CODEPOINTS * 4) - 8)
while start < tag_pos and is_continuation_byte(s:byte(start)) do
start = start + 1
end
local ring = {}
local i = start
while i < tag_pos do
local cp, next_i = utf8_next(s, i)
if not cp or next_i <= i or next_i > tag_pos then break end
push_ring(ring, cp)
i = next_i
end
local rendered = {}
for _, cp in ipairs(ring) do
rendered[#rendered + 1] = render_context_cp(cp)
end
return ring[#ring], table.concat(rendered)
end
local function right_context(s, i)
local out = {}
local count = 0
while i <= #s and count < CONTEXT_CODEPOINTS do
local cp, next_i = utf8_next(s, i)
if not cp or next_i <= i then break end
out[#out + 1] = render_context_cp(cp)
i = next_i
count = count + 1
end
return table.concat(out)
end
local function is_valid_subdivision_flag(prev_cp, seq, full_len)
if prev_cp ~= BLACK_FLAG then return false end
if full_len ~= #seq then return false end
if #seq < 2 or seq[#seq] ~= CANCEL_TAG then
return false
end
local chars = {}
for i = 1, #seq - 1 do
local cp = seq[i]
if cp < TAG_ASCII_MIN or cp > TAG_ASCII_MAX then
return false
end
chars[#chars + 1] = string.char(cp - TAG_BASE)
end
return VALID_SUBDIVISION_FLAGS[table.concat(chars)] == true
end
-- 任意の除去処理(本文・ヘッダー・ファイル名で共用)
local ZWSP_ENTITIES = {
ZeroWidthSpace = true,
NegativeVeryThinSpace = true,
NegativeThinSpace = true,
NegativeMediumSpace = true,
NegativeThickSpace = true,
}
local ZWSP_UTF8 = "\226\128\139"
local function may_have_targets(s, html)
if REMOVE_UNICODE_TAGS and s:find(TAG_UTF8_PREFIX, 1, true) then return true end
if REMOVE_ZERO_WIDTH_SPACES and s:find(ZWSP_UTF8, 1, true) then return true end
if not CLEANUP_ENABLED or not html then return false end
return s:find("", 1, true) ~= nil or
(REMOVE_ZERO_WIDTH_SPACES and
(s:find("&Zero", 1, true) ~= nil or s:find("&Negative", 1, true) ~= nil))
end
local function body_cp(s, i, html)
if html and s:sub(i, i) == "&" then
local _, last, name = s:find("^&([A-Za-z]+);", i)
if name and ZWSP_ENTITIES[name] then return 0x200B, last + 1 end
end
if html and s:sub(i, i + 1) == "" then
local a, b, digits = s:find("^[xX]([0-9a-fA-F]+);?", i)
local base = 16
if a ~= i then
a, b, digits = s:find("^([0-9]+);?", i)
base = 10
end
if a == i then
return tonumber(digits, base) or -1, b + 1
end
end
return utf8_next(s, i)
end
local function strip_body_tags(s, html)
if not may_have_targets(s, html) then return s, 0 end
local out, start, i, prev, removed = {}, 1, 1, nil, 0
while i <= #s do
local cp, next_i = body_cp(s, i, html)
if cp == 0x200B and REMOVE_ZERO_WIDTH_SPACES then
out[#out + 1] = s:sub(start, i - 1)
start, i = next_i, next_i
removed = removed + 1
prev = cp
elseif is_tag(cp) and REMOVE_UNICODE_TAGS then
local first, seq, count = i, {}, 0
while is_tag(cp) do
count = count + 1
if #seq < 8 then seq[#seq + 1] = cp end
i = next_i
cp, next_i = body_cp(s, i, html)
end
if not is_valid_subdivision_flag(prev, seq, count) then
out[#out + 1] = s:sub(start, first - 1)
start = i
removed = removed + count
end
prev = nil
else
prev, i = cp, next_i
end
end
if removed == 0 then return s, 0 end
out[#out + 1] = s:sub(start)
return table.concat(out), removed
end
-- 引用符内のセミコロンを分割しない
local function mime_parameters(value)
local fields, first, quoted, escaped = {}, 1, false, false
for i = 1, #value do
local c = value:sub(i, i)
if escaped then escaped = false
elseif quoted and c == "\\" then escaped = true
elseif c == '"' then quoted = not quoted
elseif not quoted and c == ";" then
fields[#fields + 1] = value:sub(first, i - 1)
first = i + 1
end
end
if quoted then error("unclosed MIME parameter quote") end
fields[#fields + 1] = value:sub(first)
return fields
end
local function utf8_content_type(value)
local fields = mime_parameters(value)
local out = {fields[1]}
for i = 2, #fields do
local field = fields[i]
if not field:match("^%s*$") then
local attr = field:match("^%s*([^%s=]+)%s*=")
attr = attr and attr:lower()
if not attr or (attr ~= "charset" and not attr:match("^charset%*")) then
out[#out + 1] = field
end
end
end
out[#out + 1] = ' charset="utf-8"'
return table.concat(out, ";")
end
local function sanitized_headers(raw, subtype, nl)
local out, drop, ct, in_ct = {}, false, nil, false
for line in (raw:gsub("\r\n", "\n") .. "\n"):gmatch("([^\n]*)\n") do
if line ~= "" then
if not line:match("^[ \t]") then
local name = line:match("^([^:]+):")
name = name and name:lower()
in_ct = name == "content-type"
if in_ct then
ct = line:match("^[^:]+:%s*(.*)$")
end
drop = name == "content-type" or name == "content-transfer-encoding"
or name == "content-length" or name == "content-md5"
elseif in_ct then
ct = ct .. " " .. line:gsub("^[ \t]+", "")
end
if not drop then out[#out + 1] = line end
end
end
local content_type = utf8_content_type(ct or ('text/' .. subtype))
out[#out + 1] = 'Content-Type: ' ..
rspamd_util.fold_header('Content-Type', content_type, nl == "\r\n" and "crlf" or "lf")
out[#out + 1] = 'Content-Transfer-Encoding: base64'
return table.concat(out, nl) .. nl .. nl, content_type
end
local function encode_word(s)
local out, i = {}, 1
while i <= #s do
local last = math.min(i + 41, #s)
while last > i and last < #s and is_continuation_byte(s:byte(last + 1)) do
last = last - 1
end
out[#out + 1] = "=?UTF-8?B?" ..
tostring(rspamd_util.encode_base64(s:sub(i, last), 0)):gsub("%s", "") .. "?="
i = last + 1
end
return table.concat(out, " ")
end
-- RFC 2047の符号化部分を個別に処理し、アドレス構文を保持
local function clean_header_value(value)
local removed = 0
local result = value:gsub("=%?([^?%s]+)%?([bBqQ])%?([^?]*)%?=",
function(charset, encoding, payload)
local decoded
if encoding:lower() == "b" then
decoded = rspamd_util.decode_base64(payload)
else
decoded = rspamd_util.decode_qp((payload:gsub("_", " ")))
end
if not decoded then error("MIME word decode failed") end
if charset:lower() ~= "utf-8" and charset:lower() ~= "utf8" then
decoded = rspamd_util.to_utf8(decoded, charset)
end
if not decoded then error("MIME word charset conversion failed") end
local clean, count = strip_body_tags(tostring(decoded), false)
if count == 0 then return nil end -- retain original encoded word exactly
removed = removed + count
return encode_word(clean)
end)
local count
result, count = strip_body_tags(result, false)
return result, removed + count
end
local function sanitize_message_headers(task, state)
if not CLEANUP_ENABLED then return end
local changes = {remove = {}, add = {}, order = {}}
local total, failures = 0, 0
for _, item in ipairs(HEADER_SOURCES) do
local name = item[1]
local headers = task:get_header_full(name) or {}
local replacements, count, failed = {}, 0, false
for _, hdr in ipairs(headers) do
if hdr.value == nil then failed = true; break end
local value = tostring(hdr.value)
local candidate = hdr.decoded ~= nil and
may_have_targets(tostring(hdr.decoded), false) or
(hdr.decoded == nil and (may_have_targets(value, false) or
value:find("=%?[^?%s]+%?[bBqQ]%?") ~= nil))
local ok, clean, n = true, value, 0
if candidate then ok, clean, n = pcall(clean_header_value, value) end
if not ok then failed = true; break end
replacements[#replacements + 1] = {value = clean, order = -1}
count = count + n
end
if failed then
failures = failures + 1
elseif count > 0 then
changes.remove[name] = 0
changes.add[name] = replacements
changes.order[#changes.order + 1] = name
total = total + count
end
end
if total > 0 then lua_mime.modify_headers(task, changes) end
state.header_cleanup = failures > 0 and
(total > 0 and "partial-error" or "error") or
(total > 0 and "removed" or "unchanged")
state.header_removed_chars = total
end
local function filename_parameter(value, key, filename)
local fields = mime_parameters(value)
local out = {fields[1]}
for i = 2, #fields do
local field = fields[i]
if not field:match("^%s*$") then
local attr = field:match("^%s*([^%s=]+)%s*=")
attr = attr and attr:lower()
if not attr or (attr ~= key and not attr:match("^" .. key .. "%*")) then
out[#out + 1] = field
end
end
end
local encoded = filename:gsub(".", function(c)
return string.format("%%%02X", c:byte())
end)
if encoded == "" then encoded = "%61%74%74%61%63%68%6D%65%6E%74" end
local index = 0
for i = 1, #encoded, 48 do
out[#out + 1] = " " .. key .. "*" .. index .. "*=" ..
(index == 0 and "utf-8''" or "") .. encoded:sub(i, i + 47)
index = index + 1
end
return table.concat(out, ";")
end
local function filename_headers(raw, filename)
local nl = raw:find("\r\n", 1, true) and "\r\n" or "\n"
local blocks = {}
for line in (raw:gsub("\r\n", "\n") .. "\n"):gmatch("([^\n]*)\n") do
if line ~= "" then
if line:match("^[ \t]") and #blocks > 0 then
blocks[#blocks] = blocks[#blocks] .. "\n" .. line
else blocks[#blocks + 1] = line end
end
end
local changed, has_cd = {}, false
for i, block in ipairs(blocks) do
local name, value = block:match("^([^:]+):%s*(.*)$")
if name and (name:lower() == "content-type" or
name:lower() == "content-disposition") then
value = value:gsub("\n[ \t]+", " ")
local key = name:lower() == "content-type" and "name" or "filename"
name = key == "name" and "Content-Type" or "Content-Disposition"
value = filename_parameter(value, key, filename)
changed[name] = value
blocks[i] = name .. ": " .. rspamd_util.fold_header(name, value, "lf")
if key == "filename" then has_cd = true end
end
end
if not has_cd then
changed['Content-Disposition'] = filename_parameter("inline", "filename", filename)
blocks[#blocks + 1] = 'Content-Disposition: ' ..
rspamd_util.fold_header('Content-Disposition', changed['Content-Disposition'], "lf")
end
local tail = raw:match("([\r\n]*)$") or ""
return table.concat(blocks, "\n"):gsub("\n", nl) .. tail, changed
end
-- 元メッセージの位置を基準にMIME変更をまとめ、一度だけ適用
local function new_mime_edits()
return {edits = {}, filenames = {}, top_changes = {}}
end
local function original_message(task, transaction)
if not transaction.message then
transaction.message = tostring(task:get_content())
end
return transaction.message
end
local function commit_mime_edits(task, transaction)
local edits = {}
for _, edit in ipairs(transaction.edits) do
if not edit.replaced then edits[#edits + 1] = edit end
end
if #edits == 0 then return end
table.sort(edits, function(a, b) return a.first < b.first end)
local message, out, pos = transaction.message, {}, 1
for _, edit in ipairs(edits) do
if edit.first < pos then error("overlapping MIME edits") end
out[#out + 1] = message:sub(pos, edit.first - 1)
out[#out + 1] = edit.value
pos = edit.last + 1
end
out[#out + 1] = message:sub(pos)
local rewritten = table.concat(out)
if not task:has_header("MIME-Version") then
local nl = message:find("\r\n", 1, true) and "\r\n" or "\n"
rewritten = "MIME-Version: 1.0" .. nl .. rewritten
transaction.top_changes['MIME-Version'] = '1.0'
end
if not task:set_message(rewritten) then error("set_message failed") end
local changes = {add = {}, remove = {}}
for name, value in pairs(transaction.top_changes) do
changes.remove[name] = 0
if value ~= false then changes.add[name] = {value = value, order = 1} end
end
if next(changes.remove) then lua_mime.modify_headers(task, changes) end
end
local function sanitize_filenames(task, state, transaction)
if not CLEANUP_ENABLED then return end
local standalone = transaction == nil
transaction = transaction or new_mime_edits()
state.filename_cleanup = "unchanged"
if task:get_size() > MAX_SANITIZE_BYTES then
state.filename_cleanup = "skipped-size"; return
end
local edits, top_changes, total, skipped = {}, {}, 0, 0
for _, part in ipairs(task:get_parts() or {}) do
local filename = part:get_filename()
if filename then
local clean, count = strip_body_tags(tostring(filename), false)
if count > 0 then
local parent, protected = part:get_parent(), false
while parent do
local t, st = parent:get_type()
if parent:is_message() or (t == "multipart" and
(st == "signed" or st == "encrypted")) then protected = true end
parent = parent:get_parent()
end
if protected then skipped = skipped + 1
else
local raw = tostring(part:get_raw_headers())
if not part:get_parent() then raw = tostring(task:get_raw_headers()) end
local replacement, changed = filename_headers(raw, clean)
local message = original_message(task, transaction)
local a, b = message:find(raw, 1, true)
if raw == "" or not a or message:find(raw, a + 1, true) then
state.filename_cleanup = "skipped-ambiguous"; return
end
if not part:get_parent() then top_changes = changed end
edits[#edits + 1] = {first = a, last = b, value = replacement}
total = total + count
end
end
end
end
for _, edit in ipairs(edits) do
transaction.edits[#transaction.edits + 1] = edit
transaction.filenames[edit.first] = edit
end
for name, value in pairs(top_changes) do transaction.top_changes[name] = value end
if standalone then commit_mime_edits(task, transaction) end
state.filename_cleanup = skipped > 0 and
(total > 0 and "partial-protected" or "skipped-protected") or
(total > 0 and "removed" or "unchanged")
state.filename_removed_chars = total
end
local function protected_body_part(part)
local p = part
while p do
local t, st = p:get_type()
if p:is_attachment() or p:is_message() or
(t == "multipart" and (st == "signed" or st == "encrypted")) then
return true
end
p = p:get_parent()
end
return false
end
local function sanitize_body(task, state, transaction)
if not CLEANUP_ENABLED then return end
local standalone = transaction == nil
transaction = transaction or new_mime_edits()
state.body_cleanup = "unchanged"
if task:get_size() > MAX_SANITIZE_BYTES then
state.body_cleanup = "skipped-size"
return
end
local edits, removed, skipped, bytes = {}, 0, 0, 0
local top_ct
for _, part in ipairs(task:get_parts() or {}) do
local t, st = part:get_type()
if part:is_text() and t == "text" and (st == "plain" or st == "html") then
local content = part:get_text():get_content("raw_utf")
if content then
local text = tostring(content)
bytes = bytes + #text
if bytes > MAX_SANITIZE_BYTES then
state.body_cleanup = "skipped-size"
return -- atomic: no partial edits
end
local clean, count = strip_body_tags(text, st == "html")
if count > 0 and protected_body_part(part) then
skipped = skipped + 1
elseif count > 0 then
local message = original_message(task, transaction)
local raw = tostring(part:get_raw_content())
local a, b = message:find(raw, 1, true)
if raw == "" or not a or message:find(raw, a + 1, true) then
state.body_cleanup = "skipped-ambiguous"
return
end
local headers, first
if not part:get_parent() then
headers, first = message:sub(1, a - 1), 1
else
local rh = tostring(part:get_raw_headers())
for _, gap in ipairs({"", "\r\n", "\n", "\r\n\r\n", "\n\n"}) do
local candidate = rh .. gap
local pos = a - #candidate
if #rh > 0 and pos > 0 and message:sub(pos, a - 1) == candidate then
headers, first = candidate, pos
break
end
end
end
if not headers then
state.body_cleanup = "skipped-layout"
return
end
local filename_edit = transaction.filenames[first]
if filename_edit then
if filename_edit.first ~= first or filename_edit.last >= a then
state.body_cleanup = "skipped-layout"; return
end
headers = filename_edit.value .. message:sub(filename_edit.last + 1, a - 1)
end
local nl = headers:find("\r\n", 1, true) and "\r\n" or "\n"
local encoded = tostring(rspamd_util.encode_base64(clean))
:gsub("%s", "")
local lines = {}
for n = 1, #encoded, 76 do lines[#lines + 1] = encoded:sub(n, n + 75) end
local replacement, ct = sanitized_headers(headers, st, nl)
if not part:get_parent() then top_ct = ct end
replacement = replacement .. table.concat(lines, nl) .. nl
edits[#edits + 1] = {first = first, last = b, value = replacement,
filename_edit = filename_edit}
removed = removed + count
end
end
end
end
if #edits == 0 then
if skipped > 0 then state.body_cleanup = "skipped-protected" end
return
end
for _, edit in ipairs(edits) do
if edit.filename_edit then edit.filename_edit.replaced = true end
transaction.edits[#transaction.edits + 1] = edit
end
if top_ct then
transaction.top_changes['Content-Type'] = top_ct
transaction.top_changes['Content-Transfer-Encoding'] = 'base64'
transaction.top_changes['Content-Length'] = false
transaction.top_changes['Content-MD5'] = false
end
if standalone then commit_mime_edits(task, transaction) end
state.body_cleanup = skipped > 0 and "partial-protected" or "removed"
state.body_removed_chars = removed
end
local function has_tag_html_entity(s)
if not s or not s:find("", 1, true) then
return false
end
for hex in s:gmatch("[xX]([0-9A-Fa-f]+);?") do
local cp = tonumber(hex, 16)
if cp and cp >= TAG_MIN and cp <= TAG_MAX then
return true
end
end
for dec in s:gmatch("([0-9]+);?") do
local cp = tonumber(dec, 10)
if cp and cp >= TAG_MIN and cp <= TAG_MAX then
return true
end
end
return false
end
local function add_finding(state, source_group, source_label, seq, seq_len, context)
state.total_sequences = state.total_sequences + 1
state.total_tag_chars = state.total_tag_chars + seq_len
state.sources[source_group] = true
if seq_len > MAX_SEQ_STORE then
state.long_sequence = true
end
if #state.findings < MAX_FINDINGS then
state.findings[#state.findings + 1] = {
source = source_label,
decoded = clip(
decode_tag_sequence_for_diag(seq, seq_len),
MAX_TEXT_PER_FINDING
),
context = clip(context, MAX_CONTEXT_PER_FINDING),
}
end
end
local function scan_text(s, source_group, source_label, state, meta)
if not s or s == "" or state.scan_truncated then return end
if not s:find(TAG_UTF8_PREFIX, 1, true) then return end
local pos = 1
while not state.scan_truncated do
local found = s:find(TAG_UTF8_PREFIX, pos, true)
if not found then break end
local cp = utf8_next(s, found)
if not is_tag(cp) then
pos = found + 2
else
state.scanned_sequences = state.scanned_sequences + 1
if state.scanned_sequences > MAX_SEQUENCES then
state.scan_truncated = true
break
end
local seq = {}
local seq_len = 0
local j = found
while j <= #s do
local tag_cp, tag_next = utf8_next(s, j)
if not is_tag(tag_cp) then break end
state.scanned_tag_chars = state.scanned_tag_chars + 1
if state.scanned_tag_chars > MAX_TAG_CHARS_SCAN then
state.scan_truncated = true
break
end
seq_len = seq_len + 1
if #seq < MAX_SEQ_STORE then
seq[#seq + 1] = tag_cp
end
j = tag_next
end
if seq_len > 0 then
local prev_cp, left = left_context(s, found)
if not is_valid_subdivision_flag(prev_cp, seq, seq_len) then
if meta and meta.html_entity then
state.html_entity = true
end
local decoded = decode_tag_sequence_for_diag(seq, seq_len)
local marker
if seq_len == 1 and seq[1] == 0xE0020 then
marker = "<TAG_SPACE>"
else
marker = "<TAGS:" .. clip(decoded, 64) .. ">"
end
local context = ""
if #state.findings < MAX_FINDINGS then
context = left .. marker .. right_context(s, j)
end
add_finding(
state, source_group, source_label,
seq, seq_len, context
)
end
end
if state.scan_truncated then break end
pos = j
end
end
end
local function scan_header(task, header_name, source_group, state)
if state.scan_truncated then return end
local headers = task:get_header_full(header_name)
if not headers then return end
for n, hdr in ipairs(headers) do
if state.scan_truncated then break end
local value = hdr.decoded or hdr.value
if value then
local label = source_group
if #headers > 1 then
label = string.format("%s#%d", source_group, n)
end
scan_text(
tostring(value),
source_group,
label,
state
)
end
end
end
-- HTMLは実文字と文字参照の二重カウントを避ける
local function scan_body(task, state)
local parts = task:get_text_parts()
if not parts then return end
for n, part in ipairs(parts) do
if state.scan_truncated then break end
local raw = part:get_content("raw_utf")
if raw then
local s = tostring(raw)
local meta = nil
if part:is_html() then
local entity_tag_present =
has_tag_html_entity(s)
local ok, decoded =
pcall(rspamd_util.decode_html_entities, s)
if ok and decoded then
s = tostring(decoded)
end
if entity_tag_present then
meta = { html_entity = true }
end
end
scan_text(
s,
"body",
string.format("body#%d", n),
state,
meta
)
end
end
end
local function scan_filenames(task, state)
local parts = task:get_parts()
if not parts then return end
for n, part in ipairs(parts) do
if state.scan_truncated then break end
local filename = part:get_filename()
if filename and filename ~= "" then
scan_text(
tostring(filename),
"filename",
string.format("filename#%d", n),
state
)
end
end
end
local function build_policy_options(state)
local options = {}
if state.total_sequences > 0 then
options[#options + 1] = "DETECTED"
end
for _, source in ipairs(SOURCE_ORDER) do
if state.sources[source] then
options[#options + 1] = SOURCE_TOKEN[source]
end
end
if state.html_entity then
options[#options + 1] = "HTML_ENTITY"
end
if state.long_sequence then
options[#options + 1] = "LONG_SEQUENCE"
end
if state.scan_truncated then
options[#options + 1] = "SCAN_TRUNCATED"
end
return options
end
local function sources_to_string(state)
local result = {}
for _, source in ipairs(SOURCE_ORDER) do
if state.sources[source] then
result[#result + 1] = source
end
end
return table.concat(result, ",")
end
local function build_text_header(state)
local out = {}
for _, finding in ipairs(state.findings) do
out[#out + 1] = finding.source .. "=" .. finding.decoded
end
return clip(table.concat(out, " | "), MAX_HEADER_VALUE)
end
local function build_context_header(state)
local out = {}
for _, finding in ipairs(state.findings) do
out[#out + 1] = finding.source .. "=" .. finding.context
end
return clip(table.concat(out, " | "), MAX_HEADER_VALUE)
end
local function build_status_header(state)
local status
if state.total_sequences > 0 then
status = string.format(
"yes; source=%s; sequences=%d; chars=%d",
sources_to_string(state),
state.total_sequences,
state.total_tag_chars
)
elseif state.scan_truncated then
status = string.format(
"scan-truncated; scanned_sequences=%d; scanned_chars=%d",
state.scanned_sequences,
state.scanned_tag_chars
)
else
status = "no"
end
if state.html_entity then
status = status .. "; html_entity=yes"
end
if state.long_sequence then
status = status .. "; long_sequence=yes"
end
if state.scan_truncated then
status = status .. "; truncated=yes"
end
if state.total_sequences > #state.findings then
status = status .. string.format("; shown=%d", #state.findings)
end
if state.body_cleanup then
status = status .. "; body_cleanup=" .. state.body_cleanup
if state.body_removed_chars then
status = status .. "; removed_chars=" .. state.body_removed_chars
end
end
for _, area in ipairs({"header", "filename"}) do
if state[area .. "_cleanup"] then
status = status .. "; " .. area .. "_cleanup=" .. state[area .. "_cleanup"]
if (state[area .. "_removed_chars"] or 0) > 0 then
status = status .. "; " .. area .. "_removed_chars=" .. state[area .. "_removed_chars"]
end
end
end
return clip(status, MAX_HEADER_VALUE)
end
-- 検出段階では結果を保存し、固定tokenのみをポリシー判定に渡す
local function ascii_smuggling_check(task)
local state = {
total_sequences = 0,
total_tag_chars = 0,
scanned_sequences = 0,
scanned_tag_chars = 0,
findings = {},
sources = {},
html_entity = false,
long_sequence = false,
scan_truncated = false,
}
for _, item in ipairs(HEADER_SOURCES) do
scan_header(task, item[1], item[2], state)
end
scan_filenames(task, state)
scan_body(task, state)
task:cache_set(CACHE_KEY, state)
local options = build_policy_options(state)
if #options > 0 then
task:insert_result(
RESULT_SYMBOL,
1.0,
unpack_fn(options)
)
end
return false
end
local function cleanup_skip_reason(task)
if CLEANUP_INBOUND_ONLY then
local user = task:get_user()
if user and user ~= "" then return "skipped-authenticated" end
local ip = task:get_from_ip()
if not ip or not ip:is_valid() then return "skipped-unknown-source" end
if ip:is_local() then return "skipped-local" end
end
-- 自サーバーの署名を保護(署名シンボル名を変更した場合はここも合わせる)
if not IGNORE_SIGNATURE_PROTECTION and
(task:has_symbol("DKIM_SIGNED") or task:has_symbol("ARC_SIGNED")) then
return "skipped-signed"
end
end
-- idempotent段階で除去と診断ヘッダー更新を行う
local function ascii_smuggling_headers(task)
local state = task:cache_get(CACHE_KEY)
if state and CLEANUP_ENABLED then
local checked, reason = pcall(cleanup_skip_reason, task)
if not checked then reason = "skipped-unknown-source" end
if reason then
for _, area in ipairs({"body", "filename", "header"}) do
state[area .. "_cleanup"] = reason
state[area .. "_removed_chars"] = 0
end
else
local ok, err = pcall(function()
local transaction = new_mime_edits()
sanitize_filenames(task, state, transaction)
sanitize_body(task, state, transaction)
commit_mime_edits(task, transaction)
end)
if not ok then
state.filename_cleanup, state.body_cleanup = "error", "error"
state.filename_removed_chars, state.body_removed_chars = 0, 0
require("rspamd_logger").errx(task, "ASCII smuggling MIME cleanup: %s", err)
end
ok, err = pcall(sanitize_message_headers, task, state)
if not ok then
state.header_cleanup = "error"
require("rspamd_logger").errx(task, "ASCII smuggling header cleanup: %s", err)
end
end
task:cache_set(CACHE_KEY, state)
end
local has_existing =
task:has_header(HDR_STATUS) or
task:has_header(HDR_TEXT) or
task:has_header(HDR_CONTEXT)
local has_signal =
state and (
state.total_sequences > 0 or
state.scan_truncated or
(state.body_removed_chars or 0) > 0 or
(state.header_removed_chars or 0) > 0 or
(state.filename_removed_chars or 0) > 0
)
if not has_existing and not has_signal then
return false
end
local changes = {
remove = {
[HDR_STATUS] = 0,
[HDR_TEXT] = 0,
[HDR_CONTEXT] = 0,
},
order = {
HDR_STATUS,
HDR_TEXT,
HDR_CONTEXT,
},
}
if has_signal then
local add = {}
add[HDR_STATUS] = {
value = build_status_header(state),
order = 1,
}
if #state.findings > 0 then
add[HDR_TEXT] = {
value = build_text_header(state),
order = 1,
}
add[HDR_CONTEXT] = {
value = build_context_header(state),
order = 1,
}
end
changes.add = add
end
lua_mime.modify_headers(task, changes)
return false
end
-- シンボル登録
local check_id = rspamd_config:register_symbol({
name = CHECK_SYMBOL,
type = "callback,mime",
callback = ascii_smuggling_check,
score = 0.0,
group = "phishing",
description =
"Detect Unicode Tags potentially used for ASCII smuggling",
})
rspamd_config:register_symbol({
name = RESULT_SYMBOL,
type = "virtual",
parent = check_id,
score = 0.0,
group = "phishing",
description =
"Unicode Tag / ASCII smuggling scan result",
})
rspamd_config:register_symbol({
name = HEADER_SYMBOL,
type = "idempotent",
flags = "explicit_disable,nostat",
callback = ascii_smuggling_headers,
score = 0.0,
group = "phishing",
description =
"Add ASCII smuggling diagnostic headers after filtering",
})Multimap
ASCII Smuggling の判定ルールを multimap.conf の最後に追加します。
ASCII_SMUGGLING_POLICY {
description = "ASCII smuggling 検出";
target_symbol = "ASCII_SMUGGLING_TAGS";
type = "symbol_options";
dynamic_symbols = true;
map = "${LOCAL_CONFDIR}/local.d/ascii_smuggling.map";
multi = true;
score = 1.0;
}Lua から出力される、DETECTED・SOURCE_BODY・SOURCE_FROM・SOURCE_SUBJECT などの固定値を Multimap で判定します。
Map
Map ファイル(ascii_smuggling.map)を作成し、スコアを設定します。
DETECTED ASCII_SMUGGLING:5.0
SOURCE_FROM ASCII_SMUGGLING_FROM:3.0
SOURCE_SENDER ASCII_SMUGGLING_SENDER:3.0
SOURCE_REPLY_TO ASCII_SMUGGLING_REPLY_TO:3.0
SOURCE_SUBJECT ASCII_SMUGGLING_SUBJECT:2.0
SOURCE_TO ASCII_SMUGGLING_TO:1.0
SOURCE_CC ASCII_SMUGGLING_CC:1.0
SOURCE_FILENAME ASCII_SMUGGLING_FILENAME:2.0
SOURCE_BODY ASCII_SMUGGLING_BODY:0.0
HTML_ENTITY ASCII_SMUGGLING_HTML_ENTITY:2.0
LONG_SEQUENCE ASCII_SMUGGLING_LONG_SEQUENCE:3.0
SCAN_TRUNCATED ASCII_SMUGGLING_SCAN_TRUNCATED:5.0上記の例だと、本文中に Unicode タグが含まれていた場合は ASCII_SMUGGLING:5 + ASCII_SMUGGLING_BODY:0 で、スコアは 5.0 になります。
差出人の表示名に Unicode タグが含まれていた場合だと、ASCII_SMUGGLING:5 + ASCII_SMUGGLING_FROM:3 で、スコアは 8.0 になります。
設定が済んだら Web GUI から操作できるように、所有者を変更しておきます。
chown _rspamd:_rspamd /etc/rspamd/local.d/ascii_smuggling.map設定を反映
設定に問題がないか確認します。
rspamadm configtest
(out) syntax OK問題なければ Rspamd をリロードして、設定を反映します。
systemctl reload rspamd動作確認
テスト用の Python スクリプトを作成します。
#!/usr/bin/env python3
"""Send one harmless Unicode Tags / zero-width space test message via SMTP."""
import argparse
import getpass
import smtplib
import ssl
import sys
from email.message import EmailMessage
from email.policy import SMTP
from email.utils import formatdate, make_msgid
from pathlib import Path
TAG_BASE = 0xE0000
TAG_MIN = 0xE0000
TAG_MAX = 0xE007F
ZWSP = "\u200b"
def tags(text: str) -> str:
"""Encode printable ASCII as Unicode Tags."""
if any(not 0x20 <= ord(char) <= 0x7E for char in text):
raise ValueError("tags() accepts printable ASCII only")
return "".join(chr(TAG_BASE + ord(char)) for char in text)
def build_message(sender: str, recipient: str) -> EmailMessage:
# The hidden text is diagnostic prose only. It contains no executable command.
body = (
"ASCII smuggling detection test\n\n"
"Visible split word: fun" + tags(" ") + "ding\n"
"Hidden tag text follows: " + tags("harmless test") + "\n"
"Four findings: "
+ " ".join("x" + tags(char) for char in "abcd")
+ "\n"
"ZWSP split word: zero" + ZWSP + "width\n"
"ZWSP boundaries: " + ZWSP + "middle" + ZWSP + "\n"
)
message = EmailMessage(policy=SMTP)
message["From"] = sender
message["To"] = recipient
message["Subject"] = "ASCII smuggling detection test"
message["Date"] = formatdate(localtime=True)
message["Message-ID"] = make_msgid(domain="ascii-smuggling-test.invalid")
message["Auto-Submitted"] = "auto-generated"
message["X-ASCII-Smuggling-Test"] = "harmless-local-test"
# Keep invisible characters inside an ASCII-only Base64 MIME body.
message.set_content(body, charset="utf-8", cte="base64")
return message
def validate_message(message: EmailMessage) -> tuple[int, int]:
decoded = message.get_content()
count = sum(TAG_MIN <= ord(char) <= TAG_MAX for char in decoded)
if count != 18:
raise RuntimeError(f"internal validation failed: expected 18 Tags, got {count}")
zwsp_count = decoded.count(ZWSP)
if zwsp_count != 3:
raise RuntimeError(f"internal validation failed: expected 3 ZWSP, got {zwsp_count}")
return count, zwsp_count
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Send an ASCII smuggling test message to your own mail server."
)
parser.add_argument("recipient", help="recipient address on the server under test")
parser.add_argument("--host", default="127.0.0.1", help="SMTP host (default: 127.0.0.1)")
parser.add_argument("--port", type=int, default=25, help="SMTP port (default: 25)")
parser.add_argument(
"--from-address",
help="sender address (default: recipient address)",
)
parser.add_argument("--starttls", action="store_true", help="use SMTP STARTTLS")
parser.add_argument("--username", help="SMTP username; password is prompted securely")
parser.add_argument(
"--output",
type=Path,
help="write an .eml file instead of sending it",
)
args = parser.parse_args()
args.recipient = args.recipient.strip()
if not args.recipient:
parser.error("recipient address must not be empty")
if args.from_address is None:
args.from_address = args.recipient
else:
args.from_address = args.from_address.strip()
if not args.from_address:
parser.error("sender address must not be empty")
return args
def main() -> int:
args = parse_args()
message = build_message(args.from_address, args.recipient)
tag_count, zwsp_count = validate_message(message)
if args.output:
args.output.write_bytes(message.as_bytes())
print(f"Wrote {args.output} ({tag_count} Unicode Tags, {zwsp_count} ZWSP)")
return 0
try:
with smtplib.SMTP(args.host, args.port, timeout=15) as smtp:
smtp.ehlo()
if args.starttls:
smtp.starttls(context=ssl.create_default_context())
smtp.ehlo()
if args.username:
smtp.login(args.username, getpass.getpass("SMTP password: "))
refused = smtp.send_message(
message,
from_addr=args.from_address,
to_addrs=[args.recipient],
)
except (OSError, smtplib.SMTPException) as exc:
print(f"SMTP send failed: {exc}", file=sys.stderr)
return 1
if refused:
print(f"Recipient refused: {refused}", file=sys.stderr)
return 1
print(f"Sent: {message['Message-ID']}")
print(f"Embedded Unicode Tags: {tag_count}")
print(f"Embedded zero-width spaces (U+200B): {zwsp_count}")
print("Check X-ASCII-Smuggling* headers in the delivered message.")
print("Expected body removed_chars: Tags only=18, ZWSP only=3, both=21.")
print("SMTP acceptance does not confirm delivery or Rspamd filtering.")
return 0
if __name__ == "__main__":
raise SystemExit(main())自分のメールアドレスに向けてテストメールを送信します。
python3 send_ascii_smuggling_test.py 'test@example.com'
(out) Sent: <xxxxxxxxxx@ascii-smuggling-test.invalid>
(out) Embedded Unicode Tags: 18
(out) Embedded zero-width spaces (U+200B): 3
(out) Check X-ASCII-Smuggling* headers in the delivered message.
(out) Expected body removed_chars: Tags only=18, ZWSP only=3, both=21.
(out) SMTP acceptance does not confirm delivery or Rspamd filtering.X-ASCII-Smuggling-Context: body#1=it word: fun<TAG_SPACE>ding Hidden | body#1=xt follows: <TAGS:harmless<SP>test> Four findi | body#1= findings: x<TAGS:a> x<TAG:b> x<TAG:c> x<TAG:d> Z
X-ASCII-Smuggling-Text: body#1=<SP> | body#1=harmless<SP>test | body#1=a
X-ASCII-Smuggling: yes; source=body; sequences=6; chars=18; shown=3
X-ASCII-Smuggling-Test: harmless-local-test自動削除を有効化
Lua(ascii_smuggling.lua)のオプションで、タグ文字とゼロ幅スペースを自動的に除去するように変更しました。
- REMOVE_UNICODE_TAGS = false ⇒ true
- REMOVE_ZERO_WIDTH_SPACES = false ⇒ true
Rspamd をリロードして変更を反映させ、再びテストメールを送った結果が以下になります。
X-ASCII-Smuggling-Context: body#1=it word: fun<TAG_SPACE>ding Hidden | body#1=xt follows: <TAGS:harmless<SP>test> Four findi | body#1= findings: x<TAGS:a> x<TAG:b> x<TAG:c> x<TAG:d> Z
X-ASCII-Smuggling-Text: body#1=<SP> | body#1=harmless<SP>test | body#1=a
X-ASCII-Smuggling: yes; source=body; sequences=6; chars=18; shown=3; body_cleanup=removed; removed_chars=21; header_cleanup=unchanged; filename_cleanup=unchanged
X-ASCII-Smuggling-Test: harmless-local-testまとめ
AI へのプロンプトインジェクションで知られていた不可視文字列(ASCII Smuggling)ですが、実際のフィッシングメールでもメールフィルター回避に利用され始めたようです。
記事を作成中に Rspamd が v4.2.0 に大幅アップデートしたので、挙動が変わっている可能性があります。引き続き調査し、変更が必要な場合は都度修正していきます。

コメント