揭秘GitHub高危漏洞:环境变量泄露与GHES远程代码执行

本文详细分析了CVE-2024-0200漏洞的发现过程,通过Ruby的反射机制成功利用GitHub.com环境变量泄露并实现在GitHub Enterprise Server上的远程代码执行,包含技术细节与缓解方案。

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

目录

背景故事

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

Ruby反射快速入门

在深入之前,先简要介绍Ruby。

与JavaScript类似,Ruby中几乎所有内容(如布尔值、字符串、整数)都是对象。Object包含了Kernel模块作为混入,使得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 = 'arbitrary Ruby code here'

obj.send(user_input1, user_input2)
# 等价于:
obj.send('eval', 'arbitrary Ruby code here')
# 进一步等价于:
Kernel.eval('arbitrary Ruby code here')
# 效果等同于:
eval('arbitrary Ruby code here')
# 注意:因为所有内容都是对象,包括当前上下文

如果你有超过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
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安装 somehow 没有正常工作,必须重新安装。此时,我注意到大多数方法可能只影响我自己的组织仓库,或者允许我泄露服务器上的某些信息,如文件路径,这在未来可能有用。

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

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

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

那是约2MB的GitHub.com环境变量,包含响应体中的大量访问密钥和秘密。这些秘密怎么会在这里?!

获取环境变量

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

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
module GitRPC
  class Backend
    ...
    rpc_writer
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计