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(
|