JS颜文字混淆:基于AST的代码保护与逆向门槛提升实践
1. 颜文字混淆到底在混淆什么第一次听到js颜文字混淆这个词很多人会以为是给代码加上一堆表情符号让它变得可爱。实际上恰恰相反——它是利用颜文字这类多字节字符来替换JavaScript代码中的变量名、函数名让代码在保持功能不变的前提下变得极难阅读和逆向。我最早接触这个思路是在做前端代码保护的时候。常规的混淆工具比如UglifyJS、Terser它们把变量名压缩成a、b、c这种单字母虽然短但逆向的人稍微有点耐心就能靠上下文推断出每个变量的用途。而颜文字混淆走的是另一条路把变量名替换成(๑•̀ㅂ•́)و✧、(╯°□°╯︵┻━┻这类字符组合。这些字符在JavaScript的标识符规则下是合法的但人眼几乎无法快速区分逆向成本直接拉满。核心原理其实不复杂。JavaScript的标识符允许使用Unicode字符只要符合ID_Start和ID_Continue规范。颜文字里大量使用的假名、注音符号、数学符号、箭头符号等很多都落在合法范围内。混淆器要做的就是解析AST收集所有可重命名的标识符生成一组颜文字作为新名字然后做作用域安全的替换。这里有个关键点容易被忽略不是所有颜文字都能当变量名。比如(๑•̀ㅂ•́)و✧里的•和✧前者是项目符号后者是装饰符号它们在ECMAScript的标识符规范里是否合法取决于具体的Unicode类别。我实测下来比较稳妥的做法是只使用平假名、片假名、注音符号、部分数学运算符如∀、∂和箭头类符号。括号和标点符号基本不能用因为它们在语法层面有特殊含义。提示如果你打算自己写一个颜文字混淆器第一步不是急着生成字符而是先写一个校验函数用new Function或者正则去测试候选字符是否真的能作为标识符使用。我踩过这个坑生成了一堆看起来没问题但一跑就报SyntaxError的名字。从应用场景来看颜文字混淆主要面向几个需求前端代码需要交付给第三方但又不想完全暴露逻辑某些在线工具类产品希望增加逆向门槛还有就是纯粹的技术研究和CTF题目。它不适合用来保护真正的核心算法——因为任何客户端混淆都只是提高门槛不能做到绝对安全。2. 从AST到颜文字混淆器的完整工作链路2.1 解析阶段为什么必须用AST而不是正则很多人第一反应是用正则去匹配变量名然后替换。这个思路在小规模代码上可能碰巧能跑通但只要代码稍微复杂一点就会出问题。比如字符串里出现了同名的单词、对象属性名和变量名重名、不同作用域下有同名变量正则根本区分不了。正确的做法是走AST路线。用babel/parser或者acorn把源码解析成抽象语法树然后遍历树节点收集所有Identifier类型的节点。Babel的babel/traverse提供了非常方便的遍历能力可以精确地区分变量声明、函数参数、引用位置。const parser require(babel/parser); const traverse require(babel/traverse).default; const code function greet(name) { return hello name; }; const ast parser.parse(code); traverse(ast, { Identifier(path) { // 这里可以拿到每个标识符节点 console.log(path.node.name); } });解析阶段还要注意一个细节源码里可能已经包含了Unicode标识符。如果你的混淆器不做检查就直接替换可能会把原本合法的名字覆盖掉导致冲突。我的做法是在收集阶段就把所有已存在的标识符存进一个Set生成新名字时做去重。2.2 作用域分析混淆器最容易翻车的地方作用域分析是颜文字混淆的核心难点。JavaScript的作用域规则包括函数作用域、块级作用域let/const、闭包、提升等。如果你只是简单地把所有name替换成同一个颜文字那不同作用域下的name就会互相干扰代码直接报废。Babel的path.scope提供了作用域相关的API。每个标识符节点都可以通过path.scope.getBinding(name)拿到它的绑定信息包括声明位置、引用位置、是否被修改等。基于这些信息你可以安全地做重命名。我通常采用的策略是按作用域层级分组同一层级内不重复不同层级可以复用。这样既能保证正确性又能控制生成的名字数量。具体实现时我会给每个作用域分配一个独立的命名空间生成颜文字时从对应的池子里取。const yenPool [(๑•̀ㅂ•́)و✧, (╯°□°╯︵┻━┻, (´_), (๑•́ ₃ •̀๑)]; function renameInScope(scope) { const bindings scope.bindings; let index 0; for (const name in bindings) { const binding bindings[name]; const newName yenPool[index % yenPool.length] index; binding.identifier.name newName; binding.referencePaths.forEach(p p.node.name newName); index; } }上面这段代码是简化版实际使用时要处理binding.constantViolations被重新赋值的情况、binding.referenced是否被引用等。还有一个坑对象属性名不要混淆。obj.name里的name是属性名不是变量混淆了就会导致运行时找不到属性。Babel的path.isReferencedIdentifier()可以帮你判断。2.3 颜文字生成策略可读性与混淆强度的平衡生成颜文字名字时有几个维度需要权衡。第一是字符集选择只用平假名的话名字看起来像日文混入数学符号和箭头视觉混乱度更高。第二是长度控制太短容易重复太长会让代码体积膨胀。第三是可逆性如果你需要保留source map或者做调试名字里最好带一点可追溯的信息。我自己的方案是采用基础颜文字数字后缀的模式。基础颜文字从预置池里取数字后缀保证唯一性。这样既保证了视觉上的混淆效果又避免了纯随机生成导致的冲突检测开销。function generateYenName(index) { const bases [(๑•̀ㅂ•́)و✧, (╯°□°╯︵┻━┻, (´_), (๑•́ ₃ •̀๑)]; const base bases[index % bases.length]; const suffix Math.floor(index / bases.length); return suffix 0 ? base : base suffix; }实测下来这种方案在几千行代码的项目上表现稳定生成的名字肉眼几乎无法区分但AST层面完全合法。3. 那些年我在颜文字混淆上踩过的坑3.1 字符编码问题UTF-8不是万能药颜文字混淆最隐蔽的坑是编码。JavaScript源码文件如果保存为UTF-8理论上所有Unicode字符都能正常存储。但问题出在传输和加载环节如果服务器返回的Content-Type没有明确指定charsetutf-8浏览器可能会用默认编码解析导致颜文字变成乱码代码直接报错。我遇到过一次线上事故本地测试一切正常部署到CDN后部分用户反馈页面白屏。排查了半天才发现是某个边缘节点的响应头里charset字段被覆盖了。解决方案是在HTML的script标签上显式加上charsetutf-8同时在服务器配置里强制指定编码。注意如果你的代码需要经过构建工具处理比如Webpack、Rollup要确认这些工具在读取和输出文件时都使用了UTF-8编码。有些老版本的插件默认用Latin-1会把颜文字直接吃掉。3.2 压缩工具的二次处理Terser会把你的颜文字干掉另一个大坑是混淆后的代码再经过压缩工具。Terser、UglifyJS这类工具默认会做变量名压缩mangle它们不认识颜文字可能会把你的精心设计的名字重新替换成a、b、c混淆效果直接归零。解决办法有两个一是关闭压缩工具的mangle选项二是把颜文字混淆放在压缩之后执行。我推荐后者因为压缩本身也能减小体积先压缩再混淆两者互不干扰。// terser配置示例 const terser require(terser); const result await terser.minify(code, { mangle: false, // 关闭变量名压缩 compress: true }); // 然后再对result.code做颜文字混淆如果你用的是Webpack可以在optimization.minimizer里配置TerserPlugin的terserOptions.mangle为false然后在构建流程的最后加一个自定义插件做颜文字替换。3.3 调试噩梦Source Map救不了你颜文字混淆之后代码基本没法调试。断点打上去调用栈里全是(๑•̀ㅂ•́)و✧你根本不知道对应的是哪个函数。Source Map可以解决这个问题但前提是你在混淆时正确生成了映射关系。Babel的babel/generator支持生成source map你需要在混淆前后保留节点位置信息。具体做法是在替换名字时不要修改节点的loc属性这样生成的map就能正确映射回原始位置。但即使有source map调试体验依然很差。我的建议是开发环境不做颜文字混淆只在生产构建时启用。通过环境变量或者构建配置区分避免日常开发被混淆代码折磨。4. 颜文字混淆的边界与替代方案4.1 它防不住什么必须清醒地认识到颜文字混淆只是提高阅读门槛不是加密。任何人拿到代码后可以用格式化工具还原缩进用AST工具做重命名分析甚至写一个脚本把颜文字批量替换成可读名字。对于有经验的逆向人员来说这只是多花几个小时的问题。它防不住的手段包括动态调试直接在浏览器DevTools里断点跟踪、AST自动还原用Babel做反向重命名、以及基于执行轨迹的分析。所以不要把核心算法、密钥、敏感逻辑放在客户端这是基本原则。4.2 和其他混淆手段的配合颜文字混淆通常不会单独使用而是和其他手段组合。常见的搭配有混淆手段作用与颜文字混淆的配合方式字符串加密隐藏字符串常量先加密字符串再做颜文字重命名控制流平坦化打乱执行顺序在AST层面同时处理互不冲突死代码注入增加干扰注入的代码也参与颜文字重命名反调试检测调试器独立模块不影响混淆流程我一般会先用字符串加密处理敏感常量然后做控制流平坦化最后执行颜文字重命名。这个顺序的原因是颜文字重命名会改变AST结构如果放在前面后续的加密和平坦化可能会依赖原始名字导致处理失败。4.3 性能与体积的取舍颜文字字符在UTF-8下通常占3到4个字节而普通ASCII字符只占1个字节。如果大量使用颜文字作为变量名代码体积会明显膨胀。我实测过一个项目混淆后体积增加了约15%到20%。对于体积敏感的场景这个开销需要纳入考虑。优化思路是只混淆关键标识符。比如只混淆函数名和顶层变量保留循环变量和临时变量的短名字。这样既能增加阅读难度又能控制体积增长。Babel的path.scope可以帮你判断一个标识符是否属于顶层作用域。5. 自己动手实现一个最小可用版本5.1 环境准备与依赖安装如果你想自己实现一个颜文字混淆器最省力的路线是基于Babel生态。需要安装的包不多npm install babel/parser babel/traverse babel/generator babel/types这四个包分别负责解析、遍历、生成和类型判断。版本上建议用最新的稳定版因为Unicode标识符的支持在较新版本里更完善。5.2 核心代码逐段拆解整个混淆器的流程可以概括为解析源码得到AST遍历AST收集和替换标识符生成新代码。下面是一个可运行的最小实现const parser require(babel/parser); const traverse require(babel/traverse).default; const generate require(babel/generator).default; const YEN_BASES [(๑•̀ㅂ•́)و✧, (╯°□°╯︵┻━┻, (´_), (๑•́ ₃ •̀๑)]; function yenName(index) { const base YEN_BASES[index % YEN_BASES.length]; const suffix Math.floor(index / YEN_BASES.length); return suffix 0 ? base : base suffix; } function obfuscate(code) { const ast parser.parse(code, { sourceType: module }); let counter 0; traverse(ast, { Scope(path) { const bindings path.scope.bindings; for (const name in bindings) { const binding bindings[name]; if (!binding.referenced !binding.constantViolations.length) continue; const newName yenName(counter); binding.identifier.name newName; binding.referencePaths.forEach(p { p.node.name newName; }); } } }); return generate(ast, { comments: false }).code; } const input function add(a, b) { return a b; } console.log(add(1, 2));; console.log(obfuscate(input));这段代码的核心逻辑在Scope访问器里。Babel会在遍历过程中为每个作用域触发这个访问器我们拿到bindings后逐个重命名。binding.referencePaths包含了所有引用该变量的位置统一替换名字即可。5.3 测试与验证确保功能不变混淆之后必须做功能验证。最直接的方法是对比混淆前后的执行结果const original eval((${input})); const obfuscated eval((${obfuscate(input)})); // 比较两者的输出是否一致对于复杂项目建议写一套单元测试覆盖主要功能路径。我通常会用Jest跑一遍原始测试用例确保混淆后的代码全部通过。如果某个用例失败就针对性地检查那个模块的AST处理逻辑。还有一个验证点是语法合法性。用new Function(obfuscatedCode)尝试构造函数如果不报错说明语法层面没问题。这个检查比直接运行更轻量适合在构建流程里做快速校验。6. 关于颜文字混淆的一些个人体会颜文字混淆这个方向技术含量其实不在颜文字本身而在于对JavaScript作用域和AST的精确操作。我见过不少人兴致勃勃地写了一堆颜文字生成逻辑结果卡在作用域处理上混淆后的代码一跑就崩。所以如果你要动手做先把Babel的scope相关API吃透比研究字符集重要得多。另外混淆强度和使用场景要匹配。给一个内部管理系统做混淆用简单的变量重命名就够了给面向公众的付费产品做保护才需要上颜文字这种视觉干扰强的手段。过度混淆只会增加自己的维护成本得不偿失。最后分享一个实用技巧保留一个调试模式开关。在构建配置里加一个DEBUG_OBFUSCATE环境变量开启时跳过颜文字替换只做基础的压缩。这样线上出问题时你可以快速构建一个可读版本做对比排查不用对着满屏颜文字干瞪眼。这个习惯帮我省过好几次通宵排查的时间。