剖析依赖混淆:一种利用 Source Map 文件的新型检测方法
本文深入探讨了依赖混淆漏洞,介绍了一种新型的检测与利用技术,并提供了可落地的步骤来缓解与该漏洞相关的风险。
引言
软件供应链涵盖了代码生命周期中涉及的所有要素,从开发到部署,包括代码、二进制文件,以及来自代码仓库或包管理器的依赖。
虽然使用第三方代码可以节省时间和精力,但它也引入了潜在的安全风险,因为这些依赖中的缺陷可能危及整条供应链的安全。
一些公司选择使用内部依赖而非第三方代码,将其托管在私有注册表上,或托管在带有私有作用域的公共注册表上,但这种做法并不能免受安全问题的影响,并且可能导致依赖混淆——一种软件供应链中的新型漏洞模式。
依赖混淆可能发生在许多不同的包管理器中。在本文中,我们将聚焦于 npm,这是一个被广泛使用的 JavaScript 包管理器。
依赖混淆概述
当恶意行为者设法诱骗包管理器(NPM、PyPI、RubyGems、JFrog Artifactory……)下载恶意的包或依赖、而非合法的包时,就会发生依赖混淆。这可以通过若干配置错误和错误假设来实现,其中尤为值得注意的有:
拼写错误
依赖混淆可能发生的最简单方式之一是通过拼写错误:攻击者可能预料到,开发者会因打字错误或疏忽而无意中安装了恶意包,而非原本想要的包。其典型做法是发布一个与热门包相似、但名称中带有拼写错误或细微差别的恶意包。
包的优先级
开发者可能会假设包管理器总是会优先选择内部包而非公共包,从而让他们依赖那些在内部存在、但在公共注册表上并不存在的包名。这种做法在恶意行为者弄清该包名并决定在公共注册表上注册它之前,都能很好地运作。
以下是一个 JavaScript 项目的 package.json 文件示例
{
"name": "my-project",
"version": "1.0.0",
"description": "Project 1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Corp",
"license": "MIT",
"dependencies": {
"express": "^4.17.1",
"lodash": "^4.17.21",
"axios": "^0.25.0",
"moment": "^2.29.1",
"internal-corp-package": "^1.5.0"
},
"devDependencies": {
"nodemon": "^2.0.15",
"eslint": "^8.6.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.25.1"
}
}
有一个依赖十分醒目,即 internal-corp-package。它的名称表明它来自内部注册表,和/或供内部使用。我们可以验证它是否存在于公共注册表上。

这意味着该包只存在于内部注册表上。假设某恶意行为者发现了这个内部包,并决定在公共 npm 注册表上发布一个同名的恶意包,我们就会得到以下工作流程:

