Skip to content

Commit dd8afc8

Browse files
committed
Create m_antimixedutf8.cpp
1 parent edd292a commit dd8afc8

1 file changed

Lines changed: 377 additions & 0 deletions

File tree

src/modules/m_antimixedutf8.cpp

Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
/*
2+
* InspIRCd -- Internet Relay Chat Daemon
3+
*
4+
* (C) 2026 reverse - mike.chevronnet@gmail.com
5+
*
6+
* Detects spam that mixes Unicode scripts within words (e.g. Latin letters
7+
* with Cyrillic/Greek look-alikes — "FᏒee Ⅴ1аgrа"), a very common spam
8+
* obfuscation. Inspired by UnrealIRCd's antimixedutf8 module.
9+
*
10+
* Per word: if the word contains letters from more than one script, it scores.
11+
* The message score is the number of such mixed words (plus a bonus for a high
12+
* ratio of non-Latin letters in a mostly-ASCII message). At/above <threshold>
13+
* the configured action is taken (block | kill | gline | zline | kline).
14+
*/
15+
16+
/// $ModAuthor: reverse - mike.chevronnet@gmail.com
17+
/// $ModDepends: core 4
18+
/// $ModDesc: Blocks spam that mixes Unicode scripts within words (look-alike obfuscation).
19+
/// $ModConfig: <antimixedutf8 threshold="8" minlen="10" action="block" duration="1h" reason="Mixed-script text (spam)." target="both">
20+
21+
#include "inspircd.h"
22+
#include "numerichelper.h"
23+
#include "modules/account.h"
24+
#include "modules/ssl.h"
25+
#include "xline.h"
26+
#include "timeutils.h"
27+
28+
namespace
29+
{
30+
enum class Script
31+
{
32+
OTHER, // digits, punctuation, symbols, emoji — ignored for mixing
33+
LATIN,
34+
CYRILLIC,
35+
GREEK,
36+
ARMENIAN,
37+
HEBREW,
38+
ARABIC,
39+
CJK,
40+
};
41+
42+
// Decode one UTF-8 codepoint starting at i; advances i past it.
43+
uint32_t DecodeUTF8(const std::string& s, size_t& i)
44+
{
45+
const unsigned char c = static_cast<unsigned char>(s[i]);
46+
uint32_t cp;
47+
size_t extra;
48+
49+
if (c < 0x80) { cp = c; extra = 0; }
50+
else if ((c >> 5) == 0x6) { cp = c & 0x1F; extra = 1; }
51+
else if ((c >> 4) == 0xE) { cp = c & 0x0F; extra = 2; }
52+
else if ((c >> 3) == 0x1E){ cp = c & 0x07; extra = 3; }
53+
else { i += 1; return 0xFFFD; } // invalid lead byte
54+
55+
for (size_t k = 0; k < extra; ++k)
56+
{
57+
if (i + 1 + k >= s.size())
58+
{ i = s.size(); return 0xFFFD; }
59+
const unsigned char cc = static_cast<unsigned char>(s[i + 1 + k]);
60+
if ((cc >> 6) != 0x2)
61+
{ i += 1; return 0xFFFD; } // invalid continuation
62+
cp = (cp << 6) | (cc & 0x3F);
63+
}
64+
i += 1 + extra;
65+
return cp;
66+
}
67+
68+
// Map a codepoint to a script. Returns OTHER for anything that isn't a
69+
// letter we care about (so spaces/digits/punct don't count as "mixing").
70+
Script ClassifyScript(uint32_t cp)
71+
{
72+
if ((cp >= 'A' && cp <= 'Z') || (cp >= 'a' && cp <= 'z'))
73+
return Script::LATIN;
74+
if (cp >= 0x00C0 && cp <= 0x024F) return Script::LATIN; // Latin-1 suppl + extended
75+
if (cp >= 0x0370 && cp <= 0x03FF) return Script::GREEK;
76+
if (cp >= 0x0400 && cp <= 0x04FF) return Script::CYRILLIC;
77+
if (cp >= 0x0530 && cp <= 0x058F) return Script::ARMENIAN;
78+
if (cp >= 0x0590 && cp <= 0x05FF) return Script::HEBREW;
79+
if (cp >= 0x0600 && cp <= 0x06FF) return Script::ARABIC;
80+
if (cp >= 0x4E00 && cp <= 0x9FFF) return Script::CJK; // CJK unified
81+
if (cp >= 0x3040 && cp <= 0x30FF) return Script::CJK; // hiragana/katakana
82+
return Script::OTHER;
83+
}
84+
85+
// Is this codepoint a non-Latin letter that LOOKS like an ASCII Latin
86+
// letter? These are the homoglyphs spammers swap in. Detecting them
87+
// directly catches pure-homoglyph words (e.g. all-Cyrillic "Ѕесurіtу")
88+
// that script-mixing alone would miss, while never tripping on genuine
89+
// monolingual text (which a human reads as its own script, not as Latin).
90+
bool IsLatinConfusable(uint32_t cp)
91+
{
92+
switch (cp)
93+
{
94+
// Cyrillic look-alikes
95+
case 0x0430: case 0x0410: // а А -> a A
96+
case 0x0435: case 0x0415: // е Е -> e E
97+
case 0x043E: case 0x041E: // о О -> o O
98+
case 0x0440: case 0x0420: // р Р -> p P
99+
case 0x0441: case 0x0421: // с С -> c C
100+
case 0x0443: case 0x0423: // у У -> y Y
101+
case 0x0445: case 0x0425: // х Х -> x X
102+
case 0x0456: case 0x0406: // і І -> i I
103+
case 0x0455: case 0x0405: // ѕ Ѕ -> s S
104+
case 0x0458: case 0x0408: // ј Ј -> j J
105+
case 0x043A: case 0x041A: // к К -> k (loose)
106+
case 0x043C: case 0x041C: // м М -> m M
107+
case 0x043D: case 0x041D: // н Н -> h H (loose)
108+
case 0x0432: case 0x0412: // в В -> b B (loose)
109+
case 0x0442: case 0x0422: // т Т -> t T
110+
// Greek look-alikes
111+
case 0x03BF: case 0x039F: // ο Ο -> o O
112+
case 0x03B1: case 0x0391: // α Α -> a A
113+
case 0x03B5: case 0x0395: // ε Ε -> e E
114+
case 0x03C1: case 0x03A1: // ρ Ρ -> p P
115+
case 0x03C5: case 0x03A5: // υ Υ -> y Y
116+
case 0x03BD: // ν -> v
117+
case 0x03BA: case 0x039A: // κ Κ -> k K
118+
case 0x03B9: case 0x0399: // ι Ι -> i I
119+
case 0x03BC: // μ -> u (loose)
120+
case 0x0392: // Β -> B
121+
case 0x039D: // Ν -> N
122+
case 0x03A4: // Τ -> T
123+
case 0x0397: // Η -> H
124+
case 0x03A7: // Χ -> X
125+
case 0x0396: // Ζ -> Z
126+
return true;
127+
default:
128+
return false;
129+
}
130+
}
131+
132+
// "Fancy" Latin: fullwidth, mathematical alphanumerics, enclosed/parenthesised
133+
// letters. These render as styled ASCII ("𝐅𝐫𝐞𝐞", "Free", "🅵🆁🅴🅴") and are a
134+
// pure-obfuscation signal — normal users don't type whole words like this.
135+
bool IsFancyLatin(uint32_t cp)
136+
{
137+
if (cp >= 0xFF21 && cp <= 0xFF5A) return true; // fullwidth A-Z a-z
138+
if (cp >= 0x1D400 && cp <= 0x1D7FF) return true; // mathematical alphanumeric symbols
139+
if (cp >= 0x1F130 && cp <= 0x1F189) return true; // squared/enclosed latin
140+
if (cp >= 0x24B6 && cp <= 0x24E9) return true; // circled latin
141+
if (cp >= 0x2460 && cp <= 0x24FF) return true; // enclosed alphanumerics (loose)
142+
return false;
143+
}
144+
145+
// Invisible / zero-width characters used to split words and evade filters.
146+
bool IsInvisible(uint32_t cp)
147+
{
148+
switch (cp)
149+
{
150+
case 0x00AD: // soft hyphen
151+
case 0x200B: // zero width space
152+
case 0x200C: // zero width non-joiner
153+
case 0x200D: // zero width joiner
154+
case 0x2060: // word joiner
155+
case 0xFEFF: // BOM / zero width no-break space
156+
case 0x180E: // mongolian vowel separator
157+
return true;
158+
default:
159+
return false;
160+
}
161+
}
162+
163+
// Score a message for look-alike / obfuscated-text spam. Higher = more
164+
// suspicious. Designed to catch more than plain script-mixing while keeping
165+
// genuine monolingual text (any script) at score 0.
166+
unsigned int ScoreMessage(const std::string& text)
167+
{
168+
unsigned int mixedwords = 0; // words mixing >1 real script
169+
unsigned int homoglyphwords = 0; // ASCII + confusable letters in one word
170+
unsigned int purehomowords = 0; // word made (almost) ENTIRELY of confusables
171+
unsigned int fancywords = 0; // words containing fancy/styled latin
172+
unsigned int invisibles = 0; // zero-width chars anywhere
173+
unsigned int totalletters = 0, latinletters = 0;
174+
175+
bool wordhas[8] = { false };
176+
bool word_has_ascii = false, word_has_confusable = false, word_has_fancy = false;
177+
unsigned int word_letters = 0, word_confusables = 0;
178+
auto resetword = [&]() {
179+
for (bool& b : wordhas) b = false;
180+
word_has_ascii = word_has_confusable = word_has_fancy = false;
181+
word_letters = word_confusables = 0;
182+
};
183+
auto wordscripts = [&]() {
184+
unsigned int n = 0;
185+
for (int s = 1; s < 8; ++s) if (wordhas[s]) n++;
186+
return n;
187+
};
188+
auto endword = [&]() {
189+
// Confusable letter mixed WITH real ASCII Latin in one word = the
190+
// classic "swap a few letters" attack. This already IS script-mixing,
191+
// so count it ONCE here (not also as a mixedword) — otherwise a single
192+
// stray homoglyph in one word double-scores and false-positives.
193+
if (word_has_confusable && word_has_ascii) homoglyphwords++;
194+
else if (wordscripts() >= 2) mixedwords++;
195+
// A single-script word with NO ASCII Latin but built almost entirely
196+
// of Latin-confusable letters is Latin spelled in disguise (e.g.
197+
// all-Cyrillic "оссаѕіоп"). Genuine Russian/Greek words contain
198+
// non-confusable letters of their script, so they stay below 80%.
199+
else if (!word_has_ascii && wordscripts() == 1 && word_letters >= 4 &&
200+
word_confusables * 100 / word_letters >= 80)
201+
purehomowords++;
202+
if (word_has_fancy) fancywords++;
203+
resetword();
204+
};
205+
206+
resetword();
207+
for (size_t i = 0; i < text.size(); )
208+
{
209+
uint32_t cp = DecodeUTF8(text, i);
210+
211+
if (IsInvisible(cp)) { invisibles++; continue; } // don't treat as boundary
212+
213+
const bool fancy = IsFancyLatin(cp);
214+
const bool confusable = IsLatinConfusable(cp);
215+
Script sc = ClassifyScript(cp);
216+
217+
const bool isletter = (sc != Script::OTHER) || fancy;
218+
const bool isboundary = (cp == ' ' || cp == '\t' || cp == ',' || cp == '.' ||
219+
cp == '!' || cp == '?' || cp == 0xFFFD);
220+
221+
if (isletter)
222+
{
223+
if (sc != Script::OTHER) wordhas[static_cast<int>(sc)] = true;
224+
totalletters++;
225+
word_letters++;
226+
if (sc == Script::LATIN) { latinletters++; word_has_ascii = true; }
227+
if (confusable) { word_has_confusable = true; word_confusables++; }
228+
if (fancy) word_has_fancy = true;
229+
}
230+
231+
if (isboundary)
232+
endword();
233+
}
234+
endword(); // final word
235+
236+
// Count how many "disguised words" the message contains. A single one is
237+
// almost always an accident (someone pasted one Cyrillic letter), so we
238+
// grant a 1-word grace: real attacks disguise MANY words. This is what
239+
// keeps "nоwhola..." (1 stray homoglyph) from being blocked while still
240+
// catching genuine multi-word obfuscation.
241+
const unsigned int disguised = homoglyphwords + mixedwords + purehomowords + fancywords;
242+
const unsigned int effective = disguised > 1 ? disguised - 1 : 0;
243+
244+
unsigned int score = 0;
245+
score += effective * 5; // each disguised word past the first
246+
score += fancywords * 1; // small extra weight: styled unicode is rarely innocent
247+
score += invisibles * 3; // zero-width evasion is always suspicious
248+
249+
// Ratio bonus: only when there is REAL disguise (>=2 disguised words) and
250+
// non-Latin letters heavily dominate — never on a lone stray character.
251+
if (totalletters >= 8 && disguised >= 2)
252+
{
253+
const unsigned int nonlatin = totalletters - latinletters;
254+
if (nonlatin > 0 && latinletters > 0 && nonlatin * 100 / totalletters >= 40)
255+
score += 3;
256+
}
257+
258+
return score;
259+
}
260+
}
261+
262+
class ModuleAntiMixedUTF8 final
263+
: public Module
264+
{
265+
private:
266+
Account::API accountapi;
267+
268+
unsigned int threshold = 8;
269+
size_t minlen = 10;
270+
std::string action = "block";
271+
unsigned long duration = 3600;
272+
std::string reason = "Mixed-script text (spam).";
273+
bool check_channel = true;
274+
bool check_private = true;
275+
276+
public:
277+
ModuleAntiMixedUTF8()
278+
: Module(VF_VENDOR, "Blocks spam that mixes Unicode scripts within words.")
279+
, accountapi(this)
280+
{
281+
}
282+
283+
void ReadConfig(ConfigStatus& status) override
284+
{
285+
const auto& tag = ServerInstance->Config->ConfValue("antimixedutf8");
286+
threshold = tag->getNum<unsigned int>("threshold", 8, 1);
287+
minlen = tag->getNum<size_t>("minlen", 10, 1);
288+
action = tag->getString("action", "block");
289+
duration = tag->getDuration("duration", 3600, 1);
290+
reason = tag->getString("reason", "Mixed-script text (spam).");
291+
292+
const std::string tgt = tag->getString("target", "both");
293+
check_channel = (irc::equals(tgt, "both") || irc::equals(tgt, "channel"));
294+
check_private = (irc::equals(tgt, "both") || irc::equals(tgt, "private"));
295+
}
296+
297+
ModResult OnUserPreMessage(User* user, MessageTarget& target, MessageDetails& details) override
298+
{
299+
LocalUser* luser = IS_LOCAL(user);
300+
if (!luser)
301+
return MOD_RES_PASSTHRU;
302+
303+
// Exempt opers, exempt users, and logged-in accounts.
304+
if (luser->IsOper() || luser->exempt)
305+
return MOD_RES_PASSTHRU;
306+
if (accountapi && accountapi->GetAccountName(luser))
307+
return MOD_RES_PASSTHRU;
308+
309+
if (target.type == MessageTarget::TYPE_CHANNEL && !check_channel)
310+
return MOD_RES_PASSTHRU;
311+
if (target.type == MessageTarget::TYPE_USER && !check_private)
312+
return MOD_RES_PASSTHRU;
313+
if (target.type == MessageTarget::TYPE_SERVER)
314+
return MOD_RES_PASSTHRU;
315+
316+
// Only the ACTION body of CTCPs is checked; other CTCPs are skipped.
317+
std::string_view ctcpname;
318+
std::string_view body(details.text);
319+
if (details.IsCTCP(ctcpname, body))
320+
{
321+
if (!irc::equals(ctcpname, "ACTION"))
322+
return MOD_RES_PASSTHRU;
323+
}
324+
325+
if (body.length() < minlen)
326+
return MOD_RES_PASSTHRU;
327+
328+
const unsigned int score = ScoreMessage(std::string(body));
329+
if (score < threshold)
330+
return MOD_RES_PASSTHRU;
331+
332+
ServerInstance->SNO.WriteGlobalSno('a',
333+
"ANTIMIXEDUTF8: blocked message from {} (score {} >= {})",
334+
luser->GetRealMask(), score, threshold);
335+
336+
if (irc::equals(action, "block"))
337+
{
338+
const std::string msg = "Oups ! Votre message contient des caractères mélangés souvent utilisés par les spams. Réécrivez-le simplement et réessayez. 🙂";
339+
if (target.type == MessageTarget::TYPE_CHANNEL)
340+
luser->WriteNumeric(Numerics::CannotSendTo(target.Get<Channel>(), msg));
341+
else
342+
luser->WriteNotice("*** " + msg);
343+
return MOD_RES_DENY;
344+
}
345+
346+
// Punitive actions: drop the message AND act on the user.
347+
if (irc::equals(action, "gline"))
348+
AddLine<GLine>("G-line", luser, luser->GetBanUser(true), luser->GetAddress());
349+
else if (irc::equals(action, "kline"))
350+
AddLine<KLine>("K-line", luser, luser->GetBanUser(true), luser->GetAddress());
351+
else if (irc::equals(action, "zline"))
352+
AddLine<ZLine>("Z-line", luser, luser->GetAddress());
353+
else if (irc::equals(action, "kill"))
354+
ServerInstance->Users.QuitUser(luser, reason);
355+
356+
return MOD_RES_DENY;
357+
}
358+
359+
private:
360+
template <typename Line, typename... Extra>
361+
void AddLine(const char* type, LocalUser* user, Extra&&... extra)
362+
{
363+
auto* line = new Line(ServerInstance->Time(), duration, MODNAME "@" + ServerInstance->Config->ServerName,
364+
reason, std::forward<Extra>(extra)...);
365+
if (!ServerInstance->XLines->AddLine(line, nullptr))
366+
{
367+
delete line;
368+
ServerInstance->Users.QuitUser(user, reason);
369+
return;
370+
}
371+
ServerInstance->SNO.WriteToSnoMask('x', "{} added a timed {} on {}, expires in {}: {}",
372+
line->source, type, line->Displayable(), Duration::ToLongString(line->duration), line->reason);
373+
ServerInstance->XLines->ApplyLines();
374+
}
375+
};
376+
377+
MODULE_INIT(ModuleAntiMixedUTF8)

0 commit comments

Comments
 (0)