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