以下是对上图的分解说明:
- 注册表未被显式配置
这意味着用户没有显式地指示 npm 使用内部注册表,既没有运行 npm config set registry http://[internal registry],也没有在 .npmrc 文件中指定它。在这种情况下,npm 会自动默认使用公共 npm 注册表 https://registry.npmjs.org/ 进行安装,也就是说它会安装攻击者发布的恶意包。
- 注册表已被正确配置
此处我们有以下两种情形:
- 所指定的包版本是严格的
如果所指定的包版本是像 1.0.0 这样严格的版本,并且该版本存在于私有注册表上,npm 会自动默认使用它,也就是说它会安装合法的包。一个版本严格的 package.json 示例如下:
{
...
"dependencies": {
...
"internal-corp-package": "1.0.0"
},
...
}
- 所指定的包版本是宽松的(范围)
除了严格版本之外,npm 还允许以三种不同的方式来表达版本:
^1.0.0:npm 会安装 1.0.0 版本,或任何大于 1.0.0 且小于 2.0.0 的版本,如 1.9.5(可以升级次版本号和补丁级别,但不能升级主版本号)。~1.0.0:意味着 npm 会安装 1.0.0 版本,或任何大于等于 1.0.0 且小于 1.1.0 的版本,如 1.0.9(只能升级补丁级别)。*:意味着对所安装版本没有任何限制,在这种情况下 npm 会默认使用最新版本。
在以上所有情形中,只要攻击者设法拿到正确的版本,他们仍然可以实施依赖混淆攻击。例如,如果所指定的版本是 ^1.0.0,攻击者可以创建并发布一个版本为 1.0.1 的恶意包,它就能通过检查。
依赖混淆的影响
依赖混淆可以相当容易地升级为远程代码执行。攻击者有许多方法通过恶意包来执行任意代码。实现这一点最简单的方式之一是通过 package.json 的 scripts 属性,攻击者可以在其中指定在安装的任意阶段要执行的命令,例如:
{
"name": "internal-corp-package",
"version": "1.0.1",
"description": "Definitely not a malicious package!",
"main": "index.js",
"scripts": {
"postinstall": "ping [malicious host]",
},
"author": "",
"license": "ISC"
}
未被注册的组织名称
NPM 注册表有一项功能,可以使用组织作用域,此时包名形如 @org/package。有些组织可能会在私有注册表上注册其组织名称,但没有在公共 npm 注册表上注册。如果某恶意行为者设法在公共 npm 注册表上注册了该组织名称,他们就可以利用它来实施依赖混淆攻击。
通过 Source Map 文件利用依赖混淆
Source map 文件,通常简称为 source map,是提供程序源代码与被浏览器或其他 JavaScript 引擎执行的转换后代码之间映射关系的文件。它们在 Web 开发中被广泛用于帮助调试和错误报告,尤其是在处理经过压缩(minified)或转译(transpiled)的代码时。
虽然 source map 对于开发和调试很有用,但它们可能带来安全影响,尤其是当它们在生产构建版本中被无意暴露时。如果一个 source map 文件在生产构建版本中被公开暴露,它可以让攻击者重建原始的未压缩源代码,并列出其依赖——这些依赖通常位于 node_modules 文件夹中——而能够访问依赖列表,可能对实施依赖混淆至关重要。
一个由 webpack 生成的典型 source map 文件如下所示:
{
"version": 3,
"file": "bundle.js.map",
"sources": [
"webpack:///./src/file1.ts",
"webpack:///./src/file2.ts",
"webpack:///./node_modules/internal-corp-package/utils.js"
],
"sourcesContent": [
"console.log('This is file1');",
"console.log('This is file2');",
"console.log('This is internal package utils file');"
],
"mappings": "AAAA,OAAO,EAAE;AACP,OAAO,CAAC,GAAG,EAAE",
"sourceRoot": ""
}
这个 source map 让我们能够恢复 src/file.ts、src/file2.ts 和 node_modules/internal-corp-package/utils.js 的原始未压缩代码。
在某些情况下,有可能发现一些直接嵌入在源代码中的依赖,而能够获取这些依赖的名称,同样可能对实施依赖混淆至关重要。
关于这项技术更令人担忧的一点是:由于攻击者现在已经能够访问上述依赖的原始代码,他们可以在不破坏代码的情况下向其中注入一个隐蔽的后门,从而在目标环境中运行恶意代码,同时保持其正常运作——这使得检测变得更加困难,因为依赖此存在漏洞的包的应用会继续运行,不会有任何被入侵的迹象,这与上文提到的、很容易被发现的 scripts 字段不同。
除了依赖混淆的风险之外,当攻击者恢复出内部 JavaScript 应用的原始源代码时,这些代码可能包含在压缩代码中难以发现的硬编码凭据,或在转译代码中本会被剥除的注释。
对漏洞赏金项目进行扫描
在我们的分析过程中,我们对来自不同漏洞赏金项目的 191 项资产进行了扫描,发现其中约 5% 使用这项技术被判定为存在依赖混淆漏洞。

防范依赖混淆攻击
虽然对于依赖混淆并没有万能的灵丹妙药,但您可以采取一些步骤在一定程度上缓解风险,其中尤为值得注意的有:
- 使用组织作用域
NPM 注册表提供了注册组织名称并将其用作作用域的选项。该组织可以用在您的内部注册表中,但您也必须在公共 npm 注册表上注册它。这样一来,攻击者在无法访问您的组织的情况下,就无从实施依赖混淆。
- 在公共 npm 注册表上注册内部包的名称
主动领先于攻击者的一种方式,是在公共 npm 注册表上注册您在内部使用的包名。然而,这种做法并非万无一失,因为您可能会遗漏某些包。
- 验证包的完整性
每个 npm 包版本都有一个 sha512 哈希。您可以使用该哈希在下载之前验证包的完整性。
- 版本锁定(Version Pinning)
与其使用像 ^1.0.0 这样的宽松版本,您可以使用精确的 1.0.0 版本,这样 NPM 就不会默认使用更高的版本。这里需要注意的是,版本锁定不会应用于传递依赖(transitive dependencies)。
- 包锁定(Package Locking)
利用 package-lock.json 或 yarn.lock 文件来锁定项目中所使用依赖的具体版本。这可以防止在安装过程中依赖版本发生意外变更,同时锁定直接依赖和传递依赖。
结论
总之,依赖混淆已经演变成一个紧迫的问题,可能危及整条软件供应链的安全,因此,采取措施加以防范已变得至关重要。
标签:
supply chain