Somebody beat my profanity filter with a 6
My own leetspeak table mapped 6 to g, so 6ex normalized to gex and walked straight onto the leaderboard.
I built a global leaderboard for the games on this site. Anyone can submit a name, so I wrote a profanity filter, and I was quite pleased with it. Then somebody got 6ex onto the board, and the reason it worked is the single most embarrassing kind of bug: my own cleverness did it.
The filter, version one
The obvious attack on a wordlist is leetspeak. Nobody types the banned word, they type s3x or f4ggot or $hit. So before comparing against the list, I normalized: lowercase everything, map each leetspeak character back to the letter it is imitating, throw away anything that is not a letter, and collapse repeats so seeeex becomes sex.
const LEET: Record<string, string> = {
"0": "o", "1": "i", "2": "z", "3": "e", "4": "a", "5": "s",
"6": "g", "7": "t", "8": "b", "9": "g", "@": "a", "quot;: "s",
"!": "i", "+": "t", "|": "i", "€": "e", "£": "l",
};Read that map again and see if you can spot the hole before I tell you. I could not, and I wrote it.
Why 6ex sailed straight through
"6": "g". Six looks like a lowercase g. That is a perfectly reasonable mapping, and it is the mapping most leetspeak tables use.
Which means 6ex normalizes to gex. And gex is not on any banned list, because gex is not a word. The filter looked at it, correctly applied my rule, and let it through.
The fix: stop guessing what the character means
The second version does not try to decide what a digit stands for. It treats every digit and symbol as a wildcard that can stand for any letter, then slides that pattern along each banned word.
So 6ex against sex becomes: wildcard, then e, then x. Both real letters line up. Match.
Which immediately raises the obvious objection: if every digit matches every letter, does Player 123 now get banned for something?
const w = word.length;
const needLetters = Math.max(1, Math.floor(w / 2));
// ... slide the window, then:
if (match && letters >= needLetters) return true;That is the guard. A hit only counts if at least half the characters matched were genuine letters. 6ex against sex contributes two real letters out of a required one, so it is caught. Player 123 lines its digits up against sex and contributes zero real letters, so it is left alone.
Two nets, not one
The filter now runs both passes, and they fail in opposite directions on purpose:
- The normalizer catches deliberate substitutions where the mapping is unambiguous. It is precise and it under-catches.
- The wildcard sweep catches everything the normalizer guessed wrong about. It over-catches by nature, so the half-letters rule reins it back in.
Neither is good enough alone. Together they have held so far, which is the most confident sentence anybody should ever write about a profanity filter.
What I would tell past me
- Any normalizer that maps many inputs to one output is throwing information away, and an attacker gets to choose which information.
- Run the filter server-side even when it also runs in the browser. Mine does both, from the same shared module, because a filter that only runs client-side is a suggestion.
- Wordlist filters are a speed bump, not a wall. The goal is to make casual abuse tedious, and to make the rest easy to delete afterwards. I keep an admin endpoint to remove entries for exactly that reason.