ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JS颜文字混淆实战:原理、实现与逆向对抗全解析

JS颜文字混淆实战:原理、实现与逆向对抗全解析 1. 颜文字混淆到底在玩什么花样第一次听到“js颜文字混淆”这个词很多人脑子里冒出来的画面可能是满屏的(╯°□°╯︵ ┻━┻或者(๑•̀ㅂ•́)و✧这类表情符号然后心里嘀咕这玩意儿还能跟代码混淆扯上关系说实话我刚开始接触的时候也是这个反应。但真正上手拆过几个样本之后才发现这套路比想象中要野得多也实用得多。所谓js颜文字混淆本质上是一种利用 Unicode 字符集中那些看起来像表情、符号、特殊文字的字形来替换 JavaScript 代码中变量名、函数名甚至运算符的代码保护手段。它的核心目的和传统的 JS 混淆一样——让代码变得难以阅读、难以逆向但它的表现形式更加“离谱”因为混淆后的代码看起来就像一坨乱码表情包人类肉眼几乎无法直接理解而浏览器和 Node.js 引擎却能正常解析执行。这东西能做什么简单说它可以把你一段正常的业务逻辑代码变成一堆(∀)、(╯‵□′)╯、(๑•́ ₃ •̀๑)这样的字符组合但功能完全不变。适合谁来研究如果你是前端开发、爬虫工程师、安全研究人员或者单纯对 JS 代码保护感兴趣的开发者这套东西值得花时间摸一摸。尤其是做js逆向和js反爬实战的朋友理解颜文字混淆的原理能帮你在遇到这类样本时快速定位突破口而不是对着屏幕发呆。我见过不少做爬虫的兄弟一看到目标站点的 JS 文件里全是表情符号第一反应就是“这什么鬼”然后直接放弃。其实拆开来看它的底层逻辑并不复杂只是换了一层皮。接下来我会从设计思路、核心原理、实操还原、常见坑点几个维度把这块内容彻底讲透。2. 核心思路与方案选型拆解2.1 为什么选择颜文字而不是传统十六进制混淆传统 JS 混淆常见的手段包括变量名替换成_0x1a2b这种十六进制形式、字符串数组化、控制流平坦化等。这些方案成熟、工具多比如 UglifyJS、Terser、javascript-obfuscator 都能干。但它们的缺点是“看起来太像混淆了”——有经验的人一眼就能识别出这是混淆代码然后针对性地用工具去还原。颜文字混淆走的是另一条路利用视觉欺骗和字符集广度来增加分析成本。它的优势体现在几个方面。第一字符种类极多。Unicode 里可以用来做标识符的字符范围非常广从希腊字母、西里尔字母到各种符号、表情、甚至古文字组合出来的变量名空间巨大暴力枚举的成本直线上升。第二视觉干扰强。当代码里全是(´_)、(๑•̀ㅂ•́)و这类字符时人眼很难快速区分不同变量因为它们在视觉上太相似了容易看串行。第三部分工具对这类字符的处理不够友好。一些常规的 JS 美化工具、AST 解析器在面对极端 Unicode 字符时可能会出现解析异常或者输出混乱这就给逆向增加了额外障碍。不过要注意颜文字混淆并不是万能的。它的主要作用是提高阅读门槛和自动化分析难度而不是提供真正的加密安全。任何在浏览器里能跑的 JS最终都能被还原只是时间成本问题。所以它更适合用在“不想让人轻易看懂”的场景而不是“绝对不能让人看懂”的场景。2.2 字符集选择背后的逻辑做颜文字混淆第一步是选字符集。不是所有 Unicode 字符都能直接当 JS 标识符用。ECMAScript 规范里标识符的字符必须属于ID_Start和ID_Continue这两个 Unicode 类别。简单说字母、部分符号、部分表情符号是可以的但纯标点、数字开头、某些控制字符就不行。我实测下来比较常用的几类字符包括希腊字母α、β、γ、西里尔字母а、б、в、日文假名あ、い、う、韩文谚文가、나、다、以及各种数学符号和装饰性符号。颜文字里常见的(∀)这种组合其实是由括号、日文片假名、希腊字母等混合而成的。关键点是括号本身不能作为标识符的一部分但可以作为运算符或者分隔符存在。所以颜文字混淆的实现方式通常是用合法标识符字符组成变量名再用括号、点号、逗号等符号把它们连接起来形成视觉上的“颜文字”效果。举个例子(∀)在 JS 里如果直接写(和)是语法符号、∀、需要是合法标识符字符。实际上∀是数学符号属于ID_Start吗我查过∀的 Unicode 类别是 Sm数学符号并不在ID_Start里。所以真正能用的颜文字混淆往往是用合法字符拼出“看起来像颜文字”的标识符而不是直接用现成的颜文字表情。这一点很多教程没讲清楚导致新手一上来就踩坑。2.3 混淆强度与可执行性的平衡做混淆最怕什么最怕混淆完代码跑不起来。颜文字混淆尤其容易出这个问题因为字符集太杂稍不注意就用了非法字符或者触发了引擎的解析 bug。我踩过的坑包括某些字符在 V8 里能跑在 JavaScriptCore 里报错某些字符在源码里没问题但经过 HTTP 传输或者文件保存时编码转换后变成乱码。所以方案选型时必须考虑目标运行环境。如果代码只在 Chrome 里跑那 V8 支持的字符范围就是上限。如果要兼容多端就得保守一点优先选 ASCII 扩展区或者拉丁字母变体。另外混淆后的代码体积会膨胀因为一个原本 3 个字符的变量名可能变成 10 个字符的颜文字组合。如果对体积敏感还得做权衡。我的建议是先确定运行环境再选字符集最后做小范围测试。不要一上来就全量混淆先拿一个函数试确认能跑通再扩大范围。3. 核心细节解析与实操要点3.1 合法标识符字符的筛选方法要自己动手做颜文字混淆第一步是搞清楚哪些字符能用作变量名。最靠谱的方法是直接查 ECMAScript 规范里的ID_Start和ID_Continue定义或者用现成的库比如unicode-ident来查询。我一般用 Node.js 写个小脚本遍历 Unicode 范围把属于ID_Start的字符筛出来然后再从中挑视觉上有“颜文字感”的。const unicode require(unicode-ident); function isIdStart(ch) { return unicode.isIDStart(ch.codePointAt(0)); } // 测试几个候选字符 const candidates [, ∀, ω, ๑, •, ̀, ́, ㅂ]; candidates.forEach(ch { console.log(ch, isIdStart(ch)); });实测结果ω、๑、ㅂ是合法的、∀、•不一定合法。所以真正做混淆时得用合法字符去“拼”出颜文字的效果而不是直接复制粘贴表情。比如用ω代替∀用๑代替•视觉上差不多但合法性有保障。注意不同 JS 引擎对 Unicode 版本的支持不一样。V8 通常比较新但一些老版本浏览器可能只支持到 Unicode 5.0 或 6.0。如果目标用户里有老设备字符集要选保守一点。3.2 变量名替换的自动化流程手动替换变量名不现实必须写脚本自动化。核心流程是解析原始 JS 代码提取所有标识符生成对应的颜文字标识符映射表然后替换。解析这一步可以用 Babel 或者 Acorn它们能生成 AST方便精确替换。const parser require(babel/parser); const traverse require(babel/traverse).default; const generate require(babel/generator).default; const code function hello(name) { return Hi, name; }; const ast parser.parse(code); const map {}; let counter 0; const charset αβγδεζηθικλμνξοπρστυφχψω; traverse(ast, { Identifier(path) { const oldName path.node.name; if (!map[oldName]) { // 生成颜文字风格的标识符 let newName ; let n counter; do { newName charset[n % charset.length] newName; n Math.floor(n / charset.length); } while (n 0); map[oldName] newName; } path.node.name map[oldName]; } }); const output generate(ast, { comments: false }).code; console.log(output);这段代码会把hello、name替换成希腊字母组合。如果要更像颜文字可以在字符集里混入日文假名、韩文谚文甚至用合法字符拼出(๑•̀ω•́๑)的视觉效果——注意括号还是得作为语法符号单独处理。3.3 字符串与运算符的处理策略变量名替换只是第一步。真正让代码“面目全非”的还得处理字符串和运算符。字符串可以用 Base64 或者十六进制编码后再用atob或者String.fromCharCode还原。运算符可以用等价替换比如换成-(-换成加类型判断等等。但颜文字混淆的特色在于用字符本身的视觉混乱来干扰阅读所以字符串处理可以更“花哨”一点。比如把字符串拆成单个字符用颜文字变量拼接// 原始 const msg hello; // 混淆后 const ω h, ๑ e, ㅂ l, o; const (´_) ω ๑ ㅂ ㅂ ;当然(´_)这种带括号的不能直接做变量名得改成合法形式比如๑ω๑或者ω_ω。核心思路是用合法字符拼出视觉上像颜文字的标识符再用这些标识符去承载字符串片段。运算符方面我一般不会做太复杂的替换因为容易引入 bug。简单的等价替换就够了比如a b换成a - (-b)a b换成!(a ! b)。这些替换在 AST 层面做安全可控。3.4 控制流平坦化的颜文字适配控制流平坦化是高级混淆的标配它把顺序执行的代码打散成 switch-case 结构用状态变量控制执行顺序。颜文字混淆可以和它结合状态变量用颜文字标识符case 标签用数字或者颜文字编码。// 原始 function calc(a, b) { const sum a b; const product a * b; return sum product; } // 平坦化 颜文字混淆后简化示意 function αβγ(ω, ๑) { let ㅂ 0; const []; while (true) { switch (ㅂ) { case 0: .push(ω ๑); ㅂ 1; break; case 1: .push(ω * ๑); ㅂ 2; break; case 2: return [0] [1]; } } }这种结构配合颜文字标识符阅读难度直接拉满。但要注意控制流平坦化会显著增加代码体积和运行开销如果对性能敏感得控制使用范围。4. 实操过程与核心环节实现4.1 环境准备与工具链搭建动手之前先把环境搭好。我用的工具链是 Node.js Babel 自定义脚本。Node.js 版本建议 16 以上Babel 用最新版。需要安装的包包括babel/parser、babel/traverse、babel/generator、babel/types以及可选的unicode-ident用来查字符属性。npm init -y npm install babel/parser babel/traverse babel/generator babel/types unicode-ident如果你不想自己写全套也可以用现成的混淆工具做二次开发。比如javascript-obfuscator支持自定义标识符生成器你可以把它的标识符生成逻辑改成颜文字风格。但现成工具的限制比较多深度定制还是自己写脚本灵活。提示写脚本时建议把字符集、映射规则、替换范围都做成配置项方便调整。不要硬编码在代码里否则改起来很痛苦。4.2 完整混淆脚本的编写与调试下面是一个相对完整的混淆脚本框架涵盖了标识符替换、字符串编码、简单运算符替换三个环节。const fs require(fs); const parser require(babel/parser); const traverse require(babel/traverse).default; const generate require(babel/generator).default; const t require(babel/types); // 配置颜文字字符集确保都是合法 ID_Start 字符 const CHARSET αβγδεζηθικλμνξοπρστυφχψωあいうえおかきくけこ; const PREFIX ω; // 统一前缀避免与内置标识符冲突 function generateName(index) { let name ; let n index; do { name CHARSET[n % CHARSET.length] name; n Math.floor(n / CHARSET.length); } while (n 0); return PREFIX name; } function obfuscate(code) { const ast parser.parse(code, { sourceType: module }); const nameMap new Map(); let counter 0; // 第一遍收集所有需要替换的标识符 traverse(ast, { Identifier(path) { const name path.node.name; // 跳过关键字和内置对象 if ([undefined, null, true, false, this].includes(name)) return; if (path.parent.type MemberExpression path.key property !path.parent.computed) return; if (!nameMap.has(name)) { nameMap.set(name, generateName(counter)); } } }); // 第二遍执行替换 traverse(ast, { Identifier(path) { const name path.node.name; if (nameMap.has(name)) { path.node.name nameMap.get(name); } }, StringLiteral(path) { // 字符串转 Base64 atob 还原 const value path.node.value; if (value.length 3) { const encoded Buffer.from(value).toString(base64); path.replaceWith( t.callExpression( t.identifier(atob), [t.stringLiteral(encoded)] ) ); } } }); return generate(ast, { comments: false, compact: true }).code; } // 使用 const input fs.readFileSync(input.js, utf8); const output obfuscate(input); fs.writeFileSync(output.js, output); console.log(混淆完成输出到 output.js);调试时先用小段代码测试确认输出能正常运行。如果报错检查是不是替换了不该替换的标识符比如对象属性名、import 的模块名等。这些地方替换了会导致运行时找不到属性或模块。4.3 混淆效果验证与性能测试混淆完不能直接上线得验证功能和性能。功能验证就是跑一遍原有测试用例确保输出一致。性能测试是看混淆后的代码执行时间、内存占用有没有明显变化。我一般用console.time和console.timeEnd做简单基准测试console.time(original); // 执行原始代码 console.timeEnd(original); console.time(obfuscated); // 执行混淆后代码 console.timeEnd(obfuscated);实测下来纯标识符替换对性能影响很小通常在 5% 以内。字符串 Base64 编码加atob还原会有一点开销但如果字符串不多可以忽略。控制流平坦化影响最大可能让执行时间翻倍甚至更多所以要谨慎使用。另外混淆后的代码体积也要关注。我做过一个测试一个 10KB 的原始文件纯标识符替换后变成 15KB 左右加上字符串编码变成 20KB再加上控制流平坦化变成 35KB。如果对加载速度敏感得控制混淆强度。4.4 与构建工具集成实际项目中混淆通常是构建流程的一环。可以把上面的脚本包装成 Webpack 插件或者 Rollup 插件在打包完成后自动执行。也可以直接用javascript-obfuscator的 API在它的基础上做定制。// webpack 插件示例 class YanzhiObfuscatorPlugin { apply(compiler) { compiler.hooks.emit.tap(YanzhiObfuscatorPlugin, (compilation) { Object.keys(compilation.assets).forEach((filename) { if (filename.endsWith(.js)) { const source compilation.assets[filename].source(); const obfuscated obfuscate(source); compilation.assets[filename] { source: () obfuscated, size: () obfuscated.length }; } }); }); } }集成时要注意只混淆业务代码不要混淆第三方库。第三方库混淆后可能出各种奇怪问题而且体积也会膨胀。另外source map 要关掉否则混淆就白做了。5. 常见问题与排查技巧实录5.1 混淆后代码报错怎么办这是最常见的问题。报错原因通常有几类替换了不该替换的标识符、用了非法字符、字符串编码后还原失败、运算符替换引入逻辑错误。排查步骤我一般这样走先把混淆后的代码用格式化工具跑一遍看看结构有没有明显异常。然后逐步缩小范围把混淆脚本的替换规则一条条关掉看哪条规则导致报错。最后用try-catch包裹可疑代码段打印中间变量定位具体出错位置。注意有些报错是运行时才出现的比如某个变量在特定条件下才被访问。这种情况要用断点调试或者加日志输出。5.2 字符编码与传输问题颜文字混淆后的代码包含大量非 ASCII 字符如果文件保存时编码不对或者 HTTP 传输时没声明 UTF-8就会变成乱码。我踩过的坑包括文件用 GBK 保存导致字符丢失、服务器返回头没带charsetutf-8导致浏览器解析错误、CDN 压缩时把某些字符转义了。解决办法很简单确保全链路 UTF-8。文件保存用 UTF-8HTML 里声明meta charsetutf-8服务器返回头加Content-Type: application/javascript; charsetutf-8。如果还不行可以在混淆后的代码开头加 BOM但 BOM 有时会引发其他问题慎用。5.3 常见问题速查表问题现象可能原因排查方法解决方案代码报SyntaxError使用了非法标识符字符检查字符是否属于 ID_Start用 unicode-ident 筛选合法字符运行时报undefined替换了对象属性名或模块名检查 AST 替换范围跳过 MemberExpression 的 property 和 import 标识符字符串乱码编码转换丢失字符对比原始和混淆后的字符串全链路使用 UTF-8性能明显下降控制流平坦化过度做基准测试对比减少平坦化范围或关闭该功能代码体积暴涨字符串编码膨胀统计各环节体积变化只对长字符串编码短字符串保留原样某些浏览器报错字符集兼容性问题在多端测试使用保守字符集避免生僻字符5.4 独家避坑经验分享做了这么多轮颜文字混淆有几个经验是文档里不会写的。第一不要贪多。字符集不是越大越好太多生僻字符反而容易触发引擎 bug。我一般控制在 50 个字符以内够用就行。第二保留调试开关。混淆脚本里加一个debug模式输出映射表方便出问题时对照。第三版本控制。每次混淆前把原始代码和混淆后代码都提交到 git出问题可以快速回滚。第四不要混淆关键配置。比如 API 地址、密钥之类的混淆后自己都找不到维护成本太高。还有一个坑是混淆后的代码在 IDE 里没法看。变量名全是颜文字搜索、跳转、重构全部失效。所以混淆只应该用在最终产物上开发阶段千万别混淆。我见过有人把混淆脚本接到开发服务器上结果调试时痛不欲生。6. 逆向对抗与还原思路6.1 遇到颜文字混淆代码怎么下手如果你是在做js逆向遇到颜文字混淆的样本第一步不是急着还原而是先观察。看看混淆的粒度是只替换了变量名还是连字符串、控制流都处理了。如果只是变量名替换那还原很简单用 AST 工具把标识符统一改成可读形式就行。// 批量重命名标识符 traverse(ast, { Identifier(path) { if (isYanzhiName(path.node.name)) { path.node.name var_ (counter); } } });如果字符串也被编码了那就先处理字符串。找到atob或者String.fromCharCode的调用把参数解码后替换回字符串字面量。这一步可以用 Babel 的path.evaluate()做常量折叠。6.2 自动化还原工具的设计思路手动还原效率太低我一般会写个半自动工具。核心功能包括识别颜文字标识符、批量重命名、字符串解码、控制流还原。控制流还原最难因为平坦化后的 switch-case 结构需要分析状态变量的流转逻辑才能恢复原始顺序。我的做法是先用 AST 把 switch-case 结构提取出来分析每个 case 里的赋值语句追踪状态变量的变化然后按执行顺序重新排列代码块。这个过程不是 100% 可靠复杂情况下需要人工介入。但至少能把大部分结构还原出来剩下的手动补。6.3 对抗升级与防护建议如果你是用颜文字混淆来保护自己的代码那得知道逆向的人也在进步。单纯的标识符替换已经不够看了得叠加多层防护字符串加密、控制流平坦化、反调试、代码自校验。但每加一层维护成本和性能开销都会上升得根据实际需求权衡。我的建议是混淆的目标是提高攻击成本而不是追求绝对安全。如果代码价值很高那应该考虑服务端执行而不是全靠前端混淆。前端代码最终都会暴露在用户浏览器里混淆只是让获取成本变高不是不可能。7. 我个人的一些实操体会颜文字混淆这东西刚接触时觉得挺花哨用多了会发现它就是个工具有适用场景也有局限。我现在的做法是对性能不敏感、对代码保护有需求的场景会用轻度颜文字混淆加上字符串编码够用且不折腾。对性能敏感的场景基本不做混淆或者只用最简单的变量名替换。另外字符集的选择上我倾向于用希腊字母和日文假名混合视觉效果好兼容性也不错。韩文谚文虽然合法但在某些老设备上显示有问题我一般不用。数学符号和装饰符号尽量少用坑太多。最后分享一个小技巧混淆脚本里加一个“白名单”配置把不想混淆的变量名、函数名列进去比如export出去的接口、全局注册的组件名等。这样既能保护核心逻辑又不会破坏对外接口。这个白名单机制在实际项目中救过我很多次强烈建议加上。
返回列表