
Cloud Vulnerability DB
A community-led vulnerabilities database
Savon::Model dynamically generates instance and class methods named after the SOAP operations advertised by a WSDL document. A guard in Savon::Model#operation_method_name (lib/savon/model.rb) rejects operation names that collide with the mixin's own methods plus a short hard-coded list (client, global, raise_initialization_error!). That guard does not reserve the inherited Object#class or Kernel#public_send methods that every generated instance method dereferences. A WSDL advertising an operation named class therefore passes the guard, defines a method that shadows Object#class, and turns each generated instance operation into unbounded self-recursion that raises SystemStackError, disabling the model's entire SOAP client surface. No authentication is required; the attacker only needs to influence the WSDL operation names the model loads.
The security invariant is that no generated operation method may shadow a method that the generated method bodies themselves rely on. Each instance operation body is:
def define_instance_operation(method_name)
instance_operation_module.define_method(method_name) do |locals = {}|
self.class.public_send(method_name, locals) # depends on Object#class and Kernel#public_send
end
endThe guard that is supposed to enforce the invariant only considers Savon::Model's own methods and three explicit accessors:
def operation_method_name(operation)
method_name = StringUtils.snakecase(operation.to_s).to_sym
reserved_methods = Model.instance_methods(false) + Model.private_instance_methods(false) +
%i[client global raise_initialization_error!]
raise ArgumentError, "... conflicts with reserved Savon::Model method ..." if reserved_methods.include?(method_name)
method_name
endThe reserved set never contains the inherited :class or :public_send. When an operation name normalizes to :class, the generated instance method is defined in a module that is included ahead of Object in the ancestor chain, so self.class inside the body dispatches back into the generated method instead of Object#class, producing infinite recursion. The sibling collision public_send breaks every instance body's dispatch call for the same reason.
Prerequisites: the application uses the documented Savon::Model mixin and loads operations from a WSDL whose operation names the attacker can influence (for example a remote or third-party WSDL, or a service description an attacker can register). No authentication, special configuration, or user interaction is required, and the operation set is normally loaded automatically via all_operations.
Demonstrated capability: a single operation named class causes every generated instance operation on the model to raise SystemStackError when invoked, because each body begins with self.class. This is a complete availability loss for the model's SOAP client surface within the hosting process. A plausible secondary effect is that reflective calls such as ModelClass.class become unintended outbound SOAP dispatches rather than returning the class object; this follows directly from the method shadowing and is noted as plausible, not observed as an external exploit.
Dependencies: Ruby 3.2.x. The PoC loads the verbatim released lib/savon/model.rb and
lib/savon/string_utils.rb. Only Savon::Client is stubbed to advertise a WSDL operation
named "class"; the stub is never reached because the recursion occurs before any client call.Steps:
Savon::Model and load operations from a WSDL that advertises an operation named class (simulated by a client stub returning [:class, :get_user]).all_operations to generate the operation methods.require "savon/string_utils"
module Savon
class InitializationError < StandardError; end
class Client
def initialize(g = {}); end
def operations; [:class, :get_user]; end
def call(op, locals = {}); [[op.to_s, locals]]; end
end
module Model; end
end
load "lib/savon/model.rb"
klass = Class.new
klass.extend(Savon::Model)
p klass.all_operations # => [:class, :get_user] (name "class" accepted)
inst = klass.new
inst.class # => raises SystemStackError
inst.get_user # => raises SystemStackError (every instance op bricked)all_operations accepted WSDL op 'class' (fix bypassed): true
inst.class -> SystemStackError (unbounded recursion / DoS)
inst.get_user -> SystemStackError (every instance op bricked)
[control] op 'client' still BLOCKED: SOAP operation "client" conflicts with reserved Savon::Model method :client
recursion backtrace frame: lib/savon/model.rb:41:in 'block in define_instance_operation'Savon::Model and calls all_operations, which loads operation names from the configured WSDL (operations(*client.operations)).class, Class, and CLASS all normalize through StringUtils.snakecase to the string "class" and then to the symbol :class.all_operations passes each name to operation_method_name, then operations calls define_class_operation and define_instance_operation for the surviving name.operation_method_name is the only guard. It omits the inherited :class and :public_send, so :class passes.define_instance_operation(:class) defines :class on the instance operation module, which is included ahead of Object, shadowing Object#class. The generated body self.class.public_send(:class, locals) re-dispatches into itself.SystemStackError, disabling the model's SOAP operation methods for the process.The relevant prior fix is the reserved-name guard added in Savon::Model#operation_method_name (published as GHSA-x99v-rwvj-g9p5, the CVE-2026-53510 recursion advisory), which prevents an operation named client from overwriting the internal client accessor. That guard is present and effective for its original input: an operation named client still raises ArgumentError on the released 2.17.5 artifact.
The guard is incomplete. It reserves only Savon::Model's own methods and the literals client, global, and raise_initialization_error!. It does not reserve the inherited Object#class or Kernel#public_send that the generated instance bodies dereference. Verified against the released source:
git show v2.17.5:lib/savon/model.rb shows the identical incomplete reserved list; the fix commit and HEAD carry the same code.[:all_operations, :class_operation_module, :client, :define_class_operation, :define_instance_operation, :global, :instance_operation_module, :operation_method_name, :operations, :raise_initialization_error!, :setup]; membership tests return false for both :class and :public_send.snakecase("class") == "class", snakecase("Class") == "class", snakecase("CLASS") == "class", all yielding :class.
Disproof attempts and outcomes: (a) that snakecase mangles the name, disproven, :class survives; (b) that the generated override calls Object#class rather than itself, disproven, the runtime backtrace originates at lib/savon/model.rb:41 and recursion is confirmed; (c) that the recursion lives only in stub code, disproven, the recursion occurs before any client call and the client stub is never reached; (d) that the fix already blocks this input, disproven, client is blocked while class is accepted. A leading-dot name .class normalizes to _class (a safe name) and is not part of the bypass set; the valid aliases are class, Class, and CLASS.Ecosystem: RubyGemsPackage: savonConfirmed affected range: <= 2.17.5Latest release checked: 2.17.5 (RubyGems; repository HEAD 5a70d2f = v2.17.5 plus 8 commits carries the identical guard)Fix status: not fixed (no published release blocks the class / public_send vector)
The incomplete guard shipped with the recursion fix in 2.17.5, the latest published release, which is affected. Releases earlier than 2.17.5 have no reserved-name check at all and are equally or more exposed to an operation named class, so the lower bound is left open. No published release fixes the class / public_send vector, and the current development head still carries the same incomplete guard, so no fixed version is inferred from the development branch.Enforce the invariant that a generated operation name may not shadow any method the generated bodies depend on. In operation_method_name, extend the reserved set beyond Savon::Model's own methods to also reject names that collide with the inherited methods the generated instance and class bodies dereference, at minimum :class and :public_send, and defensively :send and :__send__. A more robust approach validates the candidate name against the actual receiver's ancestor method table (for example via respond_to? or method_defined? against the target module and its ancestors) rather than only Savon::Model's own methods, so future body dependencies are covered automatically. As an interim mitigation without a code change, deployments can validate or allowlist WSDL operation names before loading them into a model, rejecting reserved Ruby object method names such as class and public_send.
Reported by zx (GitHub: @manus-pi).
Source: NVD
Free Vulnerability Assessment
Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.
Get a personalized demo
"Best User Experience I have ever seen, provides full visibility to cloud workloads."
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
"We know that if Wiz identifies something as critical, it actually is."