@@ -90,9 +90,61 @@ export function hasInvisibleChars(text: string): boolean {
9090// TRANSFORM FUNCTIONS
9191// ═══════════════════════════════════════════════════════════════════════
9292
93- /** Apply NFKC unicode normalization (homoglyphs → ASCII). */
93+ /**
94+ * TR39 confusable character mappings.
95+ * Characters from other scripts that visually resemble ASCII but have
96+ * different codepoints. Applied AFTER NFKC to catch what normalization misses.
97+ */
98+ const CONFUSABLES : Map < string , string > = new Map ( [
99+ // — Cyrillic uppercase —
100+ [ "\u0410" , "A" ] , [ "\u0412" , "B" ] , [ "\u0421" , "C" ] , [ "\u0415" , "E" ] ,
101+ [ "\u041D" , "H" ] , [ "\u0406" , "I" ] , [ "\u0408" , "J" ] , [ "\u041A" , "K" ] ,
102+ [ "\u041C" , "M" ] , [ "\u041E" , "O" ] , [ "\u0420" , "P" ] , [ "\u0405" , "S" ] ,
103+ [ "\u0422" , "T" ] , [ "\u0425" , "X" ] , [ "\u0423" , "Y" ] , [ "\u0417" , "Z" ] ,
104+ // — Cyrillic lowercase —
105+ [ "\u0430" , "a" ] , [ "\u0441" , "c" ] , [ "\u0435" , "e" ] , [ "\u04BB" , "h" ] ,
106+ [ "\u0456" , "i" ] , [ "\u0458" , "j" ] , [ "\u043E" , "o" ] , [ "\u0440" , "p" ] ,
107+ [ "\u0455" , "s" ] , [ "\u0445" , "x" ] , [ "\u0443" , "y" ] ,
108+ // — Greek uppercase —
109+ [ "\u0391" , "A" ] , [ "\u0392" , "B" ] , [ "\u0395" , "E" ] , [ "\u0397" , "H" ] ,
110+ [ "\u0399" , "I" ] , [ "\u039A" , "K" ] , [ "\u039C" , "M" ] , [ "\u039D" , "N" ] ,
111+ [ "\u039F" , "O" ] , [ "\u03A1" , "P" ] , [ "\u03A4" , "T" ] , [ "\u03A7" , "X" ] ,
112+ [ "\u03A5" , "Y" ] , [ "\u0396" , "Z" ] ,
113+ // — Greek lowercase —
114+ [ "\u03BF" , "o" ] , [ "\u03B1" , "a" ] ,
115+ // — Cherokee —
116+ [ "\u13A0" , "D" ] , [ "\u13A1" , "R" ] , [ "\u13A2" , "T" ] , [ "\u13AA" , "G" ] ,
117+ [ "\u13B3" , "W" ] , [ "\u13D2" , "S" ] , [ "\u13DA" , "S" ] ,
118+ [ "\uAB4E" , "s" ] , [ "\uAB4F" , "s" ] , [ "\uABA3" , "s" ] , [ "\uABAA" , "s" ] ,
119+ // — Turkish dotless i —
120+ [ "\u0131" , "i" ] ,
121+ // — Small caps —
122+ [ "\u1D00" , "A" ] , [ "\u0299" , "B" ] , [ "\u1D04" , "C" ] ,
123+ // — Fullwidth Latin uppercase A–Z (U+FF21–U+FF3A) —
124+ ...Array . from ( { length : 26 } , ( _ , i ) : [ string , string ] => [
125+ String . fromCharCode ( 0xFF21 + i ) ,
126+ String . fromCharCode ( 0x41 + i ) ,
127+ ] ) ,
128+ // — Fullwidth Latin lowercase a–z (U+FF41–U+FF5A) —
129+ ...Array . from ( { length : 26 } , ( _ , i ) : [ string , string ] => [
130+ String . fromCharCode ( 0xFF41 + i ) ,
131+ String . fromCharCode ( 0x61 + i ) ,
132+ ] ) ,
133+ ] ) ;
134+
135+ /**
136+ * Apply NFKC unicode normalization then TR39 confusable mapping.
137+ * NFKC handles compatibility decompositions (fullwidth, ligatures).
138+ * The confusable map catches cross-script homoglyphs that NFKC misses
139+ * (Cyrillic, Greek, Cherokee, etc.).
140+ */
94141export function normalizeUnicode ( text : string ) : string {
95- return text . normalize ( "NFKC" ) ;
142+ let result = text . normalize ( "NFKC" ) ;
143+ let out = "" ;
144+ for ( const ch of result ) {
145+ out += CONFUSABLES . get ( ch ) ?? ch ;
146+ }
147+ return out ;
96148}
97149
98150/** Check if decoded bytes are valid printable text. */
@@ -186,34 +238,67 @@ export function expandStringConcat(text: string): string {
186238 return text ;
187239}
188240
241+ // ═══════════════════════════════════════════════════════════════════════
242+ // HTML ENTITY DECODING
243+ // ═══════════════════════════════════════════════════════════════════════
244+
245+ const NAMED_ENTITIES : Record < string , string > = {
246+ amp : "&" , lt : "<" , gt : ">" , quot : '"' , apos : "'" ,
247+ nbsp : "\u00A0" , copy : "\u00A9" , reg : "\u00AE" ,
248+ } ;
249+
250+ /**
251+ * Decode HTML character references (numeric, hex, and named).
252+ * Handles c (decimal), c (hex), and & (named) forms.
253+ */
254+ export function decodeHtmlEntities ( text : string ) : string {
255+ return text
256+ . replace ( / & # x ( [ 0 - 9 a - f A - F ] + ) ; / g, ( _ , hex ) => String . fromCodePoint ( parseInt ( hex , 16 ) ) )
257+ . replace ( / & # ( \d + ) ; / g, ( _ , dec ) => String . fromCodePoint ( parseInt ( dec , 10 ) ) )
258+ . replace ( / & ( [ a - z A - Z ] + ) ; / g, ( match , name ) => NAMED_ENTITIES [ name . toLowerCase ( ) ] ?? match ) ;
259+ }
260+
189261// ═══════════════════════════════════════════════════════════════════════
190262// MAIN PIPELINE
191263// ═══════════════════════════════════════════════════════════════════════
192264
193265/**
194- * Apply all deobfuscation transforms to text .
266+ * Single deobfuscation pass — all transforms in order .
195267 *
196- * Returns cleaned text for regex pattern matching.
197- * Transforms applied in order (same as Python):
198268 * 1. stripZeroWidth
199269 * 2. stripTagChars
200270 * 3. stripVariationSelectors
201271 * 4. stripBidiControls
202272 * 5. stripHtmlComments
203- * 6. normalizeUnicode (NFKC)
204- * 7. decodeBase64Blocks
205- * 8. unescapeSequences
206- * 9. expandStringConcat
273+ * 6. decodeHtmlEntities
274+ * 7. normalizeUnicode (NFKC + TR39 confusables)
275+ * 8. decodeBase64Blocks
276+ * 9. unescapeSequences
277+ * 10. expandStringConcat
207278 */
208- export function deobfuscate ( text : string ) : string {
279+ function _deobfuscatePass ( text : string ) : string {
209280 text = stripZeroWidth ( text ) ;
210281 text = stripTagChars ( text ) ;
211282 text = stripVariationSelectors ( text ) ;
212283 text = stripBidiControls ( text ) ;
213284 text = stripHtmlComments ( text ) ;
285+ text = decodeHtmlEntities ( text ) ;
214286 text = normalizeUnicode ( text ) ;
215287 text = decodeBase64Blocks ( text ) ;
216288 text = unescapeSequences ( text ) ;
217289 text = expandStringConcat ( text ) ;
218290 return text ;
219291}
292+
293+ /**
294+ * Apply all deobfuscation transforms to text (2-pass pipeline).
295+ *
296+ * Two passes catch nested obfuscation where the first pass reveals
297+ * content that a second pass can further decode (e.g. base64 hidden
298+ * inside zero-width splits, or escape sequences inside HTML entities).
299+ */
300+ export function deobfuscate ( text : string ) : string {
301+ text = _deobfuscatePass ( text ) ;
302+ text = _deobfuscatePass ( text ) ;
303+ return text ;
304+ }
0 commit comments