import echoircd — from-scratch irc daemon in native rust
This commit is contained in:
commit
9b12791774
38 changed files with 9757 additions and 0 deletions
371
src/modules/antimixedutf8.rs
Normal file
371
src/modules/antimixedutf8.rs
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
//! antimixedutf8 — blocks spam that mixes Unicode scripts within words (Latin
|
||||
//! letters swapped for Cyrillic/Greek look-alikes: "FᏒee Ⅴ1аgrа"), a very common
|
||||
//! obfuscation. This is reverse's own detection model — the scoring rules and the
|
||||
//! confusable / fancy-Latin / zero-width tables — implemented from scratch in
|
||||
//! native Rust. (The *tables and weights* are the detector's spec: they define
|
||||
//! what counts as spam. Everything around them is original echoIRCd code.)
|
||||
//!
|
||||
//! Per word: letters from more than one script score; so do words that are ASCII
|
||||
//! mixed with Latin-confusable letters, words built almost entirely of confusables,
|
||||
//! and "fancy" styled-Latin words. Zero-width chars score too. At/above the
|
||||
//! configured threshold the action fires (block | kill | gline | kline | zline).
|
||||
//!
|
||||
//! Rust strings are valid UTF-8, so we walk codepoints straight from `chars()` and
|
||||
//! fold the per-word state through a `Scorer` — idiomatic Rust, no manual UTF-8
|
||||
//! decoding and no ref-capturing lambdas.
|
||||
|
||||
use crate::module::{ModResult, Module};
|
||||
use crate::server::Server;
|
||||
use crate::xline::XKind;
|
||||
use crate::Uid;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Script {
|
||||
Other = 0, // digits, punctuation, symbols — ignored for mixing
|
||||
Latin,
|
||||
Cyrillic,
|
||||
Greek,
|
||||
Armenian,
|
||||
Hebrew,
|
||||
Arabic,
|
||||
Cjk,
|
||||
}
|
||||
|
||||
/// Map a codepoint to a script; `Other` for anything that isn't a letter we track.
|
||||
fn classify_script(cp: u32) -> Script {
|
||||
match cp {
|
||||
0x41..=0x5A | 0x61..=0x7A => Script::Latin, // ASCII A-Z a-z
|
||||
0x00C0..=0x024F => Script::Latin, // Latin-1 suppl + extended
|
||||
0x0370..=0x03FF => Script::Greek,
|
||||
0x0400..=0x04FF => Script::Cyrillic,
|
||||
0x0530..=0x058F => Script::Armenian,
|
||||
0x0590..=0x05FF => Script::Hebrew,
|
||||
0x0600..=0x06FF => Script::Arabic,
|
||||
0x4E00..=0x9FFF => Script::Cjk, // CJK unified
|
||||
0x3040..=0x30FF => Script::Cjk, // hiragana / katakana
|
||||
_ => Script::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-Latin letter that LOOKS like an ASCII Latin letter (the homoglyphs
|
||||
/// spammers swap in). Catches pure-homoglyph words that script-mixing misses,
|
||||
/// without tripping on genuine monolingual text.
|
||||
fn is_latin_confusable(cp: u32) -> bool {
|
||||
matches!(
|
||||
cp,
|
||||
// Cyrillic look-alikes
|
||||
0x0430 | 0x0410 | 0x0435 | 0x0415 | 0x043E | 0x041E | 0x0440 | 0x0420 |
|
||||
0x0441 | 0x0421 | 0x0443 | 0x0423 | 0x0445 | 0x0425 | 0x0456 | 0x0406 |
|
||||
0x0455 | 0x0405 | 0x0458 | 0x0408 | 0x043A | 0x041A | 0x043C | 0x041C |
|
||||
0x043D | 0x041D | 0x0432 | 0x0412 | 0x0442 | 0x0422 |
|
||||
// Greek look-alikes
|
||||
0x03BF | 0x039F | 0x03B1 | 0x0391 | 0x03B5 | 0x0395 | 0x03C1 | 0x03A1 |
|
||||
0x03C5 | 0x03A5 | 0x03BD | 0x03BA | 0x039A | 0x03B9 | 0x0399 | 0x03BC |
|
||||
0x0392 | 0x039D | 0x03A4 | 0x0397 | 0x03A7 | 0x0396
|
||||
)
|
||||
}
|
||||
|
||||
/// "Fancy" Latin: fullwidth, mathematical alphanumerics, enclosed/circled letters.
|
||||
/// These render as styled ASCII ("𝐅𝐫𝐞𝐞", "Free", "🅵🆁🅴🅴") — pure obfuscation.
|
||||
fn is_fancy_latin(cp: u32) -> bool {
|
||||
matches!(
|
||||
cp,
|
||||
0xFF21..=0xFF5A // fullwidth A-Z a-z
|
||||
| 0x1D400..=0x1D7FF // mathematical alphanumeric symbols
|
||||
| 0x1F130..=0x1F189 // squared/enclosed latin
|
||||
| 0x24B6..=0x24E9 // circled latin
|
||||
| 0x2460..=0x24FF // enclosed alphanumerics (loose)
|
||||
)
|
||||
}
|
||||
|
||||
/// Invisible / zero-width characters used to split words and evade filters.
|
||||
fn is_invisible(cp: u32) -> bool {
|
||||
matches!(
|
||||
cp,
|
||||
0x00AD | 0x200B | 0x200C | 0x200D | 0x2060 | 0xFEFF | 0x180E
|
||||
)
|
||||
}
|
||||
|
||||
/// Per-message tally, folded word by word: the per-word scoring state as a struct.
|
||||
#[derive(Default)]
|
||||
struct Scorer {
|
||||
mixedwords: u32, // words mixing >1 real script
|
||||
homoglyphwords: u32, // ASCII + confusable letters in one word
|
||||
purehomowords: u32, // word made (almost) entirely of confusables
|
||||
fancywords: u32, // words containing fancy/styled latin
|
||||
invisibles: u32, // zero-width chars anywhere
|
||||
totalletters: u32,
|
||||
latinletters: u32,
|
||||
wordhas: [bool; 8],
|
||||
word_has_ascii: bool,
|
||||
word_has_confusable: bool,
|
||||
word_has_fancy: bool,
|
||||
word_letters: u32,
|
||||
word_confusables: u32,
|
||||
}
|
||||
|
||||
impl Scorer {
|
||||
fn word_scripts(&self) -> u32 {
|
||||
(1..8).filter(|&s| self.wordhas[s]).count() as u32
|
||||
}
|
||||
|
||||
fn reset_word(&mut self) {
|
||||
self.wordhas = [false; 8];
|
||||
self.word_has_ascii = false;
|
||||
self.word_has_confusable = false;
|
||||
self.word_has_fancy = false;
|
||||
self.word_letters = 0;
|
||||
self.word_confusables = 0;
|
||||
}
|
||||
|
||||
fn end_word(&mut self) {
|
||||
// Confusable mixed WITH real ASCII in one word = the classic "swap a few
|
||||
// letters" attack (already script-mixing) — count it ONCE here so a single
|
||||
// stray homoglyph doesn't double-score.
|
||||
if self.word_has_confusable && self.word_has_ascii {
|
||||
self.homoglyphwords += 1;
|
||||
} else if self.word_scripts() >= 2 {
|
||||
self.mixedwords += 1;
|
||||
} else if !self.word_has_ascii
|
||||
&& self.word_scripts() == 1
|
||||
&& self.word_letters >= 4
|
||||
&& self.word_confusables * 100 / self.word_letters >= 80
|
||||
{
|
||||
// single-script word with no ASCII, ≥80% confusables = Latin in disguise
|
||||
self.purehomowords += 1;
|
||||
}
|
||||
if self.word_has_fancy {
|
||||
self.fancywords += 1;
|
||||
}
|
||||
self.reset_word();
|
||||
}
|
||||
}
|
||||
|
||||
/// Score a message for look-alike / obfuscated-text spam. Higher = worse; genuine
|
||||
/// monolingual text (any script) stays at 0.
|
||||
fn score_message(text: &str) -> u32 {
|
||||
let mut sc = Scorer::default();
|
||||
for cp in text.chars().map(|c| c as u32) {
|
||||
if is_invisible(cp) {
|
||||
sc.invisibles += 1;
|
||||
continue; // not a word boundary
|
||||
}
|
||||
let fancy = is_fancy_latin(cp);
|
||||
let confusable = is_latin_confusable(cp);
|
||||
let script = classify_script(cp);
|
||||
let isletter = script != Script::Other || fancy;
|
||||
let isboundary = matches!(cp, 0x20 | 0x09 | 0x2C | 0x2E | 0x21 | 0x3F | 0xFFFD);
|
||||
|
||||
if isletter {
|
||||
if script != Script::Other {
|
||||
sc.wordhas[script as usize] = true;
|
||||
}
|
||||
sc.totalletters += 1;
|
||||
sc.word_letters += 1;
|
||||
if script == Script::Latin {
|
||||
sc.latinletters += 1;
|
||||
sc.word_has_ascii = true;
|
||||
}
|
||||
if confusable {
|
||||
sc.word_has_confusable = true;
|
||||
sc.word_confusables += 1;
|
||||
}
|
||||
if fancy {
|
||||
sc.word_has_fancy = true;
|
||||
}
|
||||
}
|
||||
if isboundary {
|
||||
sc.end_word();
|
||||
}
|
||||
}
|
||||
sc.end_word(); // final word
|
||||
|
||||
// One disguised word is usually an accident (a pasted Cyrillic letter); real
|
||||
// attacks disguise MANY. Grant a 1-word grace.
|
||||
let disguised = sc.homoglyphwords + sc.mixedwords + sc.purehomowords + sc.fancywords;
|
||||
let effective = disguised.saturating_sub(1);
|
||||
|
||||
let mut score = 0u32;
|
||||
score += effective * 5; // each disguised word past the first
|
||||
score += sc.fancywords; // styled unicode is rarely innocent
|
||||
score += sc.invisibles * 3; // zero-width evasion is always suspicious
|
||||
|
||||
// Ratio bonus: only with real disguise (≥2 words) and non-Latin dominance.
|
||||
if sc.totalletters >= 8 && disguised >= 2 {
|
||||
let nonlatin = sc.totalletters - sc.latinletters;
|
||||
if nonlatin > 0 && sc.latinletters > 0 && nonlatin * 100 / sc.totalletters >= 40 {
|
||||
score += 3;
|
||||
}
|
||||
}
|
||||
score
|
||||
}
|
||||
|
||||
/// If `text` is a CTCP, return the ACTION body to check, else `None` to skip
|
||||
/// (non-ACTION CTCPs aren't scanned). Plain messages return the text unchanged.
|
||||
fn checkable(text: &str) -> Option<&str> {
|
||||
let Some(inner) = text.strip_prefix('\u{01}') else {
|
||||
return Some(text);
|
||||
};
|
||||
let inner = inner.strip_suffix('\u{01}').unwrap_or(inner);
|
||||
let (name, body) = inner.split_once(' ').unwrap_or((inner, ""));
|
||||
if name.eq_ignore_ascii_case("ACTION") {
|
||||
Some(body)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A single-line, length-capped snippet of a blocked message for the oper
|
||||
/// snotice. Keeps the look-alike glyphs visible (that's the point) but neutralises
|
||||
/// every control byte (CR/LF, mIRC formatting) so it can't inject into or break
|
||||
/// the protocol line the snotice is embedded in.
|
||||
fn snippet(text: &str) -> String {
|
||||
const MAX: usize = 120;
|
||||
let mut out = String::new();
|
||||
for (i, ch) in text.chars().enumerate() {
|
||||
if i >= MAX {
|
||||
out.push('…');
|
||||
break;
|
||||
}
|
||||
if (ch as u32) < 0x20 || ch == '\u{7f}' {
|
||||
out.push(' ');
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub struct AntiMixedUtf8;
|
||||
|
||||
impl Module for AntiMixedUtf8 {
|
||||
fn name(&self) -> &'static str {
|
||||
"antimixedutf8"
|
||||
}
|
||||
|
||||
fn on_pre_message(
|
||||
&mut self,
|
||||
srv: &mut Server,
|
||||
uid: Uid,
|
||||
target: &str,
|
||||
text: &str,
|
||||
) -> ModResult {
|
||||
if !srv.amu.enable {
|
||||
return ModResult::Passthru;
|
||||
}
|
||||
// exempt opers and users logged into an account
|
||||
if srv.is_oper(uid) || srv.is_logged_in(uid) {
|
||||
return ModResult::Passthru;
|
||||
}
|
||||
let is_channel = target.starts_with('#');
|
||||
if (is_channel && !srv.amu.check_channel) || (!is_channel && !srv.amu.check_private) {
|
||||
return ModResult::Passthru;
|
||||
}
|
||||
let Some(body) = checkable(text) else {
|
||||
return ModResult::Passthru;
|
||||
};
|
||||
if body.chars().count() < srv.amu.minlen {
|
||||
return ModResult::Passthru;
|
||||
}
|
||||
let score = score_message(body);
|
||||
if score < srv.amu.threshold {
|
||||
return ModResult::Passthru;
|
||||
}
|
||||
|
||||
let (nick, mask, host, ip) = {
|
||||
let Some(u) = srv.users.get(&uid) else {
|
||||
return ModResult::Passthru;
|
||||
};
|
||||
(
|
||||
u.nick.clone(),
|
||||
u.prefix(),
|
||||
u.host.clone(),
|
||||
u.addr.ip().to_string(),
|
||||
)
|
||||
};
|
||||
// Show opers WHAT was blocked (a sanitized snippet) so they can judge the
|
||||
// catch and spot false positives — the whole point of an antispam log.
|
||||
srv.snotice(&format!(
|
||||
"ANTIMIXEDUTF8: blocked spam from {mask} to {target} (score {score} >= {}): {}",
|
||||
srv.amu.threshold,
|
||||
snippet(body)
|
||||
));
|
||||
|
||||
// Always tell the sender their message was blocked and that opers were
|
||||
// told — even for punitive actions, since the writer flushes queued lines
|
||||
// before a disconnect.
|
||||
srv.send(
|
||||
uid,
|
||||
format!(
|
||||
":{} NOTICE {nick} :*** {} (Flagged by the spam filter; network operators have been notified.)",
|
||||
srv.name, srv.amu.block_msg
|
||||
),
|
||||
);
|
||||
|
||||
let action = srv.amu.action.to_ascii_lowercase();
|
||||
let (dur, reason, setter) = (
|
||||
srv.amu.duration,
|
||||
srv.amu.reason.clone(),
|
||||
format!("antimixedutf8@{}", srv.name),
|
||||
);
|
||||
match action.as_str() {
|
||||
"gline" => srv.add_xline(XKind::Gline, &format!("*@{host}"), dur, &setter, &reason),
|
||||
"kline" => srv.add_xline(XKind::Kline, &format!("*@{host}"), dur, &setter, &reason),
|
||||
"zline" => srv.add_xline(XKind::Zline, &ip, dur, &setter, &reason),
|
||||
"kill" => srv.remove_user(uid, &reason),
|
||||
// "block": also emit the standard channel-failure numeric so clients
|
||||
// render the drop inline; the explanatory NOTICE above covers the rest.
|
||||
_ if is_channel => srv.numeric(
|
||||
uid,
|
||||
crate::numeric::ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :Message blocked by the spam filter"),
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
ModResult::Deny
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn genuine_monolingual_text_scores_zero() {
|
||||
assert_eq!(score_message("hello everyone how are you today"), 0); // Latin
|
||||
assert_eq!(score_message("привет всем как у вас дела сегодня"), 0); // Russian
|
||||
assert_eq!(score_message("γεια σας πως ειστε ολοι σημερα εδω"), 0); // Greek
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_script_spam_scores_high() {
|
||||
// Cyrillic look-alikes swapped into Latin words (multi-word disguise)
|
||||
assert!(score_message("Ѕесurіtу аlеrt сlісk hеrе nоw рlеаѕе") >= 8);
|
||||
// fancy/fullwidth styled word run
|
||||
assert!(score_message("Free V1agra now click here") >= 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_stray_homoglyph_is_tolerated() {
|
||||
// a single disguised word gets the 1-word grace → stays under threshold
|
||||
assert!(score_message("hello wоrld this is a normal message") < 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_width_evasion_scores() {
|
||||
// three zero-width joiners = 3*3 = 9
|
||||
assert!(score_message("buy\u{200b}now\u{200b}cheap\u{200b}deal") >= 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippet_is_one_clean_line_and_capped() {
|
||||
// CR/LF and mIRC control bytes are neutralised (no protocol injection)
|
||||
assert_eq!(snippet("hi\r\nthere"), "hi there");
|
||||
assert!(!snippet("x\u{03}04red").contains('\u{03}'));
|
||||
// look-alike glyphs survive so opers can see what was caught
|
||||
assert!(snippet("Ѕесurіtу").contains('Ѕ'));
|
||||
// long input is capped with an ellipsis
|
||||
let s = snippet(&"a".repeat(200));
|
||||
assert!(s.ends_with('…') && s.chars().count() == 121);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue