GitHub环境变量泄露与GHES远程代码执行漏洞分析

本文详细分析了CVE-2024-0200漏洞的发现过程,该漏洞通过Ruby的Kernel#send()方法实现任意方法调用,导致GitHub.com生产环境容器中所有环境变量(包括大量访问密钥和密钥)泄露,并可在GitHub Enterprise Server上进一步实现远程代码执行。

Send()-ing Myself Belated Christmas Gifts - GitHub.com’s Environment Variables & GHES Shell

目录

  • 背景故事
  • Ruby反射快速入门
  • 发现漏洞
  • 寻找影响
  • 筛选候选方法
  • 获取环境变量
  • 实际影响
  • 获取远程代码执行(RCE)
  • 利用条件
  • 建议缓解措施
  • 检测指南
  • 时间线
  • 结束语

背景故事

2023年12月初,我正在对GitHub Enterprise Server(GHES)进行研究。在休假前一天,我找到了一个潜在(但可能较小)的漏洞。圣诞节后的一天,我终于有时间对这个潜在漏洞进行分析和评估。当时,我完全没有预料到这个漏洞会有如此大的影响……直到一个意外发生。

Ruby反射快速入门

在深入讨论之前,请允许我先简要介绍Ruby。

与JavaScript类似,Ruby中几乎所有内容(例如布尔值、字符串、整数)都是对象。Object包含了Kernel模块作为mixin,使得Kernel模块中的方法可以被每个Ruby对象访问。值得注意的是,可以使用Kernel#send()进行反射(即间接方法调用),如下所示:

1
2
3
4
5
6
7
8
9
class HelloWorld
  def print(*args)
    puts("Hello " + args.join(' '))
  end
end

obj = HelloWorld.new()
obj.print('world') # => 'Hello World'
obj.send('print', 'world') # => 'Hello World'

如上所示,可以使用Kernel#send()动态调用方法,对任何对象执行反射。

自然,这使得它成为一个明显的代码接收点,因为能够在对象上调用任意方法可能是灾难性的。例如,具有2个可控参数的不安全反射很容易导致任意代码执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
user_input1 = 'eval'
user_input2 = '任意Ruby代码'

obj.send(user_input1, user_input2)
# 等同于:
obj.send('eval', '任意Ruby代码')
# 等同于:
Kernel.eval('任意Ruby代码')
# 效果相同于:
eval('任意Ruby代码')
# 注意:因为所有内容都是对象,包括当前上下文

如果你有超过2个可控参数,那么通过重复调用send()也可以轻松实现任意代码执行:

1
2
3
4
5
6
7
obj.send('send', 'send', 'send', 'send', 'eval', '1+1')
# 将调用:
obj.send('send', 'send', 'send', 'eval', '1+1')
# ...
obj.send('eval', '1+1')
# 最终调用:
eval('1+1')

有趣的是,我没有找到任何关于Kernel#send()中只有1个可控参数的不安全反射的讨论,如下所示:

1
2
user_input = 'method_name_here'
obj.send(user_input)

乍一看,在这种情况下似乎很难提升影响。从Object继承的默认方法列表中,我确定了以下有用的方法:

 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
31
32
33
34
35
36
# 泄露文件路径:
obj.send('__dir__') # 泄露包含当前文件的解析绝对路径
obj.send('caller')  # 返回执行调用堆栈,可能泄露文件路径

# 泄露类名
obj.send('class')

# 泄露方法名
obj.send('__callee__')
obj.send('__method__')
obj.send('matching_methods')
obj.send('methods') # Object#methods()返回公共和受保护方法的列表
obj.send('private_methods')
obj.send('protected_methods')
obj.send('public_methods')
obj.send('singleton_methods')

# 泄露变量名
obj.send('instance_variables')
obj.send('global_variables')
obj.send('local_variables')

# 字符串化变量
obj.send('inspect') # 递归调用to_s
obj.send('to_s')    # 对象的字符串表示

# 从标准输入读取
obj.send('gets')
obj.send('readline')
obj.send('readlines')

# 终止进程(请谨慎使用)
obj.send('abort')
obj.send('fail')
obj.send('exit')
obj.send('exit!')

这些方法在尝试收集有关目标的更多信息时可能很有用,尤其是在执行盲目的不安全反射时。

然而,在GitHub的情况下,这并不必要,因为我们可以审计GHES的源代码,这与部署在GitHub.com上的代码基本相同。现在,我们可以继续讨论这个漏洞。

发现漏洞

注意:下面展示的源代码是从GitHub Enterprise Server(GHES)3.11.0中提取的,以确定漏洞的根本原因。

在代码库中快速搜索,我在Organizations::Settings::RepositoryItemsComponent中找到了一个未经验证的Kernel#send()调用,位于app/components/organizations/settings/repository_items_component.rb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
...
class Organizations::Settings::RepositoryItemsComponent < ApplicationComponent
  def initialize(organization:, repositories:, selected_repositories:, current_page:, total_count:, data_url:, aria_id_prefix:, repository_identifier_key: :global_relay_id, form_id: nil)
    @organization = organization
    @repositories = repositories
    @selected_repositories = selected_repositories
    @show_next_page = current_page * Orgs::RepositoryItemsHelper::PER_PAGE < total_count
    @data_url = data_url
    @current_page = current_page
    @aria_id_prefix = aria_id_prefix
    @repository_identifier_key = repository_identifier_key # [2]
    @form_id = form_id
  end
  ...
  def identifier_for(repository)
    repository.send(@repository_identifier_key) # [1]
  end
  ...
end

在[1]处,repository.send(@repository_identifier_key)identifier_for()方法中被调用,没有对@repository_identifier_key(在[2]处设置)进行任何先前的输入验证。这允许调用对象可访问的所有方法(包括私有或受保护的方法,以及从祖先类继承的任何其他方法)。

Organizations::Settings::RepositoryItemsComponent类的identifier_for()方法在app/components/organizations/settings/repository_items_component.html.erb(要在HTTP响应体中呈现和返回的模板文件)中的[3]处使用:

1
2
3
4
5
6
7
8
9
<%# erblint:counter ButtonComponentMigrationCounter 1 %>
<% @repositories.each do |repository| %>
  <li <% unless first_page? %> hidden <% end %> class="css-truncate d-flex flex-items-center width-full">
    <input
      <%= "form=#{@form_id}" if @form_id.present? %>
      type="checkbox" name="repository_ids[]"
      value="<%= identifier_for(repository) %>" # [3]
      id="<%= @aria_id_prefix %>-<%= repository.id %>"
...

进一步回溯,可以看到Organizations::Settings::RepositoryItemsComponent对象在app/controllers/orgs/actions_settings/repository_items_controller.rb中初始化:

 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
31
32
33
34
35
36
37
class Orgs::ActionsSettings::RepositoryItemsController < Orgs::Controller
  ...

  def index
    ...
    respond_to do |format|
      format.html do
        render(Organizations::Settings::RepositoryItemsComponent.new(
          organization: current_organization,
          repositories: additional_repositories(selected_repository_ids),
          selected_repositories: [],
          current_page: page,
          total_count: current_organization.repositories.size,
          data_url: data_url,
          aria_id_prefix: aria_id_prefix,
          repository_identifier_key: repository_identifier_key, # [4]
          form_id: form_id
        ), layout: false)
      end
    end
  end

  ...

  def rid_key
    params[:rid_key] # [6]
  end

  ...

  def repository_identifier_key
    return :global_relay_id unless rid_key.present? # [5]
    rid_key
  end

  ...
end

在[4]处,repository_identifier_key()方法的结果作为repository_identifier_key关键字参数传递给初始化Organizations::Settings::RepositoryItemsComponent对象。在[5]处,在repository_identifier_key()中,观察到只有当rid_key()的返回值不存在时才返回:global_relay_id。否则,repository_identifier_key()方法简单地传递来自rid_key()的返回值——params[:rid_key](在[6]处)。

将所有内容放在一起,不安全的反射repository.send(@repository_identifier_key)允许在Repository对象上执行"零参数任意方法调用"。

寻找影响

这与我之前讨论的场景完全相同。不幸的是,我之前分享的选项在这种情况下都不适用——这些信息可能已经对我们可用,或者它们目前对我们没有任何用处。那么,我们如何进一步扩大影响?

关键是要认识到我们不仅限于从Object继承的方法——我们可以通过查看Repository对象可访问的方法来扩展候选方法的搜索范围。

接下来,让我们回到"零参数任意方法调用"的假设。这到底是什么意思?我们只能调用完全不接受参数的方法吗?

答案是:不。惊喜!

实际上,这是一个常见的错误假设,有一个相当简单的反例可以证明它(如下所示):

 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
31
32
33
34
35
36
37
38
class Test
  # 需要零个参数
  def zero_arg()
  end

  # 需要1个位置参数
  def one_pos_arg(arg1)
  end

  # 需要2个位置参数,但第二个参数有默认值
  def one_pos_arg_one_default_pos_arg(arg1, arg2 = 'default')
  end

  # 需要2个位置参数,但两个参数都有默认值
  def two_default_pos_args(arg1 = 'default', arg2 = 'default')
  end

  # 需要1个关键字参数(类似于位置参数)(无默认值)
  def one_keyword_arg(keyword_arg1:)
  end

  # 需要1个关键字参数(有默认值)
  def one_default_keyword_arg(keyword_arg1: 'default')
  end

  # 需要1个位置(无默认值)和1个关键字参数(有默认值)
  def one_pos_arg_one_default_keyword_arg(arg1, keyword_arg2: 'default') 
  end
end

obj = Test.new()
obj.send('zero_arg')                            # => OK
obj.send('one_pos_arg')                         # => in `one_pos_arg': wrong number of arguments (given 0, expected 1) (ArgumentError)
obj.send('one_pos_arg_one_default_pos_arg')     # => in `one_pos_arg_one_default_pos_arg': wrong number of arguments (given 0, expected 1..2) (ArgumentError)
obj.send('two_default_pos_args')                # => OK
obj.send('one_keyword_arg')                     # => in `one_keyword_arg`: missing keyword: :keyword_arg1 (ArgumentError)
obj.send('one_default_keyword_arg')             # => OK
obj.send('one_pos_arg_one_default_keyword_arg') # => in `one_pos_arg_one_default_keyword_arg': wrong number of arguments (given 0, expected 1) (ArgumentError)

显然,我们能够很好地调用需要参数的方法——只要它们有默认值!

有了这两个技巧,我们现在可以开始搜索候选方法了……但是如何搜索?

我们可以简单地grep直到找到有用的东西,但这将是一个繁琐的过程。包含Ruby on Rails应用程序源代码的主要Docker镜像包含超过10万个文件(约1.5 GB),所以我们显然需要一个更好的策略。

这个复杂任务的简单解决方案是进入测试GHES设置中的Rails控制台,并使用反射来帮助我们完成任务:

1
2
3
4
5
6
7
8
repo = Repository.find(1) # 获取第一个仓库
methods = [ # 获取Repository对象可访问的所有方法的名称
  repo.public_methods(),
  repo.private_methods(),
  repo.protected_methods(),
].flatten()

methods.length() # => 5542

是的,你没看错。当我看到输出时,我也很震惊。

为什么Repository对象甚至有5542个方法?嗯,公平地说,大部分来自Ruby on Rails自动生成的代码,它利用Ruby元编程在对象上定义getter/setter方法。

让我们通过找到符合条件的方法(即没有必需的位置或关键字参数且没有默认值)来进一步减少搜索空间。这是因为我们需要防止Ruby由于明显的不匹配参数数量而抛出ArgumentError。回到之前Test类的例子,让我们检查方法的元数:

 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
31
32
33
34
35
36
37
38
class Test
  # 需要零个参数
  def zero_arg()
  end

  # 需要1个位置参数
  def one_pos_arg(arg1)
  end

  # 需要2个位置参数,但第二个参数有默认值
  def one_pos_arg_one_default_pos_arg(arg1, arg2 = 'default')
  end

  # 需要2个位置参数,但两个参数都有默认值
  def two_default_pos_args(arg1 = 'default', arg2 = 'default')
  end

  # 需要1个关键字参数(类似于位置参数)(无默认值)
  def one_keyword_arg(keyword_arg1:)
  end

  # 需要1个关键字参数(有默认值)
  def one_default_keyword_arg(keyword_arg1: 'default')
  end

  # 需要1个位置(无默认值)和1个关键字参数(有默认值)
  def one_pos_arg_one_default_keyword_arg(arg1, keyword_arg2: 'default') 
  end
end

obj = Test.new()
obj.method('zero_arg').arity()                            # => 0
obj.method('one_pos_arg').arity()                         # => 1
obj.method('one_pos_arg_one_default_pos_arg').arity()     # => -2
obj.method('two_default_pos_args').arity()                # => -1
obj.method('one_keyword_arg').arity()                     # => 1
obj.method('one_default_keyword_arg').arity()             # => -1
obj.method('one_pos_arg_one_default_keyword_arg').arity() # => -2

看起来只有元数为0或-1的方法可以被我们使用。

现在,我们可以进一步过滤候选方法列表:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
repo = Repository.find(1)  # 获取第一个仓库
repo_methods = [           # 获取Repository对象可访问的所有方法的名称
  repo.public_methods(),
  repo.private_methods(),
  repo.protected_methods(),
].flatten()

repo_methods.length()      # => 5542
candidate_methods = repo_methods.select() do |method_name|
  [0, -1].include?(repo.method(method_name).arity())
end
candidate_methods.length() # => 3595

我想这稍微好一点……?元编程有时可能是一种诅咒。😅

虽然我可以进一步减少搜索空间,但我不想冒险错过任何潜在有用的函数。在进一步处理之前,先扫描输出以更好地了解可用的方法可能是个好主意。

让我们转储方法定义的位置:

1
2
3
4
5
6
7
8
9
candidate_methods.map!() do |method_name|
  method = repo.method(method_name)
  [
    method_name,
    method.arity(),
    method.source_location()
  ]
end
puts(candidate_methods.sort())

输出是一个包含3595个方法及其位置的长列表:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[
  [:!, [0, nil]],
  [:Nn_, [-1, ["/github/vendor/gems/3.2.2/ruby/3.2.0/gems/fast_gettext-2.2.0/lib/fast_gettext/translation.rb", 65]]],
  [:_, [-1, ["/github/vendor/gems/3.2.2/ruby/3.2.0/gems/gettext_i18n_rails-1.8.1/lib/gettext_i18n_rails/html_safe_translations.rb", 10]]],
  [:__callbacks, [0, ["/github/vendor/gems/3.2.2/ruby/3.2.0/gems/activesupport-7.1.0.alpha.bb4dbd14f8/lib/active_support/callbacks.rb", 70]]],
  ...
  [:xcode_clone_url, [-1, ["/github/app/helpers/url_helper.rb", 218]]],
  [:xcode_project?, [0, ["/github/packages/repositories/app/models/repository/git_dependency.rb", 323]]],
  [:xcode_urls_enabled?, [0, ["/github/app/helpers/url_helper.rb", 213]]],
  [:yield_self, [0, ["<internal:kernel>", 144]]]
]

筛选候选方法

我开始筛选潜在有用方法的列表,但在本地测试时,我意识到我的测试GHES安装不知何故无法正常工作,必须重新安装。此时,我注意到大多数方法可能只会影响我自己的组织仓库,或者允许我泄露服务器上的一些信息,如文件路径,这在未来可能有用。

我真的不想在等待重新安装完成时浪费宝贵的时间,所以我决定在当前可实现的潜在影响下,在我自己的测试组织中在生产GitHub.com服务器上进行测试。

不可能出什么问题,对吧……?🙈

错了。我完全错了。在我开始在生产的GitHub.com服务器上测试后不久,以下响应返回了,让我完全无语,震惊不已:

(图片显示约2MB的GitHub.com环境变量,包含大量访问密钥和密钥)

这些秘密怎么会出现在这里?!

获取环境变量

让我们检查Repository::GitDependency模块(位于packages/repositories/app/models/repository/git_dependency.rb),它包含了危险的nw_fsck()方法:

1
2
3
4
5
6
7
module Repository::GitDependency
  ...
  def nw_fsck(trust_synced: false)
    rpc.nw_fsck(trust_synced: trust_synced)
  end
  ...
end

注意:在Ruby中,任何方法/块的最后评估行是隐式返回值。

这个nw_fsck()方法非常不起眼,但包含了丰富的信息。为了理解原因,让我们检查GitRPC后端实现在vendor/gitrpc/lib/gitrpc/backend/nw.rb中的实现:

1
2
3
4
5
module GitRPC
  class Backend
    ...
    rpc_writer :nw_fsck, output_varies: true
    def nw_fsck(
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